mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 17:01:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf2587e46 | ||
|
|
4583157b49 | ||
|
|
7185ea8808 | ||
|
|
4aa1508f54 | ||
|
|
5447bf7f1d | ||
|
|
b77ebfb897 | ||
|
|
2323cd5e67 | ||
|
|
df29e5e8b9 | ||
|
|
8b411c21c4 | ||
|
|
b00af5eddd | ||
|
|
36177425c4 | ||
|
|
6bed6c04ff | ||
|
|
4c496be02f | ||
|
|
7f7b071528 | ||
|
|
2d1fb8171c | ||
|
|
e683acc565 | ||
|
|
5396889ff1 | ||
|
|
c9e8d31571 | ||
|
|
d8ad91572e | ||
|
|
c27bfebd17 | ||
|
|
f05ba5e42f | ||
|
|
d9e72abb4b | ||
|
|
505a8e8080 | ||
|
|
5cc865e6e6 | ||
|
|
6a1ce40d86 | ||
|
|
fbb7936234 | ||
|
|
569e76c79a | ||
|
|
88ad029999 | ||
|
|
838836b29c | ||
|
|
8564872181 | ||
|
|
efbefa1682 | ||
|
|
bdcd440cd6 | ||
|
|
25516c7f87 | ||
|
|
ae2706bdab | ||
|
|
32299bb506 | ||
|
|
6f8463fc91 | ||
|
|
6b03cb2e9d | ||
|
|
8cb7541678 | ||
|
|
6b5b7674d3 | ||
|
|
247dca508b | ||
|
|
90d16e52be | ||
|
|
68bde60fac | ||
|
|
a422058049 | ||
|
|
6ba0bfb7ef | ||
|
|
7699b86037 | ||
|
|
e31216d5ec | ||
|
|
538e943d3d | ||
|
|
94fca22124 | ||
|
|
37e2043ed7 | ||
|
|
31eb833cdf | ||
|
|
15da248faf | ||
|
|
c27a6f3609 | ||
|
|
f792f1a14b | ||
|
|
5e228336e4 | ||
|
|
8b10930e40 | ||
|
|
5c1b1508a2 | ||
|
|
701fcd8e2e | ||
|
|
118eacea64 | ||
|
|
aaad1fdf32 | ||
|
|
dd8c53026e | ||
|
|
218f2dba1f | ||
|
|
24aca4e31d | ||
|
|
3cce1eb3fb | ||
|
|
c89958b66d | ||
|
|
b0a92456bf | ||
|
|
67bbcc623d | ||
|
|
a91bfc0e9c | ||
|
|
948117d5de | ||
|
|
2dedab2a8b | ||
|
|
8642c4d442 | ||
|
|
46a6eb33d9 | ||
|
|
99b4ce2931 | ||
|
|
87932f5da0 | ||
|
|
9130811433 | ||
|
|
325734c6aa | ||
|
|
faf2cf9211 | ||
|
|
15ed29c667 | ||
|
|
1d1bf0fb09 | ||
|
|
d3363142b2 | ||
|
|
76929a41aa | ||
|
|
fe34a37e6f | ||
|
|
4b63eb18cc | ||
|
|
fe21d4b4df | ||
|
|
f52bc53e47 | ||
|
|
10f7ee1885 | ||
|
|
7bc6591338 | ||
|
|
53f2066945 | ||
|
|
c69dd84184 | ||
|
|
c1356fc95d | ||
|
|
aabf83b351 | ||
|
|
da6b567ac3 | ||
|
|
ccdd7943a2 | ||
|
|
7fe87babe6 | ||
|
|
9c0e1637a5 | ||
|
|
fd450dbfc2 | ||
|
|
81176dc626 | ||
|
|
ef9096f7c5 | ||
|
|
fbb5bb1ae9 | ||
|
|
c435a6adcd | ||
|
|
f67c4e8754 | ||
|
|
3b237ac526 | ||
|
|
96c572df32 | ||
|
|
17e0d519ca | ||
|
|
37c233d489 | ||
|
|
92f7e996de | ||
|
|
b1c4061247 | ||
|
|
79aa34c77a | ||
|
|
4ae2961b1c | ||
|
|
6a90aa08df | ||
|
|
356500bb1e | ||
|
|
40bd7e2c11 | ||
|
|
a7197d7b97 | ||
|
|
bc26d5e8c3 | ||
|
|
84d90ad342 | ||
|
|
9fee63d58c | ||
|
|
40903cceee | ||
|
|
5a86141677 | ||
|
|
e97eb6fff3 | ||
|
|
93b57bdd52 | ||
|
|
0227ff1790 | ||
|
|
525d7d844a | ||
|
|
f2587995a2 | ||
|
|
e6bab993ae | ||
|
|
a39a675ba9 | ||
|
|
0ce390e09f | ||
|
|
88eeaa6a4d | ||
|
|
3db5d3a825 | ||
|
|
5a1f372612 | ||
|
|
a9f15b2d23 | ||
|
|
45e7ea7948 | ||
|
|
de8a692036 | ||
|
|
8d3d77dd99 | ||
|
|
d3d9fa842d | ||
|
|
ff44cfbe87 | ||
|
|
ce89d05987 | ||
|
|
00c0ff60de | ||
|
|
07af248a07 | ||
|
|
6ab07d155e | ||
|
|
e2b0a54720 | ||
|
|
6f519b9122 | ||
|
|
983519c8e8 | ||
|
|
c9701627a9 | ||
|
|
643a22b295 | ||
|
|
890ab8c177 | ||
|
|
1b9a68dc0b | ||
|
|
2b6286e469 | ||
|
|
589f32c8e5 | ||
|
|
528a7b9ff7 | ||
|
|
6851bbab09 | ||
|
|
9323edccf4 | ||
|
|
0bccba10cf | ||
|
|
2daaf92ad7 | ||
|
|
80359b4487 | ||
|
|
b866bb6b21 | ||
|
|
3fa8c6c527 | ||
|
|
e322afbebd | ||
|
|
a4c6c038a0 | ||
|
|
864e957261 | ||
|
|
f9921780e2 | ||
|
|
1c049d06c1 | ||
|
|
6f86f7a857 | ||
|
|
c47e15c1ce | ||
|
|
185ebc429f | ||
|
|
87626ae1c9 | ||
|
|
4a8af88fe8 | ||
|
|
2ef5704132 | ||
|
|
747314b19b | ||
|
|
3787c13fb1 | ||
|
|
8136281e68 | ||
|
|
779389e68d | ||
|
|
4e3569778e | ||
|
|
6b19bac049 | ||
|
|
2671791742 | ||
|
|
1ca86c4284 | ||
|
|
a603dc9d96 | ||
|
|
8a2197126c | ||
|
|
c743a72481 | ||
|
|
605ce747ec | ||
|
|
34a27de85e | ||
|
|
3e7d57b095 | ||
|
|
e988572c82 | ||
|
|
2d94963627 | ||
|
|
d94508e72e | ||
|
|
af51f6c971 | ||
|
|
e1790b5380 | ||
|
|
be1c4dba47 | ||
|
|
51a5f7983c | ||
|
|
760f35f9de | ||
|
|
64b1ec5a7e | ||
|
|
dc6119d2cc | ||
|
|
a8b4d3b48d | ||
|
|
36ba675f02 | ||
|
|
546816f692 | ||
|
|
80af18a174 | ||
|
|
abeaaf5446 | ||
|
|
3854e3eb89 | ||
|
|
73d50c0284 | ||
|
|
6403871aa1 | ||
|
|
df22a3db64 | ||
|
|
c1a14e884e | ||
|
|
77bef773e5 | ||
|
|
a26f762635 | ||
|
|
62b6aa4eb8 | ||
|
|
c000392093 | ||
|
|
ae32af1b06 | ||
|
|
d3972b23c0 | ||
|
|
ded95f3180 | ||
|
|
09ec6f5549 | ||
|
|
47c743f17c | ||
|
|
489fc1312c | ||
|
|
0408d65d8f | ||
|
|
8d14d0c225 | ||
|
|
07963779be | ||
|
|
28d01acf91 | ||
|
|
be149597e6 | ||
|
|
3b21494867 | ||
|
|
4565988e2e | ||
|
|
4714efb9e4 | ||
|
|
365bec868c | ||
|
|
a78299ed3d | ||
|
|
eebb83c79a | ||
|
|
0b59205b0c | ||
|
|
3f8ceabc51 | ||
|
|
4921ee5ece | ||
|
|
0c4769a07f | ||
|
|
167b37f10e | ||
|
|
545e710abb | ||
|
|
e421594185 | ||
|
|
2fe926c3b9 | ||
|
|
618e83714c | ||
|
|
8b925d8a04 | ||
|
|
449a29418d | ||
|
|
46eac44635 | ||
|
|
9372cc6ff2 | ||
|
|
9cf37eab22 | ||
|
|
79ca1cda32 | ||
|
|
50c51dd6b7 | ||
|
|
d75a56a0f6 | ||
|
|
ce3344a994 | ||
|
|
656e2734ce | ||
|
|
3c221dc3ca | ||
|
|
cfda9b9bfc | ||
|
|
a428b1cd66 | ||
|
|
51d358f9d9 | ||
|
|
1c61b869c0 | ||
|
|
fc902a9ceb | ||
|
|
a3cefa424c | ||
|
|
06d0479419 | ||
|
|
613a7d4a3b | ||
|
|
6ed6d3ac3b | ||
|
|
9f72d921c3 | ||
|
|
4b5aba28cf | ||
|
|
bbed72b491 | ||
|
|
55395c80db | ||
|
|
946363e919 | ||
|
|
64631a31e6 | ||
|
|
cf38b49e4d | ||
|
|
8ba84ada9d |
@@ -32,6 +32,9 @@
|
||||
# Fireworks AI
|
||||
# FIREWORKS_API_KEY=...
|
||||
|
||||
# Novita AI (multi-model gateway)
|
||||
# NOVITA_API_KEY=...
|
||||
|
||||
# ─── Local LLM Providers (no API key needed) ─────────────────────────
|
||||
|
||||
# Ollama (default: http://localhost:11434)
|
||||
|
||||
@@ -91,7 +91,9 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo fmt --check
|
||||
# Gate every workspace crate on rustfmt to keep `cargo fmt --all --check` clean.
|
||||
# See issue #1121.
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
audit:
|
||||
name: Security Audit
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
projectPath: crates/openfang-desktop
|
||||
args: ${{ matrix.platform.args }}
|
||||
|
||||
# ── CLI Binary (5 platforms) ──────────────────────────────────────────────
|
||||
# ── CLI Binary (7 platforms) ──────────────────────────────────────────────
|
||||
cli:
|
||||
name: CLI / ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -148,6 +148,9 @@ jobs:
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-22.04
|
||||
archive: tar.gz
|
||||
- target: armv7-unknown-linux-gnueabihf
|
||||
os: ubuntu-22.04
|
||||
archive: tar.gz
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
archive: tar.gz
|
||||
@@ -169,17 +172,17 @@ jobs:
|
||||
- name: Install build deps (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev
|
||||
- name: Install cross (Linux aarch64)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
- name: Install cross (Linux aarch64/armv7)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'armv7-unknown-linux-gnueabihf'
|
||||
run: cargo install cross --locked
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: cli-${{ matrix.target }}
|
||||
- name: Build CLI (cross)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'armv7-unknown-linux-gnueabihf'
|
||||
run: cross build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Build CLI
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu' && matrix.target != 'armv7-unknown-linux-gnueabihf'
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Ad-hoc codesign CLI binary (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
@@ -201,7 +204,7 @@ jobs:
|
||||
$hash = (Get-FileHash "openfang-${{ matrix.target }}.zip" -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash openfang-${{ matrix.target }}.zip" | Out-File -Encoding ASCII "openfang-${{ matrix.target }}.zip.sha256"
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: openfang-${{ matrix.target }}.*
|
||||
env:
|
||||
@@ -235,5 +238,17 @@ jobs:
|
||||
tags: |
|
||||
ghcr.io/rightnow-ai/openfang:latest
|
||||
ghcr.io/rightnow-ai/openfang:${{ steps.version.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/RightNow-AI/openfang
|
||||
org.opencontainers.image.licenses=MIT
|
||||
org.opencontainers.image.description=OpenFang Agent OS — single-binary Rust agent framework
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Set GHCR package visibility to public
|
||||
run: |
|
||||
curl -fsSL -X PATCH \
|
||||
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/orgs/RightNow-AI/packages/container/openfang \
|
||||
-d '{"visibility":"public"}'
|
||||
|
||||
@@ -45,3 +45,6 @@ Thumbs.db
|
||||
*.swo
|
||||
*~
|
||||
.serena/
|
||||
|
||||
# Personal deploy scripts
|
||||
scripts/deploy-remote.sh
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
## Health Stack
|
||||
|
||||
- typecheck: cargo build --workspace --lib
|
||||
- lint: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- test: cargo test --workspace
|
||||
- shell: shellcheck scripts/install.sh
|
||||
@@ -5,6 +5,37 @@ All notable changes to OpenFang will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.10] - 2026-04-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Non-loopback requests with no `api_key` configured now return 401 by default. Opt out with `OPENFANG_ALLOW_NO_AUTH=1`. Fixes the B1/B2 authentication bypass from #1034.
|
||||
- Agent `context.md` is re-read on every turn so external updates take effect mid-session. Opt out per agent with `cache_context = true` on the manifest. Fixes #843.
|
||||
- `openfang config get default_model.base_url` now prints the configured URL instead of an empty string. Missing keys return a clear "not found" error. Fixes #905.
|
||||
- `schedule_create`, `schedule_list`, and `schedule_delete` tools plus the `/api/schedules` routes now use the kernel cron scheduler, so scheduled jobs actually fire. One-shot idempotent migration imports legacy shared-memory entries at startup. Fixes #1069.
|
||||
- Multimodal user messages now combine text and image blocks into a single message so the LLM sees both. Fixes #1043.
|
||||
|
||||
### Added
|
||||
|
||||
- `openfang hand config <id>` subcommand: get, set, unset, and list settings on an active hand instance. Fixes #809.
|
||||
- Optional per-channel `prefix_agent_name` setting (`off` / `bracket` / `bold_bracket`). Wraps outbound agent responses so users in multi-agent channels can see which agent replied. Default is off, byte-identical to prior behavior. Fixes #980.
|
||||
|
||||
### Closed as invalid
|
||||
|
||||
- #818 and #819. Both reference a knowledge-domain API that does not exist on `main`. Filed against an unmerged feature branch (`plan/013-audit-remediation`). Close with a note to build the proposed validation and stale-timestamp surfacing into that feature when it lands.
|
||||
|
||||
## [0.5.9] - 2026-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING:** Dashboard password hashing switched from SHA256 to Argon2id. Existing `password_hash` values in `config.toml` must be regenerated with `openfang auth hash-password`. Only affects users with `[auth] enabled = true`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Dashboard passwords were hashed with plain SHA256 (no salt), making them vulnerable to rainbow table and GPU-accelerated brute force attacks. Now uses Argon2id with random salts.
|
||||
|
||||
## [0.1.0] - 2026-02-24
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+346
-435
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.1"
|
||||
version = "0.6.9"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -63,6 +63,7 @@ clap_complete = "4"
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
@@ -75,14 +76,14 @@ bytes = "1"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
prost = "0.13"
|
||||
prost = "0.14"
|
||||
|
||||
# WebSocket client (for Discord/Slack gateway)
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
url = "2"
|
||||
|
||||
# WASM sandbox
|
||||
wasmtime = "41"
|
||||
wasmtime = "43"
|
||||
|
||||
# HTTP server (for API daemon)
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
@@ -146,7 +147,7 @@ native-tls = { version = "0.2", features = ["vendored"] }
|
||||
mailparse = "0.16"
|
||||
|
||||
# MQTT client
|
||||
rumqttc = "0.24"
|
||||
rumqttc = { version = "0.25", default-features = false, features = ["use-native-tls"] }
|
||||
|
||||
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
@@ -3,3 +3,9 @@ pre-build = [
|
||||
"dpkg --add-architecture $CROSS_DEB_ARCH",
|
||||
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
|
||||
]
|
||||
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
pre-build = [
|
||||
"dpkg --add-architecture $CROSS_DEB_ARCH",
|
||||
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM rust:1-slim-bookworm AS builder
|
||||
WORKDIR /build
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev perl make && rm -rf /var/lib/apt/lists/*
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates ./crates
|
||||
COPY xtask ./xtask
|
||||
|
||||
@@ -19,25 +19,25 @@
|
||||
<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.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/version-0.6.9-green?style=flat-square" alt="v0.6.9" />
|
||||
<img src="https://img.shields.io/badge/tests-2,696%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>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> **v0.3.30 — Security Hardening Release (March 2026)**
|
||||
> **v0.5.10 (April 2026)**
|
||||
>
|
||||
> 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)
|
||||
> OpenFang is feature complete but still pre-1.0. Expect rough edges and 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)
|
||||
|
||||
---
|
||||
|
||||
## What is OpenFang?
|
||||
|
||||
OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
|
||||
OpenFang is an **open-source Agent Operating System**. Not a chatbot framework. Not a Python wrapper around an LLM. Not a "multi-agent orchestrator." A full operating system for autonomous agents, built from scratch in Rust.
|
||||
|
||||
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** — on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
|
||||
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you**: on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
|
||||
|
||||
The entire system compiles to a **single ~32MB binary**. One install, one command, your agents are live.
|
||||
|
||||
@@ -65,13 +65,13 @@ openfang start
|
||||
|
||||
<p align="center"><em>"Traditional agents wait for you to type. Hands work <strong>for</strong> you."</em></p>
|
||||
|
||||
**Hands** are OpenFang's core innovation — pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
|
||||
**Hands** are OpenFang's core innovation. Pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
|
||||
|
||||
Each Hand bundles:
|
||||
- **HAND.toml** — Manifest declaring tools, settings, requirements, and dashboard metrics
|
||||
- **System Prompt** — Multi-phase operational playbook (not a one-liner — these are 500+ word expert procedures)
|
||||
- **SKILL.md** — Domain expertise reference injected into context at runtime
|
||||
- **Guardrails** — Approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase)
|
||||
- **HAND.toml**: manifest declaring tools, settings, requirements, and dashboard metrics.
|
||||
- **System Prompt**: multi-phase operational playbook. Not a one-liner. These are 500+ word expert procedures.
|
||||
- **SKILL.md**: domain expertise reference injected into context at runtime.
|
||||
- **Guardrails**: approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase).
|
||||
|
||||
All compiled into the binary. No downloading, no pip install, no Docker pull.
|
||||
|
||||
@@ -81,14 +81,14 @@ All compiled into the binary. No downloading, no pip install, no Docker pull.
|
||||
|------|----------------------|
|
||||
| **Clip** | Takes a YouTube URL, downloads it, identifies the best moments, cuts them into vertical shorts with captions and thumbnails, optionally adds AI voice-over, and publishes to Telegram and WhatsApp. 8-phase pipeline. FFmpeg + yt-dlp + 5 STT backends. |
|
||||
| **Lead** | Runs daily. Discovers prospects matching your ICP, enriches them with web research, scores 0-100, deduplicates against your existing database, and delivers qualified leads in CSV/JSON/Markdown. Builds ICP profiles over time. |
|
||||
| **Collector** | OSINT-grade intelligence. You give it a target (company, person, topic). It monitors continuously — change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
|
||||
| **Collector** | OSINT grade intelligence. You give it a target (company, person, topic). It monitors continuously: change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
|
||||
| **Predictor** | Superforecasting engine. Collects signals from multiple sources, builds calibrated reasoning chains, makes predictions with confidence intervals, and tracks its own accuracy using Brier scores. Has a contrarian mode that deliberately argues against consensus. |
|
||||
| **Researcher** | Deep autonomous researcher. Cross-references multiple sources, evaluates credibility using CRAAP criteria (Currency, Relevance, Authority, Accuracy, Purpose), generates cited reports with APA formatting, supports multiple languages. |
|
||||
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue — nothing posts without your OK. |
|
||||
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate** — it will never spend your money without explicit confirmation. |
|
||||
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue, so nothing posts without your OK. |
|
||||
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate**: it will never spend your money without explicit confirmation. |
|
||||
|
||||
```bash
|
||||
# Activate the Researcher Hand — it starts working immediately
|
||||
# Activate the Researcher Hand. It starts working immediately.
|
||||
openfang hand activate researcher
|
||||
|
||||
# Check its progress anytime
|
||||
@@ -116,7 +116,7 @@ openfang hand list
|
||||
|
||||
### Benchmarks: Measured, Not Marketed
|
||||
|
||||
All data from official documentation and public repositories — February 2026.
|
||||
All data from official documentation and public repositories, February 2026.
|
||||
|
||||
#### Cold Start Time (lower is better)
|
||||
|
||||
@@ -203,7 +203,7 @@ AutoGen ███████████░░░░░░░░░░░░
|
||||
|
||||
---
|
||||
|
||||
## 16 Security Systems — Defense in Depth
|
||||
## 16 Security Systems: Defense in Depth
|
||||
|
||||
OpenFang doesn't bolt security on after the fact. Every layer is independently testable and operates without a single point of failure.
|
||||
|
||||
@@ -211,19 +211,19 @@ OpenFang doesn't bolt security on after the fact. Every layer is independently t
|
||||
|---|--------|-------------|
|
||||
| 1 | **WASM Dual-Metered Sandbox** | Tool code runs in WebAssembly with fuel metering + epoch interruption. A watchdog thread kills runaway code. |
|
||||
| 2 | **Merkle Hash-Chain Audit Trail** | Every action is cryptographically linked to the previous one. Tamper with one entry and the entire chain breaks. |
|
||||
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution — secrets are tracked from source to sink. |
|
||||
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution. Secrets are tracked from source to sink. |
|
||||
| 4 | **Ed25519 Signed Agent Manifests** | Every agent identity and capability set is cryptographically signed. |
|
||||
| 5 | **SSRF Protection** | Blocks private IPs, cloud metadata endpoints, and DNS rebinding attacks. |
|
||||
| 6 | **Secret Zeroization** | `Zeroizing<String>` auto-wipes API keys from memory the instant they're no longer needed. |
|
||||
| 7 | **OFP Mutual Authentication** | HMAC-SHA256 nonce-based, constant-time verification for P2P networking. |
|
||||
| 8 | **Capability Gates** | Role-based access control — agents declare required tools, the kernel enforces it. |
|
||||
| 8 | **Capability Gates** | Role based access control. Agents declare required tools, the kernel enforces it. |
|
||||
| 9 | **Security Headers** | CSP, X-Frame-Options, HSTS, X-Content-Type-Options on every response. |
|
||||
| 10 | **Health Endpoint Redaction** | Public health check returns minimal info. Full diagnostics require authentication. |
|
||||
| 11 | **Subprocess Sandbox** | `env_clear()` + selective variable passthrough. Process tree isolation with cross-platform kill. |
|
||||
| 12 | **Prompt Injection Scanner** | Detects override attempts, data exfiltration patterns, and shell reference injection in skills. |
|
||||
| 13 | **Loop Guard** | SHA256-based tool call loop detection with circuit breaker. Handles ping-pong patterns. |
|
||||
| 14 | **Session Repair** | 7-phase message history validation and automatic recovery from corruption. |
|
||||
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. `../` doesn't work here. |
|
||||
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. ``../`` doesn't work here. |
|
||||
| 16 | **GCRA Rate Limiter** | Cost-aware token bucket rate limiting with per-IP tracking and stale cleanup. |
|
||||
|
||||
---
|
||||
@@ -268,7 +268,7 @@ Each adapter supports per-channel model overrides, DM/group policies, rate limit
|
||||
|
||||
## WhatsApp Web Gateway (QR Code)
|
||||
|
||||
Connect your personal WhatsApp account to OpenFang via QR code — just like WhatsApp Web. No Meta Business account required.
|
||||
Connect your personal WhatsApp account to OpenFang via QR code, just like WhatsApp Web. No Meta Business account required.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -357,7 +357,7 @@ For production workloads, use the [WhatsApp Cloud API](https://developers.facebo
|
||||
|
||||
---
|
||||
|
||||
## 27 LLM Providers — 123+ Models
|
||||
## 27 LLM Providers, 123+ Models
|
||||
|
||||
3 native drivers (Anthropic, Gemini, OpenAI-compatible) route to 27 providers:
|
||||
|
||||
@@ -372,7 +372,7 @@ Intelligent routing with task complexity scoring, automatic fallback, cost track
|
||||
Already running OpenClaw? One command:
|
||||
|
||||
```bash
|
||||
# Migrate everything — agents, memory, skills, configs
|
||||
# Migrate everything: agents, memory, skills, configs.
|
||||
openfang migrate --from openclaw
|
||||
|
||||
# Migrate from a specific path
|
||||
@@ -410,7 +410,7 @@ curl -X POST localhost:4200/v1/chat/completions \
|
||||
# 1. Install (macOS/Linux)
|
||||
curl -fsSL https://openfang.sh/install | sh
|
||||
|
||||
# 2. Initialize — walks you through provider setup
|
||||
# 2. Initialize. Walks you through provider setup.
|
||||
openfang init
|
||||
|
||||
# 3. Start the daemon
|
||||
@@ -418,7 +418,7 @@ openfang start
|
||||
|
||||
# 4. Dashboard is live at http://localhost:4200
|
||||
|
||||
# 5. Activate a Hand — it starts working for you
|
||||
# 5. Activate a Hand. It starts working for you.
|
||||
openfang hand activate researcher
|
||||
|
||||
# 6. Chat with an agent
|
||||
@@ -462,14 +462,14 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
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:
|
||||
OpenFang v0.5.10 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is deep. 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)
|
||||
- **Edge cases** exist — if you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues)
|
||||
- **Pin to a specific commit** for production deployments until v1.0
|
||||
- **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.
|
||||
- **Edge cases** exist. If you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues).
|
||||
- **Pin to a specific commit** for production deployments until v1.0.
|
||||
|
||||
We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
|
||||
We ship fast and fix fast. The goal is a rock solid v1.0 by mid 2026.
|
||||
|
||||
---
|
||||
|
||||
@@ -481,7 +481,7 @@ To report a security vulnerability, email **jaber@rightnowai.co**. We take all r
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it however you want.
|
||||
MIT. Use it however you want.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ hmac = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -49,12 +49,13 @@ use openfang_channels::discourse::DiscourseAdapter;
|
||||
use openfang_channels::gitter::GitterAdapter;
|
||||
use openfang_channels::gotify::GotifyAdapter;
|
||||
use openfang_channels::linkedin::LinkedInAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::mqtt::MqttAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::ntfy::NtfyAdapter;
|
||||
use openfang_channels::webhook::WebhookAdapter;
|
||||
use openfang_channels::wecom::WeComAdapter;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::kernel_handle::KernelHandle;
|
||||
use openfang_types::agent::AgentId;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -523,6 +524,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
timeout_secs: None,
|
||||
},
|
||||
delivery: openfang_types::scheduler::CronDelivery::None,
|
||||
delivery_targets: Vec::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
last_run: None,
|
||||
next_run: None,
|
||||
@@ -816,6 +818,19 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async fn free_response_channels(&self, channel_type: &str) -> Vec<String> {
|
||||
let channels = &self.kernel.config.channels;
|
||||
match channel_type {
|
||||
"discord" => channels
|
||||
.discord
|
||||
.as_ref()
|
||||
.map(|c| c.free_response_channels.clone())
|
||||
.unwrap_or_default(),
|
||||
// Add other channel types here as needed (e.g., "telegram" => ...)
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorize_channel_user(
|
||||
&self,
|
||||
channel_type: &str,
|
||||
@@ -880,6 +895,23 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_channel_message(
|
||||
&self,
|
||||
channel_type: &str,
|
||||
recipient: &str,
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
<OpenFangKernel as KernelHandle>::send_channel_message(
|
||||
&self.kernel,
|
||||
channel_type,
|
||||
recipient,
|
||||
message,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn check_auto_reply(&self, agent_id: AgentId, message: &str) -> Option<String> {
|
||||
// Check if auto-reply should fire for this message
|
||||
let channel_type = "bridge"; // Generic; the bridge layer handles specifics
|
||||
@@ -1134,11 +1166,12 @@ pub async fn start_channel_bridge_with_config(
|
||||
if let Some(ref tg_config) = config.telegram {
|
||||
if let Some(token) = read_token(&tg_config.bot_token_env, "Telegram") {
|
||||
let poll_interval = Duration::from_secs(tg_config.poll_interval_secs);
|
||||
let adapter = Arc::new(TelegramAdapter::new(
|
||||
let adapter = Arc::new(TelegramAdapter::with_thread_routes(
|
||||
token,
|
||||
tg_config.allowed_users.clone(),
|
||||
poll_interval,
|
||||
tg_config.api_url.clone(),
|
||||
tg_config.thread_routes.clone(),
|
||||
));
|
||||
adapters.push((adapter, tg_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1153,6 +1186,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
dc_config.auto_thread.clone(),
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1217,10 +1251,17 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Matrix
|
||||
if let Some(ref mx_config) = config.matrix {
|
||||
if let Some(token) = read_token(&mx_config.access_token_env, "Matrix") {
|
||||
let adapter = Arc::new(MatrixAdapter::new(
|
||||
// MSC2918 refresh-token support: optional env var, when present the
|
||||
// adapter auto-recovers from M_UNKNOWN_TOKEN 401s.
|
||||
let refresh = mx_config
|
||||
.refresh_token_env
|
||||
.as_deref()
|
||||
.and_then(|env| read_token(env, "Matrix refresh"));
|
||||
let adapter = Arc::new(MatrixAdapter::with_refresh_token(
|
||||
mx_config.homeserver_url.clone(),
|
||||
mx_config.user_id.clone(),
|
||||
token,
|
||||
refresh,
|
||||
mx_config.allowed_rooms.clone(),
|
||||
mx_config.auto_accept_invites,
|
||||
));
|
||||
@@ -1445,9 +1486,10 @@ pub async fn start_channel_bridge_with_config(
|
||||
encrypt_key,
|
||||
fs_config.bot_names.clone(),
|
||||
)),
|
||||
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket(
|
||||
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket_with_region(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
region,
|
||||
)),
|
||||
};
|
||||
adapters.push((adapter, fs_config.default_agent.clone()));
|
||||
@@ -1457,8 +1499,15 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Revolt
|
||||
if let Some(ref rv_config) = config.revolt {
|
||||
if let Some(token) = read_token(&rv_config.bot_token_env, "Revolt") {
|
||||
let adapter = Arc::new(RevoltAdapter::new(token));
|
||||
adapters.push((adapter, rv_config.default_agent.clone()));
|
||||
let mut adapter = RevoltAdapter::with_urls(
|
||||
token,
|
||||
rv_config.api_url.clone(),
|
||||
rv_config.ws_url.clone(),
|
||||
);
|
||||
if !rv_config.allowed_channels.is_empty() {
|
||||
adapter.set_allowed_channels(rv_config.allowed_channels.clone());
|
||||
}
|
||||
adapters.push((Arc::new(adapter), rv_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,35 @@
|
||||
//! Exposes agent management, status, and chat via JSON REST endpoints.
|
||||
//! The kernel runs in-process; the CLI connects over HTTP.
|
||||
|
||||
/// Decode percent-encoded strings (e.g. `%2B` → `+`).
|
||||
/// Used to normalise `?token=` values that browsers encode with `encodeURIComponent`.
|
||||
pub(crate) fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
|
||||
out.push(hi << 4 | lo);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| input.to_string())
|
||||
}
|
||||
|
||||
fn hex_val(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod channel_bridge;
|
||||
pub mod middleware;
|
||||
pub mod openai_compat;
|
||||
|
||||
@@ -49,14 +49,23 @@ pub struct AuthState {
|
||||
pub api_key: String,
|
||||
pub auth_enabled: bool,
|
||||
pub session_secret: String,
|
||||
/// Set from `OPENFANG_ALLOW_NO_AUTH=1` to permit running without an api_key
|
||||
/// on a non-loopback bind. Off by default so empty keys fail closed.
|
||||
pub allow_no_auth: bool,
|
||||
}
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// When `api_key` is non-empty (after trimming), requests to non-public
|
||||
/// endpoints must include `Authorization: Bearer <api_key>`.
|
||||
/// If the key is empty or whitespace-only, auth is disabled entirely
|
||||
/// (public/local development mode).
|
||||
///
|
||||
/// When `api_key` is empty (no key configured) the server defaults to
|
||||
/// fail-closed for any request that does NOT originate from loopback.
|
||||
/// Loopback traffic (127.0.0.1 / ::1) is always allowed through with no
|
||||
/// key so single-user local setups keep zero-config UX. To explicitly
|
||||
/// run a no-auth server on a LAN/WAN address, set
|
||||
/// `OPENFANG_ALLOW_NO_AUTH=1`; this opts out of fail-closed and is
|
||||
/// reported loudly at startup.
|
||||
///
|
||||
/// When dashboard auth is enabled, session cookies are also accepted.
|
||||
pub async fn auth(
|
||||
@@ -67,17 +76,17 @@ pub async fn auth(
|
||||
// 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 is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false); // SECURITY: default-deny; unknown origin is NOT loopback
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine). Skip token auth only
|
||||
// when the request is from loopback.
|
||||
let path = request.uri().path();
|
||||
if path == "/api/shutdown" {
|
||||
let is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
if path == "/api/shutdown" && is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these).
|
||||
@@ -117,6 +126,7 @@ pub async fn auth(
|
||||
|| (path == "/api/hands/active" && is_get)
|
||||
|| (path.starts_with("/api/hands/") && is_get)
|
||||
|| (path == "/api/skills" && is_get)
|
||||
|| (path.starts_with("/api/skills/") && path.ends_with("/config") && is_get)
|
||||
|| (path == "/api/sessions" && is_get)
|
||||
|| (path == "/api/integrations" && is_get)
|
||||
|| (path == "/api/integrations/available" && is_get)
|
||||
@@ -133,12 +143,29 @@ pub async fn auth(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// If no API key configured (empty, whitespace-only, or missing), skip auth
|
||||
// entirely. Users who don't set api_key accept that all endpoints are open.
|
||||
// To secure the dashboard, set a non-empty api_key in config.toml.
|
||||
// If no API key configured and no dashboard login is active, fail closed
|
||||
// for anything that did not come from loopback. Opting out of this
|
||||
// behavior requires setting `OPENFANG_ALLOW_NO_AUTH=1`, which is logged
|
||||
// loudly at startup.
|
||||
//
|
||||
// See issue #1034 (B1/B2): empty api_key previously bypassed auth for
|
||||
// all origins, exposing agent config, channel tokens, and LLM keys on
|
||||
// any LAN-reachable bind.
|
||||
let api_key_trimmed = auth_state.api_key.trim().to_string();
|
||||
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
|
||||
return next.run(request).await;
|
||||
if is_loopback || auth_state.allow_no_auth {
|
||||
return next.run(request).await;
|
||||
}
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("www-authenticate", "Bearer")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"error": "API key required for non-loopback requests. Set OPENFANG_API_KEY or bind to 127.0.0.1."
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
let api_key = api_key_trimmed.as_str();
|
||||
|
||||
@@ -167,13 +194,14 @@ pub async fn auth(
|
||||
|
||||
// Also check ?token= query parameter (for EventSource/SSE clients that
|
||||
// cannot set custom headers, same approach as WebSocket auth).
|
||||
let query_token = request
|
||||
let query_token_decoded = request
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")));
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(crate::percent_decode);
|
||||
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks.
|
||||
let query_auth = query_token.map(|token| {
|
||||
let query_auth = query_token_decoded.as_deref().map(|token| {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != api_key.len() {
|
||||
return false;
|
||||
@@ -188,7 +216,7 @@ pub async fn auth(
|
||||
|
||||
// Check session cookie (dashboard login sessions)
|
||||
if auth_state.auth_enabled {
|
||||
if let Some(token) = extract_session_cookie(&request) {
|
||||
if let Some(token) = crate::session_auth::extract_session_cookie(request.headers()) {
|
||||
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
|
||||
.is_some()
|
||||
{
|
||||
@@ -214,21 +242,6 @@ pub async fn auth(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extract the `openfang_session` cookie value from a request.
|
||||
fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies.split(';').find_map(|c| {
|
||||
c.trim()
|
||||
.strip_prefix("openfang_session=")
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Security headers middleware — applied to ALL API responses.
|
||||
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
|
||||
let mut response = next.run(request).await;
|
||||
@@ -264,9 +277,123 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{Method, Request};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use std::net::SocketAddr;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[test]
|
||||
fn test_request_id_header_constant() {
|
||||
assert_eq!(REQUEST_ID_HEADER, "x-request-id");
|
||||
}
|
||||
|
||||
fn auth_state_empty() -> AuthState {
|
||||
AuthState {
|
||||
api_key: String::new(),
|
||||
auth_enabled: false,
|
||||
session_secret: String::new(),
|
||||
allow_no_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_state_with_key(key: &str) -> AuthState {
|
||||
AuthState {
|
||||
api_key: key.to_string(),
|
||||
auth_enabled: false,
|
||||
session_secret: key.to_string(),
|
||||
allow_no_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn ok_handler() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
fn router(state: AuthState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/agents/1", get(ok_handler))
|
||||
.route_layer(axum::middleware::from_fn_with_state(state, auth))
|
||||
}
|
||||
|
||||
fn req_from(ip: &str) -> Request<Body> {
|
||||
let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/agents/1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(addr));
|
||||
req
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_allows_loopback() {
|
||||
let app = router(auth_state_empty());
|
||||
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_blocks_lan_origin() {
|
||||
// Issue #1034 B1: previously 192.168/10/... could hit every non-public
|
||||
// endpoint when api_key was unset. Must now be 401.
|
||||
let app = router(auth_state_empty());
|
||||
let resp = app.oneshot(req_from("192.168.1.50")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_blocks_public_origin() {
|
||||
let app = router(auth_state_empty());
|
||||
let resp = app.oneshot(req_from("203.0.113.5")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_blocks_unknown_connect_info() {
|
||||
// Paranoia: if ConnectInfo is missing for any reason, we must fail
|
||||
// closed, not open.
|
||||
let app = router(auth_state_empty());
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/agents/1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_with_allow_no_auth_opens_everything() {
|
||||
let mut s = auth_state_empty();
|
||||
s.allow_no_auth = true;
|
||||
let app = router(s);
|
||||
let resp = app.oneshot(req_from("10.0.0.9")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_key_rejects_missing_token_from_loopback() {
|
||||
let app = router(auth_state_with_key("secret"));
|
||||
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_key_accepts_bearer() {
|
||||
let app = router(auth_state_with_key("secret"));
|
||||
let addr: SocketAddr = "127.0.0.1:40000".parse().unwrap();
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/agents/1")
|
||||
.header("authorization", "Bearer secret")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(addr));
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,12 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
|
||||
OaiContent::Null => return None,
|
||||
};
|
||||
|
||||
Some(Message { role, content })
|
||||
Some(Message {
|
||||
msg_id: uuid::Uuid::new_v4().to_string(),
|
||||
provider_msg_id: None,
|
||||
role,
|
||||
content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
|
||||
("POST", p) if p.contains("/run") => NonZeroU32::new(100).unwrap(),
|
||||
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
|
||||
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
|
||||
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
|
||||
("GET", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
|
||||
NonZeroU32::new(3).unwrap()
|
||||
}
|
||||
("PUT", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
|
||||
NonZeroU32::new(10).unwrap()
|
||||
}
|
||||
("DELETE", p) if p.starts_with("/api/skills/") && p.contains("/config/") => {
|
||||
NonZeroU32::new(10).unwrap()
|
||||
}
|
||||
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
|
||||
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
|
||||
_ => NonZeroU32::new(5).unwrap(),
|
||||
|
||||
+1753
-307
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,10 @@ pub async fn build_router(
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
|
||||
});
|
||||
|
||||
// Start WS cron broadcaster — subscribes to kernel event bus and pushes
|
||||
// cron job results to all connected WebSocket clients in real-time.
|
||||
ws::start_ws_cron_broadcaster(kernel.clone());
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
// is protected anyway. For development, permissive CORS is convenient.
|
||||
let cors = if state.kernel.config.api_key.trim().is_empty() {
|
||||
@@ -104,8 +108,43 @@ pub async fn build_router(
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
// Warn if dashboard auth is enabled but the password hash is not Argon2id.
|
||||
let ph = &state.kernel.config.auth.password_hash;
|
||||
if state.kernel.config.auth.enabled && !ph.is_empty() && !ph.starts_with("$argon2") {
|
||||
tracing::warn!(
|
||||
"Dashboard auth password_hash is not in Argon2id format. \
|
||||
Login will fail. Regenerate with: openfang auth hash-password"
|
||||
);
|
||||
}
|
||||
|
||||
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Fail-closed warning: if no api_key and no dashboard auth, and the
|
||||
// server is bound to a non-loopback address without an explicit opt-in,
|
||||
// shout about it. The middleware will reject non-loopback traffic.
|
||||
let bind_is_loopback = listen_addr.ip().is_loopback();
|
||||
if api_key.is_empty() && !state.kernel.config.auth.enabled && !bind_is_loopback {
|
||||
if allow_no_auth {
|
||||
tracing::warn!(
|
||||
"OPENFANG_ALLOW_NO_AUTH=1 is set. Running WITHOUT authentication on {}. \
|
||||
Anyone reachable at this address can read/write agents, channels, and keys.",
|
||||
listen_addr
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"No api_key configured and server is bound to {} (non-loopback). \
|
||||
Non-loopback requests will be rejected with 401. \
|
||||
Set OPENFANG_API_KEY (or api_key in config.toml), or bind to 127.0.0.1, \
|
||||
or set OPENFANG_ALLOW_NO_AUTH=1 to explicitly run open.",
|
||||
listen_addr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let auth_state = crate::middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
@@ -116,6 +155,7 @@ pub async fn build_router(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
allow_no_auth,
|
||||
};
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
@@ -146,6 +186,10 @@ pub async fn build_router(
|
||||
.delete(routes::kill_agent)
|
||||
.patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/uninstall",
|
||||
axum::routing::delete(routes::uninstall_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
axum::routing::put(routes::set_agent_mode),
|
||||
@@ -159,6 +203,12 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/start",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
// Issue #890 — alias so dashboards and external orchestrators can
|
||||
// wake an inactive agent via a verb that matches the agent_activate tool.
|
||||
"/api/agents/{id}/activate",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/message",
|
||||
axum::routing::post(routes::send_message),
|
||||
@@ -307,6 +357,10 @@ pub async fn build_router(
|
||||
"/api/schedules/{id}/run",
|
||||
axum::routing::post(routes::run_schedule),
|
||||
)
|
||||
.route(
|
||||
"/api/schedules/{id}/delivery-log",
|
||||
axum::routing::get(routes::schedule_delivery_log),
|
||||
)
|
||||
// Workflow endpoints
|
||||
.route(
|
||||
"/api/workflows",
|
||||
@@ -336,6 +390,23 @@ pub async fn build_router(
|
||||
"/api/skills/uninstall",
|
||||
axum::routing::post(routes::uninstall_skill),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/reload",
|
||||
axum::routing::post(routes::reload_skills),
|
||||
)
|
||||
// Audit trail (issue #1174 — instance-side wrapper integration)
|
||||
.route(
|
||||
"/api/audit/append",
|
||||
axum::routing::post(routes::audit_append),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config",
|
||||
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config/{var_name}",
|
||||
axum::routing::delete(routes::delete_skill_config_var),
|
||||
)
|
||||
.route(
|
||||
"/api/marketplace/search",
|
||||
axum::routing::get(routes::marketplace_search),
|
||||
|
||||
@@ -17,6 +17,25 @@ pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> Str
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
|
||||
}
|
||||
|
||||
/// Extract the `openfang_session` cookie value from a `Cookie` header string.
|
||||
///
|
||||
/// Returns `None` if the header is absent or the cookie is not present.
|
||||
/// Used by both the HTTP auth middleware and the WebSocket upgrade handler so
|
||||
/// that browser sessions established via `sessionLogin()` are honored on both
|
||||
/// surfaces (issue #1085).
|
||||
pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies.split(';').find_map(|c| {
|
||||
c.trim()
|
||||
.strip_prefix("openfang_session=")
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a session token. Returns the username if valid and not expired.
|
||||
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
|
||||
use base64::Engine;
|
||||
@@ -55,20 +74,27 @@ pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a password with SHA256 for config storage.
|
||||
/// Hash a password with Argon2id for config storage.
|
||||
///
|
||||
/// Returns a PHC-format string (e.g. `$argon2id$v=19$m=19456,t=2,p=1$...`).
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
use sha2::Digest;
|
||||
hex::encode(Sha256::digest(password.as_bytes()))
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.expect("Argon2 hashing should not fail with valid inputs")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Verify a password against a stored SHA256 hash (constant-time).
|
||||
/// Verify a password against a stored Argon2id hash (PHC string format).
|
||||
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
|
||||
let computed = hash_password(password);
|
||||
use subtle::ConstantTimeEq;
|
||||
if computed.len() != stored_hash.len() {
|
||||
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
|
||||
let Ok(parsed) = PasswordHash::new(stored_hash) else {
|
||||
return false;
|
||||
}
|
||||
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -78,10 +104,31 @@ mod tests {
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
let hash = hash_password("secret123");
|
||||
assert!(
|
||||
hash.starts_with("$argon2id$"),
|
||||
"should produce Argon2id PHC string"
|
||||
);
|
||||
assert!(verify_password("secret123", &hash));
|
||||
assert!(!verify_password("wrong", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_produces_unique_salts() {
|
||||
let h1 = hash_password("same");
|
||||
let h2 = hash_password("same");
|
||||
assert_ne!(h1, h2, "each hash should use a unique salt");
|
||||
assert!(verify_password("same", &h1));
|
||||
assert!(verify_password("same", &h2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_non_argon2_hash() {
|
||||
// A plain SHA256 hex string should no longer be accepted.
|
||||
use sha2::Digest;
|
||||
let sha256_hash = hex::encode(sha2::Sha256::digest(b"password"));
|
||||
assert!(!verify_password("password", &sha256_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_verify_token() {
|
||||
let token = create_session_token("admin", "my-secret", 1);
|
||||
@@ -103,7 +150,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_hash_length_mismatch() {
|
||||
fn test_rejects_garbage_input() {
|
||||
assert!(!verify_password("x", "short"));
|
||||
assert!(!verify_password("x", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_malformed_argon2_hash() {
|
||||
// Starts with $argon2 but is not a valid PHC string.
|
||||
assert!(!verify_password("x", "$argon2id$garbage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_present() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert(
|
||||
"cookie",
|
||||
"foo=bar; openfang_session=abc.def.ghi; baz=qux"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(extract_session_cookie(&h).as_deref(), Some("abc.def.ghi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_absent() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert("cookie", "foo=bar; baz=qux".parse().unwrap());
|
||||
assert_eq!(extract_session_cookie(&h), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_no_header() {
|
||||
let h = axum::http::HeaderMap::new();
|
||||
assert_eq!(extract_session_cookie(&h), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_only_value() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert("cookie", "openfang_session=lonely".parse().unwrap());
|
||||
assert_eq!(extract_session_cookie(&h).as_deref(), Some("lonely"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,15 @@ pub struct MessageResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SkillInstallRequest {
|
||||
pub name: String,
|
||||
/// When true, reject the install unless the bundle ships a valid
|
||||
/// Ed25519 SignedManifest envelope bound to the on-disk manifest.
|
||||
/// Maps to `InstallOptions::require_signed` (issue #1170).
|
||||
#[serde(default)]
|
||||
pub require_signed: bool,
|
||||
/// Optional hex-encoded allow-list of acceptable signer public keys.
|
||||
/// Empty = TOFU (any valid signature accepted).
|
||||
#[serde(default)]
|
||||
pub allowed_signer_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Request to uninstall a skill.
|
||||
@@ -107,3 +116,104 @@ pub struct ClawHubInstallRequest {
|
||||
/// ClawHub skill slug (e.g., "github-helper").
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
/// Query parameters for `GET /api/commands`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommandsQuery {
|
||||
/// Surface filter: `web` (default), `cli`, `channel`, or `all`.
|
||||
#[serde(default)]
|
||||
pub surface: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/audit/append` (issue #1174).
|
||||
///
|
||||
/// Lets external (instance-side) wrappers append entries to the Merkle hash
|
||||
/// chain audit log. The handler maps `event_type` to an `AuditAction` and
|
||||
/// records the entry through `kernel.audit_log`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuditAppendRequest {
|
||||
/// Operator-supplied event category. Case-insensitive, matched against the
|
||||
/// `AuditAction` enum variants (e.g. `tool_invoke`, `ConfigChange`,
|
||||
/// `agent_message`). Unknown values fall back to `ToolInvoke`.
|
||||
pub event_type: String,
|
||||
/// Agent or wrapper identifier responsible for the event. When empty,
|
||||
/// recorded as `"external-wrapper"`.
|
||||
#[serde(default)]
|
||||
pub agent_id: String,
|
||||
/// Free-form detail string (e.g. tool name, URL, file path).
|
||||
#[serde(default)]
|
||||
pub detail: String,
|
||||
/// Optional arbitrary payload. When present it is serialised to JSON and
|
||||
/// appended onto the entry's detail so the wrapper retains structured
|
||||
/// context without changing the on-chain schema.
|
||||
#[serde(default)]
|
||||
pub payload: Option<serde_json::Value>,
|
||||
/// Optional outcome string (`"ok"`, `"denied"`, or an error). Defaults to
|
||||
/// `"ok"` when omitted.
|
||||
#[serde(default)]
|
||||
pub outcome: Option<String>,
|
||||
/// Optional operator-supplied signing context (e.g. wrapper identity, key
|
||||
/// fingerprint). Mixed into the detail when present so the chain captures
|
||||
/// who attested to the event.
|
||||
#[serde(default)]
|
||||
pub signing_context: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skill_install_request_defaults_back_compat() {
|
||||
// Existing callers send `{"name": "..."}` only. New optional fields
|
||||
// must default cleanly (issue #1170).
|
||||
let req: SkillInstallRequest = serde_json::from_str(r#"{"name":"github-helper"}"#).unwrap();
|
||||
assert_eq!(req.name, "github-helper");
|
||||
assert!(!req.require_signed);
|
||||
assert!(req.allowed_signer_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_install_request_parses_require_signed() {
|
||||
let req: SkillInstallRequest = serde_json::from_str(
|
||||
r#"{"name":"x","require_signed":true,"allowed_signer_keys":["abc123"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(req.require_signed);
|
||||
assert_eq!(req.allowed_signer_keys, vec!["abc123".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_append_request_required_only() {
|
||||
// Only `event_type` is required; everything else must default.
|
||||
let req: AuditAppendRequest =
|
||||
serde_json::from_str(r#"{"event_type":"ToolInvoke"}"#).unwrap();
|
||||
assert_eq!(req.event_type, "ToolInvoke");
|
||||
assert!(req.agent_id.is_empty());
|
||||
assert!(req.detail.is_empty());
|
||||
assert!(req.payload.is_none());
|
||||
assert!(req.outcome.is_none());
|
||||
assert!(req.signing_context.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_append_request_full_payload() {
|
||||
let body = r#"{
|
||||
"event_type": "config_change",
|
||||
"agent_id": "wrapper-1",
|
||||
"detail": "rotated key",
|
||||
"payload": {"key_id": "k-42", "ts": 1700000000},
|
||||
"outcome": "ok",
|
||||
"signing_context": "ed25519:deadbeef"
|
||||
}"#;
|
||||
let req: AuditAppendRequest = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(req.event_type, "config_change");
|
||||
assert_eq!(req.agent_id, "wrapper-1");
|
||||
assert_eq!(req.detail, "rotated key");
|
||||
assert_eq!(req.outcome.as_deref(), Some("ok"));
|
||||
assert_eq!(req.signing_context.as_deref(), Some("ed25519:deadbeef"));
|
||||
let payload = req.payload.expect("payload present");
|
||||
assert_eq!(payload["key_id"], "k-42");
|
||||
assert_eq!(payload["ts"], 1_700_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,11 +90,11 @@ pub async fn webchat_page() -> impl IntoResponse {
|
||||
let html = WEBCHAT_HTML.replace(NONCE_PLACEHOLDER, &nonce);
|
||||
let csp = format!(
|
||||
"default-src 'self'; \
|
||||
script-src 'self' 'nonce-{nonce}' 'unsafe-eval'; \
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; \
|
||||
script-src 'self' 'nonce-{nonce}' 'unsafe-eval' https://cdn.jsdelivr.net; \
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; \
|
||||
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; \
|
||||
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:* https://cdn.jsdelivr.net; \
|
||||
font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; \
|
||||
media-src 'self' blob:; \
|
||||
frame-src 'self' blob:; \
|
||||
object-src 'none'; \
|
||||
@@ -120,6 +120,7 @@ pub async fn webchat_page() -> impl IntoResponse {
|
||||
/// All vendor libraries (Alpine.js, marked.js, highlight.js) are bundled
|
||||
/// locally — no CDN dependency. Alpine.js is included LAST because it
|
||||
/// immediately processes x-data directives and fires alpine:init on load.
|
||||
/// KaTeX is loaded dynamically from jsdelivr CDN when needed for LaTeX rendering.
|
||||
const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/index_head.html"),
|
||||
"<style>\n",
|
||||
|
||||
+698
-45
@@ -19,17 +19,19 @@ use axum::response::IntoResponse;
|
||||
use dashmap::DashMap;
|
||||
use futures::stream::SplitSink;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::kernel_handle::KernelHandle;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_runtime::llm_errors;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::commands::{self, Surfaces};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Per-IP WebSocket connection tracker.
|
||||
@@ -97,6 +99,62 @@ fn ws_tracker() -> &'static DashMap<IpAddr, AtomicUsize> {
|
||||
TRACKER.get_or_init(DashMap::new)
|
||||
}
|
||||
|
||||
/// Per-agent WebSocket sender entry.
|
||||
struct WsSender {
|
||||
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
}
|
||||
|
||||
/// Global registry: agent_id → active WebSocket senders.
|
||||
/// Uses RwLock for fine-grained read/write access to the sender list.
|
||||
fn ws_agent_connections() -> &'static DashMap<AgentId, RwLock<Vec<WsSender>>> {
|
||||
static REGISTRY: std::sync::OnceLock<DashMap<AgentId, RwLock<Vec<WsSender>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
REGISTRY.get_or_init(DashMap::new)
|
||||
}
|
||||
|
||||
/// Register a WebSocket connection for an agent (async).
|
||||
pub async fn register_ws_connection(
|
||||
agent_id: AgentId,
|
||||
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
) {
|
||||
let entry = ws_agent_connections().entry(agent_id).or_default();
|
||||
let mut senders = entry.value().write().await;
|
||||
senders.push(WsSender { sender });
|
||||
}
|
||||
|
||||
/// Deregister a WebSocket connection for an agent.
|
||||
/// Returns the number of remaining connections for this agent.
|
||||
pub async fn deregister_ws_connection(
|
||||
agent_id: AgentId,
|
||||
sender: &Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
) -> usize {
|
||||
let entry = match ws_agent_connections().get(&agent_id) {
|
||||
Some(e) => e,
|
||||
None => return 0,
|
||||
};
|
||||
let mut senders = entry.value().write().await;
|
||||
senders.retain(|s| !Arc::ptr_eq(&s.sender, sender));
|
||||
senders.len()
|
||||
}
|
||||
|
||||
/// Broadcast a JSON message to all active WebSocket connections for an agent.
|
||||
/// Returns the number of connections the message was sent to.
|
||||
pub async fn broadcast_to_ws(agent_id: AgentId, msg: serde_json::Value) -> usize {
|
||||
let entry = match ws_agent_connections().get(&agent_id) {
|
||||
Some(e) => e,
|
||||
None => return 0,
|
||||
};
|
||||
let senders = entry.value().read().await;
|
||||
let mut success_count = 0;
|
||||
for ws_sender in senders.iter() {
|
||||
let sender = &ws_sender.sender;
|
||||
if send_json(sender, &msg).await.is_ok() {
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
success_count
|
||||
}
|
||||
|
||||
/// RAII guard that decrements the connection count on drop.
|
||||
struct WsConnectionGuard {
|
||||
ip: IpAddr,
|
||||
@@ -132,11 +190,121 @@ fn try_acquire_ws_slot(ip: IpAddr) -> Option<WsConnectionGuard> {
|
||||
// WS Upgrade Handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parameters for [`check_ws_auth`]. Kept as a struct so the auth gate stays
|
||||
/// pure and unit-testable without an `AppState` or live socket.
|
||||
pub(crate) struct WsAuthCtx<'a> {
|
||||
/// Trimmed API key from kernel config. Empty string means no key configured.
|
||||
pub api_key: &'a str,
|
||||
/// Whether dashboard session login is enabled in config.
|
||||
pub auth_enabled: bool,
|
||||
/// Secret used to verify session cookies (api_key when set, else password hash).
|
||||
pub session_secret: &'a str,
|
||||
/// Whether the request originated from a loopback address.
|
||||
pub is_loopback: bool,
|
||||
/// True iff `OPENFANG_ALLOW_NO_AUTH=1` is set (loose mode for LAN binds).
|
||||
pub allow_no_auth: bool,
|
||||
pub headers: &'a axum::http::HeaderMap,
|
||||
pub uri: &'a axum::http::Uri,
|
||||
}
|
||||
|
||||
/// Pure auth gate for WebSocket upgrades.
|
||||
///
|
||||
/// Returns `Ok(())` if the request should be allowed through, or
|
||||
/// `Err(StatusCode::UNAUTHORIZED)` otherwise. Accepts:
|
||||
/// 1. `Authorization: Bearer <api_key>` header
|
||||
/// 2. `?token=<api_key>` query parameter
|
||||
/// 3. `openfang_session=<token>` cookie when dashboard auth is enabled
|
||||
/// 4. Loopback origin when no api_key is configured
|
||||
/// 5. Any origin when `OPENFANG_ALLOW_NO_AUTH=1`
|
||||
///
|
||||
/// Fix for issue #1085: previously only (1), (2), and (4) were honored, so
|
||||
/// dashboard users logged in via session cookie saw "No active connection"
|
||||
/// because the WS upgrade rejected them even though HTTP requests succeeded.
|
||||
pub(crate) fn check_ws_auth(ctx: &WsAuthCtx<'_>) -> Result<(), axum::http::StatusCode> {
|
||||
use axum::http::StatusCode;
|
||||
|
||||
// No api_key configured: behavior depends on whether dashboard auth is on.
|
||||
//
|
||||
// Issue #1189: previously this path allowed any loopback request through
|
||||
// when api_key was empty, EVEN IF dashboard auth was enabled. That diverged
|
||||
// from the HTTP middleware (which only opens the loopback no-auth path
|
||||
// when api_key is empty AND auth.enabled is false). A local attacker with
|
||||
// loopback access could chat with agents over WS even when the operator
|
||||
// had configured dashboard credentials. Now mirror HTTP exactly.
|
||||
if ctx.api_key.is_empty() {
|
||||
// When dashboard auth is configured, require a valid session cookie
|
||||
// regardless of bind address. Loopback no longer bypasses login.
|
||||
if ctx.auth_enabled {
|
||||
if !ctx.session_secret.is_empty() {
|
||||
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
|
||||
if crate::session_auth::verify_session_token(&token, ctx.session_secret)
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
// No api_key AND dashboard auth disabled: keep the dev convenience
|
||||
// path (loopback or explicit OPENFANG_ALLOW_NO_AUTH=1).
|
||||
if ctx.is_loopback || ctx.allow_no_auth {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// SECURITY: 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 = ctx
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|token| ct_eq(token, ctx.api_key))
|
||||
.unwrap_or(false);
|
||||
if header_auth {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let query_auth = ctx
|
||||
.uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(crate::percent_decode)
|
||||
.map(|token| ct_eq(&token, ctx.api_key))
|
||||
.unwrap_or(false);
|
||||
if query_auth {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Dashboard session cookie (issue #1085). When auth_enabled is on the
|
||||
// session_secret is set by server.rs to either the api_key or the
|
||||
// configured password hash, mirroring the HTTP auth middleware.
|
||||
if ctx.auth_enabled && !ctx.session_secret.is_empty() {
|
||||
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
|
||||
if crate::session_auth::verify_session_token(&token, ctx.session_secret).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
/// GET /api/agents/:id/ws — Upgrade to WebSocket for real-time chat.
|
||||
///
|
||||
/// SECURITY: Authenticates via Bearer token in Authorization header
|
||||
/// or `?token=` query parameter (for browser WebSocket clients that
|
||||
/// cannot set custom headers).
|
||||
/// SECURITY: Authenticates via Bearer token in Authorization header,
|
||||
/// `?token=` query parameter (for browser WebSocket clients that cannot
|
||||
/// set custom headers), or the `openfang_session` cookie set by the
|
||||
/// dashboard's session login flow (issue #1085).
|
||||
pub async fn agent_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -145,37 +313,43 @@ pub async fn agent_ws(
|
||||
headers: axum::http::HeaderMap,
|
||||
uri: axum::http::Uri,
|
||||
) -> impl IntoResponse {
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
|
||||
// Trim whitespace so empty/whitespace-only api_key disables auth.
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses HTTP middleware).
|
||||
// Trim whitespace so empty/whitespace-only api_key still triggers the
|
||||
// fail-closed path for non-loopback origins (see issue #1034 B2).
|
||||
let api_key_raw = &state.kernel.config.api_key;
|
||||
let api_key = api_key_raw.trim();
|
||||
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 is_loopback = addr.ip().is_loopback();
|
||||
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let header_auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
// Mirror the session_secret derivation in server.rs::AuthState so cookies
|
||||
// issued by /api/auth/login verify the same way over HTTP and WS.
|
||||
let auth_enabled = state.kernel.config.auth.enabled;
|
||||
let session_secret_owned: String = if !api_key.is_empty() {
|
||||
api_key.to_string()
|
||||
} else if auth_enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let query_auth = uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
let auth_ctx = WsAuthCtx {
|
||||
api_key,
|
||||
auth_enabled,
|
||||
session_secret: &session_secret_owned,
|
||||
is_loopback,
|
||||
allow_no_auth,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
|
||||
if !header_auth && !query_auth {
|
||||
warn!("WebSocket upgrade rejected: invalid auth");
|
||||
return axum::http::StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
if let Err(status) = check_ws_auth(&auth_ctx) {
|
||||
warn!(
|
||||
ip = %addr.ip(),
|
||||
"WebSocket upgrade rejected: no valid Bearer token, ?token=, or openfang_session cookie"
|
||||
);
|
||||
return status.into_response();
|
||||
}
|
||||
|
||||
// SECURITY: Enforce per-IP WebSocket connection limit
|
||||
@@ -196,9 +370,29 @@ pub async fn agent_ws(
|
||||
}
|
||||
};
|
||||
|
||||
// Verify agent exists
|
||||
if state.kernel.registry.get(agent_id).is_none() {
|
||||
return axum::http::StatusCode::NOT_FOUND.into_response();
|
||||
// Verify agent exists.
|
||||
// Retry up to 5 times with 200ms backoff to handle a timing race where
|
||||
// the client connects before the agent finishes registering (#804).
|
||||
{
|
||||
let mut found = state.kernel.registry.get(agent_id).is_some();
|
||||
if !found {
|
||||
for attempt in 1..=4 {
|
||||
debug!(
|
||||
agent_id = %id,
|
||||
attempt,
|
||||
"Agent not found yet, retrying in 200ms"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
if state.kernel.registry.get(agent_id).is_some() {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
warn!(agent_id = %id, "Agent not found after 5 lookup attempts");
|
||||
return axum::http::StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let id_str = id.clone();
|
||||
@@ -226,6 +420,9 @@ async fn handle_agent_ws(
|
||||
let (sender, mut receiver) = socket.split();
|
||||
let sender = Arc::new(Mutex::new(sender));
|
||||
|
||||
// Register this connection in the global agent-WS registry
|
||||
register_ws_connection(agent_id, Arc::clone(&sender)).await;
|
||||
|
||||
// Per-connection verbose level (default: Full)
|
||||
let verbose = Arc::new(AtomicU8::new(VerboseLevel::Full as u8));
|
||||
|
||||
@@ -378,7 +575,8 @@ async fn handle_agent_ws(
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
// Cleanup: deregister from agent-WS registry and abort background tasks
|
||||
deregister_ws_connection(agent_id, &sender).await;
|
||||
update_handle.abort();
|
||||
info!(agent_id = %id_str, "WebSocket disconnected");
|
||||
}
|
||||
@@ -816,8 +1014,17 @@ async fn handle_command(
|
||||
args: &str,
|
||||
verbose: &Arc<AtomicU8>,
|
||||
) -> serde_json::Value {
|
||||
match cmd {
|
||||
"new" | "reset" => match state.kernel.reset_session(agent_id) {
|
||||
// Canonicalise through the unified command registry. This resolves aliases
|
||||
// (e.g. `reset` -> `new`) and is case-insensitive. If the command is not
|
||||
// registered on the WEB surface, fall through to the existing match so any
|
||||
// legacy/un-registered handlers still work byte-identically.
|
||||
let canonical: &str = commands::resolve(cmd)
|
||||
.filter(|def| def.surfaces.contains(Surfaces::WEB))
|
||||
.map(|def| def.name)
|
||||
.unwrap_or(cmd);
|
||||
|
||||
match canonical {
|
||||
"new" => match state.kernel.reset_session(agent_id) {
|
||||
Ok(()) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "Session reset. Chat history cleared."})
|
||||
}
|
||||
@@ -831,15 +1038,34 @@ async fn handle_command(
|
||||
serde_json::json!({"type": "error", "content": format!("Compaction failed: {e}")})
|
||||
}
|
||||
},
|
||||
"stop" => match state.kernel.stop_agent_run(agent_id) {
|
||||
Ok(true) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "Run cancelled."})
|
||||
"stop" => {
|
||||
// If this agent is owned by an active hand instance, deactivate the
|
||||
// hand entirely so the user can re-activate it (issue #1164).
|
||||
if let Some(instance) = state.kernel.hand_registry.find_by_agent(agent_id) {
|
||||
match state.kernel.deactivate_hand(instance.instance_id) {
|
||||
Ok(()) => serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": format!("Hand '{}' deactivated.", instance.hand_id),
|
||||
}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match state.kernel.stop_agent_run(agent_id) {
|
||||
Ok(true) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "Run cancelled."})
|
||||
}
|
||||
Ok(false) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "No active run to cancel."})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "No active run to cancel."})
|
||||
}
|
||||
Err(e) => serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")}),
|
||||
},
|
||||
}
|
||||
"model" => {
|
||||
if args.is_empty() {
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
@@ -986,7 +1212,20 @@ async fn handle_command(
|
||||
};
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
|
||||
}
|
||||
_ => serde_json::json!({"type": "error", "content": format!("Unknown command: {cmd}")}),
|
||||
"help" => {
|
||||
serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": commands::render_help(Surfaces::WEB),
|
||||
})
|
||||
}
|
||||
_ => serde_json::json!({
|
||||
"type": "error",
|
||||
"content": format!(
|
||||
"Unknown command: /{cmd}\n\n{}",
|
||||
commands::render_help(Surfaces::WEB)
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1273,6 +1512,110 @@ pub fn strip_think_tags(text: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron Job WS Broadcasting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Start a background task that subscribes to the kernel's event bus and
|
||||
/// broadcasts cron job results to all connected WebSocket clients for the
|
||||
/// relevant agent.
|
||||
///
|
||||
/// This runs independently of the channel bridge — it uses the kernel's
|
||||
/// event bus to receive `CronJobExecuted` events and pushes them to WS.
|
||||
pub fn start_ws_cron_broadcaster(kernel: Arc<OpenFangKernel>) {
|
||||
tokio::spawn(async move {
|
||||
let mut rx = kernel.event_bus.subscribe_all();
|
||||
loop {
|
||||
let event = rx.recv().await;
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if let openfang_types::event::EventPayload::System(
|
||||
openfang_types::event::SystemEvent::CronJobExecuted {
|
||||
agent_id,
|
||||
job_id,
|
||||
job_name,
|
||||
trigger_message,
|
||||
response,
|
||||
delivered_to_channel: _,
|
||||
},
|
||||
) = event.payload
|
||||
{
|
||||
// Build the trigger message (synthetic user message from cron)
|
||||
let trigger_msg = serde_json::json!({
|
||||
"type": "message",
|
||||
"content": trigger_message,
|
||||
"source": "cron",
|
||||
"job_id": job_id,
|
||||
"job_name": job_name
|
||||
});
|
||||
let _ = broadcast_to_ws(agent_id, trigger_msg).await;
|
||||
|
||||
// Send typing start
|
||||
let _ = broadcast_to_ws(
|
||||
agent_id,
|
||||
serde_json::json!({"state": "start", "type": "typing"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send streaming phase
|
||||
let _ = broadcast_to_ws(
|
||||
agent_id,
|
||||
serde_json::json!({"detail": null, "phase": "streaming", "type": "phase"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send text delta (full response since we don't have streaming chunks)
|
||||
let text_delta = serde_json::json!({
|
||||
"content": response,
|
||||
"type": "text_delta"
|
||||
});
|
||||
let _ = broadcast_to_ws(agent_id, text_delta).await;
|
||||
|
||||
// Send done phase
|
||||
let _ = broadcast_to_ws(
|
||||
agent_id,
|
||||
serde_json::json!({"detail": null, "phase": "done", "type": "phase"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send typing stop
|
||||
let _ = broadcast_to_ws(
|
||||
agent_id,
|
||||
serde_json::json!({"state": "stop", "type": "typing"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send final response (mimics the format from agent_loop)
|
||||
let response_msg = serde_json::json!({
|
||||
"type": "response",
|
||||
"content": response,
|
||||
"context_pressure": "low",
|
||||
"cost_usd": null,
|
||||
"input_tokens": 0,
|
||||
"iterations": 0,
|
||||
"output_tokens": 0
|
||||
});
|
||||
let _ = broadcast_to_ws(agent_id, response_msg).await;
|
||||
|
||||
info!(
|
||||
agent_id = %agent_id,
|
||||
job_id = %job_id,
|
||||
"Cron job result broadcast to WS"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(lagged_messages = n, "WS cron broadcaster lagged, skipping");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
info!("WS cron broadcaster channel closed, stopping");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1372,4 +1715,314 @@ mod tests {
|
||||
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
|
||||
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WebSocket auth gate (issue #1085)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn empty_uri() -> axum::http::Uri {
|
||||
"/api/agents/x/ws".parse().unwrap()
|
||||
}
|
||||
|
||||
fn uri_with_token(tok: &str) -> axum::http::Uri {
|
||||
format!("/api/agents/x/ws?token={tok}").parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_accepts_bearer_token() {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert("authorization", "Bearer secret".parse().unwrap());
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: false,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_accepts_query_token() {
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = uri_with_token("secret");
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: false,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_accepts_session_cookie() {
|
||||
// Issue #1085: the dashboard logs in via cookie, so WS must accept it.
|
||||
let secret = "shared-secret";
|
||||
let token = crate::session_auth::create_session_token("alice", secret, 1);
|
||||
let cookie = format!("foo=bar; openfang_session={token}");
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert("cookie", cookie.parse().unwrap());
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: secret,
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(
|
||||
check_ws_auth(&ctx).is_ok(),
|
||||
"valid session cookie should authorize WS upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_session_cookie_rejected_when_auth_disabled() {
|
||||
// If dashboard auth is off, cookies must not grant access.
|
||||
let secret = "shared-secret";
|
||||
let token = crate::session_auth::create_session_token("alice", secret, 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("openfang_session={token}").parse().unwrap(),
|
||||
);
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: secret,
|
||||
auth_enabled: false,
|
||||
session_secret: secret,
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_rejects_wrong_session_cookie() {
|
||||
// Cookie signed with the wrong secret must fail.
|
||||
let bad = crate::session_auth::create_session_token("alice", "other-secret", 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert("cookie", format!("openfang_session={bad}").parse().unwrap());
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: true,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_rejects_when_no_credentials() {
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: true,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_rejects_wrong_bearer() {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert("authorization", "Bearer wrong".parse().unwrap());
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: false,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_empty_key_loopback_ok() {
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: false,
|
||||
session_secret: "",
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_empty_key_non_loopback_rejected() {
|
||||
// Issue #1034 B2 regression guard.
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: false,
|
||||
session_secret: "",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_empty_key_allow_no_auth_opens() {
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: false,
|
||||
session_secret: "",
|
||||
is_loopback: false,
|
||||
allow_no_auth: true,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_empty_key_session_cookie_grants_non_loopback() {
|
||||
// When only dashboard login is configured (no api_key, auth_enabled=true),
|
||||
// a valid session cookie must allow non-loopback WS upgrades.
|
||||
let secret = "password-hash-style-secret";
|
||||
let token = crate::session_auth::create_session_token("admin", secret, 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("openfang_session={token}").parse().unwrap(),
|
||||
);
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Issue #1189: WS auth must mirror HTTP middleware. When dashboard auth
|
||||
// is enabled, loopback + empty api_key + no cookie must NOT bypass.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ws_auth_dashboard_on_loopback_empty_key_no_cookie_rejected() {
|
||||
// Issue #1189 regression guard: previously this returned Ok(()) because
|
||||
// the empty-api_key branch allowed any loopback request through, even
|
||||
// when dashboard credentials were configured. HTTP middleware rejects
|
||||
// this path; WS must too.
|
||||
let secret = "password-hash-style-secret";
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"loopback must not bypass dashboard auth when api_key is empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_dashboard_on_loopback_valid_cookie_accepted() {
|
||||
// With dashboard auth on, a valid session cookie is the supported
|
||||
// credential and must upgrade successfully from loopback too.
|
||||
let secret = "password-hash-style-secret";
|
||||
let token = crate::session_auth::create_session_token("admin", secret, 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("openfang_session={token}").parse().unwrap(),
|
||||
);
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(
|
||||
check_ws_auth(&ctx).is_ok(),
|
||||
"valid session cookie should authorize loopback WS upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_dashboard_off_loopback_empty_key_accepted() {
|
||||
// Preserve the development convenience path: when dashboard auth is
|
||||
// NOT configured AND api_key is empty, loopback still upgrades.
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: false,
|
||||
session_secret: "",
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(
|
||||
check_ws_auth(&ctx).is_ok(),
|
||||
"loopback dev path must work when dashboard auth is disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: var(--shadow-xs), var(--shadow-inset);
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-dim); box-shadow: var(--shadow-sm), var(--shadow-accent); transform: translateY(-1px); }
|
||||
@@ -312,6 +312,12 @@ tr:hover td { background: var(--surface2); }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* Issue #1026: live indicator for agents currently calling the LLM */
|
||||
@keyframes agent-inferencing-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 var(--accent); }
|
||||
50% { transform: scale(1.25); opacity: 0.85; box-shadow: 0 0 0 4px rgba(255, 92, 0, 0); }
|
||||
}
|
||||
|
||||
.message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
@@ -538,7 +544,7 @@ tr:hover td { background: var(--surface2); }
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
@@ -864,7 +870,7 @@ mark.search-highlight {
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1994,7 +2000,7 @@ mark.search-highlight {
|
||||
}
|
||||
|
||||
.filter-pill:hover { border-color: var(--accent); color: var(--text); }
|
||||
.filter-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
|
||||
.filter-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
|
||||
|
||||
/* ── Difficulty badges ── */
|
||||
.difficulty-badge {
|
||||
@@ -2258,7 +2264,7 @@ mark.search-highlight {
|
||||
.wizard-progress-step.wiz-active .wizard-progress-circle {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
}
|
||||
|
||||
@@ -2485,7 +2491,7 @@ mark.search-highlight {
|
||||
/* ── Try-It Mini Chat ── */
|
||||
.tryit-messages { max-height: 200px; overflow-y: auto; margin: 12px 0; }
|
||||
.tryit-msg { padding: 6px 10px; border-radius: 6px; margin: 4px 0; font-size: 12px; line-height: 1.5; word-break: break-word; }
|
||||
.tryit-msg-user { background: var(--accent); color: var(--bg-primary); margin-left: 40px; }
|
||||
.tryit-msg-user { background: var(--accent); color: var(--text-on-accent); margin-left: 40px; }
|
||||
.tryit-msg-agent { background: var(--surface2); margin-right: 40px; }
|
||||
|
||||
/* ── Suggested Message Chips ── */
|
||||
@@ -2503,7 +2509,7 @@ mark.search-highlight {
|
||||
.channel-steps { display: flex; align-items: center; gap: 0; margin-bottom: 20px; }
|
||||
.channel-step-item { display: flex; align-items: center; gap: 6px; flex: 1; }
|
||||
.channel-step-num { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; border: 2px solid var(--border); color: var(--text-dim); flex-shrink: 0; transition: all 0.2s; }
|
||||
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--bg-primary); }
|
||||
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--text-on-accent); }
|
||||
.channel-step-num.done { border-color: var(--success); background: var(--success); color: #000; }
|
||||
.channel-step-label { font-size: 11px; color: var(--text-dim); }
|
||||
.channel-step-label.active { color: var(--accent); font-weight: 600; }
|
||||
@@ -2520,7 +2526,7 @@ mark.search-highlight {
|
||||
.wizard-category-pills { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.wizard-category-pill { padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--text-dim); transition: all 0.15s; font-family: var(--font-mono); }
|
||||
.wizard-category-pill:hover { border-color: var(--accent); color: var(--text); }
|
||||
.wizard-category-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
|
||||
.wizard-category-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
|
||||
|
||||
/* ── Capability Preview Panel ── */
|
||||
.capability-preview { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px; margin-top: 12px; }
|
||||
@@ -2626,7 +2632,7 @@ mark.search-highlight {
|
||||
.personality-pill.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: 0 0 12px var(--accent-subtle);
|
||||
}
|
||||
|
||||
@@ -2693,7 +2699,7 @@ mark.search-highlight {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.nav-section-chevron {
|
||||
font-size: 8px;
|
||||
font-size: 16px;
|
||||
transition: transform var(--transition-fast);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -2799,7 +2805,7 @@ mark.search-highlight {
|
||||
.hand-step-item.active .hand-step-num {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.hand-step-item.done .hand-step-num {
|
||||
border-color: var(--success);
|
||||
@@ -3253,7 +3259,7 @@ mark.search-highlight {
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.trader-dashboard {
|
||||
background: var(--bg-card);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 96vw;
|
||||
@@ -3270,7 +3276,7 @@ mark.search-highlight {
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
background: var(--surface);
|
||||
z-index: 10;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
@@ -3330,6 +3336,7 @@ mark.search-highlight {
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.trader-chart-title {
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* OpenFang Layout — Grid + Sidebar + Responsive */
|
||||
|
||||
/* Firefox compat: hide x-cloak elements until Alpine.js initializes.
|
||||
Without this, the sidebar flashes hidden in Firefox while Alpine
|
||||
processes the nested x-data scopes for nav sections. */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
@@ -120,11 +125,11 @@
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
font-size: 9px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
padding: 12px 12px 4px;
|
||||
padding: 12px 12px 6px 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -154,7 +159,7 @@
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm), 0 2px 8px rgba(255, 92, 0, 0.2);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
/* Chat-specific */
|
||||
--agent-bg: #F5F4F2;
|
||||
--user-bg: #FFF3E6;
|
||||
--text-on-accent: #FFFFFF;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 240px;
|
||||
@@ -91,16 +92,17 @@
|
||||
--bg: #080706;
|
||||
--bg-primary: #0F0E0E;
|
||||
--bg-elevated: #161413;
|
||||
--surface: #1F1D1C;
|
||||
--surface2: #2A2725;
|
||||
--surface: #242221;
|
||||
--surface2: #2F2D2C;
|
||||
--surface3: #1A1817;
|
||||
--border: #2D2A28;
|
||||
--border-light: #3D3A38;
|
||||
--border-subtle: #232120;
|
||||
--text: #F0EFEE;
|
||||
--text-secondary: #C4C0BC;
|
||||
--text-dim: #8A8380;
|
||||
--text-muted: #5C5754;
|
||||
--border: #363230;
|
||||
--border-light: #4A4644;
|
||||
--border-subtle: #2D2A28;
|
||||
--text: #FFFFFF;
|
||||
--text-secondary: #D1D1D1;
|
||||
--text-dim: #9FA0A0;
|
||||
--text-muted: #6B6663;
|
||||
--text-on-accent: #FFFFFF;
|
||||
--accent: #FF5C00;
|
||||
--accent-light: #FF7A2E;
|
||||
--accent-dim: #E05200;
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
{
|
||||
"app.name": "OpenFang",
|
||||
"app.version": "v",
|
||||
|
||||
"nav.chat": "Chat",
|
||||
"nav.monitor": "Monitor",
|
||||
"nav.overview": "Overview",
|
||||
"nav.analytics": "Analytics",
|
||||
"nav.logs": "Logs",
|
||||
"nav.agents": "Agents",
|
||||
"nav.sessions": "Sessions",
|
||||
"nav.approvals": "Approvals",
|
||||
"nav.comms": "Comms",
|
||||
"nav.automation": "Automation",
|
||||
"nav.workflows": "Workflows",
|
||||
"nav.scheduler": "Scheduler",
|
||||
"nav.extensions": "Extensions",
|
||||
"nav.channels": "Channels",
|
||||
"nav.skills": "Skills",
|
||||
"nav.hands": "Hands",
|
||||
"nav.system": "System",
|
||||
"nav.runtime": "Runtime",
|
||||
"nav.settings": "Settings",
|
||||
|
||||
"auth.sign_in": "Sign In",
|
||||
"auth.enter_credentials": "Enter your dashboard credentials.",
|
||||
"auth.username": "Username",
|
||||
"auth.password": "Password",
|
||||
"auth.api_key_required": "API Key Required",
|
||||
"auth.api_key_desc": "This instance requires an API key. Enter the key from your config.toml.",
|
||||
"auth.api_key_hint": "Add api_key = \"your-key\" at the top of ~/.openfang/config.toml (not under any [section]).",
|
||||
"auth.enter_api_key": "Enter API key...",
|
||||
"auth.unlock_dashboard": "Unlock Dashboard",
|
||||
"auth.login_failed": "Login failed",
|
||||
|
||||
"status.agents_running": "agent(s) running",
|
||||
"status.connecting": "Connecting...",
|
||||
"status.reconnecting": "Reconnecting...",
|
||||
"status.disconnected": "disconnected",
|
||||
"status.ws": "WS",
|
||||
"status.http": "HTTP",
|
||||
"status.ready": "Ready",
|
||||
"status.loading": "Loading...",
|
||||
"status.loading_workflows": "Loading workflows...",
|
||||
"status.loading_channels": "Loading channels...",
|
||||
"status.loading_skills": "Loading skills...",
|
||||
"status.loading_jobs": "Loading scheduled jobs...",
|
||||
"status.loading_triggers": "Loading triggers...",
|
||||
"status.loading_history": "Loading run history...",
|
||||
"status.loading_hands": "Loading hands...",
|
||||
"status.loading_active_hands": "Loading active hands...",
|
||||
"status.loading_mcp": "Loading MCP servers...",
|
||||
"status.loading_files": "Loading files...",
|
||||
"status.loading_skills_details": "Loading skills details...",
|
||||
"status.no_channels_match": "No channels match your search",
|
||||
|
||||
"actions.logout": "Logout",
|
||||
"actions.new_agent": "New Agent",
|
||||
"actions.browse_skills": "Browse Skills",
|
||||
"actions.add_channel": "Add Channel",
|
||||
"actions.create_workflow": "Create Workflow",
|
||||
"actions.settings": "Settings",
|
||||
"actions.create_agent": "Create Agent",
|
||||
"actions.configure_provider": "Configure Provider",
|
||||
"actions.cancel": "Cancel",
|
||||
"actions.confirm": "Confirm",
|
||||
"actions.save": "Save",
|
||||
"actions.delete": "Delete",
|
||||
"actions.edit": "Edit",
|
||||
"actions.clone": "Clone",
|
||||
"actions.stop": "Stop",
|
||||
"actions.run": "Run",
|
||||
"actions.enable": "Enable",
|
||||
"actions.disable": "Disable",
|
||||
"actions.view_all": "View All",
|
||||
"actions.retry": "Retry",
|
||||
"actions.refresh": "Refresh",
|
||||
"actions.approve": "Approve",
|
||||
"actions.reject": "Reject",
|
||||
"actions.update": "Update",
|
||||
"actions.test_connection": "Test Connection",
|
||||
"actions.remove": "Remove",
|
||||
"actions.save_test": "Save & Test",
|
||||
"actions.export_toml": "Export TOML",
|
||||
"actions.save_workflow": "Save Workflow",
|
||||
"actions.auto_layout": "Auto Layout",
|
||||
"actions.clear": "Clear",
|
||||
"actions.zoom_out": "Zoom out",
|
||||
"actions.zoom_in": "Zoom in",
|
||||
"actions.fit": "Fit",
|
||||
"actions.duplicate": "Duplicate",
|
||||
"actions.copy_clipboard": "Copy to Clipboard",
|
||||
"actions.copied": "Copied!",
|
||||
"actions.copy": "Copy",
|
||||
"actions.hide_code": "Hide Code",
|
||||
"actions.view_code": "View Code",
|
||||
"actions.install": "Install",
|
||||
"actions.installing": "Installing...",
|
||||
"actions.installed": "Installed",
|
||||
"actions.load_more": "Load More",
|
||||
"actions.back_to_browse": "Back to browse",
|
||||
"actions.activate": "Activate",
|
||||
"actions.create_schedule": "Create Schedule",
|
||||
"actions.submit": "Submit",
|
||||
"actions.close": "Close",
|
||||
"actions.next": "Next",
|
||||
"actions.back": "Back",
|
||||
"actions.spawn_agent": "Spawn Agent",
|
||||
"actions.spawning": "Spawning...",
|
||||
"actions.create_job": "Create Job",
|
||||
"actions.spawn_wizard": "Wizard",
|
||||
"actions.raw_toml": "Raw TOML",
|
||||
"actions.setup_wizard": "Setup Wizard",
|
||||
"actions.configure_manually": "Configure Manually",
|
||||
"actions.dismiss": "Dismiss",
|
||||
"actions.create_workflow_btn": "Create Workflow",
|
||||
"actions.execute": "Execute",
|
||||
"actions.executing": "Executing...",
|
||||
"actions.generated_toml": "Generated TOML",
|
||||
"actions.running": "Running...",
|
||||
|
||||
"footer.shortcuts": "Ctrl+K agents | Ctrl+N new",
|
||||
|
||||
"theme.light": "Light",
|
||||
"theme.system": "System",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
"errors.connection_error": "Connection Error",
|
||||
"errors.daemon_unreachable": "Cannot reach daemon — is openfang running?",
|
||||
"errors.not_authorized": "Not authorized — check your API key",
|
||||
"errors.permission_denied": "Permission denied",
|
||||
"errors.resource_not_found": "Resource not found",
|
||||
"errors.rate_limited": "Rate limited — slow down and try again",
|
||||
"errors.request_too_large": "Request too large",
|
||||
"errors.server_error": "Server error — check daemon logs",
|
||||
"errors.daemon_unavailable": "Daemon unavailable — is it running?",
|
||||
"errors.unexpected": "Unexpected error",
|
||||
"errors.reconnected": "Reconnected",
|
||||
"errors.connection_lost": "Connection lost, reconnecting...",
|
||||
"errors.switched_http": "Connection lost — switched to HTTP mode",
|
||||
"errors.connection_lost": "Connection lost, reconnecting...",
|
||||
"errors.switched_http": "Connection lost — switched to HTTP mode",
|
||||
"errors.reconnected": "Reconnected",
|
||||
|
||||
"toasts.approval_waiting": "An agent is waiting for approval. Open Approvals to review.",
|
||||
"toasts.agent_created": "Agent Created",
|
||||
"toasts.agent_stopped": "Agent Stopped",
|
||||
"toasts.tool_used": "Tool Used",
|
||||
"toasts.tool_completed": "Tool Completed",
|
||||
"toasts.message_in": "Message In",
|
||||
"toasts.response_sent": "Response Sent",
|
||||
"toasts.session_reset": "Session Reset",
|
||||
"toasts.compacted": "Compacted",
|
||||
"toasts.model_changed": "Model Changed",
|
||||
"toasts.login_attempt": "Login Attempt",
|
||||
"toasts.login_ok": "Login OK",
|
||||
"toasts.login_failed": "Login Failed",
|
||||
"toasts.denied": "Denied",
|
||||
"toasts.rate_limited": "Rate Limited",
|
||||
"toasts.workflow_run": "Workflow Run",
|
||||
"toasts.trigger_fired": "Trigger Fired",
|
||||
"toasts.skill_installed": "Skill Installed",
|
||||
"toasts.mcp_connected": "MCP Connected",
|
||||
"toasts.session_deleted": "Session deleted",
|
||||
|
||||
"overview.welcome": "Welcome to OpenFang",
|
||||
"overview.getting_started": "Getting Started",
|
||||
"overview.setup_wizard": "Setup Wizard",
|
||||
"overview.steps_completed": "of 5 steps completed",
|
||||
"overview.agents_running": "Agents Running",
|
||||
"overview.tokens_used": "Tokens Used",
|
||||
"overview.total_cost": "Total Cost",
|
||||
"overview.uptime": "Uptime",
|
||||
"overview.channels": "Channels",
|
||||
"overview.skills": "Skills",
|
||||
"overview.mcp_servers": "MCP Servers",
|
||||
"overview.tool_calls": "Tool Calls",
|
||||
"overview.providers": "Providers",
|
||||
"overview.recent_activity": "Recent Activity",
|
||||
"overview.no_recent_activity": "No Recent Activity",
|
||||
"overview.chat_with_agent": "Chat with an Agent",
|
||||
"overview.system_health": "System Health",
|
||||
"overview.healthy": "Healthy",
|
||||
"overview.unreachable": "Unreachable",
|
||||
"overview.security_systems": "Security Systems",
|
||||
"overview.llm_providers": "LLM Providers",
|
||||
"overview.defense_active": "9 defense-in-depth systems active",
|
||||
"overview.quick_actions": "Quick Actions",
|
||||
|
||||
"setup.configure_provider": "Configure an LLM provider",
|
||||
"setup.create_first_agent": "Create your first agent",
|
||||
"setup.send_first_message": "Send your first message",
|
||||
"setup.connect_channel": "Connect a messaging channel",
|
||||
"setup.browse_install_skill": "Browse or install a skill",
|
||||
|
||||
"tooltips.cooling_down": "cooling down (rate limited)",
|
||||
"tooltips.circuit_open": "circuit breaker open",
|
||||
"tooltips.ready": "ready",
|
||||
"tooltips.not_configured": "not configured",
|
||||
|
||||
"chat.placeholder": "Message OpenFang... (/ for commands)",
|
||||
"chat.ready": "Ready",
|
||||
"chat.generating": "Generating...",
|
||||
"chat.queued": "queued",
|
||||
"chat.sessions": "Sessions",
|
||||
"chat.new_session": "+ New",
|
||||
"chat.no_sessions": "No sessions",
|
||||
"chat.search_messages": "Search messages...",
|
||||
"chat.select_agent": "Select an agent to start chatting",
|
||||
"chat.recording": "Recording... release to send",
|
||||
"chat.drop_files": "Drop files here",
|
||||
"chat.attach_file": "Attach file",
|
||||
"chat.stop_generating": "Stop generating",
|
||||
"chat.switch_model": "Switch model",
|
||||
"chat.search_models": "Search models...",
|
||||
"chat.no_models_found": "No models found",
|
||||
"chat.available_models": "Available models — pick one or keep typing",
|
||||
"chat.switching": "Switching...",
|
||||
"chat.model_switched": "Switched to",
|
||||
"chat.model_switch_failed": "Model switch failed",
|
||||
"chat.using_http_mode": "Using HTTP mode (no streaming)",
|
||||
"chat.session_name_prompt": "Session name (optional):",
|
||||
"chat.session_created": "Session created",
|
||||
"chat.session_create_failed": "Failed to create session",
|
||||
"chat.stop_agent_title": "Stop Agent",
|
||||
"chat.stop_agent_confirm": "Stop agent",
|
||||
"chat.agent_stopped": "Agent stopped",
|
||||
"chat.stop_agent_failed": "Failed to stop agent",
|
||||
"chat.welcome_message": "**Welcome to OpenFang Chat!**\n\n- Type `/` to see available commands\n- `/help` shows all commands\n- `/think on` enables extended reasoning\n- `/context` shows context window usage\n- `/verbose off` hides tool details\n- `Ctrl+Shift+F` toggles focus mode\n- Drag & drop files to attach them\n- `Ctrl+/` opens the command palette",
|
||||
|
||||
"chat.slash.help": "Show available commands",
|
||||
"chat.slash.agents": "Switch to Agents page",
|
||||
"chat.slash.new": "New session (clear history)",
|
||||
"chat.slash.compact": "Compact session context",
|
||||
"chat.slash.model": "Show or switch model (/model [name])",
|
||||
"chat.slash.stop": "Cancel current agent run",
|
||||
"chat.slash.usage": "Show token usage",
|
||||
"chat.slash.think": "Toggle reasoning (/think [on|off|stream])",
|
||||
"chat.slash.context": "Show context window usage",
|
||||
"chat.slash.verbose": "Toggle tool details (/verbose [off|on|full])",
|
||||
"chat.slash.queue": "Check if agent is processing",
|
||||
"chat.slash.status": "Show system status",
|
||||
"chat.slash.clear": "Clear chat",
|
||||
"chat.slash.exit": "Disconnect from agent",
|
||||
"chat.slash.budget": "Show budget limits and costs",
|
||||
"chat.slash.peers": "Show OFP network status",
|
||||
"chat.slash.a2a": "List A2A agents",
|
||||
|
||||
"commands.help": "Show available commands",
|
||||
"commands.agents": "Switch to Agents page",
|
||||
"commands.new": "Reset session",
|
||||
"commands.switch": "Switch agent",
|
||||
"commands.clear": "Clear conversation",
|
||||
"commands.model": "Switch model",
|
||||
"commands.think": "Toggle reasoning mode",
|
||||
"commands.focus": "Toggle focus mode",
|
||||
"commands.theme": "Cycle theme",
|
||||
|
||||
"tips.commands": "Type / for commands",
|
||||
"tips.think": "/think on for reasoning",
|
||||
"tips.focus": "Ctrl+Shift+F for focus mode",
|
||||
|
||||
"agents.info": "Info",
|
||||
"agents.files": "Files",
|
||||
"agents.config": "Config",
|
||||
"agents.chat": "Chat",
|
||||
"agents.clone": "Clone",
|
||||
"agents.clear_history": "Clear History",
|
||||
"agents.change": "Change",
|
||||
"agents.none_fallback": "None — add a fallback chain",
|
||||
"agents.add": "+ Add",
|
||||
"agents.loading_files": "Loading files...",
|
||||
"agents.no_workspace_files": "No workspace files found",
|
||||
"agents.save_config": "Save Config",
|
||||
"agents.tool_filters": "Tool Filters",
|
||||
"agents.allowlist": "Allowlist",
|
||||
"agents.blocklist": "Blocklist",
|
||||
"agents.agent_name": "Agent Name",
|
||||
"agents.emoji": "Emoji",
|
||||
"agents.color": "Color",
|
||||
"agents.archetype": "Archetype",
|
||||
"agents.provider": "Provider",
|
||||
"agents.model": "Model",
|
||||
"agents.system_prompt": "System Prompt",
|
||||
"agents.soul_persona": "Soul / Persona",
|
||||
"agents.tool_profile": "Tool Profile",
|
||||
"agents.minimal_profile": "Minimal — Read-only file access",
|
||||
"agents.coding_profile": "Coding — Files + shell + web fetch",
|
||||
"agents.fullstack_profile": "Full-Stack — Files + shell + web fetch + search",
|
||||
"agents.research_profile": "Research — Web + search + analysis",
|
||||
"agents.admin_profile": "Admin — Full system access (dangerous)",
|
||||
"agents.agent_created": "Agent Created",
|
||||
"agents.agent_stopped": "Agent Stopped",
|
||||
"agents.agent_deleted": "Agent Deleted",
|
||||
|
||||
"presets.professional": "Professional",
|
||||
"presets.professional_desc": "Precise, business-oriented assistant focused on efficiency and clarity. Prioritizes actionable insights and structured communication.",
|
||||
"presets.professional_soul": "Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.",
|
||||
"presets.friendly": "Friendly",
|
||||
"presets.friendly_desc": "Warm and approachable assistant that builds rapport and uses conversational language. Great for brainstorming and exploration.",
|
||||
"presets.friendly_soul": "Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.",
|
||||
"presets.technical": "Technical",
|
||||
"presets.technical_desc": "Expert developer companion optimized for code, architecture, and technical problem-solving. Precise terminology, deep dives, benchmarks.",
|
||||
"presets.technical_soul": "Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.",
|
||||
"presets.creative": "Creative",
|
||||
"presets.creative_desc": "Imaginative collaborator for content creation, design thinking, and unconventional solutions. Embraces ambiguity and explores possibilities.",
|
||||
"presets.creative_soul": "Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.",
|
||||
"presets.concise": "Concise",
|
||||
"presets.concise_desc": "Minimal and direct assistant that respects your time. Cuts through noise to deliver focused, actionable responses.",
|
||||
"presets.concise_soul": "Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.",
|
||||
"presets.mentor": "Mentor",
|
||||
"presets.mentor_desc": "Patient educator that explains concepts thoroughly, provides context, and guides learning. Socratic method when appropriate.",
|
||||
"presets.mentor_soul": "Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.",
|
||||
|
||||
"agents.profile.minimal": "Minimal",
|
||||
"agents.profile.minimal_desc": "Read-only file access",
|
||||
"agents.profile.coding": "Coding",
|
||||
"agents.profile.coding_desc": "Files + shell + web fetch",
|
||||
"agents.profile.research": "Research",
|
||||
"agents.profile.research_desc": "Web search + file read/write",
|
||||
"agents.profile.messaging": "Messaging",
|
||||
"agents.profile.messaging_desc": "Agents + memory access",
|
||||
"agents.profile.automation": "Automation",
|
||||
"agents.profile.automation_desc": "All tools except custom",
|
||||
"agents.profile.balanced": "Balanced",
|
||||
"agents.profile.balanced_desc": "General-purpose tool set",
|
||||
"agents.profile.precise": "Precise",
|
||||
"agents.profile.precise_desc": "Focused tool set for accuracy",
|
||||
"agents.profile.creative": "Creative",
|
||||
"agents.profile.creative_desc": "Full tools with creative emphasis",
|
||||
"agents.profile.full": "Full",
|
||||
"agents.profile.full_desc": "All 35+ tools",
|
||||
|
||||
"wizard.general_assistant": "General Assistant",
|
||||
"wizard.general_assistant_desc": "You are a versatile AI assistant that helps users with a wide range of tasks. You are knowledgeable, helpful, and able to adapt to the user's needs.",
|
||||
"wizard.code_helper": "Code Helper",
|
||||
"wizard.code_helper_desc": "You are an expert programming assistant specialized in software development. You help write, debug, and refactor code across multiple languages.",
|
||||
"wizard.researcher": "Research Assistant",
|
||||
"wizard.researcher_desc": "You are a research assistant that helps users find, analyze, and synthesize information from various sources.",
|
||||
"wizard.writer": "Writer",
|
||||
"wizard.writer_desc": "You are a skilled writer that helps with content creation, editing, and creative writing projects.",
|
||||
"wizard.data_analyst": "Data Analyst",
|
||||
"wizard.data_analyst_desc": "You are a data analyst that helps explore, analyze, and visualize data to extract insights.",
|
||||
"wizard.devops": "DevOps Engineer",
|
||||
"wizard.devops_desc": "You are a DevOps engineer that helps with infrastructure, deployment, CI/CD, and system administration.",
|
||||
"wizard.support": "Customer Support",
|
||||
"wizard.support_desc": "You are a customer support representative that helps resolve inquiries with patience and professionalism.",
|
||||
"wizard.tutor": "Tutor",
|
||||
"wizard.tutor_desc": "You are an educational tutor that explains concepts clearly and adapts teaching to the student's level.",
|
||||
"wizard.api_designer": "API Designer",
|
||||
"wizard.api_designer_desc": "You are an API designer that helps create well-structured, intuitive APIs following best practices.",
|
||||
"wizard.meeting_notes": "Meeting Notes",
|
||||
"wizard.meeting_notes_desc": "You are a meeting notes specialist that summarizes discussions, extracts action items, and tracks decisions.",
|
||||
|
||||
"wizard.step_welcome": "Welcome",
|
||||
"wizard.step_provider": "Provider",
|
||||
"wizard.step_agent": "Agent",
|
||||
"wizard.step_try_it": "Try It",
|
||||
"wizard.step_channel": "Channel",
|
||||
"wizard.step_done": "Done",
|
||||
|
||||
"wizard.cat_general": "General",
|
||||
"wizard.cat_development": "Development",
|
||||
"wizard.cat_research": "Research",
|
||||
"wizard.cat_writing": "Writing",
|
||||
"wizard.cat_business": "Business",
|
||||
|
||||
"wizard.channel_telegram": "Telegram",
|
||||
"wizard.channel_telegram_desc": "Connect your agent to a Telegram bot for messaging.",
|
||||
"wizard.channel_telegram_token": "Bot Token",
|
||||
"wizard.channel_telegram_help": "Create a bot via @BotFather on Telegram to get your token.",
|
||||
"wizard.channel_discord": "Discord",
|
||||
"wizard.channel_discord_desc": "Connect your agent to a Discord server via bot token.",
|
||||
"wizard.channel_discord_token": "Bot Token",
|
||||
"wizard.channel_discord_help": "Create a Discord application at discord.com/developers and add a bot.",
|
||||
"wizard.channel_slack": "Slack",
|
||||
"wizard.channel_slack_desc": "Connect your agent to a Slack workspace.",
|
||||
"wizard.channel_slack_token": "Bot Token",
|
||||
"wizard.channel_slack_help": "Create a Slack app at api.slack.com/apps and install it to your workspace.",
|
||||
|
||||
"wizard.profile_minimal": "Minimal",
|
||||
"wizard.profile_minimal_desc": "Read-only file access",
|
||||
"wizard.profile_coding": "Coding",
|
||||
"wizard.profile_coding_desc": "Files + shell + web fetch",
|
||||
"wizard.profile_research": "Research",
|
||||
"wizard.profile_research_desc": "Web search + file read/write",
|
||||
"wizard.profile_balanced": "Balanced",
|
||||
"wizard.profile_balanced_desc": "General-purpose tool set",
|
||||
"wizard.profile_precise": "Precise",
|
||||
"wizard.profile_precise_desc": "Focused tool set for accuracy",
|
||||
"wizard.profile_creative": "Creative",
|
||||
"wizard.profile_creative_desc": "Full tools with creative emphasis",
|
||||
"wizard.profile_full": "Full",
|
||||
"wizard.profile_full_desc": "All 35+ tools",
|
||||
|
||||
"wizard.enter_api_key": "Please enter an API key",
|
||||
"wizard.api_key_saved": "API key saved for",
|
||||
"wizard.failed_save_key": "Failed to save key:",
|
||||
"wizard.connected": "connected",
|
||||
"wizard.connection_failed": "Connection failed",
|
||||
"wizard.test_failed": "Test failed:",
|
||||
"wizard.enter_agent_name": "Please enter a name for your agent",
|
||||
"wizard.agent_created": "Agent created",
|
||||
"wizard.failed_create_agent": "Failed to create agent:",
|
||||
"wizard.enter_token": "Please enter the",
|
||||
"wizard.channel_configured": "configured and activated.",
|
||||
"wizard.failed_configure": "Failed:",
|
||||
|
||||
"wizard.suggestions.general.1": "What can you help me with?",
|
||||
"wizard.suggestions.general.2": "Tell me a fun fact",
|
||||
"wizard.suggestions.general.3": "Summarize the latest AI news",
|
||||
"wizard.suggestions.development.1": "Write a Python hello world",
|
||||
"wizard.suggestions.development.2": "Explain async/await",
|
||||
"wizard.suggestions.development.3": "Review this code snippet",
|
||||
"wizard.suggestions.research.1": "Explain quantum computing simply",
|
||||
"wizard.suggestions.research.2": "Compare React vs Vue",
|
||||
"wizard.suggestions.research.3": "What are the latest trends in AI?",
|
||||
"wizard.suggestions.writing.1": "Help me write a professional email",
|
||||
"wizard.suggestions.writing.2": "Improve this paragraph",
|
||||
"wizard.suggestions.writing.3": "Write a blog intro about AI",
|
||||
"wizard.suggestions.business.1": "Draft a meeting agenda",
|
||||
"wizard.suggestions.business.2": "How do I handle a complaint?",
|
||||
"wizard.suggestions.business.3": "Create a project status update",
|
||||
|
||||
"approvals.title": "Execution Approvals",
|
||||
"approvals.pending": "pending",
|
||||
"approvals.all": "All",
|
||||
"approvals.pending_tab": "Pending",
|
||||
"approvals.approved": "Approved",
|
||||
"approvals.rejected": "Rejected",
|
||||
"approvals.expired": "Expired",
|
||||
"approvals.no_approvals": "No approvals",
|
||||
"approvals.approve": "Approve",
|
||||
"approvals.reject": "Reject",
|
||||
|
||||
"workflows.title": "Workflows",
|
||||
"workflows.visual_builder": "Visual Builder",
|
||||
"workflows.what_are": "What are Workflows?",
|
||||
"workflows.no_workflows": "No workflows yet",
|
||||
"workflows.sequential": "Sequential",
|
||||
"workflows.fan_out": "Fan Out",
|
||||
"workflows.conditional": "Conditional",
|
||||
"workflows.loop": "Loop",
|
||||
"workflows.add_step": "+ Add Step",
|
||||
"workflows.execute": "Execute",
|
||||
"workflows.result": "Result",
|
||||
"workflows.node_palette": "Node Palette",
|
||||
"workflows.drag_nodes": "Drag nodes onto the canvas",
|
||||
"workflows.steps_connections": "steps, connections",
|
||||
"workflows.agent": "Agent",
|
||||
"workflows.prompt_template": "Prompt Template",
|
||||
"workflows.expression": "Expression",
|
||||
"workflows.top_port_true": "Top port = true, bottom port = false",
|
||||
"workflows.max_iterations": "Max Iterations",
|
||||
"workflows.until_stop": "Until (stop condition)",
|
||||
"workflows.fan_out_count": "Fan-out Count",
|
||||
"workflows.wait_all": "Wait for all",
|
||||
"workflows.first_finish": "First to finish",
|
||||
"workflows.majority_vote": "Majority vote",
|
||||
"workflows.connection_selected": "Connection selected",
|
||||
"workflows.delete_connection": "Delete Connection",
|
||||
|
||||
"scheduler.title": "Scheduler",
|
||||
"scheduler.scheduled_jobs": "Scheduled Jobs",
|
||||
"scheduler.event_triggers": "Event Triggers",
|
||||
"scheduler.run_history": "Run History",
|
||||
"scheduler.new_job": "+ New Job",
|
||||
"scheduler.job_name": "Job Name",
|
||||
"scheduler.cron_expression": "Cron Expression",
|
||||
"scheduler.quick_presets": "Quick Presets",
|
||||
"scheduler.target_agent": "Target Agent",
|
||||
"scheduler.any_agent": "Any available agent",
|
||||
"scheduler.message_send": "Message to Send",
|
||||
"scheduler.enabled": "Enabled (will start running immediately)",
|
||||
"scheduler.disabled": "Disabled (create paused)",
|
||||
"scheduler.active": "Active",
|
||||
"scheduler.paused": "Paused",
|
||||
"scheduler.cron_job": "Cron Job",
|
||||
"scheduler.trigger": "Trigger",
|
||||
"scheduler.no_jobs": "No scheduled jobs",
|
||||
"scheduler.no_triggers": "No event triggers",
|
||||
"scheduler.no_history": "No run history yet",
|
||||
|
||||
"channels.title": "Channels",
|
||||
"channels.configured": "configured",
|
||||
"channels.search": "Search channels...",
|
||||
"channels.setup": "Set up",
|
||||
"channels.edit": "Edit",
|
||||
"channels.configure": "Configure",
|
||||
"channels.verify": "Verify",
|
||||
"channels.ready": "Ready",
|
||||
"channels.is_ready": "is ready!",
|
||||
"channels.get_credentials": "How to get credentials",
|
||||
"channels.show_advanced": "Show advanced",
|
||||
"channels.hide_advanced": "Hide advanced",
|
||||
"channels.connecting": "Connecting to WhatsApp Web gateway...",
|
||||
"channels.linked_success": "WhatsApp linked successfully!",
|
||||
"channels.business_api": "Business API",
|
||||
|
||||
"skills.title": "Skills & Ecosystem",
|
||||
"skills.installed": "Installed",
|
||||
"skills.clawhub": "ClawHub",
|
||||
"skills.mcp_servers": "MCP Servers",
|
||||
"skills.quick_start": "Quick Start",
|
||||
"skills.no_installed": "No skills installed",
|
||||
"skills.browse_clawhub": "Browse ClawHub",
|
||||
"skills.search_clawhub": "Search ClawHub skills...",
|
||||
"skills.trending": "Trending",
|
||||
"skills.most_downloaded": "Most Downloaded",
|
||||
"skills.most_starred": "Most Starred",
|
||||
"skills.recently_updated": "Recently Updated",
|
||||
"skills.categories": "CATEGORIES",
|
||||
"skills.already_installed": "Already Installed",
|
||||
"skills.no_skills_found": "No skills found",
|
||||
"skills.security_warnings": "Security Warnings",
|
||||
"skills.security_scan": "Skills are security-scanned before installation",
|
||||
"skills.create": "Create Skill",
|
||||
"skills.created": "Created",
|
||||
|
||||
"skills.cat_coding": "Coding & IDEs",
|
||||
"skills.cat_git": "Git & GitHub",
|
||||
"skills.cat_frontend": "Web & Frontend",
|
||||
"skills.cat_devops": "DevOps & Cloud",
|
||||
"skills.cat_database": "Database",
|
||||
"skills.cat_security": "Security",
|
||||
"skills.cat_ai": "AI & ML",
|
||||
"skills.cat_data": "Data & Analytics",
|
||||
"skills.cat_mobile": "Mobile",
|
||||
"skills.cat_desktop": "Desktop Apps",
|
||||
"skills.cat_api": "API & Integrations",
|
||||
"skills.cat_testing": "Testing",
|
||||
"skills.cat_docs": "Documentation",
|
||||
"skills.cat_productivity": "Productivity",
|
||||
"skills.cat_other": "Other",
|
||||
|
||||
"skills.cat_browser": "Browser & Automation",
|
||||
"skills.cat_search": "Search & Research",
|
||||
"skills.cat_communication": "Communication",
|
||||
"skills.cat_media": "Media & Streaming",
|
||||
"skills.cat_notes": "Notes & PKM",
|
||||
"skills.cat_cli": "CLI Utilities",
|
||||
"skills.cat_marketing": "Marketing & Sales",
|
||||
"skills.cat_finance": "Finance",
|
||||
"skills.cat_smarthome": "Smart Home & IoT",
|
||||
|
||||
"skills.uninstall_skill": "Uninstall Skill",
|
||||
"skills.uninstall_confirm": "Uninstall skill",
|
||||
|
||||
"skills.source_clawhub": "ClawHub",
|
||||
"skills.source_openclaw": "OpenClaw",
|
||||
"skills.source_builtin": "Built-in",
|
||||
"skills.source_local": "Local",
|
||||
|
||||
"hands.title": "Hands — Curated Autonomous Capability Packages",
|
||||
"hands.available": "Available",
|
||||
"hands.active": "Active",
|
||||
"hands.ready": "Ready",
|
||||
"hands.setup_needed": "Setup needed",
|
||||
"hands.requirements": "REQUIREMENTS",
|
||||
"hands.details": "Details",
|
||||
"hands.no_hands": "No hands available",
|
||||
|
||||
"sessions.title": "Sessions",
|
||||
"sessions.memory": "Memory",
|
||||
"sessions.delete_session": "Delete Session",
|
||||
"sessions.delete_confirm": "This will permanently remove the session and its messages.",
|
||||
"sessions.delete_key": "Delete Key",
|
||||
"sessions.delete_key_confirm": "Delete key",
|
||||
|
||||
"logs.title": "Logs",
|
||||
"logs.live": "Live",
|
||||
"logs.audit_trail": "Audit Trail",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.providers": "Providers",
|
||||
"settings.models": "Models",
|
||||
"settings.config": "Config",
|
||||
"settings.tools": "Tools",
|
||||
"settings.migration": "Migration",
|
||||
"settings.security": "Security",
|
||||
"settings.network": "Network",
|
||||
"settings.migration": "Migration",
|
||||
"settings.language": "Language",
|
||||
|
||||
"settings.sec_path_traversal": "Path Traversal Prevention",
|
||||
"settings.sec_path_traversal_desc": "Blocks attempts to access files outside the workspace directory using .. or absolute paths.",
|
||||
"settings.sec_ssrf": "SSRF Protection",
|
||||
"settings.sec_ssrf_desc": "Prevents agents from making requests to internal IP ranges (localhost, cloud metadata, private networks).",
|
||||
"settings.sec_capability": "Capability-Based Access Control",
|
||||
"settings.sec_capability_desc": "Agents can only access explicitly granted capabilities. No implicit access to tools or data.",
|
||||
"settings.sec_taint": "Taint Tracking",
|
||||
"settings.sec_taint_desc": "Tracks untrusted data (user input, file content) through agent reasoning to prevent prompt injection.",
|
||||
"settings.sec_sandbox": "WASM Sandbox",
|
||||
"settings.sec_sandbox_desc": "Executes untrusted code in isolated WebAssembly sandboxes with memory and syscall restrictions.",
|
||||
"settings.sec_audit": "Merkle Audit",
|
||||
"settings.sec_audit_desc": "Maintains a verifiable audit log of all agent actions using Merkle tree cryptography.",
|
||||
"settings.sec_workspace": "Workspace Isolation",
|
||||
"settings.sec_workspace_desc": "Each agent has an isolated workspace directory. No cross-agent file access unless explicitly granted.",
|
||||
"settings.sec_rate_limit": "Rate Limiting",
|
||||
"settings.sec_rate_limit_desc": "Enforces per-agent and global rate limits to prevent resource exhaustion and cost overruns.",
|
||||
"settings.sec_approval": "Execution Approvals",
|
||||
"settings.sec_approval_desc": "Requires human approval for high-risk actions (shell commands, file writes, external requests).",
|
||||
|
||||
"settings.sec_enabled": "Enabled",
|
||||
"settings.sec_disabled": "Disabled",
|
||||
"settings.sec_inherited": "Inherited",
|
||||
"settings.sec_global": "Global"
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* OpenFang i18n (Internationalization) Module
|
||||
*
|
||||
* Provides runtime language switching for the OpenFang dashboard UI.
|
||||
* Supports English (default) and Russian.
|
||||
*
|
||||
* Usage:
|
||||
* - HTML: <span data-i18n="nav.overview">Overview</span>
|
||||
* - JS: window.t('nav.overview')
|
||||
* - Auto-applies translations on load based on stored/preferred language
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Language store
|
||||
let currentLang = 'en';
|
||||
let translations = {};
|
||||
let isInitialized = false;
|
||||
|
||||
/**
|
||||
* Load translations from a JSON file
|
||||
* @param {string} lang - Language code (en, ru)
|
||||
* @returns {Promise<Object>} Translation object
|
||||
*/
|
||||
async function loadTranslations(lang) {
|
||||
try {
|
||||
// Use cached translations if available
|
||||
if (window.__i18nCache && window.__i18nCache[lang]) {
|
||||
return window.__i18nCache[lang];
|
||||
}
|
||||
|
||||
const response = await fetch(`/i18n/${lang}.json`);
|
||||
if (!response.ok) {
|
||||
console.warn(`[i18n] Failed to load ${lang}.json, falling back to en`);
|
||||
if (lang !== 'en') {
|
||||
return loadTranslations('en');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Cache for future use
|
||||
if (!window.__i18nCache) window.__i18nCache = {};
|
||||
window.__i18nCache[lang] = data;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`[i18n] Error loading translations for ${lang}:`, error);
|
||||
if (lang !== 'en') {
|
||||
return loadTranslations('en');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translated string by key
|
||||
* @param {string} key - Translation key (e.g., 'nav.overview')
|
||||
* @param {Object} params - Optional interpolation parameters
|
||||
* @returns {string} Translated string or key if not found
|
||||
*/
|
||||
function t(key, params) {
|
||||
if (!isInitialized) {
|
||||
console.warn('[i18n] Not initialized, returning key');
|
||||
return key;
|
||||
}
|
||||
|
||||
let text = translations[key] || key;
|
||||
|
||||
// Handle interpolation (e.g., 'Hello, {{name}}')
|
||||
if (params && typeof params === 'object') {
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(new RegExp(`{{${param}}}`, 'g'), params[param]);
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply translations to all elements with data-i18n attribute
|
||||
* Also updates the <html> lang attribute
|
||||
*/
|
||||
function applyTranslations() {
|
||||
// Update document language
|
||||
document.documentElement.lang = currentLang;
|
||||
|
||||
// Find and translate all elements with data-i18n attribute
|
||||
const elements = document.querySelectorAll('[data-i18n]');
|
||||
elements.forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translation = t(key);
|
||||
|
||||
// Check if element is a form input/textarea
|
||||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||
// For form elements, only update if it's a placeholder or aria-label
|
||||
if (el.hasAttribute('placeholder')) {
|
||||
el.placeholder = translation;
|
||||
}
|
||||
if (el.hasAttribute('aria-label')) {
|
||||
el.setAttribute('aria-label', translation);
|
||||
}
|
||||
if (el.hasAttribute('title')) {
|
||||
el.setAttribute('title', translation);
|
||||
}
|
||||
} else {
|
||||
// For regular elements, update text content
|
||||
el.textContent = translation;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-* attributes for attributes
|
||||
const attrElements = document.querySelectorAll('[data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]');
|
||||
attrElements.forEach(el => {
|
||||
if (el.hasAttribute('data-i18n-placeholder')) {
|
||||
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||||
}
|
||||
if (el.hasAttribute('data-i18n-title')) {
|
||||
el.title = t(el.getAttribute('data-i18n-title'));
|
||||
}
|
||||
if (el.hasAttribute('data-i18n-aria-label')) {
|
||||
el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label')));
|
||||
}
|
||||
});
|
||||
|
||||
// Update meta tags
|
||||
const metaDesc = document.querySelector('meta[name="description"]');
|
||||
if (metaDesc) {
|
||||
const desc = t('app.description', { name: 'OpenFang' });
|
||||
if (desc !== 'app.description') {
|
||||
metaDesc.content = desc;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[i18n] Applied translations for language: ${currentLang}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current language and apply translations
|
||||
* @param {string} lang - Language code (en, ru)
|
||||
* @param {boolean} persist - Whether to save to localStorage
|
||||
*/
|
||||
async function setLanguage(lang, persist = true) {
|
||||
if (!['en', 'ru'].includes(lang)) {
|
||||
console.warn(`[i18n] Unknown language: ${lang}, defaulting to en`);
|
||||
lang = 'en';
|
||||
}
|
||||
|
||||
currentLang = lang;
|
||||
translations = await loadTranslations(lang);
|
||||
isInitialized = true;
|
||||
|
||||
// Save preference
|
||||
if (persist) {
|
||||
localStorage.setItem('openfang_language', lang);
|
||||
}
|
||||
|
||||
// Apply to DOM
|
||||
applyTranslations();
|
||||
|
||||
// Dispatch event for Alpine.js components to react
|
||||
window.dispatchEvent(new CustomEvent('i18n:language-changed', {
|
||||
detail: { language: lang }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current language
|
||||
* @returns {string} Current language code
|
||||
*/
|
||||
function getLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n system
|
||||
* Loads language preference and applies translations
|
||||
*/
|
||||
async function init() {
|
||||
// Determine language priority:
|
||||
// 1. localStorage (user preference)
|
||||
// 2. Browser language
|
||||
// 3. Default to English
|
||||
|
||||
let lang = localStorage.getItem('openfang_language');
|
||||
|
||||
if (!lang) {
|
||||
// Try to detect browser language
|
||||
const browserLang = navigator.language || navigator.userLanguage || '';
|
||||
if (browserLang.startsWith('ru')) {
|
||||
lang = 'ru';
|
||||
} else {
|
||||
lang = 'en';
|
||||
}
|
||||
}
|
||||
|
||||
await setLanguage(lang, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available languages
|
||||
* @returns {Array<{code: string, name: string}>}
|
||||
*/
|
||||
function getAvailableLanguages() {
|
||||
return [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'ru', name: 'Русский' }
|
||||
];
|
||||
}
|
||||
|
||||
// Expose to global scope
|
||||
window.i18n = {
|
||||
t,
|
||||
setLanguage,
|
||||
getLanguage,
|
||||
getAvailableLanguages,
|
||||
init,
|
||||
isInitialized: () => isInitialized
|
||||
};
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,607 @@
|
||||
{
|
||||
"app.name": "OpenFang",
|
||||
"app.version": "v",
|
||||
|
||||
"nav.chat": "Чат",
|
||||
"nav.monitor": "Мониторинг",
|
||||
"nav.overview": "Обзор",
|
||||
"nav.analytics": "Аналитика",
|
||||
"nav.logs": "Логи",
|
||||
"nav.agents": "Агенты",
|
||||
"nav.sessions": "Сессии",
|
||||
"nav.approvals": "Одобрения",
|
||||
"nav.comms": "Коммуникации",
|
||||
"nav.automation": "Автоматизация",
|
||||
"nav.workflows": "Рабочие процессы",
|
||||
"nav.scheduler": "Планировщик",
|
||||
"nav.extensions": "Расширения",
|
||||
"nav.channels": "Каналы",
|
||||
"nav.skills": "Навыки",
|
||||
"nav.hands": "Руки",
|
||||
"nav.system": "Система",
|
||||
"nav.runtime": "Среда выполнения",
|
||||
"nav.settings": "Настройки",
|
||||
|
||||
"auth.sign_in": "Войти",
|
||||
"auth.enter_credentials": "Введите учётные данные панели управления.",
|
||||
"auth.username": "Имя пользователя",
|
||||
"auth.password": "Пароль",
|
||||
"auth.api_key_required": "Требуется API-ключ",
|
||||
"auth.api_key_desc": "Этот экземпляр требует API-ключ. Введите ключ из вашего config.toml.",
|
||||
"auth.api_key_hint": "Добавьте api_key = \"your-key\" в начало ~/.openfang/config.toml (не внутри секции).",
|
||||
"auth.enter_api_key": "Введите API-ключ...",
|
||||
"auth.unlock_dashboard": "Разблокировать панель",
|
||||
"auth.login_failed": "Ошибка входа",
|
||||
|
||||
"status.agents_running": "агент(ов) запущено",
|
||||
"status.connecting": "Подключение...",
|
||||
"status.reconnecting": "Переподключение...",
|
||||
"status.disconnected": "отключено",
|
||||
"status.ws": "ВС",
|
||||
"status.http": "HTTP",
|
||||
"status.ready": "Готово",
|
||||
"status.loading": "Загрузка...",
|
||||
"status.loading_workflows": "Загрузка рабочих процессов...",
|
||||
"status.loading_channels": "Загрузка каналов...",
|
||||
"status.loading_skills": "Загрузка навыков...",
|
||||
"status.loading_jobs": "Загрузка заданий...",
|
||||
"status.loading_triggers": "Загрузка триггеров...",
|
||||
"status.loading_history": "Загрузка истории...",
|
||||
"status.loading_hands": "Загрузка модулей...",
|
||||
"status.loading_active_hands": "Загрузка активных модулей...",
|
||||
"status.loading_mcp": "Загрузка MCP-серверов...",
|
||||
"status.loading_files": "Загрузка файлов...",
|
||||
"status.loading_skills_details": "Загрузка деталей навыков...",
|
||||
"status.no_channels_match": "Нет каналов по запросу",
|
||||
|
||||
"actions.logout": "Выйти",
|
||||
"actions.new_agent": "Новый агент",
|
||||
"actions.browse_skills": "Навыки",
|
||||
"actions.add_channel": "Добавить канал",
|
||||
"actions.create_workflow": "Создать процесс",
|
||||
"actions.settings": "Настройки",
|
||||
"actions.create_agent": "Создать агента",
|
||||
"actions.configure_provider": "Настроить провайдера",
|
||||
"actions.cancel": "Отмена",
|
||||
"actions.confirm": "Подтвердить",
|
||||
"actions.save": "Сохранить",
|
||||
"actions.delete": "Удалить",
|
||||
"actions.edit": "Редактировать",
|
||||
"actions.clone": "Клонировать",
|
||||
"actions.stop": "Остановить",
|
||||
"actions.run": "Запустить",
|
||||
"actions.enable": "Включить",
|
||||
"actions.disable": "Отключить",
|
||||
"actions.view_all": "Показать все",
|
||||
"actions.retry": "Повторить",
|
||||
"actions.refresh": "Обновить",
|
||||
"actions.approve": "Одобрить",
|
||||
"actions.reject": "Отклонить",
|
||||
"actions.update": "Обновить",
|
||||
"actions.test_connection": "Проверить подключение",
|
||||
"actions.remove": "Удалить",
|
||||
"actions.save_test": "Сохранить и проверить",
|
||||
"actions.export_toml": "Экспорт TOML",
|
||||
"actions.save_workflow": "Сохранить процесс",
|
||||
"actions.auto_layout": "Автораскладка",
|
||||
"actions.clear": "Очистить",
|
||||
"actions.zoom_out": "Уменьшить",
|
||||
"actions.zoom_in": "Увеличить",
|
||||
"actions.fit": "По размеру",
|
||||
"actions.duplicate": "Дублировать",
|
||||
"actions.copy_clipboard": "Копировать в буфер",
|
||||
"actions.copied": "Скопировано!",
|
||||
"actions.copy": "Копировать",
|
||||
"actions.hide_code": "Скрыть код",
|
||||
"actions.view_code": "Показать код",
|
||||
"actions.install": "Установить",
|
||||
"actions.installing": "Установка...",
|
||||
"actions.installed": "Установлено",
|
||||
"actions.load_more": "Загрузить ещё",
|
||||
"actions.back_to_browse": "Назад",
|
||||
"actions.activate": "Активировать",
|
||||
"actions.create_schedule": "Создать расписание",
|
||||
"actions.submit": "Отправить",
|
||||
"actions.close": "Закрыть",
|
||||
"actions.next": "Далее",
|
||||
"actions.back": "Назад",
|
||||
"actions.spawn_agent": "Создать агента",
|
||||
"actions.spawning": "Создание...",
|
||||
"actions.spawn_wizard": "Мастер",
|
||||
"actions.raw_toml": "TOML",
|
||||
"actions.setup_wizard": "Мастер настройки",
|
||||
"actions.configure_manually": "Настроить вручную",
|
||||
"actions.dismiss": "Закрыть",
|
||||
"actions.create_workflow_btn": "Создать процесс",
|
||||
"actions.execute": "Выполнить",
|
||||
"actions.executing": "Выполнение...",
|
||||
"actions.generated_toml": "Сгенерированный TOML",
|
||||
"actions.running": "Выполняется...",
|
||||
|
||||
"footer.shortcuts": "Ctrl+K агенты | Ctrl+N новый",
|
||||
|
||||
"theme.light": "Светлая",
|
||||
"theme.system": "Системная",
|
||||
"theme.dark": "Тёмная",
|
||||
|
||||
"errors.connection_error": "Ошибка подключения",
|
||||
"errors.daemon_unreachable": "Не удаётся связаться с демоном — запущен ли openfang?",
|
||||
"errors.not_authorized": "Не авторизован — проверьте API-ключ",
|
||||
"errors.permission_denied": "Доступ запрещён",
|
||||
"errors.resource_not_found": "Ресурс не найден",
|
||||
"errors.rate_limited": "Превышен лимит — подождите и попробуйте снова",
|
||||
"errors.request_too_large": "Запрос слишком большой",
|
||||
"errors.server_error": "Ошибка сервера — проверьте логи демона",
|
||||
"errors.daemon_unavailable": "Демон недоступен — запущен ли он?",
|
||||
"errors.unexpected": "Неожиданная ошибка",
|
||||
"errors.reconnected": "Переподключено",
|
||||
"errors.connection_lost": "Соединение потеряно, переподключение...",
|
||||
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
|
||||
"errors.connection_lost": "Соединение потеряно, переподключение...",
|
||||
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
|
||||
"errors.reconnected": "Переподключено",
|
||||
|
||||
"toasts.approval_waiting": "Агент ожидает одобрения. Откройте раздел Одобрения.",
|
||||
"toasts.agent_created": "Агент создан",
|
||||
"toasts.agent_stopped": "Агент остановлен",
|
||||
"toasts.tool_used": "Инструмент использован",
|
||||
"toasts.tool_completed": "Инструмент завершён",
|
||||
"toasts.message_in": "Входящее сообщение",
|
||||
"toasts.response_sent": "Ответ отправлен",
|
||||
"toasts.session_reset": "Сессия сброшена",
|
||||
"toasts.compacted": "Сжато",
|
||||
"toasts.model_changed": "Модель изменена",
|
||||
"toasts.login_attempt": "Попытка входа",
|
||||
"toasts.login_ok": "Вход успешен",
|
||||
"toasts.login_failed": "Ошибка входа",
|
||||
"toasts.denied": "Отклонено",
|
||||
"toasts.rate_limited": "Лимит запросов",
|
||||
"toasts.workflow_run": "Запуск процесса",
|
||||
"toasts.trigger_fired": "Триггер сработал",
|
||||
"toasts.skill_installed": "Навык установлен",
|
||||
"toasts.mcp_connected": "MCP подключён",
|
||||
"toasts.session_deleted": "Сессия удалена",
|
||||
|
||||
"overview.welcome": "Добро пожаловать в OpenFang",
|
||||
"overview.getting_started": "Начало работы",
|
||||
"overview.setup_wizard": "Мастер настройки",
|
||||
"overview.steps_completed": "из 5 шагов выполнено",
|
||||
"overview.agents_running": "Агентов запущено",
|
||||
"overview.tokens_used": "Использовано токенов",
|
||||
"overview.total_cost": "Общая стоимость",
|
||||
"overview.uptime": "Время работы",
|
||||
"overview.channels": "Каналы",
|
||||
"overview.skills": "Навыки",
|
||||
"overview.mcp_servers": "MCP-серверы",
|
||||
"overview.tool_calls": "Вызовов инструментов",
|
||||
"overview.providers": "Провайдеры",
|
||||
"overview.recent_activity": "Недавняя активность",
|
||||
"overview.no_recent_activity": "Нет недавней активности",
|
||||
"overview.chat_with_agent": "Написать агенту",
|
||||
"overview.system_health": "Состояние системы",
|
||||
"overview.healthy": "Исправно",
|
||||
"overview.unreachable": "Недоступно",
|
||||
"overview.security_systems": "Системы безопасности",
|
||||
"overview.llm_providers": "LLM-провайдеры",
|
||||
"overview.defense_active": "9 уровней защиты активно",
|
||||
"overview.quick_actions": "Быстрые действия",
|
||||
|
||||
"setup.configure_provider": "Настройте LLM-провайдера",
|
||||
"setup.create_first_agent": "Создайте первого агента",
|
||||
"setup.send_first_message": "Отправьте первое сообщение",
|
||||
"setup.connect_channel": "Подключите канал связи",
|
||||
"setup.browse_install_skill": "Найдите или установите навык",
|
||||
|
||||
"tooltips.cooling_down": "остывает (лимит запросов)",
|
||||
"tooltips.circuit_open": "автомат сработал",
|
||||
"tooltips.ready": "готово",
|
||||
"tooltips.not_configured": "не настроено",
|
||||
|
||||
"chat.placeholder": "Напишите OpenFang... (/ для команд)",
|
||||
"chat.ready": "Готово",
|
||||
"chat.generating": "Генерация...",
|
||||
"chat.queued": "в очереди",
|
||||
"chat.sessions": "Сессии",
|
||||
"chat.new_session": "+ Новая",
|
||||
"chat.no_sessions": "Нет сессий",
|
||||
"chat.search_messages": "Поиск сообщений...",
|
||||
"chat.select_agent": "Выберите агента для начала общения",
|
||||
"chat.recording": "Запись... отпустите для отправки",
|
||||
"chat.drop_files": "Перетащите файлы сюда",
|
||||
"chat.attach_file": "Прикрепить файл",
|
||||
"chat.stop_generating": "Остановить генерацию",
|
||||
"chat.switch_model": "Сменить модель",
|
||||
"chat.search_models": "Поиск моделей...",
|
||||
"chat.no_models_found": "Модели не найдены",
|
||||
"chat.available_models": "Доступные модели — выберите или продолжите ввод",
|
||||
"chat.switching": "Переключение...",
|
||||
"chat.model_switched": "Модель изменена на",
|
||||
"chat.model_switch_failed": "Не удалось сменить модель",
|
||||
"chat.using_http_mode": "Используется HTTP режим (без потоковой передачи)",
|
||||
"chat.session_name_prompt": "Название сессии (необязательно):",
|
||||
"chat.session_created": "Сессия создана",
|
||||
"chat.session_create_failed": "Не удалось создать сессию",
|
||||
"chat.stop_agent_title": "Остановить агента",
|
||||
"chat.stop_agent_confirm": "Остановить агента",
|
||||
"chat.agent_stopped": "Агент остановлен",
|
||||
"chat.stop_agent_failed": "Не удалось остановить агента",
|
||||
"chat.welcome_message": "**Добро пожаловать в OpenFang Чат!**\n\n- Введите `/` для просмотра команд\n- `/help` покажет все команды\n- `/think on` включает расширенные размышления\n- `/context` покажет использование контекста\n- `/verbose off` скроет детали инструментов\n- `Ctrl+Shift+F` переключает режим фокуса\n- Перетащите файлы для прикрепления\n- `Ctrl+/` открывает палитру команд",
|
||||
|
||||
"chat.slash.help": "Показать доступные команды",
|
||||
"chat.slash.agents": "Перейти на страницу агентов",
|
||||
"chat.slash.new": "Новая сессия (очистить историю)",
|
||||
"chat.slash.compact": "Сжать контекст сессии",
|
||||
"chat.slash.model": "Показать или сменить модель (/model [имя])",
|
||||
"chat.slash.stop": "Отменить текущий запуск агента",
|
||||
"chat.slash.usage": "Показать использование токенов",
|
||||
"chat.slash.think": "Переключить размышления (/think [on|off|stream])",
|
||||
"chat.slash.context": "Показать использование контекста",
|
||||
"chat.slash.verbose": "Переключить детали инструментов (/verbose [off|on|full])",
|
||||
"chat.slash.queue": "Проверить очередь обработки",
|
||||
"chat.slash.status": "Показать статус системы",
|
||||
"chat.slash.clear": "Очистить чат",
|
||||
"chat.slash.exit": "Отключиться от агента",
|
||||
"chat.slash.budget": "Показать лимиты и расходы",
|
||||
"chat.slash.peers": "Показать статус сети OFP",
|
||||
"chat.slash.a2a": "Список A2A агентов",
|
||||
|
||||
"commands.help": "Показать доступные команды",
|
||||
"commands.agents": "Перейти на страницу агентов",
|
||||
"commands.new": "Новая сессия",
|
||||
"commands.switch": "Сменить агента",
|
||||
"commands.clear": "Очистить диалог",
|
||||
"commands.model": "Сменить модель",
|
||||
"commands.think": "Переключить режим размышлений",
|
||||
"commands.focus": "Переключить режим фокуса",
|
||||
"commands.theme": "Сменить тему",
|
||||
|
||||
"tips.commands": "Введите / для команд",
|
||||
"tips.think": "/think on для размышлений",
|
||||
"tips.focus": "Ctrl+Shift+F для режима фокуса",
|
||||
|
||||
"agents.info": "Информация",
|
||||
"agents.files": "Файлы",
|
||||
"agents.config": "Настройки",
|
||||
"agents.chat": "Чат",
|
||||
"agents.clone": "Клонировать",
|
||||
"agents.clear_history": "Очистить историю",
|
||||
"agents.change": "Изменить",
|
||||
"agents.none_fallback": "Нет — добавить цепочку резервов",
|
||||
"agents.add": "+ Добавить",
|
||||
"agents.loading_files": "Загрузка файлов...",
|
||||
"agents.no_workspace_files": "Файлы рабочей области не найдены",
|
||||
"agents.save_config": "Сохранить настройки",
|
||||
"agents.tool_filters": "Фильтры инструментов",
|
||||
"agents.allowlist": "Белый список",
|
||||
"agents.blocklist": "Чёрный список",
|
||||
"agents.agent_name": "Имя агента",
|
||||
"agents.emoji": "Эмодзи",
|
||||
"agents.color": "Цвет",
|
||||
"agents.archetype": "Архетип",
|
||||
"agents.provider": "Провайдер",
|
||||
"agents.model": "Модель",
|
||||
"agents.system_prompt": "Системный промпт",
|
||||
"agents.soul_persona": "Душa / Персона",
|
||||
"agents.tool_profile": "Профиль инструментов",
|
||||
"agents.minimal_profile": "Минимальный — только чтение файлов",
|
||||
"agents.coding_profile": "Кодинг — файлы + оболочка + веб-запросы",
|
||||
"agents.fullstack_profile": "Full-Stack — файлы + оболочка + веб + поиск",
|
||||
"agents.research_profile": "Исследование — веб + поиск + анализ",
|
||||
"agents.admin_profile": "Админ — полный доступ к системе (опасно)",
|
||||
"agents.agent_created": "Агент создан",
|
||||
"agents.agent_stopped": "Агент остановлен",
|
||||
"agents.agent_deleted": "Агент удалён",
|
||||
|
||||
"presets.professional": "Деловой",
|
||||
"presets.professional_desc": "Точный, бизнес-ориентированный ассистент, сосредоточенный на эффективности и ясности. Приоритет — практические выводы и структурированная коммуникация.",
|
||||
"presets.professional_soul": "Общайтесь чётко и профессионально. Будьте прямым и структурированным. Используйте формальный язык и выводы на основе данных. ставьте точность выше личности.",
|
||||
"presets.friendly": "Дружелюбный",
|
||||
"presets.friendly_desc": "Тёплый и открытый ассистент, который выстраивает rapport и использует разговорный язык. Отлично подходит для мозгового штурма и исследования.",
|
||||
"presets.friendly_soul": "Будьте тёплым, доступным и разговорчивым. Используйте неформальный язык и проявляйте искренний интерес к пользователю. Добавляйте личность к вашим ответам, оставаясь полезным.",
|
||||
"presets.technical": "Технический",
|
||||
"presets.technical_desc": "Эксперт-помощник по разработке, оптимизированный для кода, архитектуры и технических задач. Точная терминология, глубокие погружения, бенчмарки.",
|
||||
"presets.technical_soul": "Сосредоточьтесь на технической точности и глубине. Используйте точную терминологию. Покажите вашу работу и рассуждения. Предпочитайте примеры кода и структурированные объяснения.",
|
||||
"presets.creative": "Креативный",
|
||||
"presets.creative_desc": "Творческий партнёр для создания контента, дизайн-мышления и нестандартных решений. Приветствует неоднозначность и исследует возможности.",
|
||||
"presets.creative_soul": "Будьте изобретательным и выразительным. Используйте яркий язык, аналогии и неожиданные связи. Поощряйте творческое мышление и исследуйте различные перспективы.",
|
||||
"presets.concise": "Краткий",
|
||||
"presets.concise_desc": "Минималистичный и прямой ассистент, который ценит ваше время. Убирает лишнее и даёт сфокусированные, практичные ответы.",
|
||||
"presets.concise_soul": "Будьте предельно кратким и точным. Без воды и формальностей. Отвечайте наименьшим количеством слов, оставаясь точным и полным.",
|
||||
"presets.mentor": "Наставник",
|
||||
"presets.mentor_desc": "Терпеливый педагог, который подробно объясняет концепции, даёт контекст и направляет обучение. Метод Сократа при необходимости.",
|
||||
"presets.mentor_soul": "Будьте терпеливым и ободряющим как хороший учитель. Разбивайте сложные темы по шагам. Задавайте направляющие вопросы. Празднуйте прогресс и укрепляйте уверенность.",
|
||||
|
||||
"agents.profile.minimal": "Минимальный",
|
||||
"agents.profile.minimal_desc": "Только чтение файлов",
|
||||
"agents.profile.coding": "Кодинг",
|
||||
"agents.profile.coding_desc": "Файлы + оболочка + веб-запросы",
|
||||
"agents.profile.research": "Исследование",
|
||||
"agents.profile.research_desc": "Веб-поиск + чтение/запись файлов",
|
||||
"agents.profile.messaging": "Коммуникации",
|
||||
"agents.profile.messaging_desc": "Агенты + доступ к памяти",
|
||||
"agents.profile.automation": "Автоматизация",
|
||||
"agents.profile.automation_desc": "Все инструменты кроме пользовательских",
|
||||
"agents.profile.balanced": "Сбалансированный",
|
||||
"agents.profile.balanced_desc": "Набор инструментов общего назначения",
|
||||
"agents.profile.precise": "Точный",
|
||||
"agents.profile.precise_desc": "Фокусированный набор инструментов для точности",
|
||||
"agents.profile.creative": "Креативный",
|
||||
"agents.profile.creative_desc": "Полный набор инструментов с творческим уклоном",
|
||||
"agents.profile.full": "Полный",
|
||||
"agents.profile.full_desc": "Все 35+ инструментов",
|
||||
|
||||
"wizard.general_assistant": "Универсальный ассистент",
|
||||
"wizard.general_assistant_desc": "Вы универсальный AI-ассистент, который помогает пользователям с широким кругом задач. Вы знающий, полезный и способны адаптироваться к потребностям пользователя.",
|
||||
"wizard.code_helper": "Помощник по коду",
|
||||
"wizard.code_helper_desc": "Вы опытный программный ассистент, специализирующийся на разработке ПО. Вы помогаете писать, отлаживать и рефакторить код на разных языках.",
|
||||
"wizard.researcher": "Исследовательский ассистент",
|
||||
"wizard.researcher_desc": "Вы исследовательский ассистент, который помогает находить, анализировать и синтезировать информацию из различных источников.",
|
||||
"wizard.writer": "Писатель",
|
||||
"wizard.writer_desc": "Вы квалифицированный писатель, который помогает с созданием контента, редактированием и творческими проектами.",
|
||||
"wizard.data_analyst": "Аналитик данных",
|
||||
"wizard.data_analyst_desc": "Вы аналитик данных, который помогает исследовать, анализировать и визуализировать данные для извлечения инсайтов.",
|
||||
"wizard.devops": "DevOps-инженер",
|
||||
"wizard.devops_desc": "Вы DevOps-инженер, который помогает с инфраструктурой, деплоем, CI/CD и системным администрированием.",
|
||||
"wizard.support": "Поддержка клиентов",
|
||||
"wizard.support_desc": "Вы представитель поддержки клиентов, который помогает решать вопросы с терпением и профессионализмом.",
|
||||
"wizard.tutor": "Репетитор",
|
||||
"wizard.tutor_desc": "Вы образовательный репетитор, который ясно объясняет концепции и адаптирует обучение к уровню ученика.",
|
||||
"wizard.api_designer": "API-дизайнер",
|
||||
"wizard.api_designer_desc": "Вы дизайнер API, который помогает создавать хорошо структурированные, интуитивные API по лучшим практикам.",
|
||||
"wizard.meeting_notes": "Заметки к встрече",
|
||||
"wizard.meeting_notes_desc": "Вы специалист по заметкам встреч, который суммирует обсуждения, извлекает задачи и отслеживает решения.",
|
||||
|
||||
"wizard.step_welcome": "Приветствие",
|
||||
"wizard.step_provider": "Провайдер",
|
||||
"wizard.step_agent": "Агент",
|
||||
"wizard.step_try_it": "Попробовать",
|
||||
"wizard.step_channel": "Канал",
|
||||
"wizard.step_done": "Готово",
|
||||
|
||||
"wizard.cat_general": "Общее",
|
||||
"wizard.cat_development": "Разработка",
|
||||
"wizard.cat_research": "Исследования",
|
||||
"wizard.cat_writing": "Написание",
|
||||
"wizard.cat_business": "Бизнес",
|
||||
|
||||
"wizard.channel_telegram": "Telegram",
|
||||
"wizard.channel_telegram_desc": "Подключите агента к Telegram-боту для обмена сообщениями.",
|
||||
"wizard.channel_telegram_token": "Токен бота",
|
||||
"wizard.channel_telegram_help": "Создайте бота через @BotFather в Telegram, чтобы получить токен.",
|
||||
"wizard.channel_discord": "Discord",
|
||||
"wizard.channel_discord_desc": "Подключите агента к Discord-серверу через токен бота.",
|
||||
"wizard.channel_discord_token": "Токен бота",
|
||||
"wizard.channel_discord_help": "Создайте приложение Discord на discord.com/developers и добавьте бота.",
|
||||
"wizard.channel_slack": "Slack",
|
||||
"wizard.channel_slack_desc": "Подключите агента к рабочему пространству Slack.",
|
||||
"wizard.channel_slack_token": "Токен бота",
|
||||
"wizard.channel_slack_help": "Создайте приложение Slack на api.slack.com/apps и установите его в рабочее пространство.",
|
||||
|
||||
"wizard.profile_minimal": "Минимальный",
|
||||
"wizard.profile_minimal_desc": "Только чтение файлов",
|
||||
"wizard.profile_coding": "Кодинг",
|
||||
"wizard.profile_coding_desc": "Файлы + оболочка + веб-запросы",
|
||||
"wizard.profile_research": "Исследование",
|
||||
"wizard.profile_research_desc": "Веб-поиск + чтение/запись файлов",
|
||||
"wizard.profile_balanced": "Сбалансированный",
|
||||
"wizard.profile_balanced_desc": "Набор инструментов общего назначения",
|
||||
"wizard.profile_precise": "Точный",
|
||||
"wizard.profile_precise_desc": "Фокусированный набор инструментов для точности",
|
||||
"wizard.profile_creative": "Креативный",
|
||||
"wizard.profile_creative_desc": "Полный набор инструментов с творческим уклоном",
|
||||
"wizard.profile_full": "Полный",
|
||||
"wizard.profile_full_desc": "Все 35+ инструментов",
|
||||
|
||||
"wizard.enter_api_key": "Пожалуйста, введите API-ключ",
|
||||
"wizard.api_key_saved": "API-ключ сохранён для",
|
||||
"wizard.failed_save_key": "Не удалось сохранить ключ:",
|
||||
"wizard.connected": "подключён",
|
||||
"wizard.connection_failed": "Ошибка подключения",
|
||||
"wizard.test_failed": "Тест не прошёл:",
|
||||
"wizard.enter_agent_name": "Пожалуйста, введите имя агента",
|
||||
"wizard.agent_created": "Агент создан",
|
||||
"wizard.failed_create_agent": "Не удалось создать агента:",
|
||||
"wizard.enter_token": "Пожалуйста, введите",
|
||||
"wizard.channel_configured": "настроен и активирован.",
|
||||
"wizard.failed_configure": "Ошибка:",
|
||||
|
||||
"wizard.suggestions.general.1": "Чем вы можете помочь?",
|
||||
"wizard.suggestions.general.2": "Расскажите интересный факт",
|
||||
"wizard.suggestions.general.3": "Резюмируйте последние новости AI",
|
||||
"wizard.suggestions.development.1": "Напишите Python hello world",
|
||||
"wizard.suggestions.development.2": "Объясните async/await",
|
||||
"wizard.suggestions.development.3": "Проверьте этот фрагмент кода",
|
||||
"wizard.suggestions.research.1": "Объясните квантовые вычисления просто",
|
||||
"wizard.suggestions.research.2": "Сравните React и Vue",
|
||||
"wizard.suggestions.research.3": "Какие последние тренды в AI?",
|
||||
"wizard.suggestions.writing.1": "Помогите написать профессиональное письмо",
|
||||
"wizard.suggestions.writing.2": "Улучшите этот абзац",
|
||||
"wizard.suggestions.writing.3": "Напишите введение в блог об AI",
|
||||
"wizard.suggestions.business.1": "Составьте повестку встречи",
|
||||
"wizard.suggestions.business.2": "Как обработать жалобу?",
|
||||
"wizard.suggestions.business.3": "Создайте статус-отчёт проекта",
|
||||
|
||||
"approvals.title": "Одобрения выполнения",
|
||||
"approvals.pending": "ожидает",
|
||||
"approvals.all": "Все",
|
||||
"approvals.pending_tab": "Ожидающие",
|
||||
"approvals.approved": "Одобрено",
|
||||
"approvals.rejected": "Отклонено",
|
||||
"approvals.expired": "Истекло",
|
||||
"approvals.no_approvals": "Нет одобрений",
|
||||
"approvals.approve": "Одобрить",
|
||||
"approvals.reject": "Отклонить",
|
||||
|
||||
"workflows.title": "Рабочие процессы",
|
||||
"workflows.visual_builder": "Визуальный конструктор",
|
||||
"workflows.what_are": "Что такое рабочие процессы?",
|
||||
"workflows.no_workflows": "Нет рабочих процессов",
|
||||
"workflows.sequential": "Последовательный",
|
||||
"workflows.fan_out": "Распределение",
|
||||
"workflows.conditional": "Условный",
|
||||
"workflows.loop": "Цикл",
|
||||
"workflows.add_step": "+ Добавить шаг",
|
||||
"workflows.execute": "Выполнить",
|
||||
"workflows.result": "Результат",
|
||||
"workflows.node_palette": "Палитра узлов",
|
||||
"workflows.drag_nodes": "Перетащите узлы на холст",
|
||||
"workflows.steps_connections": "шагов, связей",
|
||||
"workflows.agent": "Агент",
|
||||
"workflows.prompt_template": "Шаблон промпта",
|
||||
"workflows.expression": "Выражение",
|
||||
"workflows.top_port_true": "Верхний порт = истина, нижний = ложь",
|
||||
"workflows.max_iterations": "Макс. итераций",
|
||||
"workflows.until_stop": "До (условие остановки)",
|
||||
"workflows.fan_out_count": "Количество ветвей",
|
||||
"workflows.wait_all": "Ждать все",
|
||||
"workflows.first_finish": "Первый завершился",
|
||||
"workflows.majority_vote": "Большинство",
|
||||
"workflows.connection_selected": "Связь выбрана",
|
||||
"workflows.delete_connection": "Удалить связь",
|
||||
|
||||
"scheduler.title": "Планировщик",
|
||||
"scheduler.scheduled_jobs": "Запланированные задания",
|
||||
"scheduler.event_triggers": "Триггеры событий",
|
||||
"scheduler.run_history": "История запусков",
|
||||
"scheduler.new_job": "+ Новое задание",
|
||||
"scheduler.job_name": "Название задания",
|
||||
"scheduler.cron_expression": "Cron-выражение",
|
||||
"scheduler.quick_presets": "Быстрые шаблоны",
|
||||
"scheduler.target_agent": "Целевой агент",
|
||||
"scheduler.any_agent": "Любой доступный агент",
|
||||
"scheduler.message_send": "Сообщение для отправки",
|
||||
"scheduler.enabled": "Включено (запустится сразу)",
|
||||
"scheduler.disabled": "Отключено (создать приостановленным)",
|
||||
"scheduler.active": "Активно",
|
||||
"scheduler.paused": "Приостановлено",
|
||||
"scheduler.cron_job": "Cron-задание",
|
||||
"scheduler.trigger": "Триггер",
|
||||
"scheduler.no_jobs": "Нет запланированных заданий",
|
||||
"scheduler.no_triggers": "Нет триггеров событий",
|
||||
"scheduler.no_history": "Нет истории запусков",
|
||||
|
||||
"channels.title": "Каналы",
|
||||
"channels.configured": "настроено",
|
||||
"channels.search": "Поиск каналов...",
|
||||
"channels.setup": "Настроить",
|
||||
"channels.edit": "Изменить",
|
||||
"channels.configure": "Настройка",
|
||||
"channels.verify": "Проверить",
|
||||
"channels.ready": "Готово",
|
||||
"channels.is_ready": "готово!",
|
||||
"channels.get_credentials": "Как получить учётные данные",
|
||||
"channels.show_advanced": "Показать расширенные",
|
||||
"channels.hide_advanced": "Скрыть расширенные",
|
||||
"channels.connecting": "Подключение к шлюзу WhatsApp Web...",
|
||||
"channels.linked_success": "WhatsApp успешно связан!",
|
||||
"channels.business_api": "Business API",
|
||||
|
||||
"skills.title": "Навыки и экосистема",
|
||||
"skills.installed": "Установленные",
|
||||
"skills.clawhub": "ClawHub",
|
||||
"skills.mcp_servers": "MCP-серверы",
|
||||
"skills.quick_start": "Быстрый старт",
|
||||
"skills.no_installed": "Нет установленных навыков",
|
||||
"skills.browse_clawhub": "Обзор ClawHub",
|
||||
"skills.search_clawhub": "Поиск навыков ClawHub...",
|
||||
"skills.trending": "Популярные",
|
||||
"skills.most_downloaded": "Самые скачиваемые",
|
||||
"skills.most_starred": "Самые оценённые",
|
||||
"skills.recently_updated": "Недавно обновлённые",
|
||||
"skills.categories": "КАТЕГОРИИ",
|
||||
"skills.already_installed": "Уже установлено",
|
||||
"skills.no_skills_found": "Навыки не найдены",
|
||||
"skills.security_warnings": "Предупреждения безопасности",
|
||||
"skills.security_scan": "Навыки проверяются на безопасность перед установкой",
|
||||
"skills.create": "Создать навык",
|
||||
"skills.created": "Создан",
|
||||
|
||||
"skills.cat_coding": "Кодинг и IDE",
|
||||
"skills.cat_git": "Git и GitHub",
|
||||
"skills.cat_frontend": "Веб и фронтенд",
|
||||
"skills.cat_devops": "DevOps и облака",
|
||||
"skills.cat_database": "Базы данных",
|
||||
"skills.cat_security": "Безопасность",
|
||||
"skills.cat_ai": "AI и ML",
|
||||
"skills.cat_data": "Данные и аналитика",
|
||||
"skills.cat_mobile": "Мобильная разработка",
|
||||
"skills.cat_desktop": "Десктопные приложения",
|
||||
"skills.cat_api": "API и интеграции",
|
||||
"skills.cat_testing": "Тестирование",
|
||||
"skills.cat_docs": "Документация",
|
||||
"skills.cat_productivity": "Продуктивность",
|
||||
"skills.cat_other": "Другое",
|
||||
|
||||
"skills.cat_browser": "Браузер и автоматизация",
|
||||
"skills.cat_search": "Поиск и исследования",
|
||||
"skills.cat_communication": "Коммуникации",
|
||||
"skills.cat_media": "Медиа и стриминг",
|
||||
"skills.cat_notes": "Заметки и PKM",
|
||||
"skills.cat_cli": "CLI утилиты",
|
||||
"skills.cat_marketing": "Маркетинг и продажи",
|
||||
"skills.cat_finance": "Финансы",
|
||||
"skills.cat_smarthome": "Умный дом и IoT",
|
||||
|
||||
"skills.uninstall_skill": "Удалить навык",
|
||||
"skills.uninstall_confirm": "Удалить навык",
|
||||
|
||||
"skills.source_clawhub": "ClawHub",
|
||||
"skills.source_openclaw": "OpenClaw",
|
||||
"skills.source_builtin": "Встроенный",
|
||||
"skills.source_local": "Локальный",
|
||||
|
||||
"hands.title": "Руки — Наборы автономных возможностей",
|
||||
"hands.available": "Доступные",
|
||||
"hands.active": "Активные",
|
||||
"hands.ready": "Готово",
|
||||
"hands.setup_needed": "Требуется настройка",
|
||||
"hands.requirements": "ТРЕБОВАНИЯ",
|
||||
"hands.details": "Подробности",
|
||||
"hands.no_hands": "Нет доступных модулей",
|
||||
|
||||
"sessions.title": "Сессии",
|
||||
"sessions.memory": "Память",
|
||||
"sessions.delete_session": "Удалить сессию",
|
||||
"sessions.delete_confirm": "Это навсегда удалит сессию и все её сообщения.",
|
||||
"sessions.delete_key": "Удалить ключ",
|
||||
"sessions.delete_key_confirm": "Удалить ключ",
|
||||
|
||||
"logs.title": "Логи",
|
||||
"logs.live": "Онлайн",
|
||||
"logs.audit_trail": "Аудит",
|
||||
|
||||
"settings.title": "Настройки",
|
||||
"settings.providers": "Провайдеры",
|
||||
"settings.models": "Модели",
|
||||
"settings.config": "Конфигурация",
|
||||
"settings.tools": "Инструменты",
|
||||
"settings.migration": "Миграция",
|
||||
"settings.security": "Безопасность",
|
||||
"settings.network": "Сеть",
|
||||
"settings.migration": "Миграция",
|
||||
"settings.language": "Язык",
|
||||
|
||||
"settings.sec_path_traversal": "Защита от обхода пути",
|
||||
"settings.sec_path_traversal_desc": "Блокирует попытки доступа к файлам за пределами рабочей директории через .. или абсолютные пути.",
|
||||
"settings.sec_ssrf": "Защита от SSRF",
|
||||
"settings.sec_ssrf_desc": "Предотвращает запросы агентов к внутренним IP-диапазонам (localhost, облачный метаданные, частные сети).",
|
||||
"settings.sec_capability": "Управление доступом по возможностям",
|
||||
"settings.sec_capability_desc": "Агенты могут получать доступ только к явно предоставленным возможностям. Нет неявного доступа к инструментам или данным.",
|
||||
"settings.sec_taint": "Отслеживание заражения",
|
||||
"settings.sec_taint_desc": "Отслеживает ненадёжные данные (ввод пользователя, содержимое файлов) через рассуждения агента для предотвращения инъекции промпта.",
|
||||
"settings.sec_sandbox": "WASM-песочница",
|
||||
"settings.sec_sandbox_desc": "Выполняет ненадёжный код в изолированных WebAssembly-песочницах с ограничениями памяти и системных вызовов.",
|
||||
"settings.sec_audit": "Меркл-проверка",
|
||||
"settings.sec_audit_desc": "Ведёт верифицируемый журнал аудита всех действий агентов с использованием криптографии деревьев Меркла.",
|
||||
"settings.sec_workspace": "Изоляция рабочих областей",
|
||||
"settings.sec_workspace_desc": "Каждый агент имеет изолированную рабочую директорию. Нет межагентного доступа к файлам без явного разрешения.",
|
||||
"settings.sec_rate_limit": "Ограничение частоты",
|
||||
"settings.sec_rate_limit_desc": "Устанавливает лимиты на запросы для каждого агента и глобально для предотвращения истощения ресурсов и перерасхода.",
|
||||
"settings.sec_approval": "Одобрения выполнения",
|
||||
"settings.sec_approval_desc": "Требует одобрения человека для рискованных действий (команды оболочки, запись файлов, внешние запросы).",
|
||||
|
||||
"settings.sec_enabled": "Включено",
|
||||
"settings.sec_disabled": "Отключено",
|
||||
"settings.sec_inherited": "Унаследовано",
|
||||
"settings.sec_global": "Глобально"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ if (typeof marked !== 'undefined') {
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
|
||||
@@ -53,14 +53,23 @@ function agentsPage() {
|
||||
'\u{2764}\uFE0F', '\u{1F31F}', '\u{1F527}', '\u{1F4DD}', '\u{1F4A1}', '\u{1F3A8}'
|
||||
],
|
||||
archetypeOptions: ['Assistant', 'Researcher', 'Coder', 'Writer', 'DevOps', 'Support', 'Analyst', 'Custom'],
|
||||
personalityPresets: [
|
||||
{ id: 'professional', label: 'Professional', soul: 'Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.' },
|
||||
{ id: 'friendly', label: 'Friendly', soul: 'Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.' },
|
||||
{ id: 'technical', label: 'Technical', soul: 'Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.' },
|
||||
{ id: 'creative', label: 'Creative', soul: 'Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.' },
|
||||
{ id: 'concise', label: 'Concise', soul: 'Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.' },
|
||||
{ id: 'mentor', label: 'Mentor', soul: 'Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.' }
|
||||
],
|
||||
_personalityPresetsLoaded: false,
|
||||
personalityPresets: [], // Loaded dynamically with i18n
|
||||
|
||||
// Load personality presets with i18n
|
||||
loadPersonalityPresets: function() {
|
||||
if (this._personalityPresetsLoaded) return;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.personalityPresets = [
|
||||
{ id: 'professional', label: t('presets.professional'), soul: t('presets.professional_soul') },
|
||||
{ id: 'friendly', label: t('presets.friendly'), soul: t('presets.friendly_soul') },
|
||||
{ id: 'technical', label: t('presets.technical'), soul: t('presets.technical_soul') },
|
||||
{ id: 'creative', label: t('presets.creative'), soul: t('presets.creative_soul') },
|
||||
{ id: 'concise', label: t('presets.concise'), soul: t('presets.concise_soul') },
|
||||
{ id: 'mentor', label: t('presets.mentor'), soul: t('presets.mentor_soul') }
|
||||
];
|
||||
this._personalityPresetsLoaded = true;
|
||||
},
|
||||
|
||||
// -- Detail modal tabs --
|
||||
detailTab: 'info',
|
||||
@@ -99,21 +108,31 @@ function agentsPage() {
|
||||
// Load templates from API
|
||||
async init() {
|
||||
await this.loadTemplates();
|
||||
// Load personality presets with i18n
|
||||
this.loadPersonalityPresets();
|
||||
},
|
||||
|
||||
// ── Profile Descriptions ──
|
||||
profileDescriptions: {
|
||||
minimal: { label: 'Minimal', desc: 'Read-only file access' },
|
||||
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
|
||||
research: { label: 'Research', desc: 'Web search + file read/write' },
|
||||
messaging: { label: 'Messaging', desc: 'Agents + memory access' },
|
||||
automation: { label: 'Automation', desc: 'All tools except custom' },
|
||||
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
|
||||
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
|
||||
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
|
||||
full: { label: 'Full', desc: 'All 35+ tools' }
|
||||
// ── Profile Descriptions (loaded dynamically with i18n) ──
|
||||
_profileDescriptionsLoaded: false,
|
||||
profileDescriptions: {},
|
||||
loadProfileDescriptions: function() {
|
||||
if (this._profileDescriptionsLoaded) return;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.profileDescriptions = {
|
||||
minimal: { label: t('agents.profile.minimal'), desc: t('agents.profile.minimal_desc') },
|
||||
coding: { label: t('agents.profile.coding'), desc: t('agents.profile.coding_desc') },
|
||||
research: { label: t('agents.profile.research'), desc: t('agents.profile.research_desc') },
|
||||
messaging: { label: t('agents.profile.messaging'), desc: t('agents.profile.messaging_desc') },
|
||||
automation: { label: t('agents.profile.automation'), desc: t('agents.profile.automation_desc') },
|
||||
balanced: { label: t('agents.profile.balanced'), desc: t('agents.profile.balanced_desc') },
|
||||
precise: { label: t('agents.profile.precise'), desc: t('agents.profile.precise_desc') },
|
||||
creative: { label: t('agents.profile.creative'), desc: t('agents.profile.creative_desc') },
|
||||
full: { label: t('agents.profile.full'), desc: t('agents.profile.full_desc') }
|
||||
};
|
||||
this._profileDescriptionsLoaded = true;
|
||||
},
|
||||
profileInfo: function(name) {
|
||||
this.loadProfileDescriptions();
|
||||
return this.profileDescriptions[name] || { label: name, desc: '' };
|
||||
},
|
||||
|
||||
@@ -197,6 +216,8 @@ function agentsPage() {
|
||||
try {
|
||||
await Alpine.store('app').refreshAgents();
|
||||
await this.loadTemplates();
|
||||
this.loadPersonalityPresets();
|
||||
this.loadProfileDescriptions();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
|
||||
}
|
||||
@@ -240,61 +261,61 @@ function agentsPage() {
|
||||
name: 'General Assistant',
|
||||
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.',
|
||||
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
|
||||
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Code Helper',
|
||||
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.',
|
||||
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
|
||||
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Researcher',
|
||||
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
|
||||
category: 'Research',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'research',
|
||||
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.',
|
||||
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
|
||||
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Writer',
|
||||
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
|
||||
category: 'Writing',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.',
|
||||
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
|
||||
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Data Analyst',
|
||||
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.',
|
||||
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
|
||||
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'DevOps Engineer',
|
||||
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'automation',
|
||||
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.',
|
||||
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
|
||||
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
|
||||
},
|
||||
...results[0].templates || []
|
||||
];
|
||||
@@ -316,29 +337,38 @@ function agentsPage() {
|
||||
OpenFangAPI.wsDisconnect();
|
||||
},
|
||||
|
||||
buildConfigForm(agent) {
|
||||
var identity = (agent && agent.identity) || {};
|
||||
return {
|
||||
name: (agent && agent.name) || '',
|
||||
system_prompt: (agent && agent.system_prompt) || '',
|
||||
emoji: identity.emoji || '',
|
||||
color: identity.color || '#FF5C00',
|
||||
archetype: identity.archetype || '',
|
||||
vibe: identity.vibe || ''
|
||||
};
|
||||
},
|
||||
|
||||
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 || '',
|
||||
emoji: (agent.identity && agent.identity.emoji) || '',
|
||||
color: (agent.identity && agent.identity.color) || '#FF5C00',
|
||||
archetype: (agent.identity && agent.identity.archetype) || '',
|
||||
vibe: (agent.identity && agent.identity.vibe) || ''
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
// Fetch full agent detail to get fallback_models
|
||||
// Load the full detail payload before opening the modal so editable
|
||||
// fields such as system_prompt and identity metadata are hydrated.
|
||||
var detail = agent;
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
|
||||
this.detailAgent._fallbacks = full.fallback_models || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
detail = Object.assign({}, agent, full, {
|
||||
identity: Object.assign({}, (agent && agent.identity) || {}, (full && full.identity) || {})
|
||||
});
|
||||
} catch(e) { /* fall back to list payload */ }
|
||||
this.detailAgent = detail;
|
||||
this.detailAgent._fallbacks = detail.fallback_models || [];
|
||||
this.configForm = this.buildConfigForm(detail);
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
killAgent(agent) {
|
||||
@@ -355,6 +385,29 @@ function agentsPage() {
|
||||
});
|
||||
},
|
||||
|
||||
// Issue #1163: uninstall an agent (kill + remove ~/.openfang/agents/<name>/).
|
||||
uninstallAgent(agent) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm(
|
||||
'Uninstall Agent',
|
||||
'Uninstall agent "' + agent.name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
var res = await OpenFangAPI.del('/api/agents/' + agent.id + '/uninstall');
|
||||
var msg = 'Agent "' + agent.name + '" uninstalled';
|
||||
if (res && res.dir_removed === false) {
|
||||
msg += ' (no on-disk files found)';
|
||||
}
|
||||
OpenFangToast.success(msg);
|
||||
self.showDetailModal = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
killAllAgents() {
|
||||
var list = this.filteredAgents;
|
||||
if (!list.length) return;
|
||||
@@ -383,8 +436,8 @@ function agentsPage() {
|
||||
this.selectedPreset = '';
|
||||
this.soulContent = '';
|
||||
this.spawnForm.name = '';
|
||||
this.spawnForm.provider = 'groq';
|
||||
this.spawnForm.model = 'llama-3.3-70b-versatile';
|
||||
this.spawnForm.provider = 'default';
|
||||
this.spawnForm.model = 'default';
|
||||
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
|
||||
this.spawnForm.profile = 'full';
|
||||
// Fetch status defaults and dynamic provider list concurrently
|
||||
|
||||
@@ -42,34 +42,31 @@ function chatPage() {
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
_modelCacheTime: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
{ cmd: '/new', desc: 'Reset session (clear history)' },
|
||||
{ cmd: '/compact', desc: 'Trigger LLM session compaction' },
|
||||
{ cmd: '/model', desc: 'Show or switch model (/model [name])' },
|
||||
{ cmd: '/stop', desc: 'Cancel current agent run' },
|
||||
{ cmd: '/usage', desc: 'Show session token usage & cost' },
|
||||
{ cmd: '/think', desc: 'Toggle extended thinking (/think [on|off|stream])' },
|
||||
{ cmd: '/context', desc: 'Show context window usage & pressure' },
|
||||
{ cmd: '/verbose', desc: 'Cycle tool detail level (/verbose [off|on|full])' },
|
||||
{ cmd: '/queue', desc: 'Check if agent is processing' },
|
||||
{ cmd: '/status', desc: 'Show system status' },
|
||||
{ cmd: '/clear', desc: 'Clear chat display' },
|
||||
{ cmd: '/exit', desc: 'Disconnect from agent' },
|
||||
{ cmd: '/budget', desc: 'Show spending limits and current costs' },
|
||||
{ cmd: '/peers', desc: 'Show OFP peer network status' },
|
||||
{ cmd: '/a2a', desc: 'List discovered external A2A agents' }
|
||||
],
|
||||
slashCommands: [], // Loaded dynamically with i18n in init()
|
||||
_slashCommandsLoaded: false,
|
||||
tokenCount: 0,
|
||||
|
||||
// ── Tip Bar ──
|
||||
tipIndex: 0,
|
||||
tips: ['Type / for commands', '/think on for reasoning', 'Ctrl+Shift+F for focus mode', 'Drag files to attach', '/model to switch models', '/context to check usage', '/verbose off to hide tool details'],
|
||||
tips: [],
|
||||
_tipsInitialized: false,
|
||||
tipTimer: null,
|
||||
get currentTip() {
|
||||
if (localStorage.getItem('of-tips-off') === 'true') return '';
|
||||
return this.tips[this.tipIndex % this.tips.length];
|
||||
if (!this._tipsInitialized) {
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.tips = [
|
||||
t('tips.commands'),
|
||||
t('tips.think'),
|
||||
t('tips.focus'),
|
||||
'Drag files to attach',
|
||||
'/model to switch models',
|
||||
'/context to check usage',
|
||||
'/verbose off to hide tool details'
|
||||
];
|
||||
this._tipsInitialized = true;
|
||||
}
|
||||
return this.tips[this.tipIndex % this.tips.length] || '';
|
||||
},
|
||||
dismissTips: function() { localStorage.setItem('of-tips-off', 'true'); },
|
||||
startTipCycle: function() {
|
||||
@@ -137,12 +134,38 @@ function chatPage() {
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
// Initialize slash commands with i18n
|
||||
this.initSlashCommands();
|
||||
|
||||
// Start tip cycle
|
||||
this.startTipCycle();
|
||||
|
||||
// Fetch dynamic commands from server
|
||||
this.fetchCommands();
|
||||
|
||||
// Observe DOM for new messages and render LaTeX
|
||||
this._latexObserver = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
var bubbles = node.querySelector ? node.querySelectorAll('.message-bubble') : [];
|
||||
if (node.classList && node.classList.contains('message-bubble')) {
|
||||
bubbles = [node];
|
||||
}
|
||||
bubbles.forEach(function(bubble) {
|
||||
if (bubble.textContent && hasLatexDelimiters(bubble.textContent)) {
|
||||
renderLatex(bubble);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
this._latexObserver.observe(document.getElementById('messages') || document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Ctrl+/ keyboard shortcut
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||
@@ -175,6 +198,10 @@ function chatPage() {
|
||||
if (store.pendingAgent) {
|
||||
self.selectAgent(store.pendingAgent);
|
||||
store.pendingAgent = null;
|
||||
} else {
|
||||
// Restore previously active agent after page refresh (#1179).
|
||||
// The agent list may not be loaded yet, so resolve once it appears.
|
||||
self._restoreActiveAgent();
|
||||
}
|
||||
|
||||
// Watch for future pending agent selections (e.g., user clicks agent while on chat)
|
||||
@@ -185,6 +212,13 @@ function chatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Re-attempt restore once the agent list arrives from the server
|
||||
this.$watch('$store.app.agents', function(agents) {
|
||||
if (!self.currentAgent && agents && agents.length) {
|
||||
self._restoreActiveAgent();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for slash commands + model autocomplete
|
||||
this.$watch('inputText', function(val) {
|
||||
var modelMatch = val.match(/^\/model\s+(.*)$/i);
|
||||
@@ -264,35 +298,78 @@ function chatPage() {
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
|
||||
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
|
||||
self.currentAgent.model_name = (resp && resp.model) || model.id;
|
||||
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
|
||||
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
|
||||
OpenFangToast.success(t('chat.model_switched') + ' ' + (model.display_name || model.id));
|
||||
self.showModelSwitcher = false;
|
||||
self.modelSwitching = false;
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Switch failed: ' + e.message);
|
||||
OpenFangToast.error(t('chat.model_switch_failed') + ': ' + e.message);
|
||||
self.modelSwitching = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
// Initialize slash commands with i18n translations
|
||||
initSlashCommands: function() {
|
||||
if (this._slashCommandsLoaded) return;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.slashCommands = [
|
||||
{ cmd: '/help', desc: t('chat.slash.help') },
|
||||
{ cmd: '/agents', desc: t('chat.slash.agents') },
|
||||
{ cmd: '/new', desc: t('chat.slash.new') },
|
||||
{ cmd: '/compact', desc: t('chat.slash.compact') },
|
||||
{ cmd: '/model', desc: t('chat.slash.model') },
|
||||
{ cmd: '/stop', desc: t('chat.slash.stop') },
|
||||
{ cmd: '/usage', desc: t('chat.slash.usage') },
|
||||
{ cmd: '/think', desc: t('chat.slash.think') },
|
||||
{ cmd: '/context', desc: t('chat.slash.context') },
|
||||
{ cmd: '/verbose', desc: t('chat.slash.verbose') },
|
||||
{ cmd: '/queue', desc: t('chat.slash.queue') },
|
||||
{ cmd: '/status', desc: t('chat.slash.status') },
|
||||
{ cmd: '/clear', desc: t('chat.slash.clear') },
|
||||
{ cmd: '/exit', desc: t('chat.slash.exit') },
|
||||
{ cmd: '/budget', desc: t('chat.slash.budget') },
|
||||
{ cmd: '/peers', desc: t('chat.slash.peers') },
|
||||
{ cmd: '/a2a', desc: t('chat.slash.a2a') }
|
||||
];
|
||||
this._slashCommandsLoaded = true;
|
||||
},
|
||||
|
||||
// Fetch slash commands from the unified registry (/api/commands?surface=web).
|
||||
// Replaces the hardcoded initSlashCommands() list once loaded — ensures
|
||||
// the help panel and autocomplete stay in sync with the backend registry.
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
OpenFangAPI.get('/api/commands').then(function(data) {
|
||||
if (data.commands && data.commands.length) {
|
||||
// Build a set of known cmds to avoid duplicates
|
||||
var existing = {};
|
||||
self.slashCommands.forEach(function(c) { existing[c.cmd] = true; });
|
||||
data.commands.forEach(function(c) {
|
||||
if (!existing[c.cmd]) {
|
||||
self.slashCommands.push({ cmd: c.cmd, desc: c.desc || '', source: c.source || 'server' });
|
||||
existing[c.cmd] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(function() { /* silent — use hardcoded list */ });
|
||||
OpenFangAPI.get('/api/commands?surface=web').then(function(data) {
|
||||
var cmds = (data && data.commands) || [];
|
||||
if (!cmds.length) return;
|
||||
self.slashCommands = cmds.map(function(c) {
|
||||
// Prefer unified-registry shape { name, aliases, description, category, requires_agent }.
|
||||
// Fall back to legacy { cmd, desc } shape so older shims keep working.
|
||||
if (c.name) {
|
||||
return {
|
||||
cmd: '/' + c.name,
|
||||
desc: c.description || '',
|
||||
category: c.category || 'general',
|
||||
aliases: c.aliases || [],
|
||||
requires_agent: !!c.requires_agent,
|
||||
source: 'registry'
|
||||
};
|
||||
}
|
||||
return {
|
||||
cmd: c.cmd,
|
||||
desc: c.desc || '',
|
||||
category: c.category || 'general',
|
||||
aliases: c.aliases || [],
|
||||
requires_agent: !!c.requires_agent,
|
||||
source: c.source || 'server'
|
||||
};
|
||||
});
|
||||
self._slashCommandsLoaded = true;
|
||||
}).catch(function() { /* silent — keep hardcoded fallback list */ });
|
||||
},
|
||||
|
||||
get filteredSlashCommands() {
|
||||
@@ -303,6 +380,44 @@ function chatPage() {
|
||||
});
|
||||
},
|
||||
|
||||
// Render `/help` output grouped by category, mirroring the
|
||||
// backend's render_help(Surfaces::WEB). Falls back to a flat list if
|
||||
// categories are not populated (pre-fetch hardcoded list).
|
||||
renderHelpText: function() {
|
||||
var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring'];
|
||||
var labels = {
|
||||
general: 'General', session: 'Session', model: 'Model', control: 'Control',
|
||||
memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring'
|
||||
};
|
||||
var anyCategorised = this.slashCommands.some(function(c) { return c.category; });
|
||||
if (!anyCategorised) {
|
||||
return this.slashCommands.map(function(c) {
|
||||
return '`' + c.cmd + '` \u2014 ' + c.desc;
|
||||
}).join('\n');
|
||||
}
|
||||
var groups = {};
|
||||
this.slashCommands.forEach(function(c) {
|
||||
var cat = c.category || 'general';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(c);
|
||||
});
|
||||
var lines = ['**Available commands:**'];
|
||||
order.forEach(function(cat) {
|
||||
var list = groups[cat];
|
||||
if (!list || !list.length) return;
|
||||
lines.push('');
|
||||
lines.push('**' + (labels[cat] || cat) + '**');
|
||||
list.forEach(function(c) {
|
||||
var aliasText = '';
|
||||
if (c.aliases && c.aliases.length) {
|
||||
aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')';
|
||||
}
|
||||
lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc);
|
||||
});
|
||||
});
|
||||
return lines.join('\n');
|
||||
},
|
||||
|
||||
// Clear any stuck typing indicator after 120s
|
||||
_resetTypingTimeout: function() {
|
||||
var self = this;
|
||||
@@ -328,7 +443,7 @@ function chatPage() {
|
||||
cmdArgs = cmdArgs || '';
|
||||
switch (cmd) {
|
||||
case '/help':
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: self.slashCommands.map(function(c) { return '`' + c.cmd + '` — ' + c.desc; }).join('\n'), meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
break;
|
||||
case '/agents':
|
||||
@@ -392,7 +507,7 @@ function chatPage() {
|
||||
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
|
||||
OpenFangAPI.wsSend({ type: 'command', command: 'context', args: '' });
|
||||
} else {
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
@@ -400,7 +515,7 @@ function chatPage() {
|
||||
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
|
||||
OpenFangAPI.wsSend({ type: 'command', command: 'verbose', args: cmdArgs });
|
||||
} else {
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
@@ -408,7 +523,7 @@ function chatPage() {
|
||||
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
|
||||
OpenFangAPI.wsSend({ type: 'command', command: 'queue', args: '' });
|
||||
} else {
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected.', meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + ').', meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
@@ -447,6 +562,7 @@ function chatPage() {
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
window.dispatchEvent(new Event('close-chat'));
|
||||
break;
|
||||
case '/budget':
|
||||
@@ -482,25 +598,35 @@ function chatPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// Restore the previously-active agent (set in selectAgent) after a page
|
||||
// refresh, so the WebSocket re-attaches to the same session and any
|
||||
// in-flight tool output streams back into the chat (#1179).
|
||||
_restoreActiveAgent: function() {
|
||||
var storedId = null;
|
||||
try { storedId = localStorage.getItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
if (!storedId) return;
|
||||
var agents = (Alpine.store('app') && Alpine.store('app').agents) || [];
|
||||
var match = null;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i] && agents[i].id === storedId) { match = agents[i]; break; }
|
||||
}
|
||||
if (match) {
|
||||
this.selectAgent(match);
|
||||
}
|
||||
},
|
||||
|
||||
selectAgent(agent) {
|
||||
this.currentAgent = agent;
|
||||
this.messages = [];
|
||||
try { localStorage.setItem('of-active-agent', agent.id); } catch(e) { /* ignore */ }
|
||||
this.connectWs(agent.id);
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
// Show welcome tips on first use
|
||||
if (!localStorage.getItem('of-chat-tips-seen')) {
|
||||
var localMsgId = 0;
|
||||
this.messages.push({
|
||||
id: ++localMsgId,
|
||||
id: ++msgId,
|
||||
role: 'system',
|
||||
text: '**Welcome to OpenFang Chat!**\n\n' +
|
||||
'- Type `/` to see available commands\n' +
|
||||
'- `/help` shows all commands\n' +
|
||||
'- `/think on` enables extended reasoning\n' +
|
||||
'- `/context` shows context window usage\n' +
|
||||
'- `/verbose off` hides tool details\n' +
|
||||
'- `Ctrl+Shift+F` toggles focus mode\n' +
|
||||
'- Drag & drop files to attach them\n' +
|
||||
'- `Ctrl+/` opens the command palette',
|
||||
text: t('chat.welcome_message'),
|
||||
meta: '',
|
||||
tools: []
|
||||
});
|
||||
@@ -519,7 +645,14 @@ function chatPage() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/agents/' + agentId + '/session');
|
||||
if (data.messages && data.messages.length) {
|
||||
self.messages = data.messages.map(function(m) {
|
||||
// Defense-in-depth (#935): never render system-role messages in the
|
||||
// conversation history view, even if the backend somehow returns
|
||||
// one. The server already filters these out by default, but we
|
||||
// guard here too so a regression cannot leak the system prompt.
|
||||
var visible = data.messages.filter(function(m) {
|
||||
return m && m.role !== 'System' && m.role !== 'system';
|
||||
});
|
||||
self.messages = visible.map(function(m) {
|
||||
var role = m.role === 'User' ? 'user' : (m.role === 'System' ? 'system' : 'agent');
|
||||
var text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
||||
// Sanitize any raw function-call text from history
|
||||
@@ -557,7 +690,8 @@ function chatPage() {
|
||||
// Multi-session: create a new session
|
||||
async createSession() {
|
||||
if (!this.currentAgent) return;
|
||||
var label = prompt('Session name (optional):');
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
var label = prompt(t('chat.session_name_prompt'));
|
||||
if (label === null) return; // cancelled
|
||||
try {
|
||||
await OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/sessions', {
|
||||
@@ -567,9 +701,9 @@ function chatPage() {
|
||||
await this.loadSession(this.currentAgent.id);
|
||||
this.messages = [];
|
||||
this.scrollToBottom();
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success('New session created');
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success(t('chat.session_created'));
|
||||
} catch(e) {
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error('Failed to create session');
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error(t('chat.session_create_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -614,6 +748,15 @@ function chatPage() {
|
||||
switch (data.type) {
|
||||
case 'connected': break;
|
||||
|
||||
// Incoming message from server (e.g., cron trigger) — display as user message
|
||||
case 'message':
|
||||
if (data.content) {
|
||||
var meta = data.source === 'cron' ? '[Scheduled: ' + (data.job_name || data.job_id || '') + ']' : '';
|
||||
this.messages.push({ id: ++msgId, role: 'user', text: data.content, meta: meta, tools: [], images: [], ts: Date.now() });
|
||||
this.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
|
||||
// Legacy thinking event (backward compat)
|
||||
case 'thinking':
|
||||
if (!this.messages.length || !this.messages[this.messages.length - 1].thinking) {
|
||||
@@ -1006,8 +1149,9 @@ function chatPage() {
|
||||
}
|
||||
|
||||
// HTTP fallback
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
if (!OpenFangAPI.isWsConnected()) {
|
||||
OpenFangToast.info('Using HTTP mode (no streaming)');
|
||||
OpenFangToast.info(t('chat.using_http_mode'));
|
||||
}
|
||||
this.messages.push({ id: ++msgId, role: 'agent', text: '', meta: '', thinking: true, tools: [], ts: Date.now() });
|
||||
this.scrollToBottom();
|
||||
@@ -1050,22 +1194,55 @@ function chatPage() {
|
||||
killAgent() {
|
||||
if (!this.currentAgent) return;
|
||||
var self = this;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
var name = this.currentAgent.name;
|
||||
OpenFangToast.confirm('Stop Agent', 'Stop agent "' + name + '"? The agent will be shut down.', async function() {
|
||||
OpenFangToast.confirm(t('chat.stop_agent_title'), t('chat.stop_agent_confirm') + ' "' + name + '"?', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/agents/' + self.currentAgent.id);
|
||||
OpenFangAPI.wsDisconnect();
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
OpenFangToast.success('Agent "' + name + '" stopped');
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
OpenFangToast.success(t('chat.agent_stopped') + ' "' + name + '"');
|
||||
Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to stop agent: ' + e.message);
|
||||
OpenFangToast.error(t('chat.stop_agent_failed') + ': ' + e.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Permanently uninstall the agent: kill + remove ~/.openfang/agents/<name>/
|
||||
// Issue #1163.
|
||||
uninstallAgent: function() {
|
||||
if (!this.currentAgent) return;
|
||||
var self = this;
|
||||
var name = this.currentAgent.name;
|
||||
var agentId = this.currentAgent.id;
|
||||
OpenFangToast.confirm(
|
||||
'Uninstall Agent',
|
||||
'Uninstall agent "' + name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
var res = await OpenFangAPI.del('/api/agents/' + agentId + '/uninstall');
|
||||
OpenFangAPI.wsDisconnect();
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
var msg = 'Agent "' + name + '" uninstalled';
|
||||
if (res && res.dir_removed === false) {
|
||||
msg += ' (no on-disk files found)';
|
||||
}
|
||||
OpenFangToast.success(msg);
|
||||
Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
_latexTimer: null,
|
||||
scrollToBottom() {
|
||||
var self = this;
|
||||
|
||||
@@ -39,7 +39,7 @@ function commsPage() {
|
||||
startSSE() {
|
||||
if (this.sseSource) this.sseSource.close();
|
||||
var self = this;
|
||||
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
|
||||
var url = '/api/comms/events/stream';
|
||||
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
|
||||
this.sseSource = new EventSource(url);
|
||||
this.sseSource.onmessage = function(ev) {
|
||||
|
||||
@@ -122,6 +122,8 @@ function handsPage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Initialize optional instance name (for multi-instance hands).
|
||||
data.instanceName = '';
|
||||
this.setupWizard = data;
|
||||
// Skip deps step if no requirements
|
||||
var hasReqs = data.requirements && data.requirements.length > 0;
|
||||
@@ -408,8 +410,14 @@ function handsPage() {
|
||||
}
|
||||
this.activatingId = handId;
|
||||
try {
|
||||
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', { config: config });
|
||||
this.showToast('Hand "' + handId + '" activated as ' + (data.agent_name || data.instance_id));
|
||||
var payload = { config: config };
|
||||
var name = (this.setupWizard.instanceName || '').trim();
|
||||
if (name) {
|
||||
payload.instance_name = name;
|
||||
}
|
||||
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', payload);
|
||||
var label = data.instance_name || data.agent_name || data.instance_id;
|
||||
this.showToast('Hand "' + handId + '" activated as ' + label);
|
||||
this.closeSetupWizard();
|
||||
await this.loadActive();
|
||||
this.tab = 'active';
|
||||
|
||||
@@ -26,13 +26,33 @@ function schedulerPage() {
|
||||
cron: '',
|
||||
agent_id: '',
|
||||
message: '',
|
||||
enabled: true
|
||||
enabled: true,
|
||||
delivery_targets: []
|
||||
},
|
||||
creating: false,
|
||||
|
||||
// -- Run Now state --
|
||||
runningJobId: '',
|
||||
|
||||
// -- Delivery targets picker (create modal) --
|
||||
showTargetPicker: false,
|
||||
pickerType: 'channel',
|
||||
draftTarget: null,
|
||||
|
||||
// -- Expanded job / delivery log state --
|
||||
expandedJobId: '',
|
||||
deliveryLog: { targets: [], entries: [] },
|
||||
deliveryLogLoading: false,
|
||||
deliveryLogError: '',
|
||||
|
||||
// -- Edit targets state (per-existing-job) --
|
||||
editingTargetsJobId: '',
|
||||
editingTargets: [],
|
||||
savingTargets: false,
|
||||
|
||||
// -- Available channel types (populated from /api/channels) --
|
||||
channelTypes: [],
|
||||
|
||||
// Cron presets
|
||||
cronPresets: [
|
||||
{ label: 'Every minute', cron: '* * * * *' },
|
||||
@@ -55,12 +75,32 @@ function schedulerPage() {
|
||||
this.loadError = '';
|
||||
try {
|
||||
await this.loadJobs();
|
||||
// Channels are optional — failure is non-fatal for the scheduler page.
|
||||
this.loadChannelTypes();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load scheduler data.';
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadChannelTypes() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/channels');
|
||||
// /api/channels returns an array of channel descriptors; pull names.
|
||||
var list = Array.isArray(data) ? data : (data && data.channels) || [];
|
||||
var names = [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var ch = list[i];
|
||||
var name = ch && (ch.name || ch.display_name || ch.channel_type);
|
||||
if (name && names.indexOf(name) === -1) names.push(name);
|
||||
}
|
||||
this.channelTypes = names;
|
||||
} catch(e) {
|
||||
// Fall through silently — the form uses a plain input as fallback.
|
||||
this.channelTypes = [];
|
||||
}
|
||||
},
|
||||
|
||||
async loadJobs() {
|
||||
var data = await OpenFangAPI.get('/api/cron/jobs');
|
||||
var raw = data.jobs || [];
|
||||
@@ -82,6 +122,7 @@ function schedulerPage() {
|
||||
last_run: j.last_run,
|
||||
next_run: j.next_run,
|
||||
delivery: j.delivery ? j.delivery.kind || '' : '',
|
||||
delivery_targets: Array.isArray(j.delivery_targets) ? j.delivery_targets : [],
|
||||
created_at: j.created_at
|
||||
};
|
||||
});
|
||||
@@ -162,9 +203,12 @@ function schedulerPage() {
|
||||
delivery: { kind: 'last_channel' },
|
||||
enabled: this.newJob.enabled
|
||||
};
|
||||
if (this.newJob.delivery_targets && this.newJob.delivery_targets.length) {
|
||||
body.delivery_targets = this.newJob.delivery_targets.map(this.sanitizeTarget);
|
||||
}
|
||||
await OpenFangAPI.post('/api/cron/jobs', body);
|
||||
this.showCreateForm = false;
|
||||
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
|
||||
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true, delivery_targets: [] };
|
||||
OpenFangToast.success('Schedule "' + jobName + '" created');
|
||||
await this.loadJobs();
|
||||
} catch(e) {
|
||||
@@ -216,6 +260,210 @@ function schedulerPage() {
|
||||
this.runningJobId = '';
|
||||
},
|
||||
|
||||
// ── Delivery target editing (create modal) ──
|
||||
|
||||
openTargetPicker() {
|
||||
this.pickerType = 'channel';
|
||||
this.draftTarget = this.blankTarget('channel');
|
||||
this.showTargetPicker = true;
|
||||
},
|
||||
|
||||
cancelTargetPicker() {
|
||||
this.showTargetPicker = false;
|
||||
this.draftTarget = null;
|
||||
},
|
||||
|
||||
onPickerTypeChange() {
|
||||
this.draftTarget = this.blankTarget(this.pickerType);
|
||||
},
|
||||
|
||||
blankTarget(type) {
|
||||
if (type === 'channel') {
|
||||
return { type: 'channel', channel_type: '', recipient: '' };
|
||||
}
|
||||
if (type === 'webhook') {
|
||||
return { type: 'webhook', url: '', auth_header: '' };
|
||||
}
|
||||
if (type === 'local_file') {
|
||||
return { type: 'local_file', path: '', append: false };
|
||||
}
|
||||
if (type === 'email') {
|
||||
return { type: 'email', to: '', subject_template: '' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
addDraftTarget() {
|
||||
var err = this.validateTarget(this.draftTarget);
|
||||
if (err) {
|
||||
OpenFangToast.warn(err);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(this.newJob.delivery_targets)) this.newJob.delivery_targets = [];
|
||||
this.newJob.delivery_targets.push(this.sanitizeTarget(this.draftTarget));
|
||||
this.showTargetPicker = false;
|
||||
this.draftTarget = null;
|
||||
},
|
||||
|
||||
removeTarget(idx) {
|
||||
if (!Array.isArray(this.newJob.delivery_targets)) return;
|
||||
this.newJob.delivery_targets.splice(idx, 1);
|
||||
},
|
||||
|
||||
validateTarget(t) {
|
||||
if (!t || !t.type) return 'Pick a target type';
|
||||
if (t.type === 'channel') {
|
||||
if (!t.channel_type || !t.channel_type.trim()) return 'Channel type is required';
|
||||
if (!t.recipient || !t.recipient.trim()) return 'Recipient is required';
|
||||
} else if (t.type === 'webhook') {
|
||||
if (!t.url || !t.url.trim()) return 'Webhook URL is required';
|
||||
if (t.url.indexOf('http://') !== 0 && t.url.indexOf('https://') !== 0) {
|
||||
return 'Webhook URL must start with http:// or https://';
|
||||
}
|
||||
} else if (t.type === 'local_file') {
|
||||
if (!t.path || !t.path.trim()) return 'File path is required';
|
||||
} else if (t.type === 'email') {
|
||||
if (!t.to || !t.to.trim()) return 'Recipient email is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Strip empty-string optional fields so serde accepts the payload cleanly.
|
||||
sanitizeTarget(t) {
|
||||
if (!t) return null;
|
||||
var out = { type: t.type };
|
||||
if (t.type === 'channel') {
|
||||
out.channel_type = (t.channel_type || '').trim();
|
||||
out.recipient = (t.recipient || '').trim();
|
||||
} else if (t.type === 'webhook') {
|
||||
out.url = (t.url || '').trim();
|
||||
if (t.auth_header && t.auth_header.trim()) out.auth_header = t.auth_header.trim();
|
||||
} else if (t.type === 'local_file') {
|
||||
out.path = (t.path || '').trim();
|
||||
out.append = !!t.append;
|
||||
} else if (t.type === 'email') {
|
||||
out.to = (t.to || '').trim();
|
||||
if (t.subject_template && t.subject_template.trim()) {
|
||||
out.subject_template = t.subject_template.trim();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
// ── Chip rendering helpers ──
|
||||
|
||||
targetChipLabel(t) {
|
||||
if (!t || !t.type) return '?';
|
||||
if (t.type === 'channel') return 'CHANNEL: ' + (t.channel_type || '?');
|
||||
if (t.type === 'webhook') return 'WEBHOOK';
|
||||
if (t.type === 'local_file') return 'FILE: ' + this.truncate(t.path || '', 28);
|
||||
if (t.type === 'email') return 'EMAIL: ' + this.truncate(t.to || '', 24);
|
||||
return t.type.toUpperCase();
|
||||
},
|
||||
|
||||
targetChipClass(t) {
|
||||
if (!t || !t.type) return 'badge-dim';
|
||||
if (t.type === 'channel') return 'badge-info';
|
||||
if (t.type === 'webhook') return 'badge-created';
|
||||
if (t.type === 'local_file') return 'badge-muted';
|
||||
if (t.type === 'email') return 'badge-warn';
|
||||
return 'badge-dim';
|
||||
},
|
||||
|
||||
targetSummary(t) {
|
||||
if (!t) return '';
|
||||
if (t.type === 'channel') return (t.channel_type || '?') + ' -> ' + (t.recipient || '?');
|
||||
if (t.type === 'webhook') return t.url || '(no url)';
|
||||
if (t.type === 'local_file') return (t.append ? 'append ' : 'overwrite ') + (t.path || '');
|
||||
if (t.type === 'email') {
|
||||
var base = t.to || '';
|
||||
if (t.subject_template) base += ' · subject: ' + t.subject_template;
|
||||
return base;
|
||||
}
|
||||
return JSON.stringify(t);
|
||||
},
|
||||
|
||||
// ── Expand row / delivery log ──
|
||||
|
||||
async toggleExpand(job) {
|
||||
if (this.expandedJobId === job.id) {
|
||||
this.expandedJobId = '';
|
||||
return;
|
||||
}
|
||||
this.expandedJobId = job.id;
|
||||
this.deliveryLog = { targets: [], entries: [] };
|
||||
this.deliveryLogError = '';
|
||||
this.deliveryLogLoading = true;
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/schedules/' + job.id + '/delivery-log');
|
||||
this.deliveryLog = {
|
||||
targets: Array.isArray(data.targets) ? data.targets : [],
|
||||
entries: Array.isArray(data.entries) ? data.entries : []
|
||||
};
|
||||
} catch(e) {
|
||||
this.deliveryLogError = e.message || 'Could not load delivery log.';
|
||||
}
|
||||
this.deliveryLogLoading = false;
|
||||
},
|
||||
|
||||
// ── Edit targets on existing job ──
|
||||
|
||||
startEditTargets(job) {
|
||||
this.editingTargetsJobId = job.id;
|
||||
// Clone so cancel doesn't mutate the loaded list.
|
||||
this.editingTargets = (job.delivery_targets || []).map(function(t) {
|
||||
return JSON.parse(JSON.stringify(t));
|
||||
});
|
||||
this.pickerType = 'channel';
|
||||
this.draftTarget = null;
|
||||
this.showTargetPicker = false;
|
||||
},
|
||||
|
||||
cancelEditTargets() {
|
||||
this.editingTargetsJobId = '';
|
||||
this.editingTargets = [];
|
||||
this.draftTarget = null;
|
||||
this.showTargetPicker = false;
|
||||
},
|
||||
|
||||
addEditTarget() {
|
||||
this.pickerType = 'channel';
|
||||
this.draftTarget = this.blankTarget('channel');
|
||||
this.showTargetPicker = true;
|
||||
},
|
||||
|
||||
addDraftTargetToEdit() {
|
||||
var err = this.validateTarget(this.draftTarget);
|
||||
if (err) {
|
||||
OpenFangToast.warn(err);
|
||||
return;
|
||||
}
|
||||
this.editingTargets.push(this.sanitizeTarget(this.draftTarget));
|
||||
this.showTargetPicker = false;
|
||||
this.draftTarget = null;
|
||||
},
|
||||
|
||||
removeEditTarget(idx) {
|
||||
this.editingTargets.splice(idx, 1);
|
||||
},
|
||||
|
||||
async saveEditTargets() {
|
||||
if (!this.editingTargetsJobId) return;
|
||||
this.savingTargets = true;
|
||||
try {
|
||||
var clean = this.editingTargets.map(this.sanitizeTarget);
|
||||
await OpenFangAPI.put('/api/schedules/' + this.editingTargetsJobId, {
|
||||
delivery_targets: clean
|
||||
});
|
||||
OpenFangToast.success('Delivery targets updated');
|
||||
this.cancelEditTargets();
|
||||
await this.loadJobs();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update targets: ' + (e.message || e));
|
||||
}
|
||||
this.savingTargets = false;
|
||||
},
|
||||
|
||||
// ── Trigger helpers ──
|
||||
|
||||
triggerType(pattern) {
|
||||
@@ -376,6 +624,12 @@ function schedulerPage() {
|
||||
} catch(e) { return 'never'; }
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
if (!s) return '';
|
||||
if (s.length <= n) return s;
|
||||
return s.substring(0, n - 1) + '…';
|
||||
},
|
||||
|
||||
jobCount() {
|
||||
var enabled = 0;
|
||||
for (var i = 0; i < this.jobs.length; i++) {
|
||||
|
||||
@@ -64,15 +64,20 @@ function sessionsPage() {
|
||||
|
||||
deleteSession(sessionId) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Delete Session', 'This will permanently remove the session and its messages.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/sessions/' + sessionId);
|
||||
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
|
||||
OpenFangToast.success('Session deleted');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete session: ' + e.message);
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
OpenFangToast.confirm(
|
||||
t('sessions.delete_session') || 'Delete Session',
|
||||
t('sessions.delete_confirm') || 'This will permanently remove the session and its messages.',
|
||||
async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/sessions/' + sessionId);
|
||||
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
|
||||
OpenFangToast.success('Session deleted');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete session: ' + e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
|
||||
// -- Memory methods --
|
||||
@@ -108,15 +113,20 @@ function sessionsPage() {
|
||||
|
||||
deleteKey(key) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Delete Key', 'Delete key "' + key + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
|
||||
OpenFangToast.success('Key "' + key + '" deleted');
|
||||
await self.loadKv();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete key: ' + e.message);
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
OpenFangToast.confirm(
|
||||
t('sessions.delete_key') || 'Delete Key',
|
||||
(t('sessions.delete_key_confirm') || 'Delete key') + ' "' + key + '"? This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
|
||||
OpenFangToast.success('Key "' + key + '" deleted');
|
||||
await self.loadKv();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete key: ' + e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
|
||||
startEdit(kv) {
|
||||
|
||||
@@ -25,6 +25,9 @@ function settingsPage() {
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
providerSearch: '',
|
||||
providerStatusFilter: '',
|
||||
providerCategoryFilter: '',
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
@@ -338,6 +341,94 @@ function settingsPage() {
|
||||
return Object.keys(seen).sort();
|
||||
},
|
||||
|
||||
/// Coarse category for a provider used to group the Providers tab.
|
||||
/// Returns: 'frontier' | 'oss' | 'local' | 'aggregator' | 'regional' | 'other'.
|
||||
providerCategory(p) {
|
||||
if (!p) return 'other';
|
||||
if (p.is_local || p.key_required === false) return 'local';
|
||||
var id = (p.id || '').toLowerCase();
|
||||
var FRONTIER = ['anthropic','openai','gemini','google','xai','bedrock','azure','vertex'];
|
||||
var OSS = ['groq','together','fireworks','cerebras','sambanova','deepseek','mistral','perplexity','cohere','ai21','huggingface','replicate','nvidia','venice','novita','chutes'];
|
||||
var AGG = ['openrouter','litellm','github-copilot','claude-code'];
|
||||
var REGIONAL = ['qwen','minimax','zhipu','zai','moonshot','qianfan','volcengine','kimi'];
|
||||
if (FRONTIER.indexOf(id) !== -1) return 'frontier';
|
||||
if (REGIONAL.indexOf(id) !== -1) return 'regional';
|
||||
if (AGG.indexOf(id) !== -1) return 'aggregator';
|
||||
if (OSS.indexOf(id) !== -1) return 'oss';
|
||||
return 'other';
|
||||
},
|
||||
|
||||
providerCategoryLabel(cat) {
|
||||
switch (cat) {
|
||||
case 'frontier': return 'Frontier (Anthropic, OpenAI, Google, xAI, Bedrock)';
|
||||
case 'oss': return 'Open-Weight Hosts (Groq, Together, Fireworks, DeepSeek, etc.)';
|
||||
case 'aggregator': return 'Aggregators & Gateways (OpenRouter, GitHub Copilot)';
|
||||
case 'regional': return 'Regional / China (Qwen, Zhipu, Moonshot, MiniMax)';
|
||||
case 'local': return 'Local / Self-Hosted (Ollama, vLLM, LM Studio, Lemonade)';
|
||||
default: return 'Other Providers';
|
||||
}
|
||||
},
|
||||
|
||||
/// Stable category order for grouped rendering.
|
||||
get providerCategoriesOrdered() {
|
||||
return ['frontier', 'oss', 'aggregator', 'regional', 'local', 'other'];
|
||||
},
|
||||
|
||||
/// Returns filter-matched providers grouped by category, preserving order.
|
||||
/// Each entry: { category, label, items: [...] }. Empty groups are omitted.
|
||||
get providersGrouped() {
|
||||
var self = this;
|
||||
var filtered = this.filteredProviders;
|
||||
var by = {};
|
||||
filtered.forEach(function(p) {
|
||||
var c = self.providerCategory(p);
|
||||
if (!by[c]) by[c] = [];
|
||||
by[c].push(p);
|
||||
});
|
||||
// Sort each group: configured first, then alphabetical
|
||||
Object.keys(by).forEach(function(c) {
|
||||
by[c].sort(function(a, b) {
|
||||
var ac = a.auth_status === 'configured' ? 0 : 1;
|
||||
var bc = b.auth_status === 'configured' ? 0 : 1;
|
||||
if (ac !== bc) return ac - bc;
|
||||
return (a.display_name || a.id).localeCompare(b.display_name || b.id);
|
||||
});
|
||||
});
|
||||
var out = [];
|
||||
this.providerCategoriesOrdered.forEach(function(c) {
|
||||
if (by[c] && by[c].length) {
|
||||
out.push({ category: c, label: self.providerCategoryLabel(c), items: by[c] });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
},
|
||||
|
||||
get filteredProviders() {
|
||||
var self = this;
|
||||
return this.providers.filter(function(p) {
|
||||
if (self.providerStatusFilter === 'configured' && p.auth_status !== 'configured') return false;
|
||||
if (self.providerStatusFilter === 'unconfigured' && p.auth_status === 'configured') return false;
|
||||
if (self.providerCategoryFilter && self.providerCategory(p) !== self.providerCategoryFilter) return false;
|
||||
if (self.providerSearch) {
|
||||
var q = self.providerSearch.toLowerCase();
|
||||
if ((p.display_name || '').toLowerCase().indexOf(q) === -1 &&
|
||||
(p.id || '').toLowerCase().indexOf(q) === -1 &&
|
||||
(p.api_key_env || '').toLowerCase().indexOf(q) === -1) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
get configuredProviderCount() {
|
||||
return this.providers.filter(function(p) { return p.auth_status === 'configured'; }).length;
|
||||
},
|
||||
|
||||
clearProviderFilters() {
|
||||
this.providerSearch = '';
|
||||
this.providerStatusFilter = '';
|
||||
this.providerCategoryFilter = '';
|
||||
},
|
||||
|
||||
get uniqueTiers() {
|
||||
var seen = {};
|
||||
this.models.forEach(function(m) { if (m.tier) seen[m.tier] = true; });
|
||||
|
||||
@@ -30,31 +30,44 @@ function skillsPage() {
|
||||
skillCodeFilename: '',
|
||||
skillCodeLoading: false,
|
||||
|
||||
// Skill config modal (local skill configuration from SKILL.md frontmatter)
|
||||
configSkill: null, // skill object whose config is being edited
|
||||
configDeclared: {}, // { var_name: { description, env, default, required } }
|
||||
configResolved: {}, // { var_name: { value, source, is_secret } }
|
||||
configDraft: {}, // { var_name: user-edited string value }
|
||||
configRevealed: {}, // { var_name: bool } — toggle password reveal per row
|
||||
configLoading: false,
|
||||
configSaving: false,
|
||||
configError: '',
|
||||
|
||||
// MCP servers
|
||||
mcpServers: [],
|
||||
mcpLoading: false,
|
||||
|
||||
// Category definitions from the OpenClaw ecosystem
|
||||
categories: [
|
||||
{ id: 'coding', name: 'Coding & IDEs' },
|
||||
{ id: 'git', name: 'Git & GitHub' },
|
||||
{ id: 'web', name: 'Web & Frontend' },
|
||||
{ id: 'devops', name: 'DevOps & Cloud' },
|
||||
{ id: 'browser', name: 'Browser & Automation' },
|
||||
{ id: 'search', name: 'Search & Research' },
|
||||
{ id: 'ai', name: 'AI & LLMs' },
|
||||
{ id: 'data', name: 'Data & Analytics' },
|
||||
{ id: 'productivity', name: 'Productivity' },
|
||||
{ id: 'communication', name: 'Communication' },
|
||||
{ id: 'media', name: 'Media & Streaming' },
|
||||
{ id: 'notes', name: 'Notes & PKM' },
|
||||
{ id: 'security', name: 'Security' },
|
||||
{ id: 'cli', name: 'CLI Utilities' },
|
||||
{ id: 'marketing', name: 'Marketing & Sales' },
|
||||
{ id: 'finance', name: 'Finance' },
|
||||
{ id: 'smart-home', name: 'Smart Home & IoT' },
|
||||
{ id: 'docs', name: 'PDF & Documents' },
|
||||
],
|
||||
// Category definitions from the OpenClaw ecosystem (loaded from i18n)
|
||||
get categories() {
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
return [
|
||||
{ id: 'coding', name: t('skills.cat_coding') || 'Coding & IDEs' },
|
||||
{ id: 'git', name: t('skills.cat_git') || 'Git & GitHub' },
|
||||
{ id: 'web', name: t('skills.cat_frontend') || 'Web & Frontend' },
|
||||
{ id: 'devops', name: t('skills.cat_devops') || 'DevOps & Cloud' },
|
||||
{ id: 'browser', name: t('skills.cat_browser') || 'Browser & Automation' },
|
||||
{ id: 'search', name: t('skills.cat_search') || 'Search & Research' },
|
||||
{ id: 'ai', name: t('skills.cat_ai') || 'AI & ML' },
|
||||
{ id: 'data', name: t('skills.cat_data') || 'Data & Analytics' },
|
||||
{ id: 'productivity', name: t('skills.cat_productivity') || 'Productivity' },
|
||||
{ id: 'communication', name: t('skills.cat_communication') || 'Communication' },
|
||||
{ id: 'media', name: t('skills.cat_media') || 'Media & Streaming' },
|
||||
{ id: 'notes', name: t('skills.cat_notes') || 'Notes & PKM' },
|
||||
{ id: 'security', name: t('skills.cat_security') || 'Security' },
|
||||
{ id: 'cli', name: t('skills.cat_cli') || 'CLI Utilities' },
|
||||
{ id: 'marketing', name: t('skills.cat_marketing') || 'Marketing & Sales' },
|
||||
{ id: 'finance', name: t('skills.cat_finance') || 'Finance' },
|
||||
{ id: 'smart-home', name: t('skills.cat_smarthome') || 'Smart Home & IoT' },
|
||||
{ id: 'docs', name: t('skills.cat_docs') || 'Documentation' },
|
||||
];
|
||||
},
|
||||
|
||||
runtimeBadge: function(rt) {
|
||||
var r = (rt || '').toLowerCase();
|
||||
@@ -98,7 +111,8 @@ function skillsPage() {
|
||||
tags: s.tags || [],
|
||||
enabled: s.enabled !== false,
|
||||
source: s.source || { type: 'local' },
|
||||
has_prompt_context: !!s.has_prompt_context
|
||||
has_prompt_context: !!s.has_prompt_context,
|
||||
config_declared_count: s.config_declared_count || 0
|
||||
};
|
||||
});
|
||||
} catch(e) {
|
||||
@@ -108,6 +122,161 @@ function skillsPage() {
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
// ── Skill config editing ────────────────────────────────────────────
|
||||
async openSkillConfig(skill) {
|
||||
this.configSkill = skill;
|
||||
this.configDeclared = {};
|
||||
this.configResolved = {};
|
||||
this.configDraft = {};
|
||||
this.configRevealed = {};
|
||||
this.configError = '';
|
||||
this.configLoading = true;
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(skill.name) + '/config');
|
||||
this.configDeclared = data.declared || {};
|
||||
this.configResolved = data.resolved || {};
|
||||
// Pre-populate draft values only for vars the user has already
|
||||
// overridden — never copy redacted responses back into inputs or
|
||||
// they'd be re-saved as "****redacted****" strings.
|
||||
var names = Object.keys(this.configDeclared);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var n = names[i];
|
||||
var res = this.configResolved[n] || {};
|
||||
if (res.source === 'user' && !res.is_secret) {
|
||||
this.configDraft[n] = res.value == null ? '' : String(res.value);
|
||||
} else {
|
||||
this.configDraft[n] = '';
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
this.configError = e.message || 'Failed to load skill config.';
|
||||
}
|
||||
this.configLoading = false;
|
||||
},
|
||||
|
||||
closeSkillConfig() {
|
||||
this.configSkill = null;
|
||||
this.configDeclared = {};
|
||||
this.configResolved = {};
|
||||
this.configDraft = {};
|
||||
this.configRevealed = {};
|
||||
this.configError = '';
|
||||
},
|
||||
|
||||
configRowInvalid(name) {
|
||||
// A required var is invalid iff the user hasn't entered anything AND
|
||||
// no env/default resolves it. Source from the server tells us where
|
||||
// the current value came from; if it's "unresolved" and the draft is
|
||||
// blank, the save would write an empty string over the required var.
|
||||
var decl = this.configDeclared[name] || {};
|
||||
if (!decl.required) return false;
|
||||
var draft = (this.configDraft[name] || '').trim();
|
||||
if (draft) return false;
|
||||
var res = this.configResolved[name] || {};
|
||||
if (res.source === 'env' || res.source === 'default') return false;
|
||||
// If the user has an existing secret override we don't want to force
|
||||
// them to re-type it — treat that as "currently resolved".
|
||||
if (res.source === 'user') return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
hasInvalidConfig() {
|
||||
var names = Object.keys(this.configDeclared);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
if (this.configRowInvalid(names[i])) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
toggleReveal(name) {
|
||||
this.configRevealed[name] = !this.configRevealed[name];
|
||||
},
|
||||
|
||||
sourceBadgeClass(source) {
|
||||
switch (source) {
|
||||
case 'user': return 'badge-success';
|
||||
case 'env': return 'badge-info';
|
||||
case 'default': return 'badge-dim';
|
||||
default: return 'badge-danger';
|
||||
}
|
||||
},
|
||||
|
||||
sourceBadgeLabel(res) {
|
||||
if (!res) return 'unresolved';
|
||||
switch (res.source) {
|
||||
case 'user': return 'user override';
|
||||
case 'env': return 'env' + ((this.configDeclared[res.__name] && this.configDeclared[res.__name].env) ? ':' + this.configDeclared[res.__name].env : '');
|
||||
case 'default': return 'default';
|
||||
default: return 'unresolved';
|
||||
}
|
||||
},
|
||||
|
||||
async saveSkillConfig() {
|
||||
if (!this.configSkill) return;
|
||||
if (this.hasInvalidConfig()) {
|
||||
OpenFangToast.error('Fill in all required variables before saving.');
|
||||
return;
|
||||
}
|
||||
this.configSaving = true;
|
||||
this.configError = '';
|
||||
// Only PUT values the user actually typed. Empty strings are dropped
|
||||
// so we don't silently clobber an env/default with "".
|
||||
var payload = {};
|
||||
var names = Object.keys(this.configDeclared);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var n = names[i];
|
||||
var v = (this.configDraft[n] || '').trim();
|
||||
if (v.length > 0) payload[n] = v;
|
||||
}
|
||||
try {
|
||||
await OpenFangAPI.put('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config', { values: payload });
|
||||
OpenFangToast.success('Saved, reloading agents\u2026');
|
||||
// Refresh the modal contents so the new source/value shows up.
|
||||
var refreshed = this.configSkill;
|
||||
await this.loadSkills();
|
||||
this.closeSkillConfig();
|
||||
// Find the possibly-refreshed skill object and reopen.
|
||||
var self = this;
|
||||
var updated = this.skills.find(function(s) { return s.name === refreshed.name; });
|
||||
if (updated) await self.openSkillConfig(updated);
|
||||
} catch(e) {
|
||||
this.configError = e.message || 'Save failed.';
|
||||
OpenFangToast.error('Save failed: ' + (e.message || 'unknown error'));
|
||||
}
|
||||
this.configSaving = false;
|
||||
},
|
||||
|
||||
async resetSkillConfigVar(name) {
|
||||
if (!this.configSkill) return;
|
||||
var decl = this.configDeclared[name] || {};
|
||||
var res = this.configResolved[name] || {};
|
||||
// If the server is already reporting a non-user source there's nothing
|
||||
// to remove; just clear the draft so the input disappears.
|
||||
if (res.source !== 'user') {
|
||||
this.configDraft[name] = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await OpenFangAPI.del('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config/' + encodeURIComponent(name));
|
||||
OpenFangToast.success('Reset ' + name);
|
||||
// Refresh modal state from server.
|
||||
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config');
|
||||
this.configResolved = data.resolved || {};
|
||||
this.configDraft[name] = '';
|
||||
} catch(e) {
|
||||
var msg = e.message || 'Reset failed';
|
||||
if (msg.indexOf('required') !== -1 || msg.indexOf('409') !== -1) {
|
||||
OpenFangToast.error('Cannot reset: ' + decl.description + ' is required with no fallback.');
|
||||
} else {
|
||||
OpenFangToast.error('Reset failed: ' + msg);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
get configDeclaredNames() {
|
||||
return Object.keys(this.configDeclared).sort();
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
await this.loadSkills();
|
||||
},
|
||||
@@ -264,15 +433,20 @@ function skillsPage() {
|
||||
// Uninstall
|
||||
uninstallSkill: function(name) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Uninstall Skill', 'Uninstall skill "' + name + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
|
||||
OpenFangToast.success('Skill "' + name + '" uninstalled');
|
||||
await self.loadSkills();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
OpenFangToast.confirm(
|
||||
t('skills.uninstall_skill') || 'Uninstall Skill',
|
||||
t('skills.uninstall_confirm') + ' "' + name + '"? This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
|
||||
OpenFangToast.success('Skill "' + name + '" uninstalled');
|
||||
await self.loadSkills();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
|
||||
// Create prompt-only skill
|
||||
|
||||
@@ -191,6 +191,33 @@ function analyticsPage() {
|
||||
return segments;
|
||||
},
|
||||
|
||||
donutSegmentsSvg() {
|
||||
var segments = this.donutSegments();
|
||||
if (!segments.length) return '';
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
var out = [];
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
var seg = segments[i];
|
||||
var title = seg.provider + ': ' + seg.percent + '% (' + this.formatCost(seg.cost) + ')';
|
||||
out.push(
|
||||
'<circle cx="80" cy="80" r="60" fill="none" stroke="' + escapeXml(seg.color) + '" stroke-width="24" stroke-dasharray="' + escapeXml(seg.dasharray) + '" stroke-dashoffset="' + escapeXml(seg.dashoffset) + '" transform="rotate(-90 80 80)" class="donut-segment">' +
|
||||
'<title>' + escapeXml(title) + '</title>' +
|
||||
'</circle>'
|
||||
);
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
},
|
||||
|
||||
// ── Bar chart (last 7 days) ──
|
||||
|
||||
barChartData() {
|
||||
@@ -218,6 +245,42 @@ function analyticsPage() {
|
||||
return result;
|
||||
},
|
||||
|
||||
barChartSvg() {
|
||||
var bars = this.barChartData();
|
||||
if (!bars.length) return '';
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
var out = [];
|
||||
for (var i = 0; i < bars.length; i++) {
|
||||
var bar = bars[i];
|
||||
var x = i * 50 + 18;
|
||||
var labelX = i * 50 + 30;
|
||||
var y = 150 - bar.barHeight;
|
||||
var costLabelY = y - 4;
|
||||
var title = bar.date + ': ' + this.formatCost(bar.cost) + ' (' + bar.calls + ' calls)';
|
||||
|
||||
out.push(
|
||||
'<g>' +
|
||||
'<rect x="' + x + '" y="' + y + '" width="24" height="' + bar.barHeight + '" rx="3" fill="var(--accent)" class="cost-bar" style="opacity:0.85">' +
|
||||
'<title>' + escapeXml(title) + '</title>' +
|
||||
'</rect>' +
|
||||
'<text x="' + labelX + '" y="166" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">' + escapeXml(bar.dayName) + '</text>' +
|
||||
'<text x="' + labelX + '" y="' + costLabelY + '" text-anchor="middle" fill="var(--text-dim)" style="font-size:8px;font-family:var(--font-mono)">' + escapeXml(this.formatCost(bar.cost)) + '</text>' +
|
||||
'</g>'
|
||||
);
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
},
|
||||
|
||||
// ── Cost by model table (sorted by cost descending) ──
|
||||
|
||||
costByModelSorted() {
|
||||
|
||||
@@ -158,15 +158,17 @@ function wizardPage() {
|
||||
return this.templates.filter(function(t) { return t.category === cat; });
|
||||
},
|
||||
|
||||
// Step 3: Profile/tool descriptions
|
||||
profileDescriptions: {
|
||||
minimal: { label: 'Minimal', desc: 'Read-only file access' },
|
||||
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
|
||||
research: { label: 'Research', desc: 'Web search + file read/write' },
|
||||
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
|
||||
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
|
||||
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
|
||||
full: { label: 'Full', desc: 'All 35+ tools' }
|
||||
// Step 3: Profile/tool descriptions (loaded from i18n)
|
||||
get profileDescriptions() {
|
||||
return {
|
||||
minimal: { label: window.i18n ? window.i18n.t('wizard.profile_minimal') : 'Minimal', desc: window.i18n ? window.i18n.t('wizard.profile_minimal_desc') : 'Read-only file access' },
|
||||
coding: { label: window.i18n ? window.i18n.t('wizard.profile_coding') : 'Coding', desc: window.i18n ? window.i18n.t('wizard.profile_coding_desc') : 'Files + shell + web fetch' },
|
||||
research: { label: window.i18n ? window.i18n.t('wizard.profile_research') : 'Research', desc: window.i18n ? window.i18n.t('wizard.profile_research_desc') : 'Web search + file read/write' },
|
||||
balanced: { label: window.i18n ? window.i18n.t('wizard.profile_balanced') : 'Balanced', desc: window.i18n ? window.i18n.t('wizard.profile_balanced_desc') : 'General-purpose tool set' },
|
||||
precise: { label: window.i18n ? window.i18n.t('wizard.profile_precise') : 'Precise', desc: window.i18n ? window.i18n.t('wizard.profile_precise_desc') : 'Focused tool set for accuracy' },
|
||||
creative: { label: window.i18n ? window.i18n.t('wizard.profile_creative') : 'Creative', desc: window.i18n ? window.i18n.t('wizard.profile_creative_desc') : 'Full tools with creative emphasis' },
|
||||
full: { label: window.i18n ? window.i18n.t('wizard.profile_full') : 'Full', desc: window.i18n ? window.i18n.t('wizard.profile_full_desc') : 'All 35+ tools' }
|
||||
};
|
||||
},
|
||||
profileInfo: function(name) { return this.profileDescriptions[name] || { label: name, desc: '' }; },
|
||||
|
||||
@@ -174,12 +176,35 @@ function wizardPage() {
|
||||
tryItMessages: [],
|
||||
tryItInput: '',
|
||||
tryItSending: false,
|
||||
suggestedMessages: {
|
||||
'General': ['What can you help me with?', 'Tell me a fun fact', 'Summarize the latest AI news'],
|
||||
'Development': ['Write a Python hello world', 'Explain async/await', 'Review this code snippet'],
|
||||
'Research': ['Explain quantum computing simply', 'Compare React vs Vue', 'What are the latest trends in AI?'],
|
||||
'Writing': ['Help me write a professional email', 'Improve this paragraph', 'Write a blog intro about AI'],
|
||||
'Business': ['Draft a meeting agenda', 'How do I handle a complaint?', 'Create a project status update']
|
||||
get suggestedMessages() {
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
return {
|
||||
'General': [
|
||||
t('wizard.suggestions.general.1') || 'What can you help me with?',
|
||||
t('wizard.suggestions.general.2') || 'Tell me a fun fact',
|
||||
t('wizard.suggestions.general.3') || 'Summarize the latest AI news'
|
||||
],
|
||||
'Development': [
|
||||
t('wizard.suggestions.development.1') || 'Write a Python hello world',
|
||||
t('wizard.suggestions.development.2') || 'Explain async/await',
|
||||
t('wizard.suggestions.development.3') || 'Review this code snippet'
|
||||
],
|
||||
'Research': [
|
||||
t('wizard.suggestions.research.1') || 'Explain quantum computing simply',
|
||||
t('wizard.suggestions.research.2') || 'Compare React vs Vue',
|
||||
t('wizard.suggestions.research.3') || 'What are the latest trends in AI?'
|
||||
],
|
||||
'Writing': [
|
||||
t('wizard.suggestions.writing.1') || 'Help me write a professional email',
|
||||
t('wizard.suggestions.writing.2') || 'Improve this paragraph',
|
||||
t('wizard.suggestions.writing.3') || 'Write a blog intro about AI'
|
||||
],
|
||||
'Business': [
|
||||
t('wizard.suggestions.business.1') || 'Draft a meeting agenda',
|
||||
t('wizard.suggestions.business.2') || 'How do I handle a complaint?',
|
||||
t('wizard.suggestions.business.3') || 'Create a project status update'
|
||||
]
|
||||
};
|
||||
},
|
||||
get currentSuggestions() {
|
||||
var tpl = this.templates[this.selectedTemplate];
|
||||
@@ -204,38 +229,41 @@ function wizardPage() {
|
||||
|
||||
// Step 5: Channel setup (optional)
|
||||
channelType: '',
|
||||
channelOptions: [
|
||||
{
|
||||
name: 'telegram',
|
||||
display_name: 'Telegram',
|
||||
icon: 'TG',
|
||||
description: 'Connect your agent to a Telegram bot for messaging.',
|
||||
token_label: 'Bot Token',
|
||||
token_placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
token_env: 'TELEGRAM_BOT_TOKEN',
|
||||
help: 'Create a bot via @BotFather on Telegram to get your token.'
|
||||
},
|
||||
{
|
||||
name: 'discord',
|
||||
display_name: 'Discord',
|
||||
icon: 'DC',
|
||||
description: 'Connect your agent to a Discord server via bot token.',
|
||||
token_label: 'Bot Token',
|
||||
token_placeholder: 'MTIz...abc',
|
||||
token_env: 'DISCORD_BOT_TOKEN',
|
||||
help: 'Create a Discord application at discord.com/developers and add a bot.'
|
||||
},
|
||||
{
|
||||
name: 'slack',
|
||||
display_name: 'Slack',
|
||||
icon: 'SL',
|
||||
description: 'Connect your agent to a Slack workspace.',
|
||||
token_label: 'Bot Token',
|
||||
token_placeholder: 'xoxb-...',
|
||||
token_env: 'SLACK_BOT_TOKEN',
|
||||
help: 'Create a Slack app at api.slack.com/apps and install it to your workspace.'
|
||||
}
|
||||
],
|
||||
get channelOptions() {
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
return [
|
||||
{
|
||||
name: 'telegram',
|
||||
display_name: t('wizard.channel_telegram') || 'Telegram',
|
||||
icon: 'TG',
|
||||
description: t('wizard.channel_telegram_desc') || 'Connect your agent to a Telegram bot for messaging.',
|
||||
token_label: t('wizard.channel_telegram_token') || 'Bot Token',
|
||||
token_placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
token_env: 'TELEGRAM_BOT_TOKEN',
|
||||
help: t('wizard.channel_telegram_help') || 'Create a bot via @BotFather on Telegram to get your token.'
|
||||
},
|
||||
{
|
||||
name: 'discord',
|
||||
display_name: t('wizard.channel_discord') || 'Discord',
|
||||
icon: 'DC',
|
||||
description: t('wizard.channel_discord_desc') || 'Connect your agent to a Discord server via bot token.',
|
||||
token_label: t('wizard.channel_discord_token') || 'Bot Token',
|
||||
token_placeholder: 'MTIz...abc',
|
||||
token_env: 'DISCORD_BOT_TOKEN',
|
||||
help: t('wizard.channel_discord_help') || 'Create a Discord application at discord.com/developers and add a bot.'
|
||||
},
|
||||
{
|
||||
name: 'slack',
|
||||
display_name: t('wizard.channel_slack') || 'Slack',
|
||||
icon: 'SL',
|
||||
description: t('wizard.channel_slack_desc') || 'Connect your agent to a Slack workspace.',
|
||||
token_label: t('wizard.channel_slack_token') || 'Bot Token',
|
||||
token_placeholder: 'xoxb-...',
|
||||
token_env: 'SLACK_BOT_TOKEN',
|
||||
help: t('wizard.channel_slack_help') || 'Create a Slack app at api.slack.com/apps and install it to your workspace.'
|
||||
}
|
||||
];
|
||||
},
|
||||
channelToken: '',
|
||||
configuringChannel: false,
|
||||
channelConfigured: false,
|
||||
@@ -297,7 +325,15 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
stepLabel(n) {
|
||||
var labels = ['Welcome', 'Provider', 'Agent', 'Try It', 'Channel', 'Done'];
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
var labels = [
|
||||
t('wizard.step_welcome') || 'Welcome',
|
||||
t('wizard.step_provider') || 'Provider',
|
||||
t('wizard.step_agent') || 'Agent',
|
||||
t('wizard.step_try_it') || 'Try It',
|
||||
t('wizard.step_channel') || 'Channel',
|
||||
t('wizard.step_done') || 'Done'
|
||||
];
|
||||
return labels[n - 1] || '';
|
||||
},
|
||||
|
||||
@@ -382,7 +418,7 @@ function wizardPage() {
|
||||
if (!provider) return;
|
||||
var key = this.apiKeyInput.trim();
|
||||
if (!key) {
|
||||
OpenFangToast.error('Please enter an API key');
|
||||
OpenFangToast.error(window.i18n ? window.i18n.t('wizard.enter_api_key') : 'Please enter an API key');
|
||||
return;
|
||||
}
|
||||
this.savingKey = true;
|
||||
@@ -391,12 +427,12 @@ function wizardPage() {
|
||||
this.apiKeyInput = '';
|
||||
this.keySaved = true;
|
||||
this.setupSummary.provider = provider.display_name;
|
||||
OpenFangToast.success('API key saved for ' + provider.display_name);
|
||||
OpenFangToast.success((window.i18n ? window.i18n.t('wizard.api_key_saved') : 'API key saved for') + ' ' + provider.display_name);
|
||||
await this.loadProviders();
|
||||
// Auto-test after saving
|
||||
await this.testKey();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save key: ' + e.message);
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_save_key') : 'Failed to save key:') + ' ' + e.message);
|
||||
}
|
||||
this.savingKey = false;
|
||||
},
|
||||
@@ -410,13 +446,13 @@ function wizardPage() {
|
||||
var result = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/test', {});
|
||||
this.testResult = result;
|
||||
if (result.status === 'ok') {
|
||||
OpenFangToast.success(provider.display_name + ' connected (' + (result.latency_ms || '?') + 'ms)');
|
||||
OpenFangToast.success(provider.display_name + ' ' + (window.i18n ? window.i18n.t('wizard.connected') : 'connected') + ' (' + (result.latency_ms || '?') + 'ms)');
|
||||
} else {
|
||||
OpenFangToast.error(provider.display_name + ': ' + (result.error || 'Connection failed'));
|
||||
OpenFangToast.error(provider.display_name + ': ' + (result.error || (window.i18n ? window.i18n.t('wizard.connection_failed') : 'Connection failed')));
|
||||
}
|
||||
} catch(e) {
|
||||
this.testResult = { status: 'error', error: e.message };
|
||||
OpenFangToast.error('Test failed: ' + e.message);
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.test_failed') : 'Test failed:') + ' ' + e.message);
|
||||
}
|
||||
this.testingProvider = false;
|
||||
},
|
||||
@@ -458,7 +494,7 @@ function wizardPage() {
|
||||
if (!tpl) return;
|
||||
var name = this.agentName.trim();
|
||||
if (!name) {
|
||||
OpenFangToast.error('Please enter a name for your agent');
|
||||
OpenFangToast.error(window.i18n ? window.i18n.t('wizard.enter_agent_name') : 'Please enter a name for your agent');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -485,13 +521,13 @@ function wizardPage() {
|
||||
if (res.agent_id) {
|
||||
this.createdAgent = { id: res.agent_id, name: res.name || name };
|
||||
this.setupSummary.agent = res.name || name;
|
||||
OpenFangToast.success('Agent "' + (res.name || name) + '" created');
|
||||
OpenFangToast.success((window.i18n ? window.i18n.t('wizard.agent_created') : 'Agent') + ' "' + (res.name || name) + '" ' + (window.i18n ? window.i18n.t('wizard.agent_created_suffix') || 'created' : 'created'));
|
||||
await Alpine.store('app').refreshAgents();
|
||||
} else {
|
||||
OpenFangToast.error('Failed: ' + (res.error || 'Unknown error'));
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_create_agent') : 'Failed:') + ' ' + (res.error || 'Unknown error'));
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to create agent: ' + e.message);
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_create_agent') : 'Failed to create agent:') + ' ' + e.message);
|
||||
}
|
||||
this.creatingAgent = false;
|
||||
},
|
||||
@@ -538,7 +574,7 @@ function wizardPage() {
|
||||
if (!ch) return;
|
||||
var token = this.channelToken.trim();
|
||||
if (!token) {
|
||||
OpenFangToast.error('Please enter the ' + ch.token_label);
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.enter_token') : 'Please enter the') + ' ' + ch.token_label);
|
||||
return;
|
||||
}
|
||||
this.configuringChannel = true;
|
||||
@@ -549,9 +585,9 @@ function wizardPage() {
|
||||
await OpenFangAPI.post('/api/channels/' + ch.name + '/configure', { fields: fields });
|
||||
this.channelConfigured = true;
|
||||
this.setupSummary.channel = ch.display_name;
|
||||
OpenFangToast.success(ch.display_name + ' configured and activated.');
|
||||
OpenFangToast.success(ch.display_name + ' ' + (window.i18n ? window.i18n.t('wizard.channel_configured') : 'configured and activated.'));
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed: ' + (e.message || 'Unknown error'));
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_configure') : 'Failed:') + ' ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
this.configuringChannel = false;
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#6366f1",
|
||||
"icons": [
|
||||
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
|
||||
{"src": "/logo.png", "sizes": "128x128", "type": "image/png"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ async fn start_test_server_with_provider(
|
||||
model: model.to_string(),
|
||||
api_key_env: api_key_env.to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -101,6 +102,10 @@ async fn start_test_server_with_provider(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::delete(routes::kill_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/clone",
|
||||
axum::routing::post(routes::clone_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/triggers",
|
||||
axum::routing::get(routes::list_triggers).post(routes::create_trigger),
|
||||
@@ -122,6 +127,23 @@ async fn start_test_server_with_provider(
|
||||
axum::routing::get(routes::list_workflow_runs),
|
||||
)
|
||||
.route("/api/shutdown", axum::routing::post(routes::shutdown))
|
||||
.route("/api/commands", axum::routing::get(routes::list_commands))
|
||||
.route(
|
||||
"/api/schedules",
|
||||
axum::routing::get(routes::list_schedules).post(routes::create_schedule),
|
||||
)
|
||||
.route(
|
||||
"/api/schedules/{id}",
|
||||
axum::routing::delete(routes::delete_schedule).put(routes::update_schedule),
|
||||
)
|
||||
.route(
|
||||
"/api/schedules/{id}/delivery-log",
|
||||
axum::routing::get(routes::schedule_delivery_log),
|
||||
)
|
||||
.route(
|
||||
"/api/cron/jobs",
|
||||
axum::routing::get(routes::list_cron_jobs).post(routes::create_cron_job),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive())
|
||||
@@ -284,6 +306,86 @@ async fn test_spawn_list_kill_agent() {
|
||||
assert_eq!(agents[0]["name"], "assistant");
|
||||
}
|
||||
|
||||
/// Regression test for issue #1026: GET /api/agents returns `is_inferencing`
|
||||
/// reflecting whether the agent has an in-flight LLM task. This drives the
|
||||
/// live dashboard indicator that shows which agents are calling the LLM.
|
||||
#[tokio::test]
|
||||
async fn test_list_agents_includes_inferencing_flag() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn a test agent.
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let agent_id_str = body["agent_id"].as_str().unwrap().to_string();
|
||||
let agent_id: openfang_types::agent::AgentId = agent_id_str.parse().unwrap();
|
||||
|
||||
// Baseline: idle agent must report is_inferencing = false.
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
let test_agent = agents
|
||||
.iter()
|
||||
.find(|a| a["id"] == agent_id_str)
|
||||
.expect("spawned agent should appear in list");
|
||||
assert_eq!(
|
||||
test_agent["is_inferencing"], false,
|
||||
"freshly spawned agent should not be inferencing"
|
||||
);
|
||||
|
||||
// Simulate an in-flight LLM call by inserting a real AbortHandle into
|
||||
// the kernel's running_tasks map. This is exactly what the agent loop
|
||||
// does when it starts processing a message.
|
||||
let handle = tokio::spawn(async {
|
||||
// Long-lived task we will abort at end of test.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
});
|
||||
server
|
||||
.state
|
||||
.kernel
|
||||
.running_tasks
|
||||
.insert(agent_id, handle.abort_handle());
|
||||
|
||||
// Now list_agents should report is_inferencing = true for that agent.
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
let test_agent = agents
|
||||
.iter()
|
||||
.find(|a| a["id"] == agent_id_str)
|
||||
.expect("spawned agent should still appear in list");
|
||||
assert_eq!(
|
||||
test_agent["is_inferencing"], true,
|
||||
"agent with an entry in running_tasks must be flagged is_inferencing"
|
||||
);
|
||||
|
||||
// Other agents (the default assistant) must NOT be flagged.
|
||||
if let Some(other) = agents.iter().find(|a| a["id"] != agent_id_str) {
|
||||
assert_eq!(
|
||||
other["is_inferencing"], false,
|
||||
"agents without a running task must not be flagged"
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup so the spawned future does not outlive the test.
|
||||
server.state.kernel.running_tasks.remove(&agent_id);
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_session_empty() {
|
||||
let server = start_test_server().await;
|
||||
@@ -314,6 +416,119 @@ async fn test_agent_session_empty() {
|
||||
assert_eq!(body["messages"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
/// Regression test for #935: the GET /api/agents/:id/session endpoint
|
||||
/// must NOT expose internal system-prompt messages to the Web UI.
|
||||
///
|
||||
/// We construct a session containing a System message + a User message + an
|
||||
/// Assistant message, persist it via the kernel's memory store, then call the
|
||||
/// HTTP endpoint and assert:
|
||||
/// 1. The default response excludes the system message entirely.
|
||||
/// 2. `message_count` reflects only the visible (user + assistant) messages.
|
||||
/// 3. `raw_message_count` exposes the underlying total.
|
||||
/// 4. With `?include_system=true`, the system message IS returned (debug
|
||||
/// mode opt-in).
|
||||
#[tokio::test]
|
||||
async fn test_agent_session_filters_system_messages() {
|
||||
use openfang_types::message::{Message, Role};
|
||||
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn agent
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let agent_id_str = body["agent_id"].as_str().unwrap().to_string();
|
||||
|
||||
// Look up the agent's session id and inject a forged history that
|
||||
// contains a system-role message (simulating what an OpenAI-compat
|
||||
// client could push, or what a future regression might persist).
|
||||
let agent_id: openfang_types::agent::AgentId = agent_id_str.parse().unwrap();
|
||||
let entry = server.state.kernel.registry.get(agent_id).unwrap();
|
||||
let session_id = entry.session_id;
|
||||
let mut session = server
|
||||
.state
|
||||
.kernel
|
||||
.memory
|
||||
.get_session(session_id)
|
||||
.unwrap()
|
||||
.expect("session should exist after spawn");
|
||||
|
||||
session.messages = vec![
|
||||
Message {
|
||||
role: Role::System,
|
||||
content: openfang_types::message::MessageContent::Text(
|
||||
"INTERNAL SYSTEM PROMPT — must not leak to UI".to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
Message::user("hello"),
|
||||
Message::assistant("hi there"),
|
||||
];
|
||||
server.state.kernel.memory.save_session(&session).unwrap();
|
||||
|
||||
// --- Default request: system message must be filtered out ---
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/agents/{}/session",
|
||||
server.base_url, agent_id_str
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 2, "should only see user + assistant");
|
||||
assert_eq!(body["message_count"], 2);
|
||||
assert_eq!(body["raw_message_count"], 3);
|
||||
|
||||
// No message in the response should carry the System role label, and
|
||||
// the system prompt text MUST NOT appear anywhere in the payload.
|
||||
for m in messages {
|
||||
let role = m["role"].as_str().unwrap_or("");
|
||||
assert_ne!(role, "System", "system role leaked into UI history");
|
||||
assert_ne!(role, "system", "system role leaked into UI history");
|
||||
}
|
||||
let body_str = serde_json::to_string(&body).unwrap();
|
||||
assert!(
|
||||
!body_str.contains("INTERNAL SYSTEM PROMPT"),
|
||||
"system prompt content leaked into session response: {body_str}"
|
||||
);
|
||||
|
||||
// Verify the visible roles are exactly what we expect.
|
||||
assert_eq!(messages[0]["role"], "User");
|
||||
assert_eq!(messages[0]["content"], "hello");
|
||||
assert_eq!(messages[1]["role"], "Assistant");
|
||||
assert_eq!(messages[1]["content"], "hi there");
|
||||
|
||||
// --- Opt-in debug mode: ?include_system=true returns it ---
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/agents/{}/session?include_system=true",
|
||||
server.base_url, agent_id_str
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 3, "include_system=true should return all 3");
|
||||
assert_eq!(messages[0]["role"], "System");
|
||||
assert_eq!(
|
||||
messages[0]["content"],
|
||||
"INTERNAL SYSTEM PROMPT — must not leak to UI"
|
||||
);
|
||||
assert_eq!(body["message_count"], 3);
|
||||
assert_eq!(body["raw_message_count"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_with_llm() {
|
||||
if std::env::var("GROQ_API_KEY").is_err() {
|
||||
@@ -691,6 +906,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
model: "test-model".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -722,6 +938,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
allow_no_auth: true,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
@@ -744,6 +961,10 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
"/api/agents/{id}",
|
||||
axum::routing::delete(routes::kill_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/clone",
|
||||
axum::routing::post(routes::clone_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/triggers",
|
||||
axum::routing::get(routes::list_triggers).post(routes::create_trigger),
|
||||
@@ -870,3 +1091,710 @@ async fn test_auth_disabled_when_no_key() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /api/commands — unified command registry endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Default (no surface query) returns web-surface commands.
|
||||
#[tokio::test]
|
||||
async fn test_commands_default_returns_web() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "web");
|
||||
|
||||
let commands = body["commands"].as_array().expect("commands is array");
|
||||
assert!(!commands.is_empty(), "web surface should have commands");
|
||||
|
||||
// Every entry has the documented shape.
|
||||
for c in commands {
|
||||
assert!(c["name"].is_string());
|
||||
assert!(c["aliases"].is_array());
|
||||
assert!(c["description"].is_string());
|
||||
assert!(c["category"].is_string());
|
||||
assert!(c["requires_agent"].is_boolean());
|
||||
}
|
||||
|
||||
// Sanity: web surface must include `/help` and `/verbose` and must NOT
|
||||
// include CLI-only `/kill`.
|
||||
let names: Vec<&str> = commands
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(names.contains(&"help"));
|
||||
assert!(names.contains(&"verbose"));
|
||||
assert!(!names.contains(&"kill"));
|
||||
}
|
||||
|
||||
/// `?surface=cli` returns CLI-only commands and includes the alias array.
|
||||
#[tokio::test]
|
||||
async fn test_commands_cli_surface() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=cli", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "cli");
|
||||
|
||||
let commands = body["commands"].as_array().unwrap();
|
||||
let names: Vec<&str> = commands
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(names.contains(&"kill"));
|
||||
assert!(names.contains(&"clear"));
|
||||
assert!(names.contains(&"exit"));
|
||||
// `start` is channel-only — must not appear on CLI.
|
||||
assert!(!names.contains(&"start"));
|
||||
|
||||
// `/exit` carries the `quit` alias.
|
||||
let exit = commands
|
||||
.iter()
|
||||
.find(|c| c["name"] == "exit")
|
||||
.expect("exit command must be present on CLI");
|
||||
let aliases = exit["aliases"].as_array().unwrap();
|
||||
assert!(
|
||||
aliases.iter().any(|a| a == "quit"),
|
||||
"quit alias should be attached to /exit"
|
||||
);
|
||||
}
|
||||
|
||||
/// `?surface=all` includes commands from every surface.
|
||||
#[tokio::test]
|
||||
async fn test_commands_all_surface() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=all", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "all");
|
||||
|
||||
let names: Vec<&str> = body["commands"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
|
||||
// Surface-specific probes: all three unique-per-surface commands appear.
|
||||
assert!(names.contains(&"kill"), "CLI-only /kill missing from /all");
|
||||
assert!(
|
||||
names.contains(&"start"),
|
||||
"channel-only /start missing from /all"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"verbose"),
|
||||
"web-only /verbose missing from /all"
|
||||
);
|
||||
}
|
||||
|
||||
/// `?surface=channel` returns channel commands only.
|
||||
#[tokio::test]
|
||||
async fn test_commands_channel_surface() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=channel", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "channel");
|
||||
|
||||
let names: Vec<&str> = body["commands"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(names.contains(&"start"));
|
||||
// CLI-only must not appear here.
|
||||
assert!(!names.contains(&"kill"));
|
||||
}
|
||||
|
||||
/// Unknown surface returns 400 with a JSON error body.
|
||||
#[tokio::test]
|
||||
async fn test_commands_invalid_surface_400() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=bogus", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let err = body["error"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
err.contains("bogus"),
|
||||
"error should mention the bad value: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule delivery_targets round-trip tests
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These exercise the `/api/schedules` and `/api/cron/jobs` endpoints to
|
||||
// confirm `CronDeliveryTarget` variants round-trip cleanly through create /
|
||||
// list / update / delivery-log, and that bad input is rejected at the API
|
||||
// layer rather than silently dropped.
|
||||
|
||||
async fn spawn_test_agent(server: &TestServer) -> String {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
body["agent_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// POST /api/schedules with all four `CronDeliveryTarget` variants should
|
||||
/// store them and return them on GET /api/schedules.
|
||||
#[tokio::test]
|
||||
async fn test_schedules_delivery_targets_roundtrip() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let delivery_targets = serde_json::json!([
|
||||
{ "type": "channel", "channel_type": "telegram", "recipient": "chat_12345" },
|
||||
{ "type": "webhook", "url": "https://example.com/hook", "auth_header": "Bearer abc" },
|
||||
{ "type": "local_file", "path": "/tmp/openfang-test.log", "append": true },
|
||||
{ "type": "email", "to": "alice@example.com", "subject_template": "Cron: {job}" },
|
||||
]);
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "multi-destination-test",
|
||||
"cron": "0 9 * * 1-5",
|
||||
"agent_id": agent_id,
|
||||
"message": "Generate the daily brief.",
|
||||
"enabled": true,
|
||||
"delivery_targets": delivery_targets,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let sched_id = body["id"]
|
||||
.as_str()
|
||||
.expect("created schedule id")
|
||||
.to_string();
|
||||
let got = body["delivery_targets"]
|
||||
.as_array()
|
||||
.expect("response must include delivery_targets");
|
||||
assert_eq!(got.len(), 4, "all four targets should round-trip");
|
||||
assert_eq!(got[0]["type"], "channel");
|
||||
assert_eq!(got[0]["channel_type"], "telegram");
|
||||
assert_eq!(got[0]["recipient"], "chat_12345");
|
||||
assert_eq!(got[1]["type"], "webhook");
|
||||
assert_eq!(got[1]["url"], "https://example.com/hook");
|
||||
assert_eq!(got[1]["auth_header"], "Bearer abc");
|
||||
assert_eq!(got[2]["type"], "local_file");
|
||||
assert_eq!(got[2]["append"], true);
|
||||
assert_eq!(got[3]["type"], "email");
|
||||
assert_eq!(got[3]["subject_template"], "Cron: {job}");
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/schedules", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let schedules = body["schedules"].as_array().unwrap();
|
||||
let created = schedules
|
||||
.iter()
|
||||
.find(|s| s["id"] == sched_id)
|
||||
.expect("created schedule must appear in list");
|
||||
let listed = created["delivery_targets"].as_array().unwrap();
|
||||
assert_eq!(listed.len(), 4);
|
||||
assert_eq!(listed[0]["channel_type"], "telegram");
|
||||
|
||||
let _ = client
|
||||
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// PUT /api/schedules/{id} with `delivery_targets` should fully replace the
|
||||
/// target list.
|
||||
#[tokio::test]
|
||||
async fn test_schedules_delivery_targets_update() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "update-target-test",
|
||||
"cron": "*/15 * * * *",
|
||||
"agent_id": agent_id,
|
||||
"message": "hi",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let sched_id = body["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(
|
||||
body["delivery_targets"].as_array().map(|a| a.len()),
|
||||
Some(0)
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.json(&serde_json::json!({
|
||||
"delivery_targets": [
|
||||
{ "type": "webhook", "url": "https://new.example.com/hook" },
|
||||
{ "type": "local_file", "path": "/tmp/new.log" },
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["status"], "updated");
|
||||
let echoed = &body["schedule"]["delivery_targets"];
|
||||
let arr = echoed
|
||||
.as_array()
|
||||
.expect("schedule.delivery_targets must be array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["type"], "webhook");
|
||||
assert_eq!(arr[1]["type"], "local_file");
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/schedules", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let created = body["schedules"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["id"] == sched_id)
|
||||
.unwrap();
|
||||
let listed = created["delivery_targets"].as_array().unwrap();
|
||||
assert_eq!(listed.len(), 2);
|
||||
|
||||
let resp = client
|
||||
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.json(&serde_json::json!({"delivery_targets": []}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let resp = client
|
||||
.get(format!("{}/api/schedules", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let created = body["schedules"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["id"] == sched_id)
|
||||
.unwrap();
|
||||
let listed = created["delivery_targets"].as_array().unwrap();
|
||||
assert_eq!(listed.len(), 0);
|
||||
|
||||
let _ = client
|
||||
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Malformed `delivery_targets` should return 400, not silently succeed.
|
||||
#[tokio::test]
|
||||
async fn test_schedules_rejects_bad_delivery_target() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "bad-target-test",
|
||||
"cron": "*/10 * * * *",
|
||||
"agent_id": agent_id,
|
||||
"message": "hi",
|
||||
"delivery_targets": [
|
||||
{ "type": "channel" /* missing channel_type + recipient */ }
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let err = body["error"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
err.contains("delivery_targets"),
|
||||
"error should mention delivery_targets, got: {err}"
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "bad-array-test",
|
||||
"cron": "*/10 * * * *",
|
||||
"agent_id": agent_id,
|
||||
"message": "hi",
|
||||
"delivery_targets": "not-an-array",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
}
|
||||
|
||||
/// GET /api/schedules/{id}/delivery-log returns the configured targets and an
|
||||
/// empty entries array for a known schedule, and 404 for a random UUID.
|
||||
#[tokio::test]
|
||||
async fn test_schedules_delivery_log_endpoint() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "log-test",
|
||||
"cron": "0 * * * *",
|
||||
"agent_id": agent_id,
|
||||
"message": "x",
|
||||
"delivery_targets": [
|
||||
{ "type": "webhook", "url": "https://example.com/h" }
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let sched_id = resp.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/schedules/{}/delivery-log",
|
||||
server.base_url, sched_id
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["schedule_id"], sched_id);
|
||||
let targets = body["targets"].as_array().expect("targets array");
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0]["type"], "webhook");
|
||||
let entries = body["entries"].as_array().expect("entries array");
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"delivery history is not persisted yet — entries must be empty"
|
||||
);
|
||||
|
||||
let random = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/schedules/{}/delivery-log",
|
||||
server.base_url, random
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/schedules/not-a-uuid/delivery-log",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
let _ = client
|
||||
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// POST /api/cron/jobs with `delivery_targets` should persist them and they
|
||||
/// should appear on the subsequent GET.
|
||||
#[tokio::test]
|
||||
async fn test_cron_jobs_delivery_targets_roundtrip() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/cron/jobs", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"agent_id": agent_id,
|
||||
"name": "cron-fanout",
|
||||
"schedule": { "kind": "cron", "expr": "*/20 * * * *" },
|
||||
"action": { "kind": "agent_turn", "message": "pulse" },
|
||||
"delivery": { "kind": "none" },
|
||||
"delivery_targets": [
|
||||
{ "type": "local_file", "path": "/tmp/pulse.log", "append": true },
|
||||
{ "type": "webhook", "url": "http://example.com/pulse" }
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/cron/jobs?agent_id={}",
|
||||
server.base_url, agent_id
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let jobs = body["jobs"].as_array().unwrap();
|
||||
let job = jobs
|
||||
.iter()
|
||||
.find(|j| j["name"] == "cron-fanout")
|
||||
.expect("created job must be listed");
|
||||
let targets = job["delivery_targets"].as_array().expect("targets array");
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert_eq!(targets[0]["type"], "local_file");
|
||||
assert_eq!(targets[0]["path"], "/tmp/pulse.log");
|
||||
assert_eq!(targets[0]["append"], true);
|
||||
assert_eq!(targets[1]["type"], "webhook");
|
||||
assert_eq!(targets[1]["url"], "http://example.com/pulse");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clone agent endpoint tests (issue #868)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Happy path: clone an existing template agent into a new agent with a
|
||||
/// distinct name. The clone must get a fresh ID, fresh workspace path, and
|
||||
/// inherit non-name manifest fields from the template.
|
||||
#[tokio::test]
|
||||
async fn test_clone_agent_happy_path() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn a template agent.
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let template_id = body["agent_id"].as_str().unwrap().to_string();
|
||||
|
||||
// Clone it.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"new_name": "cloned-user-1",
|
||||
"overrides": {
|
||||
"description": "Cloned for user 1",
|
||||
"tags": ["clone", "user-1"]
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201, "clone should succeed");
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
let new_id = body["agent_id"].as_str().unwrap();
|
||||
assert_ne!(new_id, template_id, "clone must have a fresh agent ID");
|
||||
assert_eq!(body["name"], "cloned-user-1");
|
||||
|
||||
// The full manifest should be returned and reflect the new name + overrides.
|
||||
let manifest = &body["manifest"];
|
||||
assert!(manifest.is_object(), "manifest must be returned");
|
||||
assert_eq!(manifest["name"], "cloned-user-1");
|
||||
assert_eq!(manifest["description"], "Cloned for user 1");
|
||||
assert_eq!(
|
||||
manifest["tags"].as_array().unwrap(),
|
||||
&vec![serde_json::json!("clone"), serde_json::json!("user-1"),]
|
||||
);
|
||||
// Inherited from template — the system_prompt should match.
|
||||
assert_eq!(
|
||||
manifest["model"]["system_prompt"],
|
||||
"You are a test agent. Reply concisely."
|
||||
);
|
||||
|
||||
// The agent list should now contain both template and clone.
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
let names: Vec<&str> = agents.iter().map(|a| a["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"test-agent"));
|
||||
assert!(names.contains(&"cloned-user-1"));
|
||||
}
|
||||
|
||||
/// Cloning into a name that's already taken must fail with 409 Conflict.
|
||||
#[tokio::test]
|
||||
async fn test_clone_agent_name_collision() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn a template agent named "test-agent".
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let template_id = body["agent_id"].as_str().unwrap().to_string();
|
||||
|
||||
// First clone — succeeds.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({"new_name": "duplicate-name"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
|
||||
// Second clone with the same name — must be rejected.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({"new_name": "duplicate-name"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
409,
|
||||
"duplicate name must return 409 Conflict"
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"].as_str().unwrap().contains("already exists"));
|
||||
|
||||
// Cloning into the template's own name must also be rejected.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({"new_name": "test-agent"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 409);
|
||||
}
|
||||
|
||||
/// Cloning a non-existent template must return 404.
|
||||
#[tokio::test]
|
||||
async fn test_clone_agent_template_not_found() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Random valid UUID that does not match any agent.
|
||||
let bogus_id = "00000000-0000-0000-0000-000000000000";
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents/{}/clone", server.base_url, bogus_id))
|
||||
.json(&serde_json::json!({"new_name": "ghost-clone"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Template agent not found"));
|
||||
|
||||
// Malformed agent id → 400.
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents/not-a-uuid/clone", server.base_url))
|
||||
.json(&serde_json::json!({"new_name": "ghost-clone"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
}
|
||||
|
||||
/// Empty new_name must be rejected with 400.
|
||||
#[tokio::test]
|
||||
async fn test_clone_agent_empty_name_rejected() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn a template agent.
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let template_id = body["agent_id"].as_str().unwrap().to_string();
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({"new_name": " "}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
model: "test".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -225,6 +226,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
model: "test".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ async fn start_test_server() -> TestServer {
|
||||
model: "test-model".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Integration tests for the `/api/skills/{id}/config` surface.
|
||||
//!
|
||||
//! These boot a real kernel, start a real axum server on a random port, plant
|
||||
//! a synthetic skill on disk whose SKILL.md declares a `config:` section, and
|
||||
//! exercise GET / PUT / DELETE end to end. Bundled skills currently declare
|
||||
//! no runtime config, so the synthetic skill fixture is what lets us prove
|
||||
//! the wire contract.
|
||||
//!
|
||||
//! Run: cargo test -p openfang-api --test skill_config_api_test -- --nocapture
|
||||
|
||||
use axum::Router;
|
||||
use openfang_api::middleware;
|
||||
use openfang_api::routes::{self, AppState};
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_types::config::{DefaultModelConfig, KernelConfig};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test server harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
home_dir: std::path::PathBuf,
|
||||
#[allow(dead_code)]
|
||||
state: Arc<AppState>,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.state.kernel.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a skill fixture under `<home>/skills/<name>/SKILL.md` that declares
|
||||
/// a `config:` section. This matches the on-disk format that OpenClaw skills
|
||||
/// use, so the loader's real `parse_skillmd_str` path is exercised.
|
||||
fn plant_skill_with_config(home: &std::path::Path, skill_name: &str) {
|
||||
let skill_dir = home.join("skills").join(skill_name);
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
// Leading four spaces inside YAML lists matter — keep them.
|
||||
let skillmd = format!(
|
||||
"---
|
||||
name: {skill_name}
|
||||
description: Synthetic skill for config endpoint tests
|
||||
config:
|
||||
github_token:
|
||||
description: GitHub personal access token
|
||||
env: OPENFANG_TEST_SKILLCFG_GH_TOKEN
|
||||
required: true
|
||||
default_branch:
|
||||
description: Default branch name
|
||||
default: main
|
||||
required: false
|
||||
---
|
||||
# Test Skill
|
||||
|
||||
Placeholder body so the parser accepts this as a valid prompt-only skill.
|
||||
"
|
||||
);
|
||||
std::fs::write(skill_dir.join("SKILL.md"), skillmd).unwrap();
|
||||
}
|
||||
|
||||
async fn start_test_server() -> TestServer {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let home = tmp.path().to_path_buf();
|
||||
|
||||
let config = KernelConfig {
|
||||
home_dir: home.clone(),
|
||||
data_dir: home.join("data"),
|
||||
default_model: DefaultModelConfig {
|
||||
provider: "ollama".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
// Plant synthetic skill BEFORE booting so the initial skill load picks it up.
|
||||
plant_skill_with_config(&home, "test-config-skill");
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boot");
|
||||
let kernel = Arc::new(kernel);
|
||||
kernel.set_self_handle();
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
kernel,
|
||||
started_at: Instant::now(),
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/skills", axum::routing::get(routes::list_skills))
|
||||
.route(
|
||||
"/api/skills/{id}/config",
|
||||
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config/{var_name}",
|
||||
axum::routing::delete(routes::delete_skill_config_var),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state.clone());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test port");
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
TestServer {
|
||||
base_url: format!("http://{}", addr),
|
||||
home_dir: home,
|
||||
state,
|
||||
_tmp: tmp,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_returns_declared_and_resolved() {
|
||||
// Make sure no host leak from prior tests interferes.
|
||||
// SAFETY: single-threaded test, env var is unique to this test suite.
|
||||
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
|
||||
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
assert_eq!(body["skill"], "test-config-skill");
|
||||
// Both vars declared
|
||||
assert!(body["declared"]["github_token"].is_object());
|
||||
assert!(body["declared"]["default_branch"].is_object());
|
||||
assert_eq!(body["declared"]["github_token"]["required"], true);
|
||||
assert_eq!(body["declared"]["default_branch"]["required"], false);
|
||||
|
||||
// github_token has no user override, no env, no default -> unresolved.
|
||||
assert_eq!(
|
||||
body["resolved"]["github_token"]["source"], "unresolved",
|
||||
"github_token should be unresolved without env"
|
||||
);
|
||||
assert!(body["resolved"]["github_token"]["is_secret"]
|
||||
.as_bool()
|
||||
.unwrap());
|
||||
|
||||
// default_branch falls back to default "main".
|
||||
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
|
||||
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_redacts_secret_values_after_put() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Write a real-looking token via PUT.
|
||||
let payload = serde_json::json!({
|
||||
"values": {
|
||||
"github_token": "ghp_realsecretvalue_DO_NOT_LEAK",
|
||||
"default_branch": "develop"
|
||||
}
|
||||
});
|
||||
let resp = client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "PUT should succeed");
|
||||
|
||||
// GET back and confirm the secret is redacted and non-secret is visible.
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
let returned_token = body["resolved"]["github_token"]["value"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(
|
||||
!returned_token.contains("realsecretvalue"),
|
||||
"secret leaked on the wire: {returned_token}"
|
||||
);
|
||||
assert!(
|
||||
returned_token.contains("redacted"),
|
||||
"expected redaction marker, got: {returned_token}"
|
||||
);
|
||||
assert_eq!(body["resolved"]["github_token"]["source"], "user");
|
||||
|
||||
// Non-secret var kept as-is.
|
||||
assert_eq!(body["resolved"]["default_branch"]["value"], "develop");
|
||||
assert_eq!(body["resolved"]["default_branch"]["source"], "user");
|
||||
|
||||
// config.toml persisted the change, including the full secret value
|
||||
// (redaction is only on the wire — disk is the source of truth).
|
||||
let cfg = std::fs::read_to_string(server.home_dir.join("config.toml")).unwrap();
|
||||
assert!(
|
||||
cfg.contains("[skills.test-config-skill]"),
|
||||
"skills section missing: {cfg}"
|
||||
);
|
||||
assert!(cfg.contains("realsecretvalue"));
|
||||
assert!(cfg.contains("develop"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_rejects_unknown_variable() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"values": { "nonexistent_var": "value" }
|
||||
});
|
||||
let resp = client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"].as_str().unwrap().contains("nonexistent_var"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_override_reverts_to_default() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Set override first.
|
||||
client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"values": { "default_branch": "develop" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Remove it.
|
||||
let resp = client
|
||||
.delete(format!(
|
||||
"{}/api/skills/test-config-skill/config/default_branch",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Now source should be "default" again with value "main".
|
||||
let body: serde_json::Value = client
|
||||
.get(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
|
||||
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_refuses_to_strand_required_var() {
|
||||
// github_token is required, has no default, and no env — so removing an
|
||||
// override would leave it unresolvable. The endpoint must refuse.
|
||||
// SAFETY: single-threaded test.
|
||||
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
|
||||
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Set an override.
|
||||
client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"values": { "github_token": "ghp_value" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to delete — should 409.
|
||||
let resp = client
|
||||
.delete(format!(
|
||||
"{}/api/skills/test-config-skill/config/github_token",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 409);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_unknown_skill_returns_404() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/skills/this-skill-does-not-exist/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_reloads_registry_so_agents_see_change() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"values": {
|
||||
"github_token": "ghp_new",
|
||||
"default_branch": "release"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The kernel's live override map must now hold the new values.
|
||||
let guard = server.state.kernel.skill_config_overrides.read().unwrap();
|
||||
let overrides = guard.as_ref().expect("override map set after PUT");
|
||||
let skill_cfg = overrides.get("test-config-skill").expect("skill present");
|
||||
assert_eq!(skill_cfg.get("github_token").unwrap(), "ghp_new");
|
||||
assert_eq!(skill_cfg.get("default_branch").unwrap(), "release");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -42,8 +42,8 @@ const MAX_MESSAGE_LEN: usize = 4000;
|
||||
/// Token refresh buffer — refresh 5 minutes before actual expiry.
|
||||
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
|
||||
|
||||
/// Feishu websocket endpoint discovery API.
|
||||
const FEISHU_WS_ENDPOINT_URL: &str = "https://open.feishu.cn/callback/ws/endpoint";
|
||||
/// WebSocket endpoint path (appended to the region domain).
|
||||
const FEISHU_WS_ENDPOINT_PATH: &str = "/callback/ws/endpoint";
|
||||
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
@@ -160,7 +160,14 @@ impl DedupCache {
|
||||
|
||||
/// Returns `true` if the ID was already seen (duplicate).
|
||||
fn check_and_insert(&self, id: &str) -> bool {
|
||||
let mut ids = self.ids.lock().unwrap();
|
||||
let mut ids = match self.ids.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => {
|
||||
// Recover from a poisoned mutex rather than panicking.
|
||||
warn!("Dedup cache mutex was poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
if ids.iter().any(|s| s == id) {
|
||||
return true;
|
||||
}
|
||||
@@ -262,13 +269,24 @@ impl FeishuAdapter {
|
||||
///
|
||||
/// WebSocket mode does not require a public IP or webhook configuration.
|
||||
pub fn new_websocket(app_id: String, app_secret: String) -> Self {
|
||||
Self::new_websocket_with_region(app_id, app_secret, FeishuRegion::Cn)
|
||||
}
|
||||
|
||||
/// Create a new Feishu adapter in WebSocket mode with an explicit region.
|
||||
///
|
||||
/// Use this when the app is registered on Lark international (`open.larksuite.com`).
|
||||
pub fn new_websocket_with_region(
|
||||
app_id: String,
|
||||
app_secret: String,
|
||||
region: FeishuRegion,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
app_id,
|
||||
app_secret: Zeroizing::new(app_secret),
|
||||
connection_mode: FeishuConnectionMode::WebSocket,
|
||||
webhook_port: 0,
|
||||
region: FeishuRegion::Cn,
|
||||
region,
|
||||
webhook_path: String::new(),
|
||||
verification_token: None,
|
||||
encrypt_key: None,
|
||||
@@ -911,9 +929,10 @@ struct FeishuAdapterClone {
|
||||
impl FeishuAdapterClone {
|
||||
/// Get WebSocket endpoint from Feishu API.
|
||||
async fn get_websocket_endpoint(&self) -> Result<FeishuWsEndpoint, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}{}", self.region.domain(), FEISHU_WS_ENDPOINT_PATH);
|
||||
let resp = self
|
||||
.client
|
||||
.post(FEISHU_WS_ENDPOINT_URL)
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"AppID": self.app_id,
|
||||
"AppSecret": self.app_secret.as_str(),
|
||||
@@ -1030,7 +1049,10 @@ fn combine_payload(
|
||||
*entry = vec![Vec::new(); sum];
|
||||
}
|
||||
|
||||
entry[seq] = payload;
|
||||
match entry.get_mut(seq) {
|
||||
Some(slot) => *slot = payload,
|
||||
None => return None,
|
||||
}
|
||||
|
||||
if entry.iter().any(|part| part.is_empty()) {
|
||||
return None;
|
||||
@@ -1105,7 +1127,10 @@ fn extract_text_from_post(content: &serde_json::Value) -> Option<String> {
|
||||
let mut text_parts = Vec::new();
|
||||
|
||||
for paragraph in paragraphs {
|
||||
let elements = paragraph.as_array()?;
|
||||
let elements = match paragraph.as_array() {
|
||||
Some(elems) => elems,
|
||||
None => continue,
|
||||
};
|
||||
for element in elements {
|
||||
let tag = element["tag"].as_str().unwrap_or("");
|
||||
match tag {
|
||||
@@ -1165,8 +1190,10 @@ fn should_respond_in_group(text: &str, mentions: &serde_json::Value, bot_names:
|
||||
|
||||
/// Strip @mention placeholders from text (`@_user_N` format).
|
||||
fn strip_mention_placeholders(text: &str) -> String {
|
||||
let re = regex_lite::Regex::new(r"@_user_\d+\s*").unwrap();
|
||||
re.replace_all(text, "").trim().to_string()
|
||||
match regex_lite::Regex::new(r"@_user_\d+\s*") {
|
||||
Ok(re) => re.replace_all(text, "").trim().to_string(),
|
||||
Err(_) => text.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt an AES-256-CBC encrypted event payload.
|
||||
|
||||
@@ -326,19 +326,17 @@ impl ChannelAdapter for IrcAdapter {
|
||||
}
|
||||
|
||||
// RPL_WELCOME (001) — registration complete, join channels
|
||||
"001" => {
|
||||
if !joined {
|
||||
info!("IRC registered as {nick_clone}");
|
||||
for ch in &channels_clone {
|
||||
let join_cmd = format!("JOIN {ch}\r\n");
|
||||
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
|
||||
warn!("IRC JOIN send failed: {e}");
|
||||
break 'inner true;
|
||||
}
|
||||
info!("IRC joining {ch}");
|
||||
"001" if !joined => {
|
||||
info!("IRC registered as {nick_clone}");
|
||||
for ch in &channels_clone {
|
||||
let join_cmd = format!("JOIN {ch}\r\n");
|
||||
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
|
||||
warn!("IRC JOIN send failed: {e}");
|
||||
break 'inner true;
|
||||
}
|
||||
joined = true;
|
||||
info!("IRC joining {ch}");
|
||||
}
|
||||
joined = true;
|
||||
}
|
||||
|
||||
// PRIVMSG — incoming message
|
||||
|
||||
@@ -48,8 +48,8 @@ pub mod discourse;
|
||||
pub mod gitter;
|
||||
pub mod gotify;
|
||||
pub mod linkedin;
|
||||
pub mod mumble;
|
||||
pub mod mqtt;
|
||||
pub mod mumble;
|
||||
pub mod ntfy;
|
||||
pub mod webhook;
|
||||
pub mod wecom;
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// LINE push message API endpoint.
|
||||
@@ -62,8 +62,8 @@ impl LineAdapter {
|
||||
pub fn new(channel_secret: String, access_token: String, webhook_port: u16) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
channel_secret: Zeroizing::new(channel_secret),
|
||||
access_token: Zeroizing::new(access_token),
|
||||
channel_secret: Zeroizing::new(channel_secret.trim().to_string()),
|
||||
access_token: Zeroizing::new(access_token.trim().to_string()),
|
||||
webhook_port,
|
||||
client: reqwest::Client::new(),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -96,12 +96,35 @@ impl LineAdapter {
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if result.len() != expected.len() {
|
||||
debug!(
|
||||
"LINE: signature length mismatch: computed={} received={}",
|
||||
result.len(),
|
||||
expected.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (a, b) in result.iter().zip(expected.iter()) {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
if diff != 0 {
|
||||
let computed = base64::engine::general_purpose::STANDARD.encode(result);
|
||||
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
|
||||
let comp_redacted = format!(
|
||||
"{}...{}",
|
||||
&computed[..4.min(computed.len())],
|
||||
&computed[computed.len().saturating_sub(4)..]
|
||||
);
|
||||
let recv_redacted = format!(
|
||||
"{}...{}",
|
||||
&signature[..4.min(signature.len())],
|
||||
&signature[signature.len().saturating_sub(4)..]
|
||||
);
|
||||
debug!(
|
||||
"LINE: signature mismatch: computed={comp_redacted} received={recv_redacted} body_len={}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
@@ -358,19 +381,18 @@ impl ChannelAdapter for LineAdapter {
|
||||
axum::routing::post({
|
||||
let secret = Arc::clone(&channel_secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
move |headers: axum::http::HeaderMap,
|
||||
body: axum::extract::Json<serde_json::Value>| {
|
||||
move |headers: axum::http::HeaderMap, body: axum::body::Bytes| {
|
||||
let secret = Arc::clone(&secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
async move {
|
||||
// Verify X-Line-Signature
|
||||
// Verify X-Line-Signature using the raw request
|
||||
// body bytes — NOT re-serialized JSON — because the
|
||||
// HMAC must be computed over the exact bytes LINE sent.
|
||||
let signature = headers
|
||||
.get("x-line-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let body_bytes = serde_json::to_vec(&body.0).unwrap_or_default();
|
||||
|
||||
// Create a temporary adapter-like verifier
|
||||
let adapter = LineAdapter {
|
||||
channel_secret: secret.as_ref().clone(),
|
||||
@@ -381,15 +403,23 @@ impl ChannelAdapter for LineAdapter {
|
||||
shutdown_rx: watch::channel(false).1,
|
||||
};
|
||||
|
||||
if !signature.is_empty()
|
||||
&& !adapter.verify_signature(&body_bytes, signature)
|
||||
if !signature.is_empty() && !adapter.verify_signature(&body, signature)
|
||||
{
|
||||
warn!("LINE: invalid webhook signature");
|
||||
return axum::http::StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
|
||||
// Parse the raw bytes into JSON after signature verification
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("LINE: failed to parse webhook body as JSON: {e}");
|
||||
return axum::http::StatusCode::BAD_REQUEST;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse events array
|
||||
if let Some(events) = body.0["events"].as_array() {
|
||||
if let Some(events) = parsed["events"].as_array() {
|
||||
for event in events {
|
||||
if let Some(msg) = parse_line_event(event) {
|
||||
let _ = tx.send(msg).await;
|
||||
@@ -626,6 +656,71 @@ mod tests {
|
||||
assert!(parse_line_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_with_raw_body() {
|
||||
// Verify that HMAC-SHA256 signature validation works with raw body bytes
|
||||
let secret = "test-channel-secret";
|
||||
let adapter = LineAdapter::new(secret.to_string(), "token".to_string(), 9000);
|
||||
|
||||
// Compute the expected signature manually
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = br#"{"events":[{"type":"message"}]}"#;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let expected_sig =
|
||||
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
assert!(adapter.verify_signature(body, &expected_sig));
|
||||
|
||||
// Re-serialized JSON should NOT match (this was the bug)
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap();
|
||||
let reserialized = serde_json::to_vec(&parsed).unwrap();
|
||||
// The re-serialized form may differ in whitespace/key order
|
||||
// If it happens to be identical for this input, the test still validates
|
||||
// the core mechanism works with raw bytes
|
||||
if reserialized != body.to_vec() {
|
||||
assert!(!adapter.verify_signature(&reserialized, &expected_sig));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_secret_trimmed() {
|
||||
// Environment variables often have trailing newlines or spaces
|
||||
let adapter = LineAdapter::new(
|
||||
" my-secret\n".to_string(),
|
||||
" my-token\r\n".to_string(),
|
||||
9000,
|
||||
);
|
||||
assert_eq!(adapter.channel_secret.as_str(), "my-secret");
|
||||
assert_eq!(adapter.access_token.as_str(), "my-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_bad_base64() {
|
||||
let adapter = LineAdapter::new("secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(b"body", "not-valid-base64!!!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_wrong_secret() {
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = b"test body";
|
||||
let mut mac = HmacSha256::new_from_slice(b"wrong-secret").unwrap();
|
||||
mac.update(body);
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
let adapter = LineAdapter::new("correct-secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_event_room_source() {
|
||||
let event = serde_json::json!({
|
||||
|
||||
@@ -18,14 +18,20 @@ use zeroize::Zeroizing;
|
||||
const SYNC_TIMEOUT_MS: u64 = 30000;
|
||||
const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// Shared access + refresh token pair. Tokens are zeroized on drop and rotated
|
||||
/// in place when MSC2918 refresh succeeds.
|
||||
type TokenPair = Arc<RwLock<(Zeroizing<String>, Option<Zeroizing<String>>)>>;
|
||||
|
||||
/// Matrix channel adapter using the Client-Server API.
|
||||
pub struct MatrixAdapter {
|
||||
/// Matrix homeserver URL (e.g., `"https://matrix.org"`).
|
||||
homeserver_url: String,
|
||||
/// Bot's user ID (e.g., "@openfang:matrix.org").
|
||||
user_id: String,
|
||||
/// SECURITY: Access token is zeroized on drop.
|
||||
access_token: Zeroizing<String>,
|
||||
/// SECURITY: Access + refresh tokens are zeroized on drop. Stored behind
|
||||
/// an RwLock so the sync loop and send paths see rotated tokens after a
|
||||
/// MSC2918 /refresh call (matrix.org/MAS rotates both tokens every refresh).
|
||||
tokens: TokenPair,
|
||||
/// HTTP client.
|
||||
client: reqwest::Client,
|
||||
/// Allowed room IDs (empty = all joined rooms).
|
||||
@@ -40,19 +46,47 @@ pub struct MatrixAdapter {
|
||||
}
|
||||
|
||||
impl MatrixAdapter {
|
||||
/// Create a new Matrix adapter.
|
||||
/// Create a new Matrix adapter without a refresh token.
|
||||
pub fn new(
|
||||
homeserver_url: String,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
Self::with_refresh_token(
|
||||
homeserver_url,
|
||||
user_id,
|
||||
access_token,
|
||||
None,
|
||||
allowed_rooms,
|
||||
auto_accept_invites,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new Matrix adapter with an optional refresh token (MSC2918).
|
||||
///
|
||||
/// When `refresh_token` is `Some`, the adapter will automatically call
|
||||
/// `POST /_matrix/client/v3/refresh` on `401 M_UNKNOWN_TOKEN` responses
|
||||
/// and retry the failed request once. Both tokens rotate on each refresh
|
||||
/// under Matrix Authentication Service (MAS).
|
||||
pub fn with_refresh_token(
|
||||
homeserver_url: String,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let tokens: TokenPair = Arc::new(RwLock::new((
|
||||
Zeroizing::new(access_token),
|
||||
refresh_token.map(Zeroizing::new),
|
||||
)));
|
||||
Self {
|
||||
homeserver_url,
|
||||
user_id,
|
||||
access_token: Zeroizing::new(access_token),
|
||||
tokens,
|
||||
client: reqwest::Client::new(),
|
||||
allowed_rooms,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -62,6 +96,11 @@ impl MatrixAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current access token (cloned).
|
||||
async fn current_access_token(&self) -> String {
|
||||
self.tokens.read().await.0.as_str().to_string()
|
||||
}
|
||||
|
||||
/// Send a text message to a Matrix room.
|
||||
async fn api_send_message(
|
||||
&self,
|
||||
@@ -81,18 +120,46 @@ impl MatrixAdapter {
|
||||
"body": chunk,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let token = self.current_access_token().await;
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Matrix API error {status}: {body}").into());
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
|
||||
// Try a single refresh+retry on M_UNKNOWN_TOKEN (MSC2918).
|
||||
if attempt == 1
|
||||
&& status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& is_unknown_token_body(&body_text)
|
||||
{
|
||||
match try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens).await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Matrix: access token refreshed via MSC2918, retrying send");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Matrix API error {status}: {body_text} (refresh failed: {e})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("Matrix API error {status}: {body_text}").into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,21 +170,32 @@ impl MatrixAdapter {
|
||||
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.send()
|
||||
.await?;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let token = self.current_access_token().await;
|
||||
let resp = self.client.get(&url).bearer_auth(&token).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
if resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
|
||||
return Ok(user_id);
|
||||
}
|
||||
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
if attempt == 1
|
||||
&& status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& is_unknown_token_body(&body_text)
|
||||
&& try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
info!("Matrix: access token refreshed via MSC2918, retrying /whoami");
|
||||
continue;
|
||||
}
|
||||
return Err("Matrix authentication failed".into());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -126,6 +204,87 @@ impl MatrixAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect `M_UNKNOWN_TOKEN` errors in a Matrix response body.
|
||||
///
|
||||
/// Matrix returns 401 for multiple reasons; we only want to refresh on
|
||||
/// `M_UNKNOWN_TOKEN` (the access token expired or was revoked). See
|
||||
/// <https://spec.matrix.org/latest/client-server-api/#soft-logout>.
|
||||
fn is_unknown_token_body(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("errcode").and_then(|c| c.as_str()).map(String::from))
|
||||
.map(|c| c == "M_UNKNOWN_TOKEN")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a Matrix 401 body indicates a hard logout (operator must re-login).
|
||||
///
|
||||
/// `soft_logout: true` (or absent — default per spec) means the device is still
|
||||
/// known to the server and a refresh-token grant is valid. `soft_logout: false`
|
||||
/// means the device was invalidated and the operator must perform a new
|
||||
/// `m.login.password` flow.
|
||||
fn is_hard_logout(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("soft_logout").and_then(|s| s.as_bool()))
|
||||
.map(|soft| !soft)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Call `POST /_matrix/client/v3/refresh` (MSC2918) and rotate the stored tokens.
|
||||
///
|
||||
/// On success, replaces the access token and (if the server returned one) the
|
||||
/// refresh token. MAS (matrix.org since 2025-04-07) rotates the refresh token
|
||||
/// on every call, so callers must use the new value next time.
|
||||
async fn try_refresh_tokens(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
tokens: &TokenPair,
|
||||
) -> Result<(), String> {
|
||||
let refresh_token = {
|
||||
let guard = tokens.read().await;
|
||||
match guard.1.as_ref() {
|
||||
Some(rt) => rt.as_str().to_string(),
|
||||
None => return Err("no refresh token configured".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{homeserver}/_matrix/client/v3/refresh");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "refresh_token": refresh_token }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("refresh request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("refresh returned {status}: {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("refresh response parse error: {e}"))?;
|
||||
|
||||
let new_access = body
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "refresh response missing access_token".to_string())?;
|
||||
let new_refresh = body
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let mut guard = tokens.write().await;
|
||||
guard.0 = Zeroizing::new(new_access.to_string());
|
||||
if let Some(rt) = new_refresh {
|
||||
guard.1 = Some(Zeroizing::new(rt));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
|
||||
async fn accept_invite(
|
||||
client: &reqwest::Client,
|
||||
@@ -218,7 +377,7 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let homeserver = self.homeserver_url.clone();
|
||||
let access_token = self.access_token.clone();
|
||||
let tokens = Arc::clone(&self.tokens);
|
||||
// Use the validated user ID from /whoami instead of the config value.
|
||||
// Matrix server delegation or casing differences can cause self.user_id
|
||||
// to not match the sender field in timeline events, making the bot
|
||||
@@ -232,9 +391,10 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
// FIX #4: Do an initial sync to get the since token, skipping old messages.
|
||||
if since_token.read().await.is_none() {
|
||||
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
|
||||
let token = self.current_access_token().await;
|
||||
if let Some(next) = initial_sync(&client, &homeserver, &token).await {
|
||||
info!("Matrix: initial sync complete, skipping old messages");
|
||||
*since_token.write().await = Some(token);
|
||||
*since_token.write().await = Some(next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,12 +417,13 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
url.push_str(&format!("&since={token}"));
|
||||
}
|
||||
|
||||
let current_token = tokens.read().await.0.as_str().to_string();
|
||||
let resp = tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Matrix adapter shutting down");
|
||||
break;
|
||||
}
|
||||
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
|
||||
result = client.get(&url).bearer_auth(¤t_token).send() => {
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -276,7 +437,38 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Matrix sync returned {}", resp.status());
|
||||
let status = resp.status();
|
||||
// MSC2918: on 401 M_UNKNOWN_TOKEN with a refresh token configured,
|
||||
// try refreshing once and loop again immediately. Hard logout
|
||||
// (soft_logout:false) is unrecoverable here — the operator must
|
||||
// perform a fresh m.login.password.
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
if is_unknown_token_body(&body_text) {
|
||||
if is_hard_logout(&body_text) {
|
||||
warn!(
|
||||
"Matrix: hard logout (soft_logout=false), operator must re-login"
|
||||
);
|
||||
} else {
|
||||
match try_refresh_tokens(&client, &homeserver, &tokens).await {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"Matrix: access token refreshed via MSC2918, resuming /sync"
|
||||
);
|
||||
backoff = Duration::from_secs(1);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Matrix: token refresh failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Matrix sync returned {status}: {body_text}");
|
||||
}
|
||||
} else {
|
||||
warn!("Matrix sync returned {status}");
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(60));
|
||||
continue;
|
||||
@@ -309,8 +501,8 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
|
||||
.await;
|
||||
let tok = tokens.read().await.0.as_str().to_string();
|
||||
accept_invite(&client, &homeserver, &tok, room_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,10 +572,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let tok_for_count = tokens.read().await.0.as_str().to_string();
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
&tok_for_count,
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
@@ -409,10 +602,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let tok_for_count = tokens.read().await.0.as_str().to_string();
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
&tok_for_count,
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
@@ -485,10 +679,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
"timeout": 5000,
|
||||
});
|
||||
|
||||
let token = self.current_access_token().await;
|
||||
let _ = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
@@ -518,6 +713,79 @@ mod tests {
|
||||
assert_eq!(adapter.name(), "matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_unknown_token_body() {
|
||||
// Real matrix.org body for M_UNKNOWN_TOKEN under MAS.
|
||||
let body =
|
||||
r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Token is not active","soft_logout":true}"#;
|
||||
assert!(is_unknown_token_body(body));
|
||||
assert!(!is_hard_logout(body));
|
||||
|
||||
let hard = r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Invalidated","soft_logout":false}"#;
|
||||
assert!(is_unknown_token_body(hard));
|
||||
assert!(is_hard_logout(hard));
|
||||
|
||||
let other = r#"{"errcode":"M_FORBIDDEN","error":"You are not allowed"}"#;
|
||||
assert!(!is_unknown_token_body(other));
|
||||
assert!(!is_hard_logout(other));
|
||||
|
||||
// Empty / non-JSON must not trigger refresh.
|
||||
assert!(!is_unknown_token_body(""));
|
||||
assert!(!is_unknown_token_body("not json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_tokens_rotates_pair() {
|
||||
// Spin up a tiny axum server that mimics MSC2918 /refresh: rotates both
|
||||
// access and refresh tokens and returns the new pair.
|
||||
use axum::{routing::post, Json, Router};
|
||||
|
||||
async fn refresh_handler(Json(body): Json<serde_json::Value>) -> Json<serde_json::Value> {
|
||||
let incoming = body
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
assert_eq!(incoming, "old_refresh");
|
||||
Json(serde_json::json!({
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "new_refresh",
|
||||
"expires_in_ms": 3_600_000u64,
|
||||
}))
|
||||
}
|
||||
|
||||
let app = Router::new().route("/_matrix/client/v3/refresh", post(refresh_handler));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let homeserver = format!("http://{addr}");
|
||||
let tokens: TokenPair = Arc::new(RwLock::new((
|
||||
Zeroizing::new("old_access".to_string()),
|
||||
Some(Zeroizing::new("old_refresh".to_string())),
|
||||
)));
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
try_refresh_tokens(&client, &homeserver, &tokens)
|
||||
.await
|
||||
.expect("refresh succeeds");
|
||||
|
||||
let guard = tokens.read().await;
|
||||
assert_eq!(guard.0.as_str(), "new_access");
|
||||
assert_eq!(guard.1.as_ref().map(|s| s.as_str()), Some("new_refresh"));
|
||||
drop(guard);
|
||||
|
||||
// Refresh with no refresh token configured must fail cleanly.
|
||||
let no_refresh: TokenPair = Arc::new(RwLock::new((Zeroizing::new("a".to_string()), None)));
|
||||
let err = try_refresh_tokens(&client, &homeserver, &no_refresh)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no refresh token"));
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matrix_allowed_rooms() {
|
||||
let adapter = MatrixAdapter::new(
|
||||
|
||||
@@ -152,7 +152,10 @@ impl MqttAdapter {
|
||||
}
|
||||
|
||||
/// Parse host:port string.
|
||||
fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
fn parse_host_port(
|
||||
s: &str,
|
||||
default_port: u16,
|
||||
) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
let s = s.trim();
|
||||
if let Some(colon_pos) = s.rfind(':') {
|
||||
let host = s[..colon_pos].to_string();
|
||||
@@ -239,7 +242,8 @@ impl ChannelAdapter for MqttAdapter {
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>> {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let options = self.build_mqtt_options()?;
|
||||
let (client, mut eventloop) = AsyncClient::new(options, 10);
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ impl ChannelAdapter for NextcloudAdapter {
|
||||
|
||||
// Use lookIntoFuture=1 and lastKnownMessageId for incremental polling
|
||||
let url = format!(
|
||||
"{}/ocs/v2.php/apps/spreed/api/v4/room/{}/chat?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
|
||||
"{}/ocs/v2.php/apps/spreed/api/v1/chat/{}?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
|
||||
server_url, room_token, last_id
|
||||
);
|
||||
|
||||
|
||||
@@ -94,6 +94,11 @@ impl RevoltAdapter {
|
||||
adapter
|
||||
}
|
||||
|
||||
/// Set allowed channel IDs (empty = all channels the bot is in).
|
||||
pub fn set_allowed_channels(&mut self, channels: Vec<String>) {
|
||||
self.allowed_channels = channels;
|
||||
}
|
||||
|
||||
/// Add the bot token header to a request builder.
|
||||
fn auth_header(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("x-bot-token", self.bot_token.as_str())
|
||||
|
||||
@@ -18,6 +18,10 @@ pub struct BindingContext {
|
||||
pub peer_id: String,
|
||||
/// Guild/server ID.
|
||||
pub guild_id: Option<String>,
|
||||
/// Channel/conversation ID (e.g. Discord channel, Slack conversation,
|
||||
/// Telegram chat, IRC channel name). Populated by bridges so bindings can
|
||||
/// route by room independent of which user posted.
|
||||
pub channel_id: Option<String>,
|
||||
/// User's roles.
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
@@ -143,6 +147,19 @@ impl AgentRouter {
|
||||
channel_type: &ChannelType,
|
||||
platform_user_id: &str,
|
||||
user_key: Option<&str>,
|
||||
) -> Option<AgentId> {
|
||||
self.resolve_with_channel_id(channel_type, platform_user_id, user_key, None)
|
||||
}
|
||||
|
||||
/// Resolve with an explicit channel/conversation ID, so bindings whose
|
||||
/// `match_rule.channel_id` is set can match. Used by bridges that know the
|
||||
/// room/conversation the message arrived in (Discord/Slack/Telegram/IRC).
|
||||
pub fn resolve_with_channel_id(
|
||||
&self,
|
||||
channel_type: &ChannelType,
|
||||
platform_user_id: &str,
|
||||
user_key: Option<&str>,
|
||||
channel_id: Option<&str>,
|
||||
) -> Option<AgentId> {
|
||||
let channel_key = format!("{channel_type:?}");
|
||||
|
||||
@@ -152,6 +169,7 @@ impl AgentRouter {
|
||||
account_id: None,
|
||||
peer_id: platform_user_id.to_string(),
|
||||
guild_id: None,
|
||||
channel_id: channel_id.map(|s| s.to_string()),
|
||||
roles: Vec::new(),
|
||||
};
|
||||
if let Some(agent_id) = self.resolve_binding(&ctx) {
|
||||
@@ -329,6 +347,11 @@ impl AgentRouter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref cid) = rule.channel_id {
|
||||
if ctx.channel_id.as_ref() != Some(cid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !rule.roles.is_empty() {
|
||||
// User must have at least one of the specified roles
|
||||
let has_role = rule.roles.iter().any(|r| ctx.roles.contains(r));
|
||||
@@ -639,7 +662,129 @@ mod tests {
|
||||
guild_id: Some("guild".to_string()),
|
||||
roles: vec!["admin".to_string()],
|
||||
account_id: Some("bot".to_string()),
|
||||
channel_id: Some("ch_42".to_string()),
|
||||
};
|
||||
assert_eq!(full.specificity(), 17); // 8+4+2+2+1
|
||||
assert_eq!(full.specificity(), 25); // 8+8+4+2+2+1
|
||||
|
||||
// peer_id alone vs channel_id alone — both worth 8.
|
||||
let peer_only = BindingMatchRule {
|
||||
peer_id: Some("u".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let channel_id_only = BindingMatchRule {
|
||||
channel_id: Some("c".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(peer_only.specificity(), 8);
|
||||
assert_eq!(channel_id_only.specificity(), 8);
|
||||
|
||||
// Combined peer_id + channel_id (16) outranks either alone (8).
|
||||
let peer_and_channel = BindingMatchRule {
|
||||
peer_id: Some("u".to_string()),
|
||||
channel_id: Some("c".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(peer_and_channel.specificity(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_channel_id_match() {
|
||||
// A binding scoped to a specific Discord channel should match messages
|
||||
// from that channel and reject messages from other channels.
|
||||
let router = AgentRouter::new();
|
||||
let agent_id = AgentId::new();
|
||||
router.register_agent("ops-bot".to_string(), agent_id);
|
||||
router.load_bindings(&[AgentBinding {
|
||||
agent: "ops-bot".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel: Some("discord".to_string()),
|
||||
channel_id: Some("1477803840265781391".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
}]);
|
||||
|
||||
// Same channel, any user — matches.
|
||||
let resolved = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"any-user",
|
||||
None,
|
||||
Some("1477803840265781391"),
|
||||
);
|
||||
assert_eq!(resolved, Some(agent_id));
|
||||
|
||||
// Different channel — no match.
|
||||
let resolved = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"any-user",
|
||||
None,
|
||||
Some("9999999999999999999"),
|
||||
);
|
||||
assert_eq!(resolved, None);
|
||||
|
||||
// Missing channel_id on the wire — no match (the binding is restrictive).
|
||||
let resolved =
|
||||
router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_channel_id_plus_peer_outranks_channel_id_alone() {
|
||||
// user A in #medical → researcher; anyone else in #medical → general.
|
||||
let router = AgentRouter::new();
|
||||
let researcher = AgentId::new();
|
||||
let general = AgentId::new();
|
||||
router.register_agent("researcher".to_string(), researcher);
|
||||
router.register_agent("general".to_string(), general);
|
||||
router.load_bindings(&[
|
||||
AgentBinding {
|
||||
agent: "general".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel_id: Some("ch-medical".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
AgentBinding {
|
||||
agent: "researcher".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel_id: Some("ch-medical".to_string()),
|
||||
peer_id: Some("user-a".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// user-a in #medical → researcher (more specific wins)
|
||||
let r = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"user-a",
|
||||
None,
|
||||
Some("ch-medical"),
|
||||
);
|
||||
assert_eq!(r, Some(researcher));
|
||||
|
||||
// user-b in #medical → general (channel_id alone matches)
|
||||
let r = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"user-b",
|
||||
None,
|
||||
Some("ch-medical"),
|
||||
);
|
||||
assert_eq!(r, Some(general));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_match_rule_unknown_field_rejected() {
|
||||
// Typos like `channnel_id` must fail loudly at deserialization rather
|
||||
// than silently producing a wide-open binding. This is the highest-
|
||||
// leverage line in the patch from issue #1127.
|
||||
let bad = r#"{ "channnel_id": "ch-1" }"#;
|
||||
let r: Result<openfang_types::config::BindingMatchRule, _> = serde_json::from_str(bad);
|
||||
assert!(r.is_err(), "unknown field must be rejected by serde");
|
||||
|
||||
// Sanity: known fields still parse.
|
||||
let good = r#"{ "channel_id": "ch-1", "channel": "discord" }"#;
|
||||
let r: openfang_types::config::BindingMatchRule = serde_json::from_str(good).unwrap();
|
||||
assert_eq!(r.channel_id.as_deref(), Some("ch-1"));
|
||||
assert_eq!(r.channel.as_deref(), Some("discord"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,38 @@ const SLACK_API_BASE: &str = "https://slack.com/api";
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const SLACK_MSG_LIMIT: usize = 3000;
|
||||
/// TTL for envelope_id dedup entries. Well above the typical Slack
|
||||
/// connection-rotation overlap window (< 10s).
|
||||
const ENVELOPE_TTL: Duration = Duration::from_secs(60);
|
||||
/// Soft cap on the dedup cache size. When exceeded we GC expired entries.
|
||||
/// Recent envelope IDs are not reused by Slack, so 10k is more than enough.
|
||||
const ENVELOPE_CACHE_CAP: usize = 10_000;
|
||||
|
||||
/// Returns true if `envelope_id` was already seen within `ENVELOPE_TTL`.
|
||||
/// On first sight, records the timestamp and returns false. Performs
|
||||
/// opportunistic GC of expired entries when the cache grows large.
|
||||
///
|
||||
/// Slack Socket Mode delivers the same event to multiple active WebSocket
|
||||
/// connections during connection rotation. Apps must dedupe on `envelope_id`
|
||||
/// to avoid double-processing.
|
||||
fn is_duplicate_envelope(cache: &DashMap<String, Instant>, envelope_id: &str) -> bool {
|
||||
if envelope_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Opportunistic GC: bound growth without per-call work.
|
||||
if cache.len() > ENVELOPE_CACHE_CAP {
|
||||
cache.retain(|_, ts| ts.elapsed() < ENVELOPE_TTL);
|
||||
}
|
||||
|
||||
if let Some(prev) = cache.get(envelope_id) {
|
||||
if prev.elapsed() < ENVELOPE_TTL {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
cache.insert(envelope_id.to_string(), Instant::now());
|
||||
false
|
||||
}
|
||||
|
||||
/// Slack Socket Mode adapter.
|
||||
pub struct SlackAdapter {
|
||||
@@ -41,6 +73,9 @@ pub struct SlackAdapter {
|
||||
auto_thread_reply: bool,
|
||||
/// Whether to unfurl (expand previews for) links in posted messages.
|
||||
unfurl_links: bool,
|
||||
/// Recently-seen envelope_ids. Slack Socket Mode redelivers the same event
|
||||
/// across rotated WebSocket connections; this prevents double-processing.
|
||||
seen_envelopes: Arc<DashMap<String, Instant>>,
|
||||
}
|
||||
|
||||
impl SlackAdapter {
|
||||
@@ -65,6 +100,7 @@ impl SlackAdapter {
|
||||
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
|
||||
auto_thread_reply,
|
||||
unfurl_links,
|
||||
seen_envelopes: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +197,7 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
let active_threads = self.active_threads.clone();
|
||||
let auto_thread_reply = self.auto_thread_reply;
|
||||
let seen_envelopes = self.seen_envelopes.clone();
|
||||
|
||||
// Spawn periodic cleanup of expired thread entries.
|
||||
{
|
||||
@@ -288,6 +325,14 @@ impl ChannelAdapter for SlackAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup: Slack redelivers the same event on the new
|
||||
// connection during the rotation overlap. Ack on
|
||||
// both, but only forward to the agent once.
|
||||
if is_duplicate_envelope(&seen_envelopes, envelope_id) {
|
||||
debug!("Slack: skipping duplicate envelope_id {envelope_id}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the event
|
||||
let event = &payload["payload"]["event"];
|
||||
if let Some(msg) = parse_slack_event(
|
||||
@@ -501,6 +546,9 @@ async fn parse_slack_event(
|
||||
|
||||
// Check if the bot was @-mentioned (for group_policy = "mention_only")
|
||||
let mut metadata = HashMap::new();
|
||||
// Stash the Slack user ID so the router can key bindings on user, not channel.
|
||||
// (`sender.platform_id` below is the channel ID, used for the send path.)
|
||||
metadata.insert("sender_user_id".to_string(), serde_json::json!(user_id));
|
||||
if event_type == "app_mention" {
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
|
||||
}
|
||||
@@ -742,4 +790,58 @@ mod tests {
|
||||
);
|
||||
assert!(!adapter.unfurl_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_skips_second_delivery() {
|
||||
// Simulates Slack redelivering the same event across a connection
|
||||
// rotation: the envelope is acked on both connections but the agent
|
||||
// must only see it once.
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
let envelope_id = "8d2e1c5a-4f3b-49a1-b6e2-7c0a9f1234ab";
|
||||
|
||||
// First delivery on the old connection: not a duplicate, forward.
|
||||
assert!(
|
||||
!is_duplicate_envelope(&cache, envelope_id),
|
||||
"first sight of envelope must not be flagged as duplicate"
|
||||
);
|
||||
|
||||
// Second delivery on the new connection: duplicate, skip.
|
||||
assert!(
|
||||
is_duplicate_envelope(&cache, envelope_id),
|
||||
"second sight of same envelope must be flagged as duplicate"
|
||||
);
|
||||
|
||||
// Simulate the receive-loop pattern: count how many times the agent
|
||||
// would actually be invoked across two deliveries.
|
||||
let mut agent_invocations = 0;
|
||||
for _delivery in 0..2 {
|
||||
if !is_duplicate_envelope(&cache, envelope_id) {
|
||||
agent_invocations += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
agent_invocations, 0,
|
||||
"after initial double-delivery, no further invocations should occur within TTL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_distinct_ids_pass_through() {
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-a"));
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-b"));
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-c"));
|
||||
// Each unique envelope_id should be seen exactly once.
|
||||
assert_eq!(cache.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_empty_id_never_dedupes() {
|
||||
// Defensive: malformed payloads with no envelope_id should not poison
|
||||
// the cache or short-circuit forwarding.
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
assert!(!is_duplicate_envelope(&cache, ""));
|
||||
assert!(!is_duplicate_envelope(&cache, ""));
|
||||
assert_eq!(cache.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,16 @@ pub enum ChannelContent {
|
||||
File {
|
||||
url: String,
|
||||
filename: String,
|
||||
/// Best-effort MIME type from the source platform (e.g. Discord's
|
||||
/// `attachments[].content_type`). `None` if the platform did not
|
||||
/// provide one; downstream consumers may sniff bytes or fall back
|
||||
/// to extension-based detection.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mime: Option<String>,
|
||||
/// Size in bytes, when known. Useful for capacity gating before
|
||||
/// the bridge attempts to materialize or transmit the file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
size: Option<u64>,
|
||||
},
|
||||
/// Local file data (bytes read from disk). Used by the proactive `channel_send`
|
||||
/// tool when `file_path` is provided instead of `file_url`.
|
||||
@@ -70,6 +80,12 @@ pub enum ChannelContent {
|
||||
name: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// A composite message carrying multiple content blocks (e.g. a Discord
|
||||
/// message with several attachments, or an image with a separate file
|
||||
/// sibling). Blocks are flat-mapped by the bridge into multiple LLM
|
||||
/// content blocks. Implementations should not produce nested `Multipart`
|
||||
/// values; consumers may `debug_assert!` against nesting.
|
||||
Multipart(Vec<ChannelContent>),
|
||||
}
|
||||
|
||||
/// A unified message from any channel.
|
||||
@@ -97,6 +113,60 @@ pub struct ChannelMessage {
|
||||
pub metadata: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
// Re-export the adapter allowlist from openfang-types so config validation
|
||||
// and routing share a single source of truth (no drift between the two).
|
||||
pub use openfang_types::config::CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL;
|
||||
|
||||
impl ChannelMessage {
|
||||
/// Return the platform-native channel/conversation ID for this message,
|
||||
/// suitable for matching against an `AgentBinding`'s `channel_id` field.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. For adapters in [`CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL`],
|
||||
/// `sender.platform_id` already *is* the channel ID (these adapters
|
||||
/// overload the field because it doubles as the send target).
|
||||
/// 2. Otherwise, fall back to `metadata["channel_id"]` if present (any
|
||||
/// adapter can opt in by populating that key).
|
||||
/// 3. Otherwise, `None`.
|
||||
///
|
||||
/// This is the central routing-time accessor — config validation and the
|
||||
/// router both consult it (directly or via the same allowlist) so the two
|
||||
/// cannot drift.
|
||||
pub fn channel_id(&self) -> Option<String> {
|
||||
// For builtin variants the string is already lowercase by construction.
|
||||
// For `Custom(s)`, adapters _should_ register lowercase names but we
|
||||
// case-fold here so a stray `Custom("Twitch")` cannot silently slip
|
||||
// past the allowlist (and out of step with the validation path, which
|
||||
// already lowercases user input). Allocates only on the Custom arm.
|
||||
let channel_str: std::borrow::Cow<'_, str> = match &self.channel {
|
||||
ChannelType::Telegram => "telegram".into(),
|
||||
ChannelType::Discord => "discord".into(),
|
||||
ChannelType::Slack => "slack".into(),
|
||||
ChannelType::WhatsApp => "whatsapp".into(),
|
||||
ChannelType::Signal => "signal".into(),
|
||||
ChannelType::Matrix => "matrix".into(),
|
||||
ChannelType::Email => "email".into(),
|
||||
ChannelType::Teams => "teams".into(),
|
||||
ChannelType::Mattermost => "mattermost".into(),
|
||||
ChannelType::WebChat => "webchat".into(),
|
||||
ChannelType::CLI => "cli".into(),
|
||||
ChannelType::Mqtt => "mqtt".into(),
|
||||
ChannelType::Custom(s) => s.to_lowercase().into(),
|
||||
};
|
||||
if CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL
|
||||
.iter()
|
||||
.any(|c| *c == channel_str.as_ref())
|
||||
{
|
||||
Some(self.sender.platform_id.clone())
|
||||
} else {
|
||||
self.metadata
|
||||
.get("channel_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent lifecycle phase for UX indicators.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -271,6 +341,24 @@ pub trait ChannelAdapter: Send + Sync {
|
||||
self.send(user, content).await
|
||||
}
|
||||
|
||||
/// Determine whether to auto-create a thread for an incoming message.
|
||||
/// Returns Some(thread_name) to create a thread, or None to reply directly.
|
||||
/// Default implementation returns None (no auto-threading).
|
||||
async fn should_auto_thread(&self, _message: &ChannelMessage) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a new thread (typically triggered after should_auto_thread returns Some).
|
||||
/// Returns the new thread ID on success.
|
||||
async fn create_thread(
|
||||
&self,
|
||||
_user: &ChannelUser,
|
||||
_message_id: &str,
|
||||
_thread_name: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
Err("Thread creation not supported for this adapter".into())
|
||||
}
|
||||
|
||||
/// Whether this adapter should suppress sending internal agent errors back to the user.
|
||||
///
|
||||
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
|
||||
@@ -365,6 +453,34 @@ mod tests {
|
||||
assert_eq!(back, ChannelType::Email);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_id_custom_arm_is_case_insensitive() {
|
||||
// A stray capitalized Custom variant must still resolve through the
|
||||
// allowlist. The validation path lowercases user input; the routing
|
||||
// path needs the same case-fold to stay in sync.
|
||||
let make = |name: &str| ChannelMessage {
|
||||
channel: ChannelType::Custom(name.to_string()),
|
||||
platform_message_id: "m".to_string(),
|
||||
sender: ChannelUser {
|
||||
platform_id: "C123".to_string(),
|
||||
display_name: "x".to_string(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content: ChannelContent::Text("hi".to_string()),
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: false,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
assert_eq!(make("twitch").channel_id().as_deref(), Some("C123"));
|
||||
assert_eq!(make("Twitch").channel_id().as_deref(), Some("C123"));
|
||||
assert_eq!(make("TWITCH").channel_id().as_deref(), Some("C123"));
|
||||
// Lark spelling (Feishu Intl) must also match.
|
||||
assert_eq!(make("lark").channel_id().as_deref(), Some("C123"));
|
||||
assert_eq!(make("Lark").channel_id().as_deref(), Some("C123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_content_variants() {
|
||||
let text = ChannelContent::Text("hello".to_string());
|
||||
|
||||
@@ -271,7 +271,7 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
return Err(format!("WhatsApp API error {status}: {body}").into());
|
||||
}
|
||||
}
|
||||
ChannelContent::File { url, filename } => {
|
||||
ChannelContent::File { url, filename, .. } => {
|
||||
let body = serde_json::json!({
|
||||
"messaging_product": "whatsapp",
|
||||
"to": user.platform_id,
|
||||
|
||||
+557
-28
@@ -237,6 +237,9 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Dashboard authentication [*].
|
||||
#[command(subcommand)]
|
||||
Auth(AuthCommands),
|
||||
/// Security tools and audit trail [*].
|
||||
#[command(subcommand)]
|
||||
Security(SecurityCommands),
|
||||
@@ -403,6 +406,9 @@ enum HandCommands {
|
||||
Activate {
|
||||
/// Hand ID (e.g. "clip", "lead", "researcher").
|
||||
id: String,
|
||||
/// Optional instance name. Required to run multiple instances of the same hand.
|
||||
#[arg(long, short = 'n')]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Deactivate an active hand instance.
|
||||
Deactivate {
|
||||
@@ -434,6 +440,27 @@ enum HandCommands {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
/// Get, set, or list settings for an active hand instance.
|
||||
///
|
||||
/// With no flags, prints the current settings. Use `--set KEY=VAL`
|
||||
/// (repeatable) to update values, `--unset KEY` to remove a value,
|
||||
/// or `--get KEY` to print a single value.
|
||||
Config {
|
||||
/// Hand ID (e.g. "browser", "clip").
|
||||
id: String,
|
||||
/// Print a single setting value.
|
||||
#[arg(long, value_name = "KEY", conflicts_with_all = ["set", "unset", "list"])]
|
||||
get: Option<String>,
|
||||
/// Set a setting value. Format: `KEY=VALUE`. May be repeated.
|
||||
#[arg(long, value_name = "KEY=VALUE")]
|
||||
set: Vec<String>,
|
||||
/// Unset a setting key. May be repeated.
|
||||
#[arg(long, value_name = "KEY")]
|
||||
unset: Vec<String>,
|
||||
/// List the current settings (default when no other flag is given).
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -679,6 +706,12 @@ enum CronCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AuthCommands {
|
||||
/// Generate an Argon2id password hash for dashboard authentication.
|
||||
HashPassword,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SecurityCommands {
|
||||
/// Show security status summary.
|
||||
@@ -991,13 +1024,20 @@ fn main() {
|
||||
HandCommands::List => cmd_hand_list(),
|
||||
HandCommands::Active => cmd_hand_active(),
|
||||
HandCommands::Install { path } => cmd_hand_install(&path),
|
||||
HandCommands::Activate { id } => cmd_hand_activate(&id),
|
||||
HandCommands::Activate { id, name } => cmd_hand_activate(&id, name),
|
||||
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
|
||||
HandCommands::Info { id } => cmd_hand_info(&id),
|
||||
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
|
||||
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
|
||||
HandCommands::Pause { id } => cmd_hand_pause(&id),
|
||||
HandCommands::Resume { id } => cmd_hand_resume(&id),
|
||||
HandCommands::Config {
|
||||
id,
|
||||
get,
|
||||
set,
|
||||
unset,
|
||||
list,
|
||||
} => cmd_hand_config(&id, get.as_deref(), &set, &unset, list),
|
||||
},
|
||||
Some(Commands::Config(sub)) => match sub {
|
||||
ConfigCommands::Show => cmd_config_show(),
|
||||
@@ -1057,6 +1097,9 @@ fn main() {
|
||||
Some(Commands::Sessions { agent, json }) => cmd_sessions(agent.as_deref(), json),
|
||||
Some(Commands::Logs { lines, follow }) => cmd_logs(lines, follow),
|
||||
Some(Commands::Health { json }) => cmd_health(json),
|
||||
Some(Commands::Auth(sub)) => match sub {
|
||||
AuthCommands::HashPassword => cmd_auth_hash_password(),
|
||||
},
|
||||
Some(Commands::Security(sub)) => match sub {
|
||||
SecurityCommands::Status { json } => cmd_security_status(json),
|
||||
SecurityCommands::Audit { limit, json } => cmd_security_audit(limit, json),
|
||||
@@ -1433,9 +1476,30 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
|
||||
"openrouter/google/gemini-2.5-flash",
|
||||
"OpenRouter",
|
||||
),
|
||||
("minimax", "MINIMAX_API_KEY", "MiniMax-M2.7", "MiniMax"),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provider_list_tests {
|
||||
use super::provider_list;
|
||||
|
||||
#[test]
|
||||
fn provider_list_includes_minimax() {
|
||||
let minimax = provider_list()
|
||||
.into_iter()
|
||||
.find(|(provider, _, _, _)| *provider == "minimax");
|
||||
assert!(
|
||||
minimax.is_some(),
|
||||
"MiniMax should be exposed by provider_list()"
|
||||
);
|
||||
let (_, env_var, model, display) = minimax.unwrap();
|
||||
assert_eq!(env_var, "MINIMAX_API_KEY");
|
||||
assert_eq!(model, "MiniMax-M2.7");
|
||||
assert_eq!(display, "MiniMax");
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick probe to check if Ollama is running on localhost.
|
||||
fn check_ollama_available() -> bool {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
@@ -2414,6 +2478,7 @@ decay_rate = 0.05
|
||||
("TOGETHER_API_KEY", "Together", "together"),
|
||||
("MISTRAL_API_KEY", "Mistral", "mistral"),
|
||||
("FIREWORKS_API_KEY", "Fireworks", "fireworks"),
|
||||
("AWS_BEARER_TOKEN_BEDROCK", "AWS Bedrock", "bedrock"),
|
||||
];
|
||||
|
||||
let mut any_key_set = false;
|
||||
@@ -2442,6 +2507,20 @@ decay_rate = 0.05
|
||||
}
|
||||
}
|
||||
|
||||
// Check GitHub Copilot auth (separate from env var checks)
|
||||
{
|
||||
let openfang_dir = cli_openfang_home();
|
||||
if openfang_runtime::drivers::copilot::copilot_auth_available(&openfang_dir) {
|
||||
any_key_set = true;
|
||||
if !json {
|
||||
ui::check_ok("GitHub Copilot (authenticated via device flow)");
|
||||
}
|
||||
checks.push(
|
||||
serde_json::json!({"check": "provider", "name": "GitHub Copilot", "status": "ok"}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !any_key_set {
|
||||
if !json {
|
||||
println!();
|
||||
@@ -3510,6 +3589,7 @@ fn cmd_skill_install(source: &str) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to convert OpenClaw skill: {e}");
|
||||
@@ -3539,6 +3619,7 @@ fn cmd_skill_install(source: &str) {
|
||||
"Installed skill: {} v{}",
|
||||
manifest.skill.name, manifest.skill.version
|
||||
);
|
||||
notify_daemon_skill_reload();
|
||||
} else if source.starts_with("https://")
|
||||
|| source.starts_with("http://")
|
||||
|| source.starts_with("git@")
|
||||
@@ -3588,6 +3669,7 @@ fn cmd_skill_install(source: &str) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to convert OpenClaw skill: {e}");
|
||||
@@ -3616,6 +3698,7 @@ fn cmd_skill_install(source: &str) {
|
||||
"Installed skill: {} v{}",
|
||||
manifest.skill.name, manifest.skill.version
|
||||
);
|
||||
notify_daemon_skill_reload();
|
||||
} else {
|
||||
// Remote install from FangHub
|
||||
println!("Installing {source} from FangHub...");
|
||||
@@ -3624,7 +3707,10 @@ fn cmd_skill_install(source: &str) {
|
||||
openfang_skills::marketplace::MarketplaceConfig::default(),
|
||||
);
|
||||
match rt.block_on(client.install(source, &skills_dir)) {
|
||||
Ok(version) => println!("Installed {source} {version}"),
|
||||
Ok(version) => {
|
||||
println!("Installed {source} {version}");
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install skill: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -3633,6 +3719,25 @@ fn cmd_skill_install(source: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify the running daemon to hot-reload its skill registry after a CLI install.
|
||||
///
|
||||
/// If the daemon is not running, this is a no-op with a hint to the user.
|
||||
fn notify_daemon_skill_reload() {
|
||||
if let Some(base) = find_daemon() {
|
||||
let client = daemon_client();
|
||||
match client.post(format!("{base}/api/skills/reload")).send() {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
ui::step("Daemon notified — skill registry reloaded.");
|
||||
}
|
||||
_ => {
|
||||
ui::check_warn("Could not notify daemon. Restart with: openfang restart");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui::hint("Start the daemon to make this skill available to agents: openfang start");
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_skill_list() {
|
||||
let home = openfang_home();
|
||||
let skills_dir = home.join("skills");
|
||||
@@ -4330,23 +4435,37 @@ fn cmd_hand_active() {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_activate(id: &str) {
|
||||
fn cmd_hand_activate(id: &str, name: Option<String>) {
|
||||
let base = require_daemon("hand activate");
|
||||
let client = daemon_client();
|
||||
let request_body = match &name {
|
||||
Some(n) => serde_json::json!({ "instance_name": n }).to_string(),
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/activate"))
|
||||
.header("content-type", "application/json")
|
||||
.body("{}")
|
||||
.body(request_body)
|
||||
.send(),
|
||||
);
|
||||
if body.get("instance_id").is_some() {
|
||||
println!(
|
||||
"Hand '{}' activated (instance: {}, agent: {})",
|
||||
id,
|
||||
body["instance_id"].as_str().unwrap_or("?"),
|
||||
body["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
if let Some(n) = &name {
|
||||
println!(
|
||||
"Hand '{}' activated (instance: {}, name: {}, agent: {})",
|
||||
id,
|
||||
body["instance_id"].as_str().unwrap_or("?"),
|
||||
n,
|
||||
body["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Hand '{}' activated (instance: {}, agent: {})",
|
||||
id,
|
||||
body["instance_id"].as_str().unwrap_or("?"),
|
||||
body["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed to activate hand '{}': {}",
|
||||
@@ -4495,6 +4614,167 @@ fn cmd_hand_resume(id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `KEY=VALUE` pair passed to `--set`.
|
||||
///
|
||||
/// Empty keys are rejected so `--set =foo` or `--set =bar` surface a clear
|
||||
/// error rather than silently writing a blank setting name.
|
||||
fn parse_hand_config_pair(pair: &str) -> Result<(String, String), String> {
|
||||
let (key, value) = pair
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("Invalid --set '{pair}': expected KEY=VALUE"))?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err(format!("Invalid --set '{pair}': empty key"));
|
||||
}
|
||||
Ok((key.to_string(), value.to_string()))
|
||||
}
|
||||
|
||||
fn cmd_hand_config(
|
||||
id: &str,
|
||||
get: Option<&str>,
|
||||
set_pairs: &[String],
|
||||
unset_keys: &[String],
|
||||
list: bool,
|
||||
) {
|
||||
let base = require_daemon("hand config");
|
||||
let client = daemon_client();
|
||||
|
||||
// Always fetch current state first so we can merge updates and print
|
||||
// a useful view even when the target hand has no active instance.
|
||||
let url = format!("{base}/api/hands/{id}/settings");
|
||||
let body = daemon_json(client.get(&url).send());
|
||||
|
||||
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
|
||||
ui::error(&format!("Hand '{id}': {err}"));
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut current: std::collections::BTreeMap<String, serde_json::Value> = body
|
||||
.get("current_values")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let schema_defaults: std::collections::BTreeMap<String, String> = body
|
||||
.get("settings")
|
||||
.and_then(|v| v.get("settings"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|s| {
|
||||
let key = s.get("key").and_then(|v| v.as_str())?.to_string();
|
||||
let default = s
|
||||
.get("default")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some((key, default))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Pure read paths — no mutation, no daemon round-trip beyond the GET.
|
||||
if let Some(key) = get {
|
||||
match current
|
||||
.get(key)
|
||||
.map(value_to_display)
|
||||
.or_else(|| schema_defaults.get(key).cloned())
|
||||
{
|
||||
Some(val) => println!("{val}"),
|
||||
None => {
|
||||
ui::error(&format!("No setting '{key}' on hand '{id}'"));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let is_mutation = !set_pairs.is_empty() || !unset_keys.is_empty();
|
||||
if !is_mutation {
|
||||
print_hand_config(id, ¤t, &schema_defaults, list);
|
||||
return;
|
||||
}
|
||||
|
||||
for pair in set_pairs {
|
||||
match parse_hand_config_pair(pair) {
|
||||
Ok((k, v)) => {
|
||||
current.insert(k, serde_json::Value::String(v));
|
||||
}
|
||||
Err(e) => {
|
||||
ui::error(&e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in unset_keys {
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
ui::error("Invalid --unset: empty key");
|
||||
std::process::exit(1);
|
||||
}
|
||||
current.remove(key);
|
||||
}
|
||||
|
||||
let payload: serde_json::Map<String, serde_json::Value> = current.clone().into_iter().collect();
|
||||
let resp = daemon_json(
|
||||
client
|
||||
.put(&url)
|
||||
.json(&serde_json::Value::Object(payload))
|
||||
.send(),
|
||||
);
|
||||
if let Some(err) = resp.get("error").and_then(|v| v.as_str()) {
|
||||
ui::error(&format!("Failed to update hand '{id}' settings: {err}"));
|
||||
if err.contains("No active instance") {
|
||||
ui::hint(&format!(
|
||||
"Activate the hand first: openfang hand activate {id}"
|
||||
));
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
ui::success(&format!("Updated settings for hand '{id}'."));
|
||||
print_hand_config(id, ¤t, &schema_defaults, true);
|
||||
}
|
||||
|
||||
/// Human-readable display for a JSON setting value.
|
||||
fn value_to_display(v: &serde_json::Value) -> String {
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_hand_config(
|
||||
id: &str,
|
||||
current: &std::collections::BTreeMap<String, serde_json::Value>,
|
||||
schema_defaults: &std::collections::BTreeMap<String, String>,
|
||||
_list: bool,
|
||||
) {
|
||||
if current.is_empty() && schema_defaults.is_empty() {
|
||||
println!("No settings configured for hand '{id}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
println!("Settings for hand '{id}':");
|
||||
let mut keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
|
||||
for k in current.keys() {
|
||||
keys.insert(k.as_str());
|
||||
}
|
||||
for k in schema_defaults.keys() {
|
||||
keys.insert(k.as_str());
|
||||
}
|
||||
for key in keys {
|
||||
match current.get(key) {
|
||||
Some(v) => println!(" {key} = {}", value_to_display(v)),
|
||||
None => {
|
||||
let default = schema_defaults.get(key).map(|s| s.as_str()).unwrap_or("");
|
||||
println!(" {key} = {default} (default)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider / API key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4566,6 +4846,9 @@ pub(crate) fn test_api_key(provider: &str, env_var: &str) -> bool {
|
||||
.get("https://openrouter.ai/api/v1/models")
|
||||
.bearer_auth(&key)
|
||||
.send(),
|
||||
// Bedrock bearer tokens are only valid against bedrock-runtime, not the
|
||||
// management plane. There is no cheap region-agnostic probe, so skip.
|
||||
"bedrock" => return true,
|
||||
_ => return true, // unknown provider — skip test
|
||||
};
|
||||
|
||||
@@ -4678,6 +4961,38 @@ fn cmd_config_edit() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of looking up a dotted key path in a parsed TOML config.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ConfigGetOutcome {
|
||||
/// Scalar value formatted for display (may be the empty string).
|
||||
Value(String),
|
||||
/// Key exists but resolves to a non-scalar (table or array).
|
||||
NonScalar,
|
||||
/// Key path does not exist.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Look up a dotted key path inside a parsed TOML document and format the
|
||||
/// resulting scalar for display. Pure function so the behaviour can be tested
|
||||
/// without touching the filesystem.
|
||||
fn lookup_config_value(table: &toml::Value, key: &str) -> ConfigGetOutcome {
|
||||
let mut current = table;
|
||||
for part in key.split('.') {
|
||||
match current.get(part) {
|
||||
Some(v) => current = v,
|
||||
None => return ConfigGetOutcome::NotFound,
|
||||
}
|
||||
}
|
||||
match current {
|
||||
toml::Value::String(s) => ConfigGetOutcome::Value(s.clone()),
|
||||
toml::Value::Integer(i) => ConfigGetOutcome::Value(i.to_string()),
|
||||
toml::Value::Float(f) => ConfigGetOutcome::Value(f.to_string()),
|
||||
toml::Value::Boolean(b) => ConfigGetOutcome::Value(b.to_string()),
|
||||
toml::Value::Datetime(d) => ConfigGetOutcome::Value(d.to_string()),
|
||||
toml::Value::Array(_) | toml::Value::Table(_) => ConfigGetOutcome::NonScalar,
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_config_get(key: &str) {
|
||||
let home = openfang_home();
|
||||
let config_path = home.join("config.toml");
|
||||
@@ -4700,25 +5015,19 @@ fn cmd_config_get(key: &str) {
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Navigate dotted path
|
||||
let mut current = &table;
|
||||
for part in key.split('.') {
|
||||
match current.get(part) {
|
||||
Some(v) => current = v,
|
||||
None => {
|
||||
ui::error(&format!("Key not found: {key}"));
|
||||
std::process::exit(1);
|
||||
}
|
||||
match lookup_config_value(&table, key) {
|
||||
ConfigGetOutcome::Value(s) => println!("{s}"),
|
||||
ConfigGetOutcome::NonScalar => {
|
||||
ui::error_with_fix(
|
||||
&format!("'{key}' is a section, not a scalar value"),
|
||||
"Use a deeper dotted key (e.g. `section.field`)",
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
ConfigGetOutcome::NotFound => {
|
||||
ui::error(&format!("Key not found: {key}"));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Print value
|
||||
match current {
|
||||
toml::Value::String(s) => println!("{s}"),
|
||||
toml::Value::Integer(i) => println!("{i}"),
|
||||
toml::Value::Float(f) => println!("{f}"),
|
||||
toml::Value::Boolean(b) => println!("{b}"),
|
||||
other => println!("{other}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4914,6 +5223,29 @@ fn cmd_config_unset(key: &str) {
|
||||
}
|
||||
|
||||
fn cmd_config_set_key(provider: &str) {
|
||||
// GitHub Copilot uses OAuth device flow, not a simple API key paste.
|
||||
if provider == "github-copilot" || provider == "copilot" {
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
|
||||
ui::error(&format!("Failed to create async runtime: {e}"));
|
||||
std::process::exit(1);
|
||||
});
|
||||
match rt.block_on(openfang_runtime::drivers::copilot::run_interactive_setup(
|
||||
&openfang_dir,
|
||||
)) {
|
||||
Ok(_) => {
|
||||
ui::success("GitHub Copilot configured successfully");
|
||||
ui::hint("Restart the daemon: openfang stop && openfang start");
|
||||
}
|
||||
Err(e) => {
|
||||
ui::error(&format!("Copilot setup failed: {e}"));
|
||||
ui::hint("Check your Client ID/Secret and try again");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let env_var = provider_to_env_var(provider);
|
||||
|
||||
let key = prompt_input(&format!(" Paste your {provider} API key: "));
|
||||
@@ -5959,6 +6291,28 @@ fn cmd_health(json: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_auth_hash_password() {
|
||||
let password = prompt_input("Enter password: ");
|
||||
if password.is_empty() {
|
||||
ui::error("Empty password.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let confirm = prompt_input("Confirm password: ");
|
||||
if password != confirm {
|
||||
ui::error("Passwords do not match.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let hash = openfang_api::session_auth::hash_password(&password);
|
||||
println!();
|
||||
ui::success("Argon2id hash generated. Add this to your config.toml:");
|
||||
println!();
|
||||
println!(" [auth]");
|
||||
println!(" enabled = true");
|
||||
println!(" password_hash = \"{}\"", hash);
|
||||
println!();
|
||||
ui::hint("Restart the daemon after updating config.toml");
|
||||
}
|
||||
|
||||
fn cmd_security_status(json: bool) {
|
||||
let base = require_daemon("security status");
|
||||
let client = daemon_client();
|
||||
@@ -6907,8 +7261,183 @@ args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
assert_eq!(events.len(), 4);
|
||||
}
|
||||
|
||||
// --- Config get command unit tests ---
|
||||
|
||||
fn sample_config_with_base_url() -> &'static str {
|
||||
r#"api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
provider = "openai"
|
||||
model = "qwen3-coder-30b/qwen3-coder-30b"
|
||||
api_key_env = "OPENAI_API_KEY"
|
||||
base_url = "http://localhost:8991/v1"
|
||||
api_key = "sk-bf-test"
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
"#
|
||||
}
|
||||
|
||||
fn lookup(toml_str: &str, key: &str) -> super::ConfigGetOutcome {
|
||||
let table: toml::Value = toml::from_str(toml_str).expect("valid toml");
|
||||
super::lookup_config_value(&table, key)
|
||||
}
|
||||
|
||||
// Regression test for issue #905: `config get default_model.base_url`
|
||||
// must return the configured base_url string, not an empty string.
|
||||
#[test]
|
||||
fn config_get_returns_default_model_base_url() {
|
||||
let out = lookup(sample_config_with_base_url(), "default_model.base_url");
|
||||
assert_eq!(
|
||||
out,
|
||||
super::ConfigGetOutcome::Value("http://localhost:8991/v1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_returns_each_default_model_scalar() {
|
||||
let cfg = sample_config_with_base_url();
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.provider"),
|
||||
super::ConfigGetOutcome::Value("openai".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.model"),
|
||||
super::ConfigGetOutcome::Value("qwen3-coder-30b/qwen3-coder-30b".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.api_key_env"),
|
||||
super::ConfigGetOutcome::Value("OPENAI_API_KEY".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.api_key"),
|
||||
super::ConfigGetOutcome::Value("sk-bf-test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_top_level_scalar() {
|
||||
assert_eq!(
|
||||
lookup(sample_config_with_base_url(), "api_listen"),
|
||||
super::ConfigGetOutcome::Value("127.0.0.1:4200".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_unset_base_url_is_not_found() {
|
||||
let cfg = r#"
|
||||
[default_model]
|
||||
provider = "openai"
|
||||
model = "gpt-4o"
|
||||
api_key_env = "OPENAI_API_KEY"
|
||||
"#;
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.base_url"),
|
||||
super::ConfigGetOutcome::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_explicit_empty_string_round_trips_as_empty() {
|
||||
let cfg = r#"
|
||||
[default_model]
|
||||
provider = "openai"
|
||||
base_url = ""
|
||||
"#;
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.base_url"),
|
||||
super::ConfigGetOutcome::Value(String::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_missing_key_returns_not_found() {
|
||||
assert_eq!(
|
||||
lookup(sample_config_with_base_url(), "default_model.nope"),
|
||||
super::ConfigGetOutcome::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_section_reports_non_scalar() {
|
||||
assert_eq!(
|
||||
lookup(sample_config_with_base_url(), "default_model"),
|
||||
super::ConfigGetOutcome::NonScalar
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_numeric_and_boolean_scalars() {
|
||||
let cfg = r#"
|
||||
retries = 3
|
||||
ratio = 0.25
|
||||
enabled = true
|
||||
"#;
|
||||
assert_eq!(
|
||||
lookup(cfg, "retries"),
|
||||
super::ConfigGetOutcome::Value("3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "ratio"),
|
||||
super::ConfigGetOutcome::Value("0.25".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "enabled"),
|
||||
super::ConfigGetOutcome::Value("true".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// --- Uninstall command unit tests ---
|
||||
|
||||
// --- hand config command unit tests ---
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_ok() {
|
||||
let (k, v) = super::parse_hand_config_pair("headless=true").unwrap();
|
||||
assert_eq!(k, "headless");
|
||||
assert_eq!(v, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_value_may_contain_equals() {
|
||||
let (k, v) = super::parse_hand_config_pair("url=https://example.com?a=b").unwrap();
|
||||
assert_eq!(k, "url");
|
||||
assert_eq!(v, "https://example.com?a=b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_value_may_be_empty() {
|
||||
// Empty values are valid (useful to explicitly blank a setting before
|
||||
// PUT). Empty keys are the failure case.
|
||||
let (k, v) = super::parse_hand_config_pair("foo=").unwrap();
|
||||
assert_eq!(k, "foo");
|
||||
assert_eq!(v, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_rejects_empty_key() {
|
||||
assert!(super::parse_hand_config_pair("=bar").is_err());
|
||||
assert!(super::parse_hand_config_pair(" =bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_requires_equals() {
|
||||
assert!(super::parse_hand_config_pair("headless").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_multiple_pairs_round_trip() {
|
||||
let inputs = ["a=1", "b=two", "c=http://x.y"];
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
for pair in inputs {
|
||||
let (k, v) = super::parse_hand_config_pair(pair).unwrap();
|
||||
map.insert(k, v);
|
||||
}
|
||||
assert_eq!(map.get("a"), Some(&"1".to_string()));
|
||||
assert_eq!(map.get("b"), Some(&"two".to_string()));
|
||||
assert_eq!(map.get("c"), Some(&"http://x.y".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uninstall_path_line_filter() {
|
||||
use super::is_openfang_path_line;
|
||||
|
||||
@@ -121,6 +121,11 @@ pub enum AppEvent {
|
||||
SkillUninstalled(String),
|
||||
/// MCP servers loaded.
|
||||
McpServersLoaded(Vec<McpServerInfo>),
|
||||
/// Skill config details loaded (installed skill `c` key).
|
||||
SkillConfigLoaded {
|
||||
skill: String,
|
||||
rows: Vec<crate::tui::screens::skills::SkillConfigVarDetail>,
|
||||
},
|
||||
/// Templates providers loaded (auth status).
|
||||
TemplateProvidersLoaded(Vec<ProviderAuth>),
|
||||
/// Security features loaded.
|
||||
@@ -1417,18 +1422,36 @@ pub fn spawn_fetch_skills(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
let client = daemon_client();
|
||||
if let Ok(resp) = client.get(format!("{base_url}/api/skills")).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let skills: Vec<SkillInfo> = body
|
||||
.as_array()
|
||||
// API returns {"skills": [...], "total": N} — extract the inner array.
|
||||
// Fall back to bare array for backward compat.
|
||||
let items = body
|
||||
.get("skills")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| body.as_array());
|
||||
let skills: Vec<SkillInfo> = items
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|s| SkillInfo {
|
||||
name: s["name"].as_str().unwrap_or("").to_string(),
|
||||
runtime: s["runtime"].as_str().unwrap_or("").to_string(),
|
||||
source: s["source"].as_str().unwrap_or("").to_string(),
|
||||
// "source" is an object {"type": "..."} — extract the type string
|
||||
source: s["source"]["type"]
|
||||
.as_str()
|
||||
.or_else(|| s["source"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
description: s["description"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
config_declared: s["config_declared_count"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
as usize,
|
||||
config_resolved: s["config_resolved_count"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
as usize,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -1594,6 +1617,89 @@ pub fn spawn_fetch_mcp_servers(backend: BackendRef, tx: mpsc::Sender<AppEvent>)
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch declared + resolved config for a specific installed skill.
|
||||
///
|
||||
/// Pulls `GET /api/skills/{id}/config` and flattens the response into the
|
||||
/// `SkillConfigVarDetail` rows that the TUI details pane renders. Secret
|
||||
/// values are already redacted by the daemon so nothing sensitive crosses
|
||||
/// the wire here.
|
||||
pub fn spawn_fetch_skill_config(
|
||||
backend: BackendRef,
|
||||
skill_name: String,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
use crate::tui::screens::skills::SkillConfigVarDetail;
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let encoded: String = skill_name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
c.to_string()
|
||||
} else {
|
||||
format!("%{:02X}", c as u32)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let url = format!("{base_url}/api/skills/{encoded}/config");
|
||||
if let Ok(resp) = client.get(&url).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let declared = body.get("declared").cloned().unwrap_or_default();
|
||||
let resolved = body.get("resolved").cloned().unwrap_or_default();
|
||||
let mut rows: Vec<SkillConfigVarDetail> = Vec::new();
|
||||
if let Some(obj) = declared.as_object() {
|
||||
// Sort keys for deterministic output.
|
||||
let mut keys: Vec<&String> = obj.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
let d = &obj[k];
|
||||
let r = resolved.get(k).cloned().unwrap_or_default();
|
||||
let value_hint = r
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
rows.push(SkillConfigVarDetail {
|
||||
name: k.clone(),
|
||||
description: d
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
required: d
|
||||
.get("required")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
source: r
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unresolved")
|
||||
.to_string(),
|
||||
value_hint,
|
||||
is_secret: r
|
||||
.get("is_secret")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = tx.send(AppEvent::SkillConfigLoaded {
|
||||
skill: skill_name,
|
||||
rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::SkillConfigLoaded {
|
||||
skill: skill_name,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch provider auth status for templates screen.
|
||||
pub fn spawn_fetch_template_providers(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
std::thread::spawn(move || match backend {
|
||||
@@ -2216,13 +2322,22 @@ pub fn spawn_fetch_active_hands(backend: BackendRef, tx: mpsc::Sender<AppEvent>)
|
||||
}
|
||||
|
||||
/// Activate a hand.
|
||||
pub fn spawn_activate_hand(backend: BackendRef, hand_id: String, tx: mpsc::Sender<AppEvent>) {
|
||||
pub fn spawn_activate_hand(
|
||||
backend: BackendRef,
|
||||
hand_id: String,
|
||||
instance_name: Option<String>,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let payload = match &instance_name {
|
||||
Some(n) => serde_json::json!({ "instance_name": n }),
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
match client
|
||||
.post(format!("{base_url}/api/hands/{hand_id}/activate"))
|
||||
.json(&serde_json::json!({}))
|
||||
.json(&payload)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
@@ -2242,7 +2357,7 @@ pub fn spawn_activate_hand(backend: BackendRef, hand_id: String, tx: mpsc::Sende
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(kernel) => {
|
||||
match kernel.activate_hand(&hand_id, std::collections::HashMap::new()) {
|
||||
match kernel.activate_hand(&hand_id, std::collections::HashMap::new(), instance_name) {
|
||||
Ok(_) => {
|
||||
let _ = tx.send(AppEvent::HandActivated(hand_id));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use event::{AppEvent, BackendRef};
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::commands::{self, Surfaces};
|
||||
use screens::{
|
||||
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
|
||||
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
@@ -434,6 +435,10 @@ impl App {
|
||||
}
|
||||
self.skills.loading = false;
|
||||
}
|
||||
AppEvent::SkillConfigLoaded { skill, rows } => {
|
||||
self.skills.selected_config_details = Some((skill.clone(), rows));
|
||||
self.skills.status_msg = format!("Loaded config for '{skill}'");
|
||||
}
|
||||
AppEvent::TemplateProvidersLoaded(providers) => {
|
||||
self.templates.providers = providers;
|
||||
}
|
||||
@@ -1562,6 +1567,11 @@ impl App {
|
||||
event::spawn_fetch_mcp_servers(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
skills::SkillsAction::LoadSkillConfig(name) => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_fetch_skill_config(backend, name, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1598,9 +1608,14 @@ impl App {
|
||||
event::spawn_fetch_active_hands(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
hands::HandsAction::ActivateHand(hand_id) => {
|
||||
hands::HandsAction::ActivateHand(hand_id, instance_name) => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_activate_hand(backend, hand_id, self.event_tx.clone());
|
||||
event::spawn_activate_hand(
|
||||
backend,
|
||||
hand_id,
|
||||
instance_name,
|
||||
self.event_tx.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
hands::HandsAction::DeactivateHand(instance_id) => {
|
||||
@@ -1998,23 +2013,18 @@ impl App {
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||
match parts[0] {
|
||||
"/exit" | "/quit" => self.handle_chat_action(chat::ChatAction::Back),
|
||||
// Canonicalise through the unified command registry: `/quit` -> `exit`,
|
||||
// `/NEW` -> `new`, etc. Unregistered commands fall through unchanged so
|
||||
// this refactor does not break any existing surface-specific behaviour.
|
||||
let canonical_head: String = commands::resolve(parts[0])
|
||||
.filter(|def| def.surfaces.contains(Surfaces::CLI))
|
||||
.map(|def| format!("/{}", def.name))
|
||||
.unwrap_or_else(|| parts[0].to_string());
|
||||
match canonical_head.as_str() {
|
||||
"/exit" => self.handle_chat_action(chat::ChatAction::Back),
|
||||
"/help" => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(chat::Role::System, commands::render_help(Surfaces::CLI));
|
||||
}
|
||||
"/status" => {
|
||||
let mut s = Vec::new();
|
||||
@@ -2180,9 +2190,10 @@ impl App {
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
let help = commands::render_help(Surfaces::CLI);
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Unknown command: {}. Type /help", parts[0]),
|
||||
format!("Unknown command: {}\n\n{}", parts[0], help),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,13 +576,11 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CreateMethod;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.custom_name.is_empty() {
|
||||
if self.custom_desc.is_empty() {
|
||||
self.custom_desc = format!("A custom {} agent", self.custom_name);
|
||||
}
|
||||
self.sub = AgentSubScreen::CustomDesc;
|
||||
KeyCode::Enter if !self.custom_name.is_empty() => {
|
||||
if self.custom_desc.is_empty() {
|
||||
self.custom_desc = format!("A custom {} agent", self.custom_name);
|
||||
}
|
||||
self.sub = AgentSubScreen::CustomDesc;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.custom_name.push(c);
|
||||
@@ -641,15 +639,11 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CustomPrompt;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.tool_cursor > 0 {
|
||||
self.tool_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.tool_cursor > 0 => {
|
||||
self.tool_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.tool_cursor < TOOL_OPTIONS.len() - 1 {
|
||||
self.tool_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if self.tool_cursor < TOOL_OPTIONS.len() - 1 => {
|
||||
self.tool_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
self.tool_checks[self.tool_cursor] = !self.tool_checks[self.tool_cursor];
|
||||
@@ -674,21 +668,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CustomTools;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.skill_cursor > 0 {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.skill_cursor < len - 1 {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Advance to MCP server selection
|
||||
@@ -706,21 +694,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CustomSkills;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.mcp_cursor > 0 {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.mcp_cursor < len - 1 {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let toml = self.build_custom_toml();
|
||||
@@ -737,21 +719,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::AgentDetail;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.skill_cursor > 0 {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.skill_cursor < len - 1 {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Save — collect checked skill names (none checked = "all")
|
||||
@@ -780,21 +756,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::AgentDetail;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.mcp_cursor > 0 {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.mcp_cursor < len - 1 {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Save — collect checked server names (none checked = "all")
|
||||
|
||||
@@ -164,19 +164,15 @@ impl AuditState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.action_filter = self.action_filter.next();
|
||||
|
||||
@@ -155,19 +155,19 @@ impl CommsState {
|
||||
self.task_field = 0;
|
||||
}
|
||||
KeyCode::Char('r') => return CommsAction::Refresh,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k')
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() =>
|
||||
{
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j')
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() =>
|
||||
{
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -189,18 +189,17 @@ impl CommsState {
|
||||
self.send_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
KeyCode::Enter
|
||||
if !self.send_from.is_empty()
|
||||
&& !self.send_to.is_empty()
|
||||
&& !self.send_msg.is_empty()
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
&& !self.send_msg.is_empty() =>
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
KeyCode::Char(c) => match self.send_field {
|
||||
0 => self.send_from.push(c),
|
||||
@@ -238,15 +237,13 @@ impl CommsState {
|
||||
self.task_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.task_title.is_empty() {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
KeyCode::Enter if !self.task_title.is_empty() => {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
KeyCode::Char(c) => match self.task_field {
|
||||
0 => self.task_title.push(c),
|
||||
|
||||
@@ -152,12 +152,10 @@ impl ExtensionsState {
|
||||
self.sub = ExtSub::Health;
|
||||
return ExtensionsAction::RefreshHealth;
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
if self.sub == ExtSub::Browse {
|
||||
self.searching = true;
|
||||
self.search_query.clear();
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
KeyCode::Char('/') if self.sub == ExtSub::Browse => {
|
||||
self.searching = true;
|
||||
self.search_query.clear();
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -172,19 +170,15 @@ impl ExtensionsState {
|
||||
fn handle_browse(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
let total = self.filtered().len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered();
|
||||
@@ -222,24 +216,18 @@ impl ExtensionsState {
|
||||
|
||||
let total = self.installed_list_data().len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.installed_list.selected().is_some() {
|
||||
self.confirm_remove = true;
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete if self.installed_list.selected().is_some() => {
|
||||
self.confirm_remove = true;
|
||||
}
|
||||
KeyCode::Char('r') => return ExtensionsAction::RefreshAll,
|
||||
_ => {}
|
||||
@@ -250,19 +238,15 @@ impl ExtensionsState {
|
||||
fn handle_health(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
let total = self.health_entries.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') | KeyCode::Enter => {
|
||||
if let Some(sel) = self.health_list.selected() {
|
||||
|
||||
@@ -55,7 +55,9 @@ pub enum HandsAction {
|
||||
Continue,
|
||||
RefreshDefinitions,
|
||||
RefreshActive,
|
||||
ActivateHand(String),
|
||||
/// Activate a hand. Second field is the optional instance name.
|
||||
/// TODO: add text-input modal for custom instance names (#878 follow-up).
|
||||
ActivateHand(String, Option<String>),
|
||||
DeactivateHand(String),
|
||||
PauseHand(String),
|
||||
ResumeHand(String),
|
||||
@@ -107,24 +109,21 @@ impl HandsState {
|
||||
fn handle_marketplace(&mut self, key: KeyEvent) -> HandsAction {
|
||||
let total = self.definitions.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('a') => {
|
||||
if let Some(sel) = self.marketplace_list.selected() {
|
||||
if sel < self.definitions.len() {
|
||||
return HandsAction::ActivateHand(self.definitions[sel].id.clone());
|
||||
// TODO: add text-input modal for custom instance names (#878 follow-up)
|
||||
return HandsAction::ActivateHand(self.definitions[sel].id.clone(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,24 +153,18 @@ impl HandsState {
|
||||
|
||||
let total = self.instances.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.active_list.selected().is_some() {
|
||||
self.confirm_deactivate = true;
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete if self.active_list.selected().is_some() => {
|
||||
self.confirm_deactivate = true;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
if let Some(sel) = self.active_list.selected() {
|
||||
|
||||
@@ -148,6 +148,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "minimax",
|
||||
display: "MiniMax",
|
||||
env_var: "MINIMAX_API_KEY",
|
||||
default_model: "MiniMax-M2.7",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "huggingface",
|
||||
display: "Hugging Face",
|
||||
@@ -159,10 +167,10 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
ProviderInfo {
|
||||
name: "github-copilot",
|
||||
display: "GitHub Copilot",
|
||||
env_var: "GITHUB_TOKEN",
|
||||
default_model: "gpt-4o",
|
||||
needs_key: true,
|
||||
hint: "via PAT",
|
||||
env_var: "",
|
||||
default_model: "claude-sonnet-4.6",
|
||||
needs_key: false, // Auth handled via OAuth device flow after init
|
||||
hint: "free with subscription",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "replicate",
|
||||
@@ -196,6 +204,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "bedrock",
|
||||
display: "AWS Bedrock",
|
||||
env_var: "AWS_BEARER_TOKEN_BEDROCK",
|
||||
default_model: "anthropic.claude-sonnet-4-6",
|
||||
needs_key: true,
|
||||
hint: "bearer token",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
display: "Claude Code",
|
||||
@@ -250,6 +266,23 @@ pub enum InitResult {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PROVIDERS;
|
||||
|
||||
#[test]
|
||||
fn init_wizard_lists_minimax_provider() {
|
||||
let minimax = PROVIDERS.iter().find(|provider| provider.name == "minimax");
|
||||
assert!(
|
||||
minimax.is_some(),
|
||||
"MiniMax should be selectable in openfang init"
|
||||
);
|
||||
let minimax = minimax.unwrap();
|
||||
assert_eq!(minimax.env_var, "MINIMAX_API_KEY");
|
||||
assert_eq!(minimax.default_model, "MiniMax-M2.7");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal state ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -257,6 +290,7 @@ enum Step {
|
||||
Welcome,
|
||||
Migration,
|
||||
Provider,
|
||||
CopilotAuth,
|
||||
ApiKey,
|
||||
Model,
|
||||
Routing,
|
||||
@@ -288,6 +322,29 @@ enum KeyTestState {
|
||||
Warn,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum CopilotAuthStatus {
|
||||
/// Requesting device code from GitHub.
|
||||
Starting,
|
||||
/// Waiting for user to authorize in browser.
|
||||
WaitingForUser,
|
||||
/// Authorized, fetching models.
|
||||
FetchingModels,
|
||||
/// Done — models loaded.
|
||||
Done,
|
||||
/// Error.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
enum CopilotAuthEvent {
|
||||
DeviceCode {
|
||||
user_code: String,
|
||||
verification_uri: String,
|
||||
},
|
||||
Authenticated,
|
||||
Models(Vec<String>),
|
||||
}
|
||||
|
||||
/// A model entry for list display.
|
||||
struct ModelEntry {
|
||||
id: String,
|
||||
@@ -348,6 +405,11 @@ struct State {
|
||||
daemon_url: String,
|
||||
daemon_error: String,
|
||||
saving_done: bool,
|
||||
|
||||
// Copilot auth
|
||||
copilot_user_code: String,
|
||||
copilot_verification_uri: String,
|
||||
copilot_auth_status: CopilotAuthStatus,
|
||||
save_error: String,
|
||||
}
|
||||
|
||||
@@ -386,6 +448,9 @@ impl State {
|
||||
daemon_error: String::new(),
|
||||
saving_done: false,
|
||||
save_error: String::new(),
|
||||
copilot_user_code: String::new(),
|
||||
copilot_verification_uri: String::new(),
|
||||
copilot_auth_status: CopilotAuthStatus::Starting,
|
||||
};
|
||||
s.build_provider_order();
|
||||
s.provider_list.select(Some(0));
|
||||
@@ -431,6 +496,7 @@ impl State {
|
||||
Step::Welcome => "1 of 7",
|
||||
Step::Migration => "2 of 7",
|
||||
Step::Provider => "3 of 7",
|
||||
Step::CopilotAuth => "4 of 7",
|
||||
Step::ApiKey => "4 of 7",
|
||||
Step::Model => "5 of 7",
|
||||
Step::Routing => "6 of 7",
|
||||
@@ -610,12 +676,52 @@ pub fn run() -> InitResult {
|
||||
let (test_tx, test_rx) = std::sync::mpsc::channel::<bool>();
|
||||
let (migrate_tx, migrate_rx) =
|
||||
std::sync::mpsc::channel::<Result<openfang_migrate::report::MigrationReport, String>>();
|
||||
let (copilot_tx, copilot_rx) = std::sync::mpsc::channel::<Result<CopilotAuthEvent, String>>();
|
||||
|
||||
let result = loop {
|
||||
terminal
|
||||
.draw(|f| draw(f, f.area(), &mut state))
|
||||
.expect("draw failed");
|
||||
|
||||
// Check for Copilot auth events
|
||||
if state.step == Step::CopilotAuth {
|
||||
while let Ok(event) = copilot_rx.try_recv() {
|
||||
match event {
|
||||
Ok(CopilotAuthEvent::DeviceCode {
|
||||
user_code,
|
||||
verification_uri,
|
||||
}) => {
|
||||
state.copilot_user_code = user_code;
|
||||
state.copilot_verification_uri = verification_uri;
|
||||
state.copilot_auth_status = CopilotAuthStatus::WaitingForUser;
|
||||
}
|
||||
Ok(CopilotAuthEvent::Authenticated) => {
|
||||
state.copilot_auth_status = CopilotAuthStatus::FetchingModels;
|
||||
}
|
||||
Ok(CopilotAuthEvent::Models(models)) => {
|
||||
state.copilot_auth_status = CopilotAuthStatus::Done;
|
||||
state.model_entries.clear();
|
||||
for model_id in &models {
|
||||
state.model_entries.push(ModelEntry {
|
||||
id: model_id.clone(),
|
||||
display_name: model_id.clone(),
|
||||
tier: "copilot",
|
||||
cost: "free".to_string(),
|
||||
});
|
||||
}
|
||||
if !state.model_entries.is_empty() {
|
||||
state.model_list.select(Some(0));
|
||||
}
|
||||
// Auto-advance to model picker
|
||||
state.step = Step::Model;
|
||||
}
|
||||
Err(e) => {
|
||||
state.copilot_auth_status = CopilotAuthStatus::Failed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for background key-test result
|
||||
if state.key_test == KeyTestState::Testing {
|
||||
if let Ok(ok) = test_rx.try_recv() {
|
||||
@@ -750,7 +856,117 @@ pub fn run() -> InitResult {
|
||||
state.selected_provider = Some(prov_idx);
|
||||
let p = &PROVIDERS[prov_idx];
|
||||
|
||||
if !p.needs_key {
|
||||
if p.name == "github-copilot" {
|
||||
// Start Copilot device flow in background
|
||||
state.copilot_auth_status = CopilotAuthStatus::Starting;
|
||||
state.api_key_from_env = false;
|
||||
state.step = Step::CopilotAuth;
|
||||
|
||||
// Kick off background auth
|
||||
let copilot_tx = copilot_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let openfang_dir = crate::cli_openfang_home();
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx
|
||||
.send(Err(format!("Runtime error: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP error: {e}"));
|
||||
let http = match http {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 1: request device code
|
||||
use openfang_runtime::drivers::copilot;
|
||||
let device =
|
||||
match copilot::request_device_code(&http).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Send device code to TUI for display
|
||||
let _ =
|
||||
copilot_tx.send(Ok(CopilotAuthEvent::DeviceCode {
|
||||
user_code: device.user_code.clone(),
|
||||
verification_uri: device
|
||||
.verification_uri
|
||||
.clone(),
|
||||
}));
|
||||
|
||||
// Browser will be opened by user pressing Enter in TUI
|
||||
|
||||
// Step 2: poll for token
|
||||
let tokens = match copilot::poll_for_token(
|
||||
&http,
|
||||
&device.device_code,
|
||||
device.interval,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Save tokens
|
||||
if let Err(e) = tokens.save(&openfang_dir) {
|
||||
let _ = copilot_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = copilot_tx
|
||||
.send(Ok(CopilotAuthEvent::Authenticated));
|
||||
|
||||
// Step 3: fetch models
|
||||
let ct = match copilot::exchange_copilot_token(
|
||||
&http,
|
||||
&tokens.access_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ct) => ct,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx
|
||||
.send(Err(format!("Token exchange: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match copilot::fetch_models(
|
||||
&http,
|
||||
&ct.base_url,
|
||||
&ct.token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(models) => {
|
||||
let _ = copilot_tx
|
||||
.send(Ok(CopilotAuthEvent::Models(models)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = copilot_tx
|
||||
.send(Err(format!("Model fetch: {e}")));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if !p.needs_key {
|
||||
state.api_key_from_env = false;
|
||||
state.load_models_for_provider();
|
||||
state.step = Step::Model;
|
||||
@@ -769,6 +985,25 @@ pub fn run() -> InitResult {
|
||||
_ => {}
|
||||
},
|
||||
|
||||
Step::CopilotAuth => match key.code {
|
||||
KeyCode::Esc => {
|
||||
if matches!(state.copilot_auth_status, CopilotAuthStatus::Failed(_)) {
|
||||
state.step = Step::Provider;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter
|
||||
if matches!(
|
||||
state.copilot_auth_status,
|
||||
CopilotAuthStatus::WaitingForUser
|
||||
) && !state.copilot_verification_uri.is_empty() =>
|
||||
{
|
||||
let _ = openfang_runtime::drivers::copilot::open_verification_url(
|
||||
&state.copilot_verification_uri,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
Step::ApiKey => {
|
||||
if matches!(state.key_test, KeyTestState::Ok | KeyTestState::Warn) {
|
||||
continue;
|
||||
@@ -779,41 +1014,36 @@ pub fn run() -> InitResult {
|
||||
state.key_test = KeyTestState::Idle;
|
||||
state.step = Step::Provider;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
KeyCode::Enter
|
||||
if !state.api_key_input.is_empty()
|
||||
&& state.key_test == KeyTestState::Idle
|
||||
{
|
||||
if let Some(p) = state.provider() {
|
||||
let _ = crate::dotenv::save_env_key(
|
||||
p.env_var,
|
||||
&state.api_key_input,
|
||||
);
|
||||
}
|
||||
state.key_test = KeyTestState::Testing;
|
||||
let provider_name = state
|
||||
.provider()
|
||||
.map(|p| p.name.to_string())
|
||||
.unwrap_or_default();
|
||||
let env_var = state
|
||||
.provider()
|
||||
.map(|p| p.env_var.to_string())
|
||||
.unwrap_or_default();
|
||||
let tx = test_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let ok = crate::test_api_key(&provider_name, &env_var);
|
||||
let _ = tx.send(ok);
|
||||
});
|
||||
&& state.key_test == KeyTestState::Idle =>
|
||||
{
|
||||
if let Some(p) = state.provider() {
|
||||
let _ = crate::dotenv::save_env_key(
|
||||
p.env_var,
|
||||
&state.api_key_input,
|
||||
);
|
||||
}
|
||||
state.key_test = KeyTestState::Testing;
|
||||
let provider_name = state
|
||||
.provider()
|
||||
.map(|p| p.name.to_string())
|
||||
.unwrap_or_default();
|
||||
let env_var = state
|
||||
.provider()
|
||||
.map(|p| p.env_var.to_string())
|
||||
.unwrap_or_default();
|
||||
let tx = test_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let ok = crate::test_api_key(&provider_name, &env_var);
|
||||
let _ = tx.send(ok);
|
||||
});
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if state.key_test == KeyTestState::Idle {
|
||||
state.api_key_input.push(c);
|
||||
}
|
||||
KeyCode::Char(c) if state.key_test == KeyTestState::Idle => {
|
||||
state.api_key_input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.key_test == KeyTestState::Idle {
|
||||
state.api_key_input.pop();
|
||||
}
|
||||
KeyCode::Backspace if state.key_test == KeyTestState::Idle => {
|
||||
state.api_key_input.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1242,6 +1472,7 @@ fn draw(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
Step::Welcome => draw_welcome(f, chunks[3]),
|
||||
Step::Migration => draw_migration(f, chunks[3], state),
|
||||
Step::Provider => draw_provider(f, chunks[3], state),
|
||||
Step::CopilotAuth => draw_copilot_auth(f, chunks[3], state),
|
||||
Step::ApiKey => draw_api_key(f, chunks[3], state),
|
||||
Step::Model => draw_model(f, chunks[3], state),
|
||||
Step::Routing => draw_routing(f, chunks[3], state),
|
||||
@@ -1743,6 +1974,12 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
} else {
|
||||
"no API key needed".to_string()
|
||||
}
|
||||
} else if p.name == "github-copilot" {
|
||||
if detected {
|
||||
format!("{} detected", p.env_var)
|
||||
} else {
|
||||
"run set-key after init".to_string()
|
||||
}
|
||||
} else if detected {
|
||||
format!("{} detected", p.env_var)
|
||||
} else if !p.needs_key {
|
||||
@@ -1772,6 +2009,110 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
f.render_widget(hints, chunks[2]);
|
||||
}
|
||||
|
||||
fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // title
|
||||
Constraint::Length(1), // blank
|
||||
Constraint::Length(1), // status line 1
|
||||
Constraint::Length(1), // status line 2
|
||||
Constraint::Length(1), // blank
|
||||
Constraint::Length(1), // code label
|
||||
Constraint::Length(1), // code value
|
||||
Constraint::Length(1), // blank
|
||||
Constraint::Length(1), // url
|
||||
Constraint::Min(0), // spacer
|
||||
Constraint::Length(1), // hint
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let title = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" GitHub Copilot Authentication",
|
||||
Style::default().fg(theme::ACCENT),
|
||||
)]));
|
||||
f.render_widget(title, chunks[0]);
|
||||
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
|
||||
match &state.copilot_auth_status {
|
||||
CopilotAuthStatus::Starting => {
|
||||
let line = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
|
||||
Span::raw(" Requesting device code..."),
|
||||
]));
|
||||
f.render_widget(line, chunks[2]);
|
||||
}
|
||||
CopilotAuthStatus::WaitingForUser => {
|
||||
let line1 = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
|
||||
Span::raw(" Waiting for authorization..."),
|
||||
]));
|
||||
f.render_widget(line1, chunks[2]);
|
||||
|
||||
let code_label = Paragraph::new(Line::from(vec![Span::raw(" Enter this code:")]));
|
||||
f.render_widget(code_label, chunks[5]);
|
||||
|
||||
let code_value = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
&state.copilot_user_code,
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
f.render_widget(code_value, chunks[6]);
|
||||
|
||||
let url = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" at "),
|
||||
Span::styled(&state.copilot_verification_uri, theme::dim_style()),
|
||||
]));
|
||||
f.render_widget(url, chunks[8]);
|
||||
|
||||
let hint = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Enter] Open browser",
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
f.render_widget(hint, chunks[10]);
|
||||
}
|
||||
CopilotAuthStatus::FetchingModels => {
|
||||
let line = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
|
||||
Span::raw("Authenticated"),
|
||||
]));
|
||||
f.render_widget(line, chunks[2]);
|
||||
|
||||
let line2 = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
|
||||
Span::raw(" Fetching available models..."),
|
||||
]));
|
||||
f.render_widget(line2, chunks[3]);
|
||||
}
|
||||
CopilotAuthStatus::Done => {
|
||||
let line = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
|
||||
Span::raw("Models loaded"),
|
||||
]));
|
||||
f.render_widget(line, chunks[2]);
|
||||
}
|
||||
CopilotAuthStatus::Failed(err) => {
|
||||
let line = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" \u{2718} ", Style::default().fg(theme::RED)),
|
||||
Span::raw(err.as_str()),
|
||||
]));
|
||||
f.render_widget(line, chunks[2]);
|
||||
|
||||
let hint = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Esc to go back",
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
f.render_widget(hint, chunks[10]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_api_key(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
let p = match state.provider() {
|
||||
Some(p) => p,
|
||||
@@ -2343,6 +2684,23 @@ fn draw_complete(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bedrock credentials note ──
|
||||
if p.name == "bedrock" {
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
" AWS bearer token required \u{2014} set:",
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)]),
|
||||
Line::from(vec![Span::styled(
|
||||
" AWS_BEARER_TOKEN_BEDROCK",
|
||||
theme::dim_style(),
|
||||
)]),
|
||||
]),
|
||||
chunks[14],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bottom hints ──
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
|
||||
@@ -211,19 +211,15 @@ impl LogsState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.level_filter = self.level_filter.next();
|
||||
@@ -237,15 +233,11 @@ impl LogsState {
|
||||
self.auto_refresh = !self.auto_refresh;
|
||||
}
|
||||
KeyCode::Char('r') => return LogsAction::Refresh,
|
||||
KeyCode::End => {
|
||||
if total > 0 {
|
||||
self.list_state.select(Some(total - 1));
|
||||
}
|
||||
KeyCode::End if total > 0 => {
|
||||
self.list_state.select(Some(total - 1));
|
||||
}
|
||||
KeyCode::Home => {
|
||||
if total > 0 {
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
KeyCode::Home if total > 0 => {
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -106,19 +106,15 @@ impl MemoryState {
|
||||
fn handle_agent_select(&mut self, key: KeyEvent) -> MemoryAction {
|
||||
let total = self.agents.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.agent_list_state.selected() {
|
||||
@@ -166,19 +162,15 @@ impl MemoryState {
|
||||
self.kv_pairs.clear();
|
||||
self.selected_agent = None;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.sub = MemorySub::AddKey;
|
||||
@@ -196,10 +188,8 @@ impl MemoryState {
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if self.kv_list_state.selected().is_some() {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('d') if self.kv_list_state.selected().is_some() => {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if let Some(agent) = &self.selected_agent {
|
||||
|
||||
@@ -62,19 +62,15 @@ impl PeersState {
|
||||
}
|
||||
let total = self.peers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return PeersAction::Refresh,
|
||||
_ => {}
|
||||
|
||||
@@ -130,19 +130,15 @@ impl SessionsState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
@@ -155,10 +151,8 @@ impl SessionsState {
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if self.list_state.selected().is_some() {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('d') if self.list_state.selected().is_some() => {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
self.search_mode = true;
|
||||
|
||||
@@ -174,21 +174,17 @@ impl SettingsState {
|
||||
fn handle_providers(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.providers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some(sel) = self.provider_list.selected() {
|
||||
@@ -223,19 +219,15 @@ impl SettingsState {
|
||||
fn handle_models(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.models.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return SettingsAction::RefreshModels,
|
||||
_ => {}
|
||||
@@ -246,19 +238,15 @@ impl SettingsState {
|
||||
fn handle_tools(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.tools.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return SettingsAction::RefreshTools,
|
||||
_ => {}
|
||||
|
||||
@@ -16,6 +16,23 @@ pub struct SkillInfo {
|
||||
pub runtime: String,
|
||||
pub source: String,
|
||||
pub description: String,
|
||||
/// Number of config vars the skill's SKILL.md declares. Zero = no badge.
|
||||
pub config_declared: usize,
|
||||
/// Number of declared vars that are currently resolved (user override,
|
||||
/// env, or default). When `< config_declared` the badge shows a warning.
|
||||
pub config_resolved: usize,
|
||||
}
|
||||
|
||||
/// Declared + resolved config for a single variable, shown on the Skills
|
||||
/// detail pane when a skill with a non-empty `config:` section is selected.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SkillConfigVarDetail {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub required: bool,
|
||||
pub source: String, // "user" | "env" | "default" | "unresolved"
|
||||
pub value_hint: String,
|
||||
pub is_secret: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -82,6 +99,9 @@ pub struct SkillsState {
|
||||
pub tick: usize,
|
||||
pub confirm_uninstall: bool,
|
||||
pub status_msg: String,
|
||||
/// Config variable details for the currently selected installed skill.
|
||||
/// Keyed by skill name so refreshing the list doesn't strand stale data.
|
||||
pub selected_config_details: Option<(String, Vec<SkillConfigVarDetail>)>,
|
||||
}
|
||||
|
||||
pub enum SkillsAction {
|
||||
@@ -92,6 +112,10 @@ pub enum SkillsAction {
|
||||
InstallSkill(String),
|
||||
UninstallSkill(String),
|
||||
RefreshMcp,
|
||||
/// Fetch declared + resolved config for the named skill and display it
|
||||
/// in the details pane. Consumed by the caller which drives the API
|
||||
/// request.
|
||||
LoadSkillConfig(String),
|
||||
}
|
||||
|
||||
impl SkillsState {
|
||||
@@ -111,6 +135,7 @@ impl SkillsState {
|
||||
tick: 0,
|
||||
confirm_uninstall: false,
|
||||
status_msg: String::new(),
|
||||
selected_config_details: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,23 +192,30 @@ impl SkillsState {
|
||||
|
||||
let total = self.installed.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if self.installed_list.selected().is_some() {
|
||||
self.confirm_uninstall = true;
|
||||
KeyCode::Char('u') if self.installed_list.selected().is_some() => {
|
||||
self.confirm_uninstall = true;
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
if let Some(sel) = self.installed_list.selected() {
|
||||
if sel < self.installed.len() {
|
||||
let name = self.installed[sel].name.clone();
|
||||
// Only skills that declare config are worth fetching.
|
||||
if self.installed[sel].config_declared > 0 {
|
||||
return SkillsAction::LoadSkillConfig(name);
|
||||
} else {
|
||||
self.status_msg = format!("'{}' declares no runtime config.", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return SkillsAction::RefreshInstalled,
|
||||
@@ -217,19 +249,15 @@ impl SkillsState {
|
||||
|
||||
let total = self.clawhub_results.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('i') => {
|
||||
if let Some(sel) = self.clawhub_list.selected() {
|
||||
@@ -257,19 +285,15 @@ impl SkillsState {
|
||||
fn handle_mcp(&mut self, key: KeyEvent) -> SkillsAction {
|
||||
let total = self.mcp_servers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return SkillsAction::RefreshMcp,
|
||||
_ => {}
|
||||
@@ -336,9 +360,11 @@ fn draw_sub_tabs(f: &mut Frame, area: Rect, active: SkillsSub) {
|
||||
}
|
||||
|
||||
fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let chunks = Layout::vertical([
|
||||
// Two panes: list on the left, config details on the right, with a
|
||||
// single-line hint bar underneath.
|
||||
let outer = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Min(3), // list + details
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
@@ -346,14 +372,26 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<20} {:<8} {:<12} {}",
|
||||
"Name", "Runtime", "Source", "Description"
|
||||
" {:<18} {:<7} {:<10} {:<11} {}",
|
||||
"Name", "Runtime", "Source", "Config", "Description"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
outer[0],
|
||||
);
|
||||
|
||||
// Only show the details pane when there's room and a selection with
|
||||
// declared config exists. Below ~80 cols we collapse to list-only.
|
||||
let has_details = state.selected_config_details.is_some();
|
||||
let body_chunks: Vec<Rect> = if has_details && outer[1].width >= 70 {
|
||||
Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(outer[1])
|
||||
.to_vec()
|
||||
} else {
|
||||
vec![outer[1]]
|
||||
};
|
||||
|
||||
// Skills list (left pane or full width)
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
@@ -361,7 +399,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading skills\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
body_chunks[0],
|
||||
);
|
||||
} else if state.installed.is_empty() {
|
||||
f.render_widget(
|
||||
@@ -369,7 +407,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
" No skills installed. Press [2] to browse ClawHub.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
body_chunks[0],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
@@ -394,15 +432,29 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
"builtin" | "built-in" => Style::default().fg(theme::GREEN),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
let (config_text, config_style) = if s.config_declared == 0 {
|
||||
(String::from("-"), theme::dim_style())
|
||||
} else if s.config_resolved >= s.config_declared {
|
||||
(
|
||||
format!("[{}/{}]", s.config_resolved, s.config_declared),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("[{}/{} \u{26A0}]", s.config_resolved, s.config_declared),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", truncate(&s.name, 19)),
|
||||
format!(" {:<18}", truncate(&s.name, 17)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!(" {:<8}", runtime_badge), runtime_style),
|
||||
Span::styled(format!(" {:<12}", &s.source), source_style),
|
||||
Span::styled(format!(" {:<7}", runtime_badge), runtime_style),
|
||||
Span::styled(format!(" {:<10}", &s.source), source_style),
|
||||
Span::styled(format!(" {:<11}", config_text), config_style),
|
||||
Span::styled(
|
||||
format!(" {}", truncate(&s.description, 30)),
|
||||
format!(" {}", truncate(&s.description, 24)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]))
|
||||
@@ -412,7 +464,12 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.installed_list);
|
||||
f.render_stateful_widget(list, body_chunks[0], &mut state.installed_list);
|
||||
}
|
||||
|
||||
// Config details pane (right side)
|
||||
if has_details && body_chunks.len() > 1 {
|
||||
draw_skill_config_details(f, body_chunks[1], state);
|
||||
}
|
||||
|
||||
if state.confirm_uninstall {
|
||||
@@ -421,7 +478,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
" Uninstall this skill? [y] Yes [any] Cancel",
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)])),
|
||||
chunks[2],
|
||||
outer[2],
|
||||
);
|
||||
} else if !state.status_msg.is_empty() {
|
||||
f.render_widget(
|
||||
@@ -429,19 +486,88 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)])),
|
||||
chunks[2],
|
||||
outer[2],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [u] Uninstall [r] Refresh",
|
||||
" [\u{2191}\u{2193}] Navigate [c] View config [u] Uninstall [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
outer[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_skill_config_details(f: &mut Frame, area: Rect, state: &SkillsState) {
|
||||
let Some((name, rows)) = state.selected_config_details.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
format!(" Config: {name} "),
|
||||
Style::default().fg(theme::ACCENT),
|
||||
)]))
|
||||
.borders(Borders::LEFT)
|
||||
.border_style(theme::dim_style())
|
||||
.padding(Padding::horizontal(1));
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
if rows.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("No config declared.", theme::dim_style())),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for row in rows {
|
||||
let src_style = match row.source.as_str() {
|
||||
"user" => Style::default().fg(theme::GREEN),
|
||||
"env" => Style::default().fg(theme::CYAN),
|
||||
"default" => theme::dim_style(),
|
||||
_ => Style::default().fg(theme::RED),
|
||||
};
|
||||
let required_marker = if row.required { "*" } else { " " };
|
||||
let mut header = vec![
|
||||
Span::styled(
|
||||
format!("{required_marker} {}", truncate(&row.name, 22)),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("[{}]", row.source), src_style),
|
||||
];
|
||||
if row.is_secret {
|
||||
header.push(Span::raw(" "));
|
||||
header.push(Span::styled(
|
||||
"(secret)",
|
||||
Style::default()
|
||||
.fg(theme::YELLOW)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(header));
|
||||
if !row.value_hint.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" = {}", truncate(&row.value_hint, 32)),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
}
|
||||
if !row.description.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" {}", truncate(&row.description, 36)),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
}
|
||||
lines.push(Line::from(vec![Span::raw("")]));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn draw_clawhub(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // search / sort
|
||||
|
||||
@@ -194,19 +194,15 @@ impl TemplatesState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
|
||||
@@ -156,19 +156,15 @@ impl TriggerState {
|
||||
self.create_step -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.create_step < 5 {
|
||||
self.create_step += 1;
|
||||
}
|
||||
KeyCode::Enter if self.create_step < 5 => {
|
||||
self.create_step += 1;
|
||||
}
|
||||
KeyCode::Char(c) => match self.create_step {
|
||||
0 => self.create_agent_id.push(c),
|
||||
2 => self.create_pattern_param.push(c),
|
||||
3 => self.create_prompt.push(c),
|
||||
4 => {
|
||||
if c.is_ascii_digit() {
|
||||
self.create_max_fires.push(c);
|
||||
}
|
||||
4 if c.is_ascii_digit() => {
|
||||
self.create_max_fires.push(c);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
@@ -111,19 +111,15 @@ impl UsageState {
|
||||
UsageSub::ByModel => {
|
||||
let total = self.by_model.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return UsageAction::Refresh,
|
||||
_ => {}
|
||||
@@ -132,19 +128,15 @@ impl UsageState {
|
||||
UsageSub::ByAgent => {
|
||||
let total = self.by_agent.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return UsageAction::Refresh,
|
||||
_ => {}
|
||||
|
||||
@@ -85,6 +85,12 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "qwen-plus",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "minimax",
|
||||
env_var: "MINIMAX_API_KEY",
|
||||
default_model: "MiniMax-M2.7",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "perplexity",
|
||||
env_var: "PERPLEXITY_API_KEY",
|
||||
@@ -322,13 +328,11 @@ impl WizardState {
|
||||
KeyCode::Esc => {
|
||||
self.step = WizardStep::Provider;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.api_key_input.is_empty() {
|
||||
if let Some(p) = self.selected_provider_info() {
|
||||
self.model_input = p.default_model.to_string();
|
||||
}
|
||||
self.step = WizardStep::Model;
|
||||
KeyCode::Enter if !self.api_key_input.is_empty() => {
|
||||
if let Some(p) = self.selected_provider_info() {
|
||||
self.model_input = p.default_model.to_string();
|
||||
}
|
||||
self.step = WizardStep::Model;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.api_key_input.push(c);
|
||||
@@ -689,3 +693,20 @@ fn draw_done(f: &mut Frame, area: Rect, state: &WizardState) {
|
||||
f.render_widget(cont, chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PROVIDERS;
|
||||
|
||||
#[test]
|
||||
fn wizard_lists_minimax_provider() {
|
||||
let minimax = PROVIDERS.iter().find(|provider| provider.name == "minimax");
|
||||
assert!(
|
||||
minimax.is_some(),
|
||||
"MiniMax should be selectable in wizard provider list"
|
||||
);
|
||||
let minimax = minimax.unwrap();
|
||||
assert_eq!(minimax.env_var, "MINIMAX_API_KEY");
|
||||
assert_eq!(minimax.default_model, "MiniMax-M2.7");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenFang",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.9",
|
||||
"identifier": "ai.openfang.desktop",
|
||||
"build": {},
|
||||
"app": {
|
||||
|
||||
@@ -15,6 +15,9 @@ tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -330,6 +330,31 @@ pub fn parse_hand_toml(content: &str) -> Result<HandDefinition, toml::de::Error>
|
||||
Ok(wrapper.hand)
|
||||
}
|
||||
|
||||
/// Recursively copy a directory and all its contents.
|
||||
///
|
||||
/// Used by `HandRegistry::install_from_path` to persist a custom hand's
|
||||
/// source directory into `~/.openfang/hands/<hand_id>/` so installed hands
|
||||
/// survive daemon restarts (issue #984).
|
||||
pub(crate) fn copy_dir_all(
|
||||
src: impl AsRef<std::path::Path>,
|
||||
dst: impl AsRef<std::path::Path>,
|
||||
) -> std::io::Result<()> {
|
||||
let src = src.as_ref();
|
||||
let dst = dst.as_ref();
|
||||
std::fs::create_dir_all(dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let ty = entry.file_type()?;
|
||||
let dst_path = dst.join(entry.file_name());
|
||||
if ty.is_dir() {
|
||||
copy_dir_all(entry.path(), &dst_path)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), &dst_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Complete Hand definition — parsed from HAND.toml.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HandDefinition {
|
||||
@@ -396,6 +421,12 @@ pub struct HandInstance {
|
||||
pub instance_id: Uuid,
|
||||
/// Which hand definition this is an instance of.
|
||||
pub hand_id: String,
|
||||
/// Optional user-supplied instance label. When set, multiple instances of
|
||||
/// the same hand can coexist as long as each (hand_id, instance_name) pair
|
||||
/// is unique. When `None`, the legacy single-instance-per-hand rule
|
||||
/// applies.
|
||||
#[serde(default)]
|
||||
pub instance_name: Option<String>,
|
||||
/// Current status.
|
||||
pub status: HandStatus,
|
||||
/// The agent that was spawned for this hand.
|
||||
@@ -416,11 +447,13 @@ impl HandInstance {
|
||||
hand_id: &str,
|
||||
agent_name: &str,
|
||||
config: HashMap<String, serde_json::Value>,
|
||||
instance_name: Option<String>,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
instance_id: Uuid::new_v4(),
|
||||
hand_id: hand_id.to_string(),
|
||||
instance_name,
|
||||
status: HandStatus::Active,
|
||||
agent_id: None,
|
||||
agent_name: agent_name.to_string(),
|
||||
@@ -437,6 +470,10 @@ pub struct ActivateHandRequest {
|
||||
/// Optional configuration overrides.
|
||||
#[serde(default)]
|
||||
pub config: HashMap<String, serde_json::Value>,
|
||||
/// Optional unique instance label. Allows multiple instances of the same
|
||||
/// hand to coexist as long as each name is distinct.
|
||||
#[serde(default)]
|
||||
pub instance_name: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -462,11 +499,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hand_instance_new() {
|
||||
let instance = HandInstance::new("clip", "clip-hand", HashMap::new());
|
||||
let instance = HandInstance::new("clip", "clip-hand", HashMap::new(), None);
|
||||
assert_eq!(instance.hand_id, "clip");
|
||||
assert_eq!(instance.agent_name, "clip-hand");
|
||||
assert_eq!(instance.status, HandStatus::Active);
|
||||
assert!(instance.agent_id.is_none());
|
||||
assert!(instance.instance_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,10 +8,27 @@ use crate::{
|
||||
use dashmap::DashMap;
|
||||
use openfang_types::agent::AgentId;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Callback signature invoked on every successful hand load / reload.
|
||||
///
|
||||
/// Arguments: `(hand_id, sha256_hex_of_hand_toml)`. The kernel wires this
|
||||
/// into the Merkle audit chain so reload events leave a tamper-evident
|
||||
/// record (issue #1172). The callback must be cheap and non-blocking; it
|
||||
/// runs inline on the loader thread.
|
||||
pub type HandAuditCallback = Arc<dyn Fn(&str, &str) + Send + Sync>;
|
||||
|
||||
/// Compute the SHA-256 hex digest of raw HAND.toml content.
|
||||
fn hand_toml_sha256(toml_content: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(toml_content.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
// ─── Settings availability types ────────────────────────────────────────────
|
||||
|
||||
/// Availability status of a single setting option.
|
||||
@@ -41,6 +58,10 @@ pub struct HandRegistry {
|
||||
definitions: DashMap<String, HandDefinition>,
|
||||
/// Active hand instances, keyed by instance UUID.
|
||||
instances: DashMap<Uuid, HandInstance>,
|
||||
/// Optional callback invoked on every successful HAND.toml load with
|
||||
/// the computed SHA-256 of the file content. Wired by the kernel into
|
||||
/// the Merkle audit chain (issue #1172).
|
||||
audit_callback: RwLock<Option<HandAuditCallback>>,
|
||||
}
|
||||
|
||||
impl HandRegistry {
|
||||
@@ -49,9 +70,37 @@ impl HandRegistry {
|
||||
Self {
|
||||
definitions: DashMap::new(),
|
||||
instances: DashMap::new(),
|
||||
audit_callback: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a callback invoked on every successful HAND.toml load /
|
||||
/// reload with the file's SHA-256. The kernel wires this to
|
||||
/// `AuditLog::record(AuditAction::ConfigChange, ...)` so reload events
|
||||
/// leave a tamper-evident audit record (issue #1172).
|
||||
pub fn set_audit_callback(&self, callback: HandAuditCallback) {
|
||||
let mut guard = self
|
||||
.audit_callback
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
*guard = Some(callback);
|
||||
}
|
||||
|
||||
/// Compute SHA-256 of the given HAND.toml content and invoke the audit
|
||||
/// callback if one is registered. Returns the hex digest for callers
|
||||
/// that want to log or compare it.
|
||||
fn emit_hand_loaded_audit(&self, hand_id: &str, toml_content: &str) -> String {
|
||||
let hash = hand_toml_sha256(toml_content);
|
||||
let guard = self
|
||||
.audit_callback
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(cb) = guard.as_ref() {
|
||||
cb(hand_id, &hash);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -112,7 +161,8 @@ impl HandRegistry {
|
||||
for (id, toml_content, skill_content) in bundled {
|
||||
match bundled::parse_bundled(id, toml_content, skill_content) {
|
||||
Ok(def) => {
|
||||
info!(hand = %def.id, name = %def.name, "Loaded bundled hand");
|
||||
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
|
||||
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Loaded bundled hand");
|
||||
self.definitions.insert(def.id.clone(), def);
|
||||
count += 1;
|
||||
}
|
||||
@@ -124,6 +174,68 @@ impl HandRegistry {
|
||||
count
|
||||
}
|
||||
|
||||
/// Scan a directory for custom hand definitions and load them into the
|
||||
/// registry. Mirrors `SkillRegistry::load_workspace_skills` — each
|
||||
/// subdirectory containing a `HAND.toml` is treated as a hand, with an
|
||||
/// optional sibling `SKILL.md` attached as the skill content.
|
||||
///
|
||||
/// Parse failures on individual hands are logged and skipped so a single
|
||||
/// bad manifest cannot take down the whole registry.
|
||||
///
|
||||
/// Returns the number of hands successfully loaded. A non-existent
|
||||
/// `hands_dir` returns `Ok(0)` — this is the normal case on a fresh
|
||||
/// install where the user has not run `openfang hand install` yet.
|
||||
///
|
||||
/// Added for issue #984 — custom hands installed via `openfang hand
|
||||
/// install <path>` were only held in memory and lost on daemon restart.
|
||||
pub fn load_workspace_hands(&self, hands_dir: &std::path::Path) -> HandResult<usize> {
|
||||
if !hands_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut count = 0;
|
||||
let entries = std::fs::read_dir(hands_dir)
|
||||
.map_err(|e| HandError::Config(format!("read_dir {}: {e}", hands_dir.display())))?;
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to read hands dir entry, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let toml_path = path.join("HAND.toml");
|
||||
if !toml_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let contents = match std::fs::read_to_string(&toml_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(path = %toml_path.display(), error = %e, "Failed to read HAND.toml, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let skill_path = path.join("SKILL.md");
|
||||
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
|
||||
match bundled::parse_bundled("custom", &contents, &skill_content) {
|
||||
Ok(def) => {
|
||||
let hand_id = def.id.clone();
|
||||
let hash = self.emit_hand_loaded_audit(&hand_id, &contents);
|
||||
info!(hand = %hand_id, path = %path.display(), sha256 = %hash, "Loaded workspace hand");
|
||||
self.definitions.insert(hand_id, def);
|
||||
count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(path = %toml_path.display(), error = %e, "Invalid HAND.toml, skipping");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Install a hand from a directory containing HAND.toml (and optional SKILL.md).
|
||||
pub fn install_from_path(&self, path: &std::path::Path) -> HandResult<HandDefinition> {
|
||||
let toml_path = path.join("HAND.toml");
|
||||
@@ -143,8 +255,36 @@ impl HandRegistry {
|
||||
)));
|
||||
}
|
||||
|
||||
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
|
||||
let hash = self.emit_hand_loaded_audit(&def.id, &toml_content);
|
||||
info!(hand = %def.id, name = %def.name, path = %path.display(), sha256 = %hash, "Installed hand from path");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
|
||||
// Persist the hand to the user's data dir so it survives daemon
|
||||
// restart (issue #984). Best-effort: failures are logged but do not
|
||||
// abort the install, because the hand is already registered in
|
||||
// memory and the user gets a working install for the current
|
||||
// session. On next restart, `load_workspace_hands` will pick it up
|
||||
// from disk.
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let dest_dir = home.join(".openfang").join("hands").join(&def.id);
|
||||
// Canonicalize both paths before comparing so we don't re-copy a
|
||||
// hand that is already being installed from its persistent
|
||||
// location (e.g. `openfang hand install ~/.openfang/hands/foo`).
|
||||
let same_path = match (path.canonicalize(), dest_dir.canonicalize()) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => path == dest_dir,
|
||||
};
|
||||
if !same_path {
|
||||
if let Err(e) = std::fs::create_dir_all(&dest_dir) {
|
||||
warn!(error = %e, dest = %dest_dir.display(), "Failed to create hands persistence dir");
|
||||
} else if let Err(e) = crate::copy_dir_all(path, &dest_dir) {
|
||||
warn!(error = %e, dest = %dest_dir.display(), "Failed to persist hand");
|
||||
} else {
|
||||
info!(hand = %def.id, dest = %dest_dir.display(), "Persisted hand to workspace");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(def)
|
||||
}
|
||||
|
||||
@@ -163,7 +303,8 @@ impl HandRegistry {
|
||||
)));
|
||||
}
|
||||
|
||||
info!(hand = %def.id, name = %def.name, "Installed hand from content");
|
||||
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
|
||||
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Installed hand from content");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
Ok(def)
|
||||
}
|
||||
@@ -181,7 +322,8 @@ impl HandRegistry {
|
||||
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
|
||||
let existed = self.definitions.contains_key(&def.id);
|
||||
let verb = if existed { "Updated" } else { "Installed" };
|
||||
info!(hand = %def.id, name = %def.name, "{verb} hand from content");
|
||||
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
|
||||
info!(hand = %def.id, name = %def.name, sha256 = %hash, "{verb} hand from content");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
Ok(def)
|
||||
}
|
||||
@@ -200,27 +342,42 @@ impl HandRegistry {
|
||||
}
|
||||
|
||||
/// Activate a hand — creates an instance (agent spawning is done by kernel).
|
||||
///
|
||||
/// `instance_name` is an optional user-supplied label. When set, multiple
|
||||
/// instances of the same hand can coexist as long as each
|
||||
/// (hand_id, instance_name) pair is unique. When `None`, the legacy
|
||||
/// single-instance-per-hand rule applies — a second unnamed activation of
|
||||
/// the same hand is rejected.
|
||||
pub fn activate(
|
||||
&self,
|
||||
hand_id: &str,
|
||||
config: HashMap<String, serde_json::Value>,
|
||||
instance_name: Option<String>,
|
||||
) -> HandResult<HandInstance> {
|
||||
let def = self
|
||||
.definitions
|
||||
.get(hand_id)
|
||||
.ok_or_else(|| HandError::NotFound(hand_id.to_string()))?;
|
||||
|
||||
// Check if already active
|
||||
// Reject only when the exact same (hand_id, instance_name) is already active.
|
||||
// This lets multiple uniquely-named instances of the same hand coexist.
|
||||
for entry in self.instances.iter() {
|
||||
if entry.hand_id == hand_id && entry.status == HandStatus::Active {
|
||||
return Err(HandError::AlreadyActive(hand_id.to_string()));
|
||||
if entry.hand_id == hand_id
|
||||
&& entry.instance_name == instance_name
|
||||
&& entry.status == HandStatus::Active
|
||||
{
|
||||
let label = match &instance_name {
|
||||
Some(name) => format!("{hand_id} (instance: {name})"),
|
||||
None => hand_id.to_string(),
|
||||
};
|
||||
return Err(HandError::AlreadyActive(label));
|
||||
}
|
||||
}
|
||||
|
||||
let instance = HandInstance::new(hand_id, &def.agent.name, config);
|
||||
let instance = HandInstance::new(hand_id, &def.agent.name, config, instance_name.clone());
|
||||
let id = instance.instance_id;
|
||||
self.instances.insert(id, instance.clone());
|
||||
info!(hand = %hand_id, instance = %id, "Hand activated");
|
||||
info!(hand = %hand_id, instance = %id, instance_name = ?instance_name, "Hand activated");
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
@@ -673,7 +830,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
|
||||
assert_eq!(instance.hand_id, "clip");
|
||||
assert_eq!(instance.status, HandStatus::Active);
|
||||
|
||||
@@ -681,7 +838,7 @@ mod tests {
|
||||
assert_eq!(instances.len(), 1);
|
||||
|
||||
// Can't activate again while active
|
||||
let err = reg.activate("clip", HashMap::new());
|
||||
let err = reg.activate("clip", HashMap::new(), None);
|
||||
assert!(err.is_err());
|
||||
|
||||
// Deactivate
|
||||
@@ -695,7 +852,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
|
||||
let id = instance.instance_id;
|
||||
|
||||
reg.pause(id).unwrap();
|
||||
@@ -714,7 +871,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
|
||||
let id = instance.instance_id;
|
||||
let agent_id = AgentId::new();
|
||||
|
||||
@@ -745,7 +902,7 @@ mod tests {
|
||||
fn not_found_errors() {
|
||||
let reg = HandRegistry::new();
|
||||
assert!(reg.get_definition("nonexistent").is_none());
|
||||
assert!(reg.activate("nonexistent", HashMap::new()).is_err());
|
||||
assert!(reg.activate("nonexistent", HashMap::new(), None).is_err());
|
||||
assert!(reg.check_requirements("nonexistent").is_err());
|
||||
assert!(reg.deactivate(Uuid::new_v4()).is_err());
|
||||
assert!(reg.pause(Uuid::new_v4()).is_err());
|
||||
@@ -757,7 +914,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
|
||||
let id = instance.instance_id;
|
||||
|
||||
reg.set_error(id, "something broke".to_string()).unwrap();
|
||||
@@ -830,7 +987,7 @@ mod tests {
|
||||
reg.load_bundled();
|
||||
|
||||
// Lead hand has no requirements — activate it
|
||||
let instance = reg.activate("lead", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("lead", HashMap::new(), None).unwrap();
|
||||
let r = reg.readiness("lead").unwrap();
|
||||
assert!(r.requirements_met);
|
||||
assert!(r.active);
|
||||
@@ -847,7 +1004,7 @@ mod tests {
|
||||
// Browser hand requires python3 (non-optional) + chromium (optional).
|
||||
// requirements_met only reflects non-optional requirements.
|
||||
// degraded = active + any requirement (including optional) unsatisfied.
|
||||
let instance = reg.activate("browser", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("browser", HashMap::new(), None).unwrap();
|
||||
let r = reg.readiness("browser").unwrap();
|
||||
assert!(r.active);
|
||||
|
||||
@@ -874,7 +1031,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("lead", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("lead", HashMap::new(), None).unwrap();
|
||||
reg.pause(instance.instance_id).unwrap();
|
||||
|
||||
let r = reg.readiness("lead").unwrap();
|
||||
@@ -897,4 +1054,312 @@ mod tests {
|
||||
};
|
||||
assert!(!req.optional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_workspace_hands_from_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let hands_dir = tmp.path();
|
||||
let hand_dir = hands_dir.join("test-custom-hand");
|
||||
std::fs::create_dir_all(&hand_dir).unwrap();
|
||||
let hand_toml = r#"
|
||||
id = "test-custom-hand"
|
||||
name = "Test Custom Hand"
|
||||
description = "A custom hand loaded from the workspace directory"
|
||||
category = "other"
|
||||
version = "0.1.0"
|
||||
author = "tester"
|
||||
|
||||
[agent]
|
||||
name = "test-agent"
|
||||
description = "A test agent"
|
||||
module = "builtin:chat"
|
||||
provider = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
system_prompt = "You are a test agent."
|
||||
"#;
|
||||
std::fs::write(hand_dir.join("HAND.toml"), hand_toml).unwrap();
|
||||
|
||||
let registry = HandRegistry::new();
|
||||
let count = registry.load_workspace_hands(hands_dir).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert!(registry.get_definition("test-custom-hand").is_some());
|
||||
let def = registry.get_definition("test-custom-hand").unwrap();
|
||||
assert_eq!(def.name, "Test Custom Hand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_workspace_hands_missing_dir_returns_zero() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let missing = tmp.path().join("does-not-exist");
|
||||
let registry = HandRegistry::new();
|
||||
let count = registry.load_workspace_hands(&missing).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_workspace_hands_skips_invalid_toml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let hands_dir = tmp.path();
|
||||
|
||||
// Valid hand
|
||||
let good_dir = hands_dir.join("good-hand");
|
||||
std::fs::create_dir_all(&good_dir).unwrap();
|
||||
let good_toml = r#"
|
||||
id = "good-hand"
|
||||
name = "Good Hand"
|
||||
description = "..."
|
||||
category = "other"
|
||||
|
||||
[agent]
|
||||
name = "good-agent"
|
||||
description = "..."
|
||||
system_prompt = "You are good."
|
||||
"#;
|
||||
std::fs::write(good_dir.join("HAND.toml"), good_toml).unwrap();
|
||||
|
||||
// Invalid hand (missing required agent section)
|
||||
let bad_dir = hands_dir.join("bad-hand");
|
||||
std::fs::create_dir_all(&bad_dir).unwrap();
|
||||
std::fs::write(bad_dir.join("HAND.toml"), "not valid toml {[[[").unwrap();
|
||||
|
||||
// Directory without HAND.toml — should be silently skipped
|
||||
let empty_dir = hands_dir.join("empty-dir");
|
||||
std::fs::create_dir_all(&empty_dir).unwrap();
|
||||
|
||||
let registry = HandRegistry::new();
|
||||
let count = registry.load_workspace_hands(hands_dir).unwrap();
|
||||
assert_eq!(count, 1, "only the valid hand should load");
|
||||
assert!(registry.get_definition("good-hand").is_some());
|
||||
assert!(registry.get_definition("bad-hand").is_none());
|
||||
}
|
||||
|
||||
/// Build a `HandRegistry` pre-populated with a single dummy hand
|
||||
/// definition that has no requirements — used by the multi-instance
|
||||
/// activation tests below.
|
||||
fn test_registry_with_dummy_hand(hand_id: &str) -> HandRegistry {
|
||||
let toml_str = format!(
|
||||
r#"
|
||||
id = "{hand_id}"
|
||||
name = "Dummy Hand"
|
||||
description = "A dummy hand for tests"
|
||||
category = "other"
|
||||
tools = []
|
||||
|
||||
[agent]
|
||||
name = "dummy-agent"
|
||||
description = "dummy"
|
||||
system_prompt = "you are a dummy."
|
||||
|
||||
[dashboard]
|
||||
metrics = []
|
||||
"#
|
||||
);
|
||||
let def = crate::bundled::parse_bundled("dummy", &toml_str, "").unwrap();
|
||||
let reg = HandRegistry::new();
|
||||
reg.definitions.insert(def.id.clone(), def);
|
||||
reg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_same_hand_twice_with_different_instance_names_succeeds() {
|
||||
let reg = test_registry_with_dummy_hand("test-hand");
|
||||
let a = reg
|
||||
.activate("test-hand", HashMap::new(), Some("instance-a".into()))
|
||||
.unwrap();
|
||||
let b = reg
|
||||
.activate("test-hand", HashMap::new(), Some("instance-b".into()))
|
||||
.unwrap();
|
||||
assert_ne!(a.instance_id, b.instance_id);
|
||||
assert_eq!(a.instance_name, Some("instance-a".into()));
|
||||
assert_eq!(b.instance_name, Some("instance-b".into()));
|
||||
let active: Vec<_> = reg
|
||||
.list_instances()
|
||||
.into_iter()
|
||||
.filter(|i| i.status == HandStatus::Active)
|
||||
.collect();
|
||||
assert_eq!(active.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_same_hand_same_instance_name_rejects() {
|
||||
let reg = test_registry_with_dummy_hand("test-hand");
|
||||
reg.activate("test-hand", HashMap::new(), Some("same".into()))
|
||||
.unwrap();
|
||||
let err = reg
|
||||
.activate("test-hand", HashMap::new(), Some("same".into()))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, HandError::AlreadyActive(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_same_hand_unnamed_twice_still_rejects() {
|
||||
let reg = test_registry_with_dummy_hand("test-hand");
|
||||
reg.activate("test-hand", HashMap::new(), None).unwrap();
|
||||
let err = reg.activate("test-hand", HashMap::new(), None).unwrap_err();
|
||||
assert!(matches!(err, HandError::AlreadyActive(_)));
|
||||
}
|
||||
|
||||
/// Issue #1172: HAND.toml SHA-256 must be emitted to the audit
|
||||
/// callback on every successful load / reload.
|
||||
#[test]
|
||||
fn audit_callback_records_hand_toml_hash_on_load() {
|
||||
use std::sync::Mutex;
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&captured);
|
||||
|
||||
let reg = HandRegistry::new();
|
||||
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
|
||||
sink.lock()
|
||||
.unwrap()
|
||||
.push((hand_id.to_string(), hash.to_string()));
|
||||
}));
|
||||
|
||||
let toml_str = r#"
|
||||
id = "audit-hand"
|
||||
name = "Audit Hand"
|
||||
description = "Used to verify audit-trail wiring"
|
||||
category = "other"
|
||||
tools = []
|
||||
|
||||
[agent]
|
||||
name = "audit-agent"
|
||||
description = "audit"
|
||||
system_prompt = "audit."
|
||||
"#;
|
||||
// Precompute the expected hash so the test fails loudly if the
|
||||
// registry ever changes how it digests the TOML content.
|
||||
let expected_hash = {
|
||||
let mut h = Sha256::new();
|
||||
h.update(toml_str.as_bytes());
|
||||
hex::encode(h.finalize())
|
||||
};
|
||||
|
||||
let def = reg.install_from_content(toml_str, "").unwrap();
|
||||
assert_eq!(def.id, "audit-hand");
|
||||
|
||||
let events = captured.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"exactly one audit event should be emitted per load"
|
||||
);
|
||||
assert_eq!(events[0].0, "audit-hand", "hand id propagated to callback");
|
||||
assert_eq!(
|
||||
events[0].1, expected_hash,
|
||||
"callback received SHA-256 of the HAND.toml content"
|
||||
);
|
||||
assert_eq!(events[0].1.len(), 64, "SHA-256 hex is 64 chars");
|
||||
}
|
||||
|
||||
/// Issue #1172: reloading the same HAND.toml via upsert must emit a
|
||||
/// fresh audit event so the chain records when the swap took effect.
|
||||
/// A content change must surface a different hash.
|
||||
#[test]
|
||||
fn audit_callback_fires_on_reload_with_new_hash() {
|
||||
use std::sync::Mutex;
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&captured);
|
||||
|
||||
let reg = HandRegistry::new();
|
||||
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
|
||||
sink.lock()
|
||||
.unwrap()
|
||||
.push((hand_id.to_string(), hash.to_string()));
|
||||
}));
|
||||
|
||||
let v1 = r#"
|
||||
id = "reload-hand"
|
||||
name = "Reload Hand v1"
|
||||
description = "v1"
|
||||
category = "other"
|
||||
tools = []
|
||||
|
||||
[agent]
|
||||
name = "reload-agent"
|
||||
description = "reload"
|
||||
system_prompt = "v1."
|
||||
"#;
|
||||
let v2 = r#"
|
||||
id = "reload-hand"
|
||||
name = "Reload Hand v2"
|
||||
description = "v2"
|
||||
category = "other"
|
||||
tools = []
|
||||
|
||||
[agent]
|
||||
name = "reload-agent"
|
||||
description = "reload"
|
||||
system_prompt = "v2 — schedule changed."
|
||||
"#;
|
||||
|
||||
reg.upsert_from_content(v1, "").unwrap();
|
||||
reg.upsert_from_content(v2, "").unwrap();
|
||||
|
||||
let events = captured.lock().unwrap().clone();
|
||||
assert_eq!(events.len(), 2, "one event per upsert (load + reload)");
|
||||
assert_eq!(events[0].0, "reload-hand");
|
||||
assert_eq!(events[1].0, "reload-hand");
|
||||
assert_ne!(
|
||||
events[0].1, events[1].1,
|
||||
"different HAND.toml content must yield different SHA-256"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test for issue #809: `hand config` round-trip.
|
||||
///
|
||||
/// Simulates what `openfang hand config <id> --set KEY=VAL` does against
|
||||
/// the registry: read current config, merge updates, write back, read
|
||||
/// again. Persists to a tempdir so the restart path also sees the change.
|
||||
#[test]
|
||||
fn test_hand_config_round_trip_via_registry() {
|
||||
let reg = test_registry_with_dummy_hand("browser");
|
||||
let inst = reg.activate("browser", HashMap::new(), None).unwrap();
|
||||
|
||||
// Read-modify-write cycle mirroring the CLI's --set behavior.
|
||||
let mut cfg = reg.get_instance(inst.instance_id).unwrap().config;
|
||||
cfg.insert(
|
||||
"headless".to_string(),
|
||||
serde_json::Value::String("true".into()),
|
||||
);
|
||||
cfg.insert(
|
||||
"user_agent".to_string(),
|
||||
serde_json::Value::String("openfang/1".into()),
|
||||
);
|
||||
reg.update_config(inst.instance_id, cfg.clone()).unwrap();
|
||||
|
||||
let after = reg.get_instance(inst.instance_id).unwrap();
|
||||
assert_eq!(
|
||||
after.config.get("headless"),
|
||||
Some(&serde_json::Value::String("true".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
after.config.get("user_agent"),
|
||||
Some(&serde_json::Value::String("openfang/1".into()))
|
||||
);
|
||||
|
||||
// --unset path: drop a key and confirm it's gone.
|
||||
cfg.remove("user_agent");
|
||||
reg.update_config(inst.instance_id, cfg).unwrap();
|
||||
let after_unset = reg.get_instance(inst.instance_id).unwrap();
|
||||
assert!(!after_unset.config.contains_key("user_agent"));
|
||||
assert_eq!(
|
||||
after_unset.config.get("headless"),
|
||||
Some(&serde_json::Value::String("true".into()))
|
||||
);
|
||||
|
||||
// State survives a persist+load round-trip through a tempdir sidecar.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let state_file = tmp.path().join("hands.json");
|
||||
reg.persist_state(&state_file).unwrap();
|
||||
let reloaded = HandRegistry::load_state(&state_file);
|
||||
assert_eq!(reloaded.len(), 1);
|
||||
let (hand_id, config, _agent_id) = &reloaded[0];
|
||||
assert_eq!(hand_id, "browser");
|
||||
assert_eq!(
|
||||
config.get("headless"),
|
||||
Some(&serde_json::Value::String("true".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,10 @@ futures = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
cron = "0.15"
|
||||
rustls = { workspace = true }
|
||||
cron = "0.16"
|
||||
zeroize = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
|
||||
@@ -69,12 +69,26 @@ pub fn load_config(path: Option<&Path>) -> KernelConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// GAP-012 (Tier 1): pre-validate the [[bindings]] array so a
|
||||
// single malformed entry doesn't poison the whole config and
|
||||
// force a fall-back to defaults (which would silently unbind
|
||||
// every agent). Bad entries are logged at ERROR and dropped;
|
||||
// survivors are passed through to typed deserialization.
|
||||
lenient_extract_bindings(&mut root_value);
|
||||
|
||||
match root_value.try_into::<KernelConfig>() {
|
||||
Ok(config) => {
|
||||
info!(path = %config_path.display(), "Loaded configuration");
|
||||
return config;
|
||||
}
|
||||
Err(e) => {
|
||||
// TODO(GAP-012-Tier-2): this fallback still silently
|
||||
// swaps the user's intent for `KernelConfig::default()`
|
||||
// on any non-binding deserialization failure. Tier 1
|
||||
// closes the binding-shape footgun; Tier 2 should
|
||||
// surface remaining failures via a health endpoint
|
||||
// and/or stderr banner so the silent-default path
|
||||
// can't hide a broken config.
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
path = %config_path.display(),
|
||||
@@ -242,6 +256,89 @@ pub fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lenient pre-pass over the `[[bindings]]` array (GAP-012 Tier 1).
|
||||
///
|
||||
/// Strict whole-config deserialization is fragile: any one malformed binding
|
||||
/// (e.g. a typo'd field that trips `deny_unknown_fields`) causes
|
||||
/// `try_into::<KernelConfig>()` to fail, which the caller then handles by
|
||||
/// falling back to `KernelConfig::default()` — silently unbinding *every*
|
||||
/// agent. That's the worst possible failure mode for a routing config: the
|
||||
/// user's intent is silently discarded, with only a single line in the logs.
|
||||
///
|
||||
/// This pass runs *before* typed deserialization. It walks the bindings
|
||||
/// array entry-by-entry, attempts to deserialize each into `AgentBinding`,
|
||||
/// logs malformed entries at ERROR with index + agent name + serde error,
|
||||
/// and replaces the array with the survivors. The downstream
|
||||
/// `try_into::<KernelConfig>()` then sees a clean array and succeeds.
|
||||
///
|
||||
/// `deny_unknown_fields` on `AgentBinding`/`BindingMatchRule` still applies
|
||||
/// per-entry — typos in surviving bindings would still produce errors here
|
||||
/// and be dropped. The strict-field guarantee is preserved at the entry
|
||||
/// level; only the all-or-nothing behavior is relaxed.
|
||||
///
|
||||
/// No-op if `root_value` is not a table or has no `bindings` array.
|
||||
fn lenient_extract_bindings(root_value: &mut toml::Value) {
|
||||
use openfang_types::config::AgentBinding;
|
||||
|
||||
let tbl = match root_value {
|
||||
toml::Value::Table(t) => t,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Replace the array in place if (and only if) `bindings` is present
|
||||
// and is an array. Anything else (missing, wrong type) we leave alone
|
||||
// so the typed deserializer can produce its own targeted error.
|
||||
let original = match tbl.get("bindings") {
|
||||
Some(toml::Value::Array(arr)) => arr.clone(),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let mut survivors: Vec<toml::Value> = Vec::with_capacity(original.len());
|
||||
let mut dropped = 0usize;
|
||||
|
||||
for (idx, entry) in original.into_iter().enumerate() {
|
||||
match entry.clone().try_into::<AgentBinding>() {
|
||||
Ok(_) => survivors.push(entry),
|
||||
Err(e) => {
|
||||
dropped += 1;
|
||||
// Lazy: only allocate the agent-name fallback string when we
|
||||
// actually need it for an error log. The happy path skips this.
|
||||
let agent_name = entry
|
||||
.get("agent")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<unknown>")
|
||||
.to_string();
|
||||
tracing::error!(
|
||||
binding_index = idx,
|
||||
agent = %agent_name,
|
||||
error = %e,
|
||||
"Skipping malformed binding #{} (agent='{}'): {}. \
|
||||
Other bindings will continue to load. \
|
||||
Fix the entry and reload to restore routing.",
|
||||
idx,
|
||||
agent_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dropped > 0 {
|
||||
// Per-entry ERRORs above carry the root cause; this summary is a
|
||||
// grep-friendly one-liner, so WARN keeps ERROR == per-binding cause.
|
||||
tracing::warn!(
|
||||
dropped,
|
||||
survivors = survivors.len(),
|
||||
"Dropped {} malformed binding(s); {} binding(s) will load. \
|
||||
See preceding ERROR lines for per-binding details.",
|
||||
dropped,
|
||||
survivors.len()
|
||||
);
|
||||
}
|
||||
|
||||
tbl.insert("bindings".to_string(), toml::Value::Array(survivors));
|
||||
}
|
||||
|
||||
/// Get the default config file path.
|
||||
///
|
||||
/// Respects `OPENFANG_HOME` env var (e.g. `OPENFANG_HOME=/opt/openfang`).
|
||||
@@ -442,6 +539,224 @@ mod tests {
|
||||
assert_eq!(config.log_level, "info"); // defaults
|
||||
}
|
||||
|
||||
// ─── GAP-012 Tier 1: lenient bindings extraction ───────────────────
|
||||
|
||||
#[test]
|
||||
fn test_lenient_bindings_drops_typo_keeps_rest() {
|
||||
// Two bindings; the first has a typo'd field (`channnel_id`) that
|
||||
// `BindingMatchRule`'s `deny_unknown_fields` would reject. The second
|
||||
// is well-formed. Pre-fix behavior: whole config falls back to
|
||||
// defaults (zero bindings). Post-fix: bad one dropped, good one loads.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(
|
||||
f,
|
||||
r#"
|
||||
log_level = "info"
|
||||
|
||||
[[bindings]]
|
||||
agent = "researcher-broken"
|
||||
match_rule = {{ channel = "discord", channnel_id = "123" }}
|
||||
|
||||
[[bindings]]
|
||||
agent = "researcher-good"
|
||||
match_rule = {{ channel = "discord", channel_id = "456" }}
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
drop(f);
|
||||
|
||||
let config = load_config(Some(&path));
|
||||
assert_eq!(
|
||||
config.bindings.len(),
|
||||
1,
|
||||
"expected exactly the well-formed binding to survive"
|
||||
);
|
||||
assert_eq!(config.bindings[0].agent, "researcher-good");
|
||||
assert_eq!(
|
||||
config.bindings[0].match_rule.channel_id.as_deref(),
|
||||
Some("456")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lenient_bindings_all_valid_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(
|
||||
f,
|
||||
r#"
|
||||
log_level = "info"
|
||||
|
||||
[[bindings]]
|
||||
agent = "a"
|
||||
match_rule = {{ channel = "discord", channel_id = "1" }}
|
||||
|
||||
[[bindings]]
|
||||
agent = "b"
|
||||
match_rule = {{ channel = "telegram", channel_id = "2" }}
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
drop(f);
|
||||
|
||||
let config = load_config(Some(&path));
|
||||
assert_eq!(config.bindings.len(), 2);
|
||||
assert_eq!(config.bindings[0].agent, "a");
|
||||
assert_eq!(config.bindings[1].agent, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lenient_bindings_all_malformed_yields_empty_but_keeps_rest_of_config() {
|
||||
// Every binding is broken, but the rest of the config (log_level,
|
||||
// api_listen) must still load. Pre-fix: total fallback to defaults.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(
|
||||
f,
|
||||
r#"
|
||||
log_level = "trace"
|
||||
api_listen = "127.0.0.1:9999"
|
||||
|
||||
[[bindings]]
|
||||
agent = "broken-1"
|
||||
match_rule = {{ channnel_id = "1" }}
|
||||
|
||||
[[bindings]]
|
||||
agent = "broken-2"
|
||||
match_rule = {{ peer_idd = "u" }}
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
drop(f);
|
||||
|
||||
let config = load_config(Some(&path));
|
||||
assert!(config.bindings.is_empty(), "all bindings should be dropped");
|
||||
assert_eq!(
|
||||
config.log_level, "trace",
|
||||
"non-binding config must still load"
|
||||
);
|
||||
assert_eq!(config.api_listen, "127.0.0.1:9999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lenient_bindings_no_bindings_section_is_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(f, "log_level = \"info\"").unwrap();
|
||||
drop(f);
|
||||
|
||||
let config = load_config(Some(&path));
|
||||
assert!(config.bindings.is_empty());
|
||||
assert_eq!(config.log_level, "info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lenient_bindings_missing_agent_field_dropped() {
|
||||
// A binding missing the required `agent` field can't deserialize at
|
||||
// all; it should be dropped (logged as agent='<unknown>') and the
|
||||
// good one should still load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(
|
||||
f,
|
||||
r#"
|
||||
[[bindings]]
|
||||
match_rule = {{ channel = "discord" }}
|
||||
|
||||
[[bindings]]
|
||||
agent = "good"
|
||||
match_rule = {{ channel = "discord", channel_id = "1" }}
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
drop(f);
|
||||
|
||||
let config = load_config(Some(&path));
|
||||
assert_eq!(config.bindings.len(), 1);
|
||||
assert_eq!(config.bindings[0].agent, "good");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lenient_bindings_preserves_survivor_order() {
|
||||
// Three bindings with the *middle* one malformed. Survivors must
|
||||
// retain their original relative order (1st, 3rd) — match-rule
|
||||
// routing can be order-sensitive (first-match-wins), so silently
|
||||
// reshuffling on a drop would be a subtle regression.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(
|
||||
f,
|
||||
r#"
|
||||
[[bindings]]
|
||||
agent = "first"
|
||||
match_rule = {{ channel = "discord", channel_id = "1" }}
|
||||
|
||||
[[bindings]]
|
||||
agent = "middle-broken"
|
||||
match_rule = {{ channnel_id = "2" }}
|
||||
|
||||
[[bindings]]
|
||||
agent = "third"
|
||||
match_rule = {{ channel = "telegram", channel_id = "3" }}
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
drop(f);
|
||||
|
||||
let config = load_config(Some(&path));
|
||||
assert_eq!(config.bindings.len(), 2, "middle binding should be dropped");
|
||||
assert_eq!(
|
||||
config.bindings[0].agent, "first",
|
||||
"first survivor must remain first"
|
||||
);
|
||||
assert_eq!(
|
||||
config.bindings[1].agent, "third",
|
||||
"third must remain after first (order preserved)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lenient_bindings_top_level_field_typo_dropped() {
|
||||
// Operator typos `agnt` instead of `agent` on the binding itself
|
||||
// (not inside `match_rule`). `AgentBinding`'s `deny_unknown_fields`
|
||||
// should reject the entry, the lenient pass should drop it, and
|
||||
// the well-formed sibling should still load. This is the more
|
||||
// common operator mistake than missing-field-entirely, so we lock
|
||||
// the behavior in explicitly.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
writeln!(
|
||||
f,
|
||||
r#"
|
||||
[[bindings]]
|
||||
agnt = "typo-at-top-level"
|
||||
match_rule = {{ channel = "discord", channel_id = "1" }}
|
||||
|
||||
[[bindings]]
|
||||
agent = "good"
|
||||
match_rule = {{ channel = "discord", channel_id = "2" }}
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
drop(f);
|
||||
|
||||
let config = load_config(Some(&path));
|
||||
assert_eq!(
|
||||
config.bindings.len(),
|
||||
1,
|
||||
"binding with top-level field typo should be dropped"
|
||||
);
|
||||
assert_eq!(config.bindings[0].agent, "good");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_includes_works() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -483,6 +483,79 @@ mod tests {
|
||||
assert!(plan.hot_actions.contains(&HotAction::ReloadProviderUrls));
|
||||
}
|
||||
|
||||
/// #1129: editing `[default_model].subprocess_timeout_secs` must produce
|
||||
/// a hot-reload action so cross-message timeout retunes don't require
|
||||
/// a daemon bounce. The whole `default_model` block round-trips through
|
||||
/// `UpdateDefaultModel`, which carries the new timeout into the override
|
||||
/// slot read by `resolve_driver`.
|
||||
#[test]
|
||||
fn test_default_model_subprocess_timeout_hot_reload() {
|
||||
let a = default_cfg();
|
||||
let mut b = default_cfg();
|
||||
b.default_model.subprocess_timeout_secs = Some(900);
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(
|
||||
!plan.restart_required,
|
||||
"subprocess_timeout_secs edits on default_model must be hot-reloadable"
|
||||
);
|
||||
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
|
||||
}
|
||||
|
||||
/// #1129: editing `[[fallback_providers]]` (including
|
||||
/// `subprocess_timeout_secs` on a non-default provider) must produce a
|
||||
/// `ReloadFallbackProviders` hot-action. Without this, mixed-fleet
|
||||
/// operators have no live tuning knob for their non-default driver.
|
||||
#[test]
|
||||
fn test_fallback_providers_subprocess_timeout_hot_reload() {
|
||||
use openfang_types::config::FallbackProviderConfig;
|
||||
let mut a = default_cfg();
|
||||
let mut b = default_cfg();
|
||||
a.fallback_providers.push(FallbackProviderConfig {
|
||||
provider: "codex".to_string(),
|
||||
model: "gpt-5-codex".to_string(),
|
||||
api_key_env: String::new(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: Some(120),
|
||||
});
|
||||
b.fallback_providers.push(FallbackProviderConfig {
|
||||
provider: "codex".to_string(),
|
||||
model: "gpt-5-codex".to_string(),
|
||||
api_key_env: String::new(),
|
||||
base_url: None,
|
||||
// Operator raises the ceiling for slow Codex turns.
|
||||
subprocess_timeout_secs: Some(900),
|
||||
});
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(
|
||||
!plan.restart_required,
|
||||
"[[fallback_providers]] edits must be hot-reloadable"
|
||||
);
|
||||
assert!(plan
|
||||
.hot_actions
|
||||
.contains(&HotAction::ReloadFallbackProviders));
|
||||
}
|
||||
|
||||
/// #1129: adding a brand-new `[[fallback_providers]]` entry on reload also
|
||||
/// emits the hot-action so the new provider is picked up without bounce.
|
||||
#[test]
|
||||
fn test_fallback_providers_add_entry_hot_reload() {
|
||||
use openfang_types::config::FallbackProviderConfig;
|
||||
let a = default_cfg();
|
||||
let mut b = default_cfg();
|
||||
b.fallback_providers.push(FallbackProviderConfig {
|
||||
provider: "ollama".to_string(),
|
||||
model: "llama3.2:latest".to_string(),
|
||||
api_key_env: String::new(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: Some(300),
|
||||
});
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(!plan.restart_required);
|
||||
assert!(plan
|
||||
.hot_actions
|
||||
.contains(&HotAction::ReloadFallbackProviders));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mixed changes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -198,6 +198,25 @@ impl CronScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the multi-destination delivery targets on an existing job.
|
||||
///
|
||||
/// The schedule, action, and primary `delivery` field are left untouched;
|
||||
/// only the `delivery_targets` fan-out list is swapped in. Call
|
||||
/// [`persist`] afterwards to write the change to disk.
|
||||
pub fn set_delivery_targets(
|
||||
&self,
|
||||
id: CronJobId,
|
||||
targets: Vec<openfang_types::scheduler::CronDeliveryTarget>,
|
||||
) -> OpenFangResult<()> {
|
||||
match self.jobs.get_mut(&id) {
|
||||
Some(mut meta) => {
|
||||
meta.job.delivery_targets = targets;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(OpenFangError::Internal(format!("Cron job {id} not found"))),
|
||||
}
|
||||
}
|
||||
|
||||
// -- Queries ------------------------------------------------------------
|
||||
|
||||
/// Get a single job by ID.
|
||||
@@ -504,6 +523,7 @@ mod tests {
|
||||
text: "ping".into(),
|
||||
},
|
||||
delivery: CronDelivery::None,
|
||||
delivery_targets: Vec::new(),
|
||||
created_at: Utc::now(),
|
||||
last_run: None,
|
||||
next_run: None,
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
//! Multi-destination cron output delivery.
|
||||
//!
|
||||
//! A single [`CronJob`] may declare zero or more [`CronDeliveryTarget`]s on
|
||||
//! its `delivery_targets` field. After the job fires and produces output,
|
||||
//! the [`CronDeliveryEngine`] fans out the same payload to every target
|
||||
//! concurrently. Failures in one target do not abort delivery to the
|
||||
//! others — every target's outcome is returned in a [`DeliveryResult`].
|
||||
//!
|
||||
//! This is the OpenFang port of the Hermes Agent multi-destination cron
|
||||
//! pattern: one job → N destinations (channels / webhooks / files / email).
|
||||
|
||||
use futures::future::join_all;
|
||||
use openfang_channels::bridge::ChannelBridgeHandle;
|
||||
use openfang_types::scheduler::CronDeliveryTarget;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Webhook HTTP timeout. Matches the legacy single-target cron webhook.
|
||||
const WEBHOOK_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Per-target delivery outcome returned by [`CronDeliveryEngine::deliver`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeliveryResult {
|
||||
/// Human-readable target description (`"channel:telegram -> chat_123"`,
|
||||
/// `"webhook:https://..."`, `"file:/tmp/out.log"`, `"email:alice@x"`).
|
||||
pub target: String,
|
||||
/// Whether delivery succeeded.
|
||||
pub success: bool,
|
||||
/// Error message if `success` is `false`.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl DeliveryResult {
|
||||
fn ok(target: String) -> Self {
|
||||
Self {
|
||||
target,
|
||||
success: true,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn err(target: String, msg: String) -> Self {
|
||||
Self {
|
||||
target,
|
||||
success: false,
|
||||
error: Some(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fan-out delivery engine for cron job output.
|
||||
///
|
||||
/// Holds a reference to the channel bridge (for adapter-based delivery) and
|
||||
/// a shared HTTP client (for webhook delivery). Constructed once per kernel
|
||||
/// and reused across every cron firing.
|
||||
pub struct CronDeliveryEngine {
|
||||
/// Bridge used to invoke `send_channel_message` on registered adapters.
|
||||
channel_bridge: Arc<dyn ChannelBridgeHandle>,
|
||||
/// Shared HTTP client for webhook delivery.
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CronDeliveryEngine {
|
||||
/// Build a new engine using the given channel bridge and a fresh
|
||||
/// `reqwest::Client`. Falls back to the default client if the builder
|
||||
/// fails (which effectively never happens on supported platforms).
|
||||
pub fn new(channel_bridge: Arc<dyn ChannelBridgeHandle>) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(WEBHOOK_TIMEOUT_SECS))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
channel_bridge,
|
||||
http,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a new engine with an explicit HTTP client — used by tests.
|
||||
pub fn with_http_client(
|
||||
channel_bridge: Arc<dyn ChannelBridgeHandle>,
|
||||
http: reqwest::Client,
|
||||
) -> Self {
|
||||
Self {
|
||||
channel_bridge,
|
||||
http,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliver `output` to every target concurrently.
|
||||
///
|
||||
/// Returns a `Vec<DeliveryResult>` with one entry per target in the same
|
||||
/// order as the input slice. One target failing does not short-circuit
|
||||
/// the others — the job already succeeded, delivery is best-effort.
|
||||
pub async fn deliver(
|
||||
&self,
|
||||
targets: &[CronDeliveryTarget],
|
||||
job_name: &str,
|
||||
output: &str,
|
||||
) -> Vec<DeliveryResult> {
|
||||
if targets.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let futures = targets
|
||||
.iter()
|
||||
.map(|t| self.deliver_one(t, job_name, output));
|
||||
join_all(futures).await
|
||||
}
|
||||
|
||||
/// Deliver to a single target. Never panics.
|
||||
async fn deliver_one(
|
||||
&self,
|
||||
target: &CronDeliveryTarget,
|
||||
job_name: &str,
|
||||
output: &str,
|
||||
) -> DeliveryResult {
|
||||
match target {
|
||||
CronDeliveryTarget::Channel {
|
||||
channel_type,
|
||||
recipient,
|
||||
} => {
|
||||
let desc = format!("channel:{channel_type} -> {recipient}");
|
||||
match self
|
||||
.channel_bridge
|
||||
.send_channel_message(channel_type, recipient, output)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
debug!(target = %desc, "Cron fan-out: channel delivery ok");
|
||||
DeliveryResult::ok(desc)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(target = %desc, error = %e, "Cron fan-out: channel delivery failed");
|
||||
DeliveryResult::err(desc, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
CronDeliveryTarget::Webhook { url, auth_header } => {
|
||||
let desc = format!("webhook:{url}");
|
||||
match deliver_webhook(&self.http, url, auth_header.as_deref(), job_name, output)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
debug!(target = %desc, "Cron fan-out: webhook delivery ok");
|
||||
DeliveryResult::ok(desc)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(target = %desc, error = %e, "Cron fan-out: webhook delivery failed");
|
||||
DeliveryResult::err(desc, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
CronDeliveryTarget::LocalFile { path, append } => {
|
||||
let desc = format!("file:{path}");
|
||||
match deliver_local_file(Path::new(path), *append, output).await {
|
||||
Ok(()) => {
|
||||
debug!(target = %desc, "Cron fan-out: file write ok");
|
||||
DeliveryResult::ok(desc)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(target = %desc, error = %e, "Cron fan-out: file write failed");
|
||||
DeliveryResult::err(desc, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
CronDeliveryTarget::Email {
|
||||
to,
|
||||
subject_template,
|
||||
} => {
|
||||
let desc = format!("email:{to}");
|
||||
let subject = render_subject(subject_template.as_deref(), job_name);
|
||||
// The existing email channel adapter sends via SMTP and does
|
||||
// not expose a subject/to pair on the trait, so we route a
|
||||
// formatted message through it. Most adapters treat the
|
||||
// recipient as a destination identifier; the email adapter
|
||||
// uses it as the RCPT TO address.
|
||||
let body = format!("{subject}\n\n{output}");
|
||||
match self
|
||||
.channel_bridge
|
||||
.send_channel_message("email", to, &body)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
debug!(target = %desc, "Cron fan-out: email delivery ok");
|
||||
DeliveryResult::ok(desc)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(target = %desc, error = %e, "Cron fan-out: email delivery failed");
|
||||
DeliveryResult::err(desc, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render an email subject from an optional template. `{job}` is the only
|
||||
/// supported placeholder; everything else passes through unchanged.
|
||||
fn render_subject(template: Option<&str>, job_name: &str) -> String {
|
||||
match template {
|
||||
Some(t) if !t.is_empty() => t.replace("{job}", job_name),
|
||||
_ => format!("Cron: {job_name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST a JSON payload `{ job, output, timestamp }` to `url` and optionally
|
||||
/// attach an `Authorization` header. Returns `Err(msg)` on non-2xx or
|
||||
/// network failure.
|
||||
async fn deliver_webhook(
|
||||
http: &reqwest::Client,
|
||||
url: &str,
|
||||
auth_header: Option<&str>,
|
||||
job_name: &str,
|
||||
output: &str,
|
||||
) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"job": job_name,
|
||||
"output": output,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let mut req = http.post(url).json(&payload);
|
||||
if let Some(auth) = auth_header {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("webhook send failed: {e}"))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("webhook returned HTTP {status}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append or overwrite `output` at `path`. Creates parent directories when
|
||||
/// missing. Returns `Err(msg)` on any I/O failure.
|
||||
async fn deliver_local_file(path: &Path, append: bool, output: &str) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| format!("create parent dir failed: {e}"))?;
|
||||
}
|
||||
}
|
||||
if append {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.await
|
||||
.map_err(|e| format!("open failed: {e}"))?;
|
||||
f.write_all(output.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("write failed: {e}"))?;
|
||||
// Newline separator between runs makes tailing nicer.
|
||||
f.write_all(b"\n")
|
||||
.await
|
||||
.map_err(|e| format!("write newline failed: {e}"))?;
|
||||
} else {
|
||||
tokio::fs::write(path, output.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("write failed: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use openfang_channels::bridge::ChannelBridgeHandle;
|
||||
use openfang_types::agent::AgentId;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Mock bridge that records every channel send. Optionally fails for
|
||||
/// specific channel names.
|
||||
struct MockBridge {
|
||||
calls: Mutex<Vec<(String, String, String)>>,
|
||||
fail_on_channel: Option<String>,
|
||||
}
|
||||
|
||||
impl MockBridge {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
fail_on_channel: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn failing_on(channel: &str) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
fail_on_channel: Some(channel.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<(String, String, String)> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelBridgeHandle for MockBridge {
|
||||
async fn send_message(&self, _: AgentId, _: &str) -> Result<String, String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn find_agent_by_name(&self, _: &str) -> Result<Option<AgentId>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn list_agents(&self) -> Result<Vec<(AgentId, String)>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn spawn_agent_by_name(&self, _: &str) -> Result<AgentId, String> {
|
||||
Err("not implemented".into())
|
||||
}
|
||||
|
||||
async fn send_channel_message(
|
||||
&self,
|
||||
channel_type: &str,
|
||||
recipient: &str,
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
self.calls.lock().unwrap().push((
|
||||
channel_type.to_string(),
|
||||
recipient.to_string(),
|
||||
message.to_string(),
|
||||
));
|
||||
if let Some(ref failing) = self.fail_on_channel {
|
||||
if failing == channel_type {
|
||||
return Err(format!("mock: forced failure on '{channel_type}'"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_engine(bridge: Arc<MockBridge>) -> CronDeliveryEngine {
|
||||
CronDeliveryEngine::new(bridge)
|
||||
}
|
||||
|
||||
// -- LocalFile: overwrite ------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn localfile_overwrite_creates_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("out.txt");
|
||||
let target = CronDeliveryTarget::LocalFile {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
append: false,
|
||||
};
|
||||
|
||||
let engine = test_engine(MockBridge::new());
|
||||
let results = engine.deliver(&[target], "job-x", "hello world").await;
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].success, "error: {:?}", results[0].error);
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn localfile_overwrite_replaces_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("replace.txt");
|
||||
std::fs::write(&path, "OLD CONTENT").unwrap();
|
||||
|
||||
let target = CronDeliveryTarget::LocalFile {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
append: false,
|
||||
};
|
||||
let engine = test_engine(MockBridge::new());
|
||||
let results = engine.deliver(&[target], "job-x", "NEW").await;
|
||||
|
||||
assert!(results[0].success);
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "NEW");
|
||||
}
|
||||
|
||||
// -- LocalFile: append ---------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn localfile_append_adds_lines() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("log.txt");
|
||||
let target = CronDeliveryTarget::LocalFile {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
append: true,
|
||||
};
|
||||
let engine = test_engine(MockBridge::new());
|
||||
|
||||
// Two sequential deliveries should accumulate.
|
||||
engine
|
||||
.deliver(std::slice::from_ref(&target), "job", "first")
|
||||
.await;
|
||||
engine.deliver(&[target], "job", "second").await;
|
||||
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
content.contains("first") && content.contains("second"),
|
||||
"expected both lines in appended file, got: {content:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn localfile_append_creates_missing_parent_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("nested/deep/out.log");
|
||||
let target = CronDeliveryTarget::LocalFile {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
append: true,
|
||||
};
|
||||
let engine = test_engine(MockBridge::new());
|
||||
let results = engine.deliver(&[target], "job", "payload").await;
|
||||
|
||||
assert!(results[0].success, "error: {:?}", results[0].error);
|
||||
assert!(path.exists(), "nested file should have been created");
|
||||
}
|
||||
|
||||
// -- Webhook: success ----------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_sends_payload() {
|
||||
let (port, rx) = spawn_mock_http_server(200, "OK").await;
|
||||
let url = format!("http://127.0.0.1:{port}/hook");
|
||||
|
||||
let target = CronDeliveryTarget::Webhook {
|
||||
url: url.clone(),
|
||||
auth_header: Some("Bearer test-token".to_string()),
|
||||
};
|
||||
let engine = test_engine(MockBridge::new());
|
||||
let results = engine
|
||||
.deliver(&[target], "daily-report", "result body")
|
||||
.await;
|
||||
|
||||
assert!(results[0].success, "error: {:?}", results[0].error);
|
||||
|
||||
let captured = rx.await.expect("mock server never received a request");
|
||||
assert!(
|
||||
captured.body.contains("\"job\":\"daily-report\""),
|
||||
"payload missing job name, got: {}",
|
||||
captured.body
|
||||
);
|
||||
assert!(
|
||||
captured.body.contains("\"output\":\"result body\""),
|
||||
"payload missing output, got: {}",
|
||||
captured.body
|
||||
);
|
||||
assert!(
|
||||
captured.body.contains("\"timestamp\""),
|
||||
"payload missing timestamp, got: {}",
|
||||
captured.body
|
||||
);
|
||||
assert!(
|
||||
captured
|
||||
.headers
|
||||
.iter()
|
||||
.any(|h| h.eq_ignore_ascii_case("authorization: Bearer test-token")),
|
||||
"missing auth header, got: {:?}",
|
||||
captured.headers
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_reports_non_2xx() {
|
||||
let (port, _rx) = spawn_mock_http_server(500, "Internal Server Error").await;
|
||||
let url = format!("http://127.0.0.1:{port}/hook");
|
||||
|
||||
let target = CronDeliveryTarget::Webhook {
|
||||
url,
|
||||
auth_header: None,
|
||||
};
|
||||
let engine = test_engine(MockBridge::new());
|
||||
let results = engine.deliver(&[target], "job", "output").await;
|
||||
|
||||
assert!(!results[0].success);
|
||||
let err = results[0].error.as_deref().unwrap_or("");
|
||||
assert!(err.contains("500"), "expected 500 in error, got: {err}");
|
||||
}
|
||||
|
||||
// -- Channel target ------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_target_invokes_bridge() {
|
||||
let bridge = MockBridge::new();
|
||||
let engine = test_engine(bridge.clone());
|
||||
let target = CronDeliveryTarget::Channel {
|
||||
channel_type: "slack".to_string(),
|
||||
recipient: "C12345".to_string(),
|
||||
};
|
||||
let results = engine.deliver(&[target], "alerts", "fire").await;
|
||||
assert!(results[0].success, "error: {:?}", results[0].error);
|
||||
let calls = bridge.calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, "slack");
|
||||
assert_eq!(calls[0].1, "C12345");
|
||||
assert_eq!(calls[0].2, "fire");
|
||||
}
|
||||
|
||||
// -- Mixed success/failure ----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn mixed_targets_one_success_one_failure() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ok_path = tmp.path().join("ok.txt");
|
||||
|
||||
let targets = vec![
|
||||
// Will succeed (file write).
|
||||
CronDeliveryTarget::LocalFile {
|
||||
path: ok_path.to_string_lossy().to_string(),
|
||||
append: false,
|
||||
},
|
||||
// Will fail (mock bridge rejects 'slack').
|
||||
CronDeliveryTarget::Channel {
|
||||
channel_type: "slack".to_string(),
|
||||
recipient: "C1".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let bridge = MockBridge::failing_on("slack");
|
||||
let engine = test_engine(bridge);
|
||||
let results = engine.deliver(&targets, "job", "payload").await;
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(
|
||||
results[0].success,
|
||||
"file delivery should succeed: {:?}",
|
||||
results[0].error
|
||||
);
|
||||
assert!(
|
||||
!results[1].success,
|
||||
"channel delivery should fail, but got success"
|
||||
);
|
||||
assert!(results[1]
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("forced failure"));
|
||||
|
||||
// File was still written even though the other target failed.
|
||||
assert_eq!(std::fs::read_to_string(&ok_path).unwrap(), "payload");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_targets_returns_empty_vec() {
|
||||
let engine = test_engine(MockBridge::new());
|
||||
let results = engine.deliver(&[], "job", "x").await;
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
// -- Serde round-trip ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_channel() {
|
||||
let t = CronDeliveryTarget::Channel {
|
||||
channel_type: "telegram".into(),
|
||||
recipient: "12345".into(),
|
||||
};
|
||||
let s = serde_json::to_string(&t).unwrap();
|
||||
assert!(s.contains("\"type\":\"channel\""), "tag missing: {s}");
|
||||
assert!(s.contains("telegram"));
|
||||
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(t, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_webhook() {
|
||||
let t = CronDeliveryTarget::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
auth_header: Some("Bearer x".into()),
|
||||
};
|
||||
let s = serde_json::to_string(&t).unwrap();
|
||||
assert!(s.contains("\"type\":\"webhook\""), "tag missing: {s}");
|
||||
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(t, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_webhook_without_auth() {
|
||||
// auth_header should default to None when omitted.
|
||||
let json = r#"{"type":"webhook","url":"https://x.test/h"}"#;
|
||||
let back: CronDeliveryTarget = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
back,
|
||||
CronDeliveryTarget::Webhook {
|
||||
url: "https://x.test/h".into(),
|
||||
auth_header: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_localfile() {
|
||||
let t = CronDeliveryTarget::LocalFile {
|
||||
path: "/var/log/cron-out.log".into(),
|
||||
append: true,
|
||||
};
|
||||
let s = serde_json::to_string(&t).unwrap();
|
||||
assert!(s.contains("\"type\":\"local_file\""), "tag missing: {s}");
|
||||
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(t, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_localfile_default_append() {
|
||||
// append should default to false when omitted.
|
||||
let json = r#"{"type":"local_file","path":"/tmp/out.log"}"#;
|
||||
let back: CronDeliveryTarget = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
back,
|
||||
CronDeliveryTarget::LocalFile {
|
||||
path: "/tmp/out.log".into(),
|
||||
append: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_email() {
|
||||
let t = CronDeliveryTarget::Email {
|
||||
to: "alice@example.com".into(),
|
||||
subject_template: Some("Report: {job}".into()),
|
||||
};
|
||||
let s = serde_json::to_string(&t).unwrap();
|
||||
assert!(s.contains("\"type\":\"email\""), "tag missing: {s}");
|
||||
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(t, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_subject_substitutes_placeholder() {
|
||||
assert_eq!(render_subject(Some("Cron: {job}"), "daily"), "Cron: daily");
|
||||
assert_eq!(
|
||||
render_subject(Some("no placeholder"), "x"),
|
||||
"no placeholder"
|
||||
);
|
||||
assert_eq!(render_subject(None, "daily"), "Cron: daily");
|
||||
assert_eq!(render_subject(Some(""), "daily"), "Cron: daily");
|
||||
}
|
||||
|
||||
// -- Minimal HTTP mock ---------------------------------------------------
|
||||
|
||||
struct CapturedRequest {
|
||||
headers: Vec<String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
/// Spawn a tiny TCP server that serves exactly one request, parses the
|
||||
/// HTTP/1.1 request line + headers + body, then responds with the given
|
||||
/// status code and reason phrase. Returns `(port, oneshot_rx)` where the
|
||||
/// oneshot resolves once the request has been received.
|
||||
async fn spawn_mock_http_server(
|
||||
status: u16,
|
||||
reason: &'static str,
|
||||
) -> (u16, tokio::sync::oneshot::Receiver<CapturedRequest>) {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Read until we have full headers and the declared body.
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
let mut tmp = [0u8; 1024];
|
||||
let mut headers_end = None;
|
||||
let mut content_length: Option<usize> = None;
|
||||
loop {
|
||||
let n = match stream.read(&mut tmp).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
if headers_end.is_none() {
|
||||
if let Some(pos) = find_subsequence(&buf, b"\r\n\r\n") {
|
||||
headers_end = Some(pos + 4);
|
||||
// Parse Content-Length.
|
||||
let head_str = String::from_utf8_lossy(&buf[..pos]);
|
||||
for line in head_str.lines() {
|
||||
if let Some(v) = line.strip_prefix("Content-Length: ") {
|
||||
content_length = v.trim().parse::<usize>().ok();
|
||||
} else if let Some(v) = line.strip_prefix("content-length: ") {
|
||||
content_length = v.trim().parse::<usize>().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(end), Some(cl)) = (headers_end, content_length) {
|
||||
if buf.len() >= end + cl {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if headers_end.is_some() && content_length.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Split into headers + body.
|
||||
let head_end = headers_end.unwrap_or(buf.len());
|
||||
let head_str = String::from_utf8_lossy(&buf[..head_end.saturating_sub(4)]).to_string();
|
||||
let body_bytes = if head_end < buf.len() {
|
||||
&buf[head_end..]
|
||||
} else {
|
||||
&[][..]
|
||||
};
|
||||
let body = String::from_utf8_lossy(body_bytes).to_string();
|
||||
let headers: Vec<String> = head_str.lines().skip(1).map(|l| l.to_string()).collect();
|
||||
|
||||
// Send response.
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
|
||||
let _ = tx.send(CapturedRequest { headers, body });
|
||||
});
|
||||
|
||||
(port, rx)
|
||||
}
|
||||
|
||||
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
use crate::registry::AgentRegistry;
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use openfang_types::agent::{AgentId, AgentState};
|
||||
use openfang_types::agent::{AgentEntry, AgentId, AgentState, ScheduleMode};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Default heartbeat check interval (seconds).
|
||||
@@ -132,6 +132,14 @@ impl Default for RecoveryTracker {
|
||||
/// and the initial `set_state(Running)` call.
|
||||
const IDLE_GRACE_SECS: i64 = 10;
|
||||
|
||||
/// Reactive agents are healthy while idle between user messages.
|
||||
///
|
||||
/// They should only participate in heartbeat failure detection while a turn is
|
||||
/// actively running. Otherwise silence is the expected steady state.
|
||||
pub(crate) fn should_exempt_idle_reactive_agent(entry: &AgentEntry, is_running_task: bool) -> bool {
|
||||
matches!(entry.manifest.schedule, ScheduleMode::Reactive) && !is_running_task
|
||||
}
|
||||
|
||||
/// Check all running and crashed agents and return their heartbeat status.
|
||||
///
|
||||
/// This is a pure function — it doesn't start a background task.
|
||||
@@ -331,10 +339,13 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
cache_context: false,
|
||||
max_history_messages: None,
|
||||
},
|
||||
state,
|
||||
mode: AgentMode::default(),
|
||||
@@ -375,6 +386,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_reactive_agent_is_exempt_when_not_processing() {
|
||||
let mut agent = make_entry(
|
||||
"reactive-idle",
|
||||
AgentState::Running,
|
||||
Utc::now() - Duration::seconds(600),
|
||||
Utc::now() - Duration::seconds(300),
|
||||
);
|
||||
agent.manifest.schedule = ScheduleMode::Reactive;
|
||||
|
||||
assert!(should_exempt_idle_reactive_agent(&agent, false));
|
||||
assert!(!should_exempt_idle_reactive_agent(&agent, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_periodic_agent_is_not_exempt_when_idle() {
|
||||
let mut agent = make_entry(
|
||||
"periodic-idle",
|
||||
AgentState::Running,
|
||||
Utc::now() - Duration::seconds(600),
|
||||
Utc::now() - Duration::seconds(300),
|
||||
);
|
||||
agent.manifest.schedule = ScheduleMode::Periodic {
|
||||
cron: "0 * * * *".to_string(),
|
||||
};
|
||||
|
||||
assert!(!should_exempt_idle_reactive_agent(&agent, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_agent_detected_unresponsive() {
|
||||
// An agent that WAS active (last_active >> created_at) but has gone
|
||||
|
||||
+2781
-213
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ pub mod capabilities;
|
||||
pub mod config;
|
||||
pub mod config_reload;
|
||||
pub mod cron;
|
||||
pub mod cron_delivery;
|
||||
pub mod error;
|
||||
pub mod event_bus;
|
||||
pub mod heartbeat;
|
||||
|
||||
@@ -234,6 +234,12 @@ pub struct BudgetStatus {
|
||||
/// Order matters: more specific patterns must come before generic ones
|
||||
/// (e.g. "gpt-4o-mini" before "gpt-4o", "gpt-4.1-mini" before "gpt-4.1").
|
||||
fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
// ── Requesty (issue #995) ──────────────────────────────────
|
||||
// Router-style gateway. IDs are `requesty/<upstream>/<model>` and
|
||||
// resolve via substring match on the upstream model name below
|
||||
// (e.g. "sonnet", "gpt-4o", "gemini", "deepseek", "llama").
|
||||
// No early-return here — fall through to upstream patterns.
|
||||
|
||||
// ── Anthropic ──────────────────────────────────────────────
|
||||
if model.contains("haiku") {
|
||||
return (0.25, 1.25);
|
||||
|
||||
@@ -134,6 +134,23 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's private state directory path. The state directory
|
||||
/// holds identity files, sessions, and per-agent memory and is always
|
||||
/// kept separate from the user-facing workspace. See issue #1097.
|
||||
pub fn update_state_dir(
|
||||
&self,
|
||||
id: AgentId,
|
||||
state_dir: Option<std::path::PathBuf>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
entry.manifest.state_dir = state_dir;
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's visual identity (emoji, avatar, color).
|
||||
pub fn update_identity(
|
||||
&self,
|
||||
@@ -391,10 +408,13 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
cache_context: false,
|
||||
max_history_messages: None,
|
||||
},
|
||||
state: AgentState::Created,
|
||||
mode: AgentMode::default(),
|
||||
|
||||
@@ -449,6 +449,14 @@ fn describe_event(event: &Event) -> String {
|
||||
"Health check failed: agent {agent_id}, unresponsive for {unresponsive_secs}s"
|
||||
)
|
||||
}
|
||||
SystemEvent::CronJobExecuted {
|
||||
agent_id,
|
||||
job_id,
|
||||
job_name,
|
||||
..
|
||||
} => {
|
||||
format!("Cron job executed: {job_name} ({job_id}) for agent {agent_id}")
|
||||
}
|
||||
},
|
||||
EventPayload::Custom(data) => {
|
||||
format!("Custom event ({} bytes)", data.len())
|
||||
|
||||
@@ -176,12 +176,15 @@ impl SetupWizard {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
profile: None,
|
||||
fallback_models: vec![],
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
cache_context: false,
|
||||
max_history_messages: None,
|
||||
};
|
||||
|
||||
let skills_to_install: Vec<String> = intent
|
||||
|
||||
@@ -19,6 +19,7 @@ fn test_config() -> KernelConfig {
|
||||
model: "llama-3.3-70b-versatile".to_string(),
|
||||
api_key_env: "GROQ_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
}
|
||||
@@ -161,3 +162,39 @@ memory_write = ["self.*"]
|
||||
kernel.kill_agent(id2).unwrap();
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_manifest_skills_parsing() {
|
||||
let toml_str = r#"
|
||||
name = "skills-test-agent"
|
||||
version = "0.1.0"
|
||||
description = "Test agent with skills"
|
||||
author = "test"
|
||||
module = "builtin:chat"
|
||||
|
||||
skills = ["Productivity", "web-search"]
|
||||
mcp_servers = ["github"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
|
||||
[capabilities]
|
||||
tools = ["file_read"]
|
||||
|
||||
[resources]
|
||||
max_llm_tokens_per_hour = 100000
|
||||
"#;
|
||||
|
||||
let manifest: AgentManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(
|
||||
manifest.skills,
|
||||
vec!["Productivity", "web-search"],
|
||||
"Skills should be parsed correctly (must be at top level, not after [capabilities])"
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.mcp_servers,
|
||||
vec!["github"],
|
||||
"MCP servers should be parsed correctly (must be at top level)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ fn test_config() -> KernelConfig {
|
||||
model: "llama-3.3-70b-versatile".to_string(),
|
||||
api_key_env: "GROQ_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ fn test_config(tmp: &tempfile::TempDir) -> KernelConfig {
|
||||
model: "test".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ fn test_config(provider: &str, model: &str, api_key_env: &str) -> KernelConfig {
|
||||
model: model.to_string(),
|
||||
api_key_env: api_key_env.to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user