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 | ||
|
|
6f86f7a857 | ||
|
|
77bef773e5 | ||
|
|
c000392093 | ||
|
|
ae32af1b06 | ||
|
|
d3972b23c0 | ||
|
|
ded95f3180 | ||
|
|
be149597e6 | ||
|
|
4565988e2e | ||
|
|
4714efb9e4 | ||
|
|
365bec868c | ||
|
|
2fe926c3b9 |
@@ -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
|
||||
|
||||
@@ -204,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:
|
||||
@@ -238,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"}'
|
||||
|
||||
@@ -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
|
||||
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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`.
|
||||
|
||||
Generated
+233
-323
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.8"
|
||||
version = "0.6.9"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -83,7 +83,7 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c
|
||||
url = "2"
|
||||
|
||||
# WASM sandbox
|
||||
wasmtime = "41"
|
||||
wasmtime = "43"
|
||||
|
||||
# HTTP server (for API daemon)
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
@@ -147,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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ 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,
|
||||
@@ -893,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
|
||||
@@ -1147,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()));
|
||||
}
|
||||
@@ -1166,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()));
|
||||
}
|
||||
@@ -1230,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,
|
||||
));
|
||||
@@ -1458,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()));
|
||||
@@ -1470,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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,7 @@ pub(crate) fn percent_decode(input: &str) -> String {
|
||||
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]),
|
||||
) {
|
||||
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
|
||||
out.push(hi << 4 | lo);
|
||||
i += 3;
|
||||
continue;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -189,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()
|
||||
{
|
||||
@@ -215,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;
|
||||
@@ -265,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()
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
|
||||
("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(),
|
||||
|
||||
+1687
-298
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() {
|
||||
@@ -115,6 +119,32 @@ pub async fn build_router(
|
||||
|
||||
// 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,
|
||||
@@ -125,6 +155,7 @@ pub async fn build_router(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
allow_no_auth,
|
||||
};
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
@@ -155,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),
|
||||
@@ -168,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),
|
||||
@@ -316,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",
|
||||
@@ -349,6 +394,19 @@ pub async fn build_router(
|
||||
"/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;
|
||||
@@ -141,4 +160,36 @@ mod tests {
|
||||
// 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
-46
@@ -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,38 +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(|raw| crate::percent_decode(raw))
|
||||
.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
|
||||
@@ -197,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();
|
||||
@@ -227,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));
|
||||
|
||||
@@ -379,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");
|
||||
}
|
||||
@@ -817,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."})
|
||||
}
|
||||
@@ -832,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) {
|
||||
@@ -987,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)
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1373,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;
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
</div>
|
||||
|
||||
<div class="app-layout" :class="{ 'focus-mode': $store.app.focusMode }">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
|
||||
<!-- Sidebar — x-cloak prevents Firefox flash-hidden during Alpine init -->
|
||||
<nav class="sidebar" x-cloak :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header-text">
|
||||
<div class="sidebar-logo">
|
||||
@@ -56,45 +56,11 @@
|
||||
</div>
|
||||
|
||||
<div class="sidebar-nav" role="navigation" aria-label="Main navigation">
|
||||
<!-- Chat — primary action -->
|
||||
<div class="nav-section" x-data="{ collapsed: false }" aria-label="Chat">
|
||||
<div class="nav-section-title" @click="collapsed = !collapsed">
|
||||
<span class="nav-label">Chat</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'agents' }" @click="navigate('agents')" :aria-current="page === 'agents' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<span class="nav-label">Chat</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Monitor -->
|
||||
<div class="nav-section" x-data="{ collapsed: false }" aria-label="Monitor">
|
||||
<div class="nav-section-title" @click="collapsed = !collapsed">
|
||||
<span class="nav-label">Monitor</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'overview' }" @click="navigate('overview')" :aria-current="page === 'overview' ? 'page' : false">
|
||||
<!-- Overview — primary action as first screen -->
|
||||
<a class="nav-item" :class="{ active: page === 'overview' }" @click="navigate('overview')" :aria-current="page === 'overview' ? 'page' : false" style="margin-bottom: 4px;">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg></span>
|
||||
<span class="nav-label">Overview</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'analytics' }" @click="navigate('analytics')" :aria-current="page === 'analytics' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M18 20V10M12 20V4M6 20v-6"/></svg></span>
|
||||
<span class="nav-label">Analytics</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'logs' }" @click="navigate('logs')" :aria-current="page === 'logs' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg></span>
|
||||
<span class="nav-label">Logs</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Agents -->
|
||||
<div class="nav-section" x-data="{ collapsed: false }" aria-label="Agents">
|
||||
@@ -102,8 +68,12 @@
|
||||
<span class="nav-label">Agents</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<!-- x-show + x-cloak: Firefox-safe replacement for nested <template x-if> which has render quirks. -->
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'agents' }" @click="navigate('agents')" :aria-current="page === 'agents' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<span class="nav-label">Chat</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'sessions' }" @click="navigate('sessions')" :aria-current="page === 'sessions' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m12 2-10 5 10 5 10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/></svg></span>
|
||||
<span class="nav-label">Sessions</span>
|
||||
@@ -117,8 +87,7 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
|
||||
<span class="nav-label">Comms</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Automation -->
|
||||
@@ -127,8 +96,7 @@
|
||||
<span class="nav-label">Automation</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'workflows' }" @click="navigate('workflows')" :aria-current="page === 'workflows' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M6 3v12M18 9a9 9 0 0 1-9 9"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/></svg></span>
|
||||
<span class="nav-label">Workflows</span>
|
||||
@@ -137,8 +105,7 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></span>
|
||||
<span class="nav-label">Scheduler</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extensions -->
|
||||
@@ -147,8 +114,7 @@
|
||||
<span class="nav-label">Extensions</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'channels' }" @click="navigate('channels')" :aria-current="page === 'channels' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 9h16M4 15h16M10 3l-2 18M16 3l-2 18"/></svg></span>
|
||||
<span class="nav-label">Channels</span>
|
||||
@@ -161,8 +127,25 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v6"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.9-5.7-2.4L3.4 16a2 2 0 0 1 3.2-2.4L8 15"/></svg></span>
|
||||
<span class="nav-label">Hands</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monitor -->
|
||||
<div class="nav-section" x-data="{ collapsed: false }" aria-label="Monitor">
|
||||
<div class="nav-section-title" @click="collapsed = !collapsed">
|
||||
<span class="nav-label">Monitor</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'analytics' }" @click="navigate('analytics')" :aria-current="page === 'analytics' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M18 20V10M12 20V4M6 20v-6"/></svg></span>
|
||||
<span class="nav-label">Analytics</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'logs' }" @click="navigate('logs')" :aria-current="page === 'logs' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg></span>
|
||||
<span class="nav-label">Logs</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
@@ -171,8 +154,7 @@
|
||||
<span class="nav-label">System</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'runtime' }" @click="navigate('runtime')" :aria-current="page === 'runtime' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></span>
|
||||
<span class="nav-label">Runtime</span>
|
||||
@@ -181,8 +163,7 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
|
||||
<span class="nav-label">Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +177,7 @@
|
||||
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-overlay" @click="mobileMenuOpen = false"></div>
|
||||
<div class="sidebar-overlay" x-cloak @click="mobileMenuOpen = false"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
@@ -257,8 +238,10 @@
|
||||
<template x-for="item in setupChecklist" :key="item.key">
|
||||
<div class="setup-checklist-item">
|
||||
<div class="setup-checklist-icon" :class="{ done: item.done }">
|
||||
<span x-show="item.done">✓</span>
|
||||
<span x-show="!item.done">○</span>
|
||||
<span x-show="item.done">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span x-show="!item.done" style="width:10px;height:10px;border-radius:50%;border:1.5px solid var(--border);display:block"></span>
|
||||
</div>
|
||||
<span style="flex:1" :style="item.done ? 'text-decoration:line-through;opacity:0.6' : ''" x-text="item.label"></span>
|
||||
<a x-show="!item.done" :href="item.action" class="btn btn-ghost btn-sm" style="font-size:10px;padding:3px 8px">Go</a>
|
||||
@@ -597,6 +580,7 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g x-show="!$store.app.focusMode"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g><g x-show="$store.app.focusMode"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></svg>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" @click="killAgent()">Stop</button>
|
||||
<button class="btn btn-danger btn-sm" @click="uninstallAgent()" title="Stop and remove agent files from workspace">Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -756,7 +740,7 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){$event.preventDefault();if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@@ -864,12 +848,19 @@
|
||||
<svg x-show="!agent.identity || !agent.identity.emoji" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
</div>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="font-bold" style="font-size:13px" x-text="agent.name"></div>
|
||||
<div class="text-xs text-dim font-mono" style="font-size:11px" x-text="agent.model_name"></div>
|
||||
<div class="font-bold" style="font-size:13px">
|
||||
<span x-text="agent.name"></span>
|
||||
<!-- Issue #1026: live inferencing indicator -->
|
||||
<span x-show="agent.is_inferencing" class="agent-inferencing-dot" title="Agent is calling the LLM right now" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;animation:agent-inferencing-pulse 1.2s ease-in-out infinite"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim font-mono" style="font-size:11px">
|
||||
<span x-show="!agent.is_inferencing" x-text="agent.model_name"></span>
|
||||
<span x-show="agent.is_inferencing" style="color:var(--accent);font-weight:600">Inferencing…</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
|
||||
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -904,7 +895,9 @@
|
||||
<span x-show="detailAgent.identity && detailAgent.identity.emoji" x-text="detailAgent.identity && detailAgent.identity.emoji" style="margin-right:6px"></span>
|
||||
<span x-text="detailAgent.name"></span>
|
||||
</h3>
|
||||
<button class="modal-close" @click="showDetailModal = false">×</button>
|
||||
<button class="modal-close" @click="showDetailModal = false">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tabs" style="margin-bottom:16px">
|
||||
<div class="tab" :class="{ active: detailTab === 'info' }" @click="detailTab = 'info'">Info</div>
|
||||
@@ -996,6 +989,7 @@
|
||||
<button class="btn btn-ghost" @click="cloneAgent(detailAgent)">Clone</button>
|
||||
<button class="btn btn-ghost" @click="clearHistory(detailAgent)">Clear History</button>
|
||||
<button class="btn btn-danger" @click="killAgent(detailAgent)">Stop</button>
|
||||
<button class="btn btn-danger" @click="uninstallAgent(detailAgent)" title="Stop and remove agent files from workspace">Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1413,7 +1407,7 @@
|
||||
<option value="conditional">Conditional</option>
|
||||
<option value="loop">Loop</option>
|
||||
</select>
|
||||
<button class="btn btn-danger btn-sm" @click="newWf.steps.splice(i,1)">×</button>
|
||||
<button class="btn btn-danger btn-sm" @click="newWf.steps.splice(i,1)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</div>
|
||||
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
|
||||
</div>
|
||||
@@ -1428,7 +1422,7 @@
|
||||
<template x-if="runModal">
|
||||
<div class="modal-overlay" @click.self="runModal = null" @keydown.escape.window="runModal = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3 x-text="'Run: ' + runModal.name"></h3><button class="modal-close" @click="runModal = null">×</button></div>
|
||||
<div class="modal-header"><h3 x-text="'Run: ' + runModal.name"></h3><button class="modal-close" @click="runModal = null"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
||||
<div class="form-group"><label>Input</label><textarea class="form-textarea" x-model="runInput" placeholder="Enter workflow input..."></textarea></div>
|
||||
<button class="btn btn-primary btn-block" @click="executeWorkflow()" :disabled="running">
|
||||
<span x-show="!running">Execute</span><span x-show="running">Running...</span>
|
||||
@@ -1444,7 +1438,7 @@
|
||||
<template x-if="editModal">
|
||||
<div class="modal-overlay" @click.self="editModal = null" @keydown.escape.window="editModal = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3 x-text="'Edit: ' + editModal.name"></h3><button class="modal-close" @click="editModal = null">×</button></div>
|
||||
<div class="modal-header"><h3 x-text="'Edit: ' + editModal.name"></h3><button class="modal-close" @click="editModal = null"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
||||
<div class="form-group"><label>Name</label><input class="form-input" x-model="editWf.name" placeholder="Workflow name"></div>
|
||||
<div class="form-group"><label>Description</label><input class="form-input" x-model="editWf.description" placeholder="What does this workflow do?"></div>
|
||||
<div class="mb-4">
|
||||
@@ -1462,7 +1456,7 @@
|
||||
<option value="conditional">Conditional</option>
|
||||
<option value="loop">Loop</option>
|
||||
</select>
|
||||
<button class="btn btn-danger btn-sm" @click="editWf.steps.splice(i,1)">×</button>
|
||||
<button class="btn btn-danger btn-sm" @click="editWf.steps.splice(i,1)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</div>
|
||||
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
|
||||
</div>
|
||||
@@ -1550,7 +1544,7 @@
|
||||
<div class="wf-editor-panel" x-show="showNodeEditor && selectedNode" x-transition>
|
||||
<div class="wf-editor-header">
|
||||
<span class="font-bold text-sm" x-text="selectedNode ? selectedNode.label : ''"></span>
|
||||
<button class="btn btn-ghost btn-sm" @click="showNodeEditor = false">×</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="showNodeEditor = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</div>
|
||||
<template x-if="selectedNode">
|
||||
<div>
|
||||
@@ -1651,7 +1645,7 @@
|
||||
<template x-if="showTomlPreview">
|
||||
<div class="modal-overlay" @click.self="showTomlPreview = false" @keydown.escape.window="showTomlPreview = false">
|
||||
<div class="modal" style="max-width:600px">
|
||||
<div class="modal-header"><h3>Generated TOML</h3><button class="modal-close" @click="showTomlPreview = false">×</button></div>
|
||||
<div class="modal-header"><h3>Generated TOML</h3><button class="modal-close" @click="showTomlPreview = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
||||
<pre class="wf-toml-preview" x-text="tomlOutput"></pre>
|
||||
<button class="btn btn-ghost btn-block mt-2" @click="navigator.clipboard.writeText(tomlOutput); OpenFangToast.success('Copied!')">Copy to Clipboard</button>
|
||||
</div>
|
||||
@@ -1662,7 +1656,7 @@
|
||||
<template x-if="showSaveModal">
|
||||
<div class="modal-overlay" @click.self="showSaveModal = false" @keydown.escape.window="showSaveModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Save Workflow</h3><button class="modal-close" @click="showSaveModal = false">×</button></div>
|
||||
<div class="modal-header"><h3>Save Workflow</h3><button class="modal-close" @click="showSaveModal = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
||||
<div class="form-group"><label>Name</label><input class="form-input" x-model="workflowName" placeholder="my-workflow"></div>
|
||||
<div class="form-group"><label>Description</label><input class="form-input" x-model="workflowDescription" placeholder="What does this workflow do?"></div>
|
||||
<div class="text-xs text-dim mb-4" x-text="nodes.filter(function(n){return n.type!=='start'&&n.type!=='end'}).length + ' steps, ' + connections.length + ' connections'"></div>
|
||||
@@ -1726,6 +1720,7 @@
|
||||
<th>Schedule</th>
|
||||
<th>Agent</th>
|
||||
<th>Status</th>
|
||||
<th>Delivery</th>
|
||||
<th>Last Run</th>
|
||||
<th>Next Run</th>
|
||||
<th>Actions</th>
|
||||
@@ -1737,6 +1732,34 @@
|
||||
<td>
|
||||
<span class="font-bold" x-text="job.name || job.description || '(unnamed)'"></span>
|
||||
<div class="text-xs text-dim" x-show="job.message" x-text="(job.message || '').substring(0, 60) + ((job.message || '').length > 60 ? '...' : '')" :title="job.message"></div>
|
||||
<div x-show="expandedJobId === job.id" style="margin-top:8px;padding:8px;background:rgba(148,163,184,0.06);border-left:3px solid var(--accent);border-radius:3px">
|
||||
<div class="font-bold" style="font-size:12px;margin-bottom:6px">Delivery Log</div>
|
||||
<div x-show="deliveryLogLoading" class="text-xs text-dim">Loading delivery log...</div>
|
||||
<div x-show="!deliveryLogLoading && deliveryLogError" class="text-xs" style="color:var(--error)" x-text="deliveryLogError"></div>
|
||||
<div x-show="!deliveryLogLoading && !deliveryLogError">
|
||||
<div class="text-xs text-dim" style="margin-bottom:4px">Configured targets (<span x-text="(deliveryLog.targets || []).length"></span>):</div>
|
||||
<div x-show="!(deliveryLog.targets || []).length" class="text-xs text-dim">No fan-out targets configured.</div>
|
||||
<template x-for="(t, ti) in (deliveryLog.targets || [])" :key="ti">
|
||||
<div class="text-xs" style="padding:2px 0">
|
||||
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
|
||||
<span class="text-dim" style="margin-left:6px" x-text="targetSummary(t)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="text-xs text-dim" style="margin-top:8px">Recent deliveries (<span x-text="(deliveryLog.entries || []).length"></span>):</div>
|
||||
<div x-show="!(deliveryLog.entries || []).length" class="text-xs text-dim">
|
||||
Delivery history will appear here after the next run. Per-run results are not yet
|
||||
persisted — the fan-out engine emits tracing events you can view under
|
||||
<a href="#logs" style="color:var(--accent)">Logs</a>.
|
||||
</div>
|
||||
<template x-for="(e, ei) in (deliveryLog.entries || [])" :key="ei">
|
||||
<div class="text-xs" style="padding:2px 0">
|
||||
<span class="badge" :class="e.success ? 'badge-success' : 'badge-error'" x-text="e.success ? 'ok' : 'err'"></span>
|
||||
<span style="margin-left:6px" x-text="e.target"></span>
|
||||
<span class="text-dim" style="margin-left:6px" x-show="e.error" x-text="e.error"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code style="font-size:11px;color:var(--accent)" x-text="job.cron"></code>
|
||||
@@ -1746,15 +1769,25 @@
|
||||
<td>
|
||||
<span class="badge" :class="job.enabled ? 'badge-success' : 'badge-dim'" x-text="job.enabled ? 'Active' : 'Paused'"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1" style="flex-wrap:wrap;max-width:240px" x-show="(job.delivery_targets || []).length">
|
||||
<template x-for="(t, ti) in (job.delivery_targets || [])" :key="ti">
|
||||
<span class="badge" :class="targetChipClass(t)" :title="targetSummary(t)" x-text="targetChipLabel(t)"></span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="text-xs text-dim" x-show="!(job.delivery_targets || []).length">(none)</span>
|
||||
</td>
|
||||
<td class="text-xs" :title="formatTime(job.last_run)" x-text="relativeTime(job.last_run)"></td>
|
||||
<td class="text-xs" :title="formatTime(job.next_run)" x-text="relativeTime(job.next_run)"></td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<div class="flex gap-1" style="flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" @click="runNow(job)" :disabled="runningJobId === job.id">
|
||||
<span x-show="runningJobId !== job.id">Run</span>
|
||||
<span x-show="runningJobId === job.id">...</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="toggleJob(job)" x-text="job.enabled ? 'Pause' : 'Enable'"></button>
|
||||
<button class="btn btn-ghost btn-sm" @click="toggleExpand(job)" x-text="expandedJobId === job.id ? 'Hide' : 'Details'"></button>
|
||||
<button class="btn btn-ghost btn-sm" @click="startEditTargets(job)">Targets</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteJob(job)">Del</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1778,7 +1811,7 @@
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Create Scheduled Job</h3>
|
||||
<button class="modal-close" @click="showCreateForm = false">×</button>
|
||||
<button class="modal-close" @click="showCreateForm = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -1826,6 +1859,104 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="flex items-center justify-between" style="margin-bottom:6px">
|
||||
<label style="margin:0">Delivery Targets</label>
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="openTargetPicker()">+ Add target</button>
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-1" x-show="!(newJob.delivery_targets || []).length">
|
||||
The job output is delivered to the agent's last channel by default. Add one or more
|
||||
fan-out destinations — channels, webhooks, local files, or email — to copy the output
|
||||
to extra destinations each run.
|
||||
</div>
|
||||
<div x-show="(newJob.delivery_targets || []).length" style="margin-top:6px">
|
||||
<template x-for="(t, ti) in (newJob.delivery_targets || [])" :key="ti">
|
||||
<div class="card" style="padding:8px;margin-bottom:6px">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="removeTarget(ti)">Remove</button>
|
||||
</div>
|
||||
<div class="text-xs text-dim" style="margin-top:4px" x-text="targetSummary(t)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target picker (shared by create + edit flows) -->
|
||||
<div class="card" x-show="showTargetPicker && !editingTargetsJobId" style="border-left:3px solid var(--accent);margin-top:8px">
|
||||
<div class="font-bold" style="font-size:12px;margin-bottom:6px">New Delivery Target</div>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<select class="form-select" x-model="pickerType" @change="onPickerTypeChange()">
|
||||
<option value="channel">Channel</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="local_file">Local File</option>
|
||||
<option value="email">Email</option>
|
||||
</select>
|
||||
</div>
|
||||
<template x-if="draftTarget && draftTarget.type === 'channel'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>Channel type</label>
|
||||
<select class="form-select" x-model="draftTarget.channel_type" x-show="channelTypes.length">
|
||||
<option value="">-- pick --</option>
|
||||
<template x-for="c in channelTypes" :key="c">
|
||||
<option :value="c" x-text="c"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input class="form-input" x-model="draftTarget.channel_type" placeholder="telegram" x-show="!channelTypes.length">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Recipient</label>
|
||||
<input class="form-input" x-model="draftTarget.recipient" placeholder="chat_12345">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="draftTarget && draftTarget.type === 'webhook'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input class="form-input" x-model="draftTarget.url" placeholder="https://example.com/hook">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Authorization header (optional)</label>
|
||||
<input class="form-input" x-model="draftTarget.auth_header" placeholder="Bearer abc123">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="draftTarget && draftTarget.type === 'local_file'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>Path</label>
|
||||
<input class="form-input" x-model="draftTarget.path" placeholder="/var/log/openfang-cron.log">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="flex items-center gap-2">
|
||||
<div class="toggle" :class="{ active: draftTarget.append }" @click="draftTarget.append = !draftTarget.append"></div>
|
||||
<span x-text="draftTarget.append ? 'Append' : 'Overwrite'"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="draftTarget && draftTarget.type === 'email'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>To</label>
|
||||
<input class="form-input" x-model="draftTarget.to" placeholder="alice@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Subject template (optional)</label>
|
||||
<input class="form-input" x-model="draftTarget.subject_template" placeholder="Cron: {job}">
|
||||
<div class="text-xs text-dim mt-1">Placeholders: <code>{job}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex gap-2" style="margin-top:6px">
|
||||
<button class="btn btn-primary btn-sm" type="button" @click="addDraftTarget()">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="cancelTargetPicker()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block mt-4" @click="createJob()" :disabled="creating">
|
||||
<span x-show="!creating">Create Schedule</span>
|
||||
<span x-show="creating">Creating...</span>
|
||||
@@ -1833,6 +1964,116 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Edit Targets Modal -->
|
||||
<template x-if="editingTargetsJobId">
|
||||
<div class="modal-overlay" @click.self="cancelEditTargets()" @keydown.escape.window="cancelEditTargets()">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Delivery Targets</h3>
|
||||
<button class="modal-close" @click="cancelEditTargets()">x</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="flex items-center justify-between" style="margin-bottom:6px">
|
||||
<div class="text-xs text-dim">
|
||||
Targets are replaced on save. Leave empty to remove all fan-out destinations.
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="addEditTarget()">+ Add target</button>
|
||||
</div>
|
||||
<div x-show="!editingTargets.length" class="text-xs text-dim">No targets configured.</div>
|
||||
<template x-for="(t, ti) in editingTargets" :key="ti">
|
||||
<div class="card" style="padding:8px;margin-bottom:6px">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="removeEditTarget(ti)">Remove</button>
|
||||
</div>
|
||||
<div class="text-xs text-dim" style="margin-top:4px" x-text="targetSummary(t)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="card" x-show="showTargetPicker" style="border-left:3px solid var(--accent)">
|
||||
<div class="font-bold" style="font-size:12px;margin-bottom:6px">New Delivery Target</div>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<select class="form-select" x-model="pickerType" @change="onPickerTypeChange()">
|
||||
<option value="channel">Channel</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="local_file">Local File</option>
|
||||
<option value="email">Email</option>
|
||||
</select>
|
||||
</div>
|
||||
<template x-if="draftTarget && draftTarget.type === 'channel'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>Channel type</label>
|
||||
<select class="form-select" x-model="draftTarget.channel_type" x-show="channelTypes.length">
|
||||
<option value="">-- pick --</option>
|
||||
<template x-for="c in channelTypes" :key="c">
|
||||
<option :value="c" x-text="c"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input class="form-input" x-model="draftTarget.channel_type" placeholder="telegram" x-show="!channelTypes.length">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Recipient</label>
|
||||
<input class="form-input" x-model="draftTarget.recipient" placeholder="chat_12345">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="draftTarget && draftTarget.type === 'webhook'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input class="form-input" x-model="draftTarget.url" placeholder="https://example.com/hook">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Authorization header (optional)</label>
|
||||
<input class="form-input" x-model="draftTarget.auth_header" placeholder="Bearer abc123">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="draftTarget && draftTarget.type === 'local_file'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>Path</label>
|
||||
<input class="form-input" x-model="draftTarget.path" placeholder="/var/log/openfang-cron.log">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="flex items-center gap-2">
|
||||
<div class="toggle" :class="{ active: draftTarget.append }" @click="draftTarget.append = !draftTarget.append"></div>
|
||||
<span x-text="draftTarget.append ? 'Append' : 'Overwrite'"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="draftTarget && draftTarget.type === 'email'">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>To</label>
|
||||
<input class="form-input" x-model="draftTarget.to" placeholder="alice@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Subject template (optional)</label>
|
||||
<input class="form-input" x-model="draftTarget.subject_template" placeholder="Cron: {job}">
|
||||
<div class="text-xs text-dim mt-1">Placeholders: <code>{job}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex gap-2" style="margin-top:6px">
|
||||
<button class="btn btn-primary btn-sm" type="button" @click="addDraftTargetToEdit()">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="cancelTargetPicker()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="saveEditTargets()" :disabled="savingTargets">
|
||||
<span x-show="!savingTargets">Save</span>
|
||||
<span x-show="savingTargets">Saving...</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost" @click="cancelEditTargets()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── TAB: Event Triggers ── -->
|
||||
@@ -1988,28 +2229,28 @@
|
||||
</h3>
|
||||
<div class="text-xs text-dim mt-1" x-text="setupModal.quick_setup || setupModal.description"></div>
|
||||
</div>
|
||||
<button class="modal-close" @click="setupModal = null">×</button>
|
||||
<button class="modal-close" @click="setupModal = null"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</div>
|
||||
|
||||
<!-- 3-step progress indicator (non-QR channels only) -->
|
||||
<div class="channel-steps" x-show="!isQrChannel()">
|
||||
<div class="channel-step-item">
|
||||
<div class="channel-step-num" :class="{ active: setupStep === 1, done: setupStep > 1 }">
|
||||
<span x-show="setupStep <= 1">1</span><span x-show="setupStep > 1">✓</span>
|
||||
<span x-show="setupStep <= 1">1</span><span x-show="setupStep > 1"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
</div>
|
||||
<span class="channel-step-label" :class="{ active: setupStep === 1, done: setupStep > 1 }">Configure</span>
|
||||
</div>
|
||||
<div class="channel-step-line" :class="{ done: setupStep > 1 }"></div>
|
||||
<div class="channel-step-item">
|
||||
<div class="channel-step-num" :class="{ active: setupStep === 2, done: setupStep > 2 }">
|
||||
<span x-show="setupStep <= 2">2</span><span x-show="setupStep > 2">✓</span>
|
||||
<span x-show="setupStep <= 2">2</span><span x-show="setupStep > 2"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
</div>
|
||||
<span class="channel-step-label" :class="{ active: setupStep === 2, done: setupStep > 2 }">Verify</span>
|
||||
</div>
|
||||
<div class="channel-step-line" :class="{ done: setupStep > 2 }"></div>
|
||||
<div class="channel-step-item">
|
||||
<div class="channel-step-num" :class="{ active: setupStep === 3, done: setupStep >= 3 }">
|
||||
<span x-show="setupStep < 3">3</span><span x-show="setupStep >= 3">✓</span>
|
||||
<span x-show="setupStep < 3">3</span><span x-show="setupStep >= 3"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
</div>
|
||||
<span class="channel-step-label" :class="{ active: setupStep === 3, done: setupStep >= 3 }">Ready</span>
|
||||
</div>
|
||||
@@ -2017,7 +2258,9 @@
|
||||
|
||||
<!-- Ready panel (step 3) for non-QR channels -->
|
||||
<div x-show="!isQrChannel() && setupStep === 3 && testPassed" class="ready-panel">
|
||||
<div class="ready-panel-icon">✓</div>
|
||||
<div class="ready-panel-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div class="ready-panel-title" x-text="setupModal.display_name + ' is ready!'"></div>
|
||||
<div class="ready-panel-desc">
|
||||
Your channel is configured and verified. It will activate automatically.
|
||||
@@ -2053,7 +2296,9 @@
|
||||
|
||||
<!-- QR: Connected! -->
|
||||
<div x-show="!qr.loading && qr.connected" style="text-align:center;padding:2rem 0">
|
||||
<div style="font-size:3rem;margin-bottom:0.5rem">✓</div>
|
||||
<div style="margin-bottom:0.5rem;display:flex;justify-content:center">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<p class="text-sm" style="font-weight:600" x-text="qr.message || 'WhatsApp linked successfully!'"></p>
|
||||
<p class="text-xs text-dim mt-1">Channel will activate automatically.</p>
|
||||
</div>
|
||||
@@ -2236,8 +2481,12 @@
|
||||
<span class="text-xs text-dim" x-text="skill.tools_count + ' tool(s)'"></span>
|
||||
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
|
||||
<span class="text-xs text-dim" x-show="skill.has_prompt_context">(prompt context)</span>
|
||||
<span class="text-xs text-dim" x-show="skill.config_declared_count > 0" x-text="skill.config_declared_count + ' config var(s)'"></span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-ghost btn-sm" x-show="skill.config_declared_count > 0" @click="openSkillConfig(skill)" title="Configure skill variables">⚙ Configure</button>
|
||||
<button class="btn btn-danger btn-sm" @click="uninstallSkill(skill.name)">Uninstall</button>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm" @click="uninstallSkill(skill.name)">Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2261,7 +2510,9 @@
|
||||
<div class="search-input mb-4" style="position:relative">
|
||||
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
|
||||
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
|
||||
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">×</button>
|
||||
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sort pills (always visible when not searching) -->
|
||||
@@ -2458,7 +2709,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div>
|
||||
<div class="modal-header">
|
||||
<h3 x-text="skillDetail.name || skillDetail.slug"></h3>
|
||||
<button class="modal-close" @click="closeDetail()">×</button>
|
||||
<button class="modal-close" @click="closeDetail()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
@@ -2507,6 +2760,80 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Skill Config Modal (SKILL.md frontmatter `config:` variables) -->
|
||||
<template x-if="configSkill">
|
||||
<div class="modal-overlay" @click.self="closeSkillConfig()" @keydown.escape.window="closeSkillConfig()">
|
||||
<div class="modal" style="max-width:640px">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3>Configure: <span x-text="configSkill.name"></span></h3>
|
||||
<div class="text-xs text-dim mt-1" x-text="configSkill.description"></div>
|
||||
</div>
|
||||
<button class="modal-close" @click="closeSkillConfig()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="configLoading" class="loading-state" style="padding:20px 0">
|
||||
<div class="spinner"></div><span>Loading configuration…</span>
|
||||
</div>
|
||||
|
||||
<div x-show="!configLoading && configError" class="error-state">
|
||||
<span class="error-icon">!</span>
|
||||
<p x-text="configError"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="!configLoading && !configError">
|
||||
<div x-show="configDeclaredNames.length === 0" class="empty-state" style="padding:16px 0">
|
||||
<p class="text-dim">This skill does not declare any runtime config variables.</p>
|
||||
</div>
|
||||
|
||||
<template x-for="name in configDeclaredNames" :key="name">
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<code x-text="name" style="font-size:0.8rem"></code>
|
||||
<span x-show="configDeclared[name] && configDeclared[name].required" class="badge badge-danger" style="font-size:0.6rem">required</span>
|
||||
<span class="badge" :class="sourceBadgeClass(configResolved[name] && configResolved[name].source)" style="font-size:0.6rem">
|
||||
<template x-if="configResolved[name] && configResolved[name].source === 'user'"><span>user override</span></template>
|
||||
<template x-if="configResolved[name] && configResolved[name].source === 'env'">
|
||||
<span>env<template x-if="configDeclared[name] && configDeclared[name].env"><span>:<span x-text="configDeclared[name].env"></span></span></template></span>
|
||||
</template>
|
||||
<template x-if="configResolved[name] && configResolved[name].source === 'default'"><span>default</span></template>
|
||||
<template x-if="!configResolved[name] || configResolved[name].source === 'unresolved'"><span>unresolved ⚠</span></template>
|
||||
</span>
|
||||
</label>
|
||||
<div class="text-xs text-dim" x-show="configDeclared[name] && configDeclared[name].description" x-text="configDeclared[name] ? configDeclared[name].description : ''" style="margin:2px 0 6px 0"></div>
|
||||
<div class="flex gap-2 items-center" style="position:relative">
|
||||
<input
|
||||
style="flex:1"
|
||||
:type="(configResolved[name] && configResolved[name].is_secret && !configRevealed[name]) ? 'password' : 'text'"
|
||||
:placeholder="(configResolved[name] && configResolved[name].source !== 'user' && configResolved[name].value != null) ? ('Currently: ' + configResolved[name].value) : ((configDeclared[name] && configDeclared[name].default) ? ('default: ' + configDeclared[name].default) : 'Enter value')"
|
||||
x-model="configDraft[name]"
|
||||
:class="{ 'input-error': configRowInvalid(name) }">
|
||||
<button type="button" class="btn btn-ghost btn-sm" x-show="configResolved[name] && configResolved[name].is_secret" @click="toggleReveal(name)" :title="configRevealed[name] ? 'Hide' : 'Reveal'">
|
||||
<svg x-show="!configRevealed[name]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg x-show="configRevealed[name]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-10-8-10-8a19.77 19.77 0 0 1 5.06-5.94M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 10 8 10 8a19.77 19.77 0 0 1-2.16 3.19M1 1l22 22"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs" style="margin-top:4px;display:flex;justify-content:space-between;align-items:center">
|
||||
<div class="text-dim" style="font-style:italic" x-show="configResolved[name] && configResolved[name].source === 'user'">Leaving blank and saving keeps existing override. Use Reset to remove it.</div>
|
||||
<a href="#" class="text-dim" style="font-size:0.7rem" x-show="configResolved[name] && configResolved[name].source === 'user'" @click.prevent="resetSkillConfigVar(name)">Reset to env/default</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="text-xs text-dim" style="margin-top:12px">
|
||||
Values are stored in <code>~/.openfang/config.toml</code> under <code>[skills.<span x-text="configSkill.name"></span>]</code>. Secrets are redacted in this view.
|
||||
</div>
|
||||
<div class="flex justify-end gap-2" style="margin-top:16px">
|
||||
<button class="btn btn-ghost" @click="closeSkillConfig()" :disabled="configSaving">Cancel</button>
|
||||
<button class="btn btn-primary" @click="saveSkillConfig()" :disabled="configSaving || hasInvalidConfig()" x-text="configSaving ? 'Saving...' : 'Save'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2651,7 +2978,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="modal" style="max-width:600px">
|
||||
<div class="modal-header">
|
||||
<h3><span x-text="detailHand.icon"></span> <span x-text="detailHand.name"></span></h3>
|
||||
<button class="modal-close" @click="detailHand = null">×</button>
|
||||
<button class="modal-close" @click="detailHand = null">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<p x-text="detailHand.description"></p>
|
||||
@@ -2705,7 +3034,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="wizard-title" x-text="'Set up ' + setupWizard.name"></div>
|
||||
<div class="wizard-subtitle" x-text="setupWizard.description"></div>
|
||||
</div>
|
||||
<button class="wizard-close" @click="closeSetupWizard()">×</button>
|
||||
<button class="wizard-close" @click="closeSetupWizard()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step Indicators -->
|
||||
@@ -2747,7 +3078,10 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
|
||||
<!-- Satisfied: green message -->
|
||||
<template x-if="req.satisfied">
|
||||
<div class="dep-met-msg">✓ Detected on your system</div>
|
||||
<div class="dep-met-msg" style="display:flex;align-items:center;gap:4px">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span>Detected on your system</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Not satisfied: show install instructions -->
|
||||
@@ -2784,7 +3118,10 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div style="margin-bottom:10px">
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px" x-text="'Paste your ' + req.label + ':'"></label>
|
||||
<input type="password" class="form-input" x-model="apiKeyInputs[req.key]" :placeholder="req.label" style="width:100%;font-family:var(--font-mono);font-size:12px">
|
||||
<div class="text-xs" style="margin-top:4px;color:var(--green)" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">✓ Token entered</div>
|
||||
<div class="text-xs" style="margin-top:4px;color:var(--green);display:flex;align-items:center;gap:4px" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span>Token entered</span>
|
||||
</div>
|
||||
</div>
|
||||
<details style="margin-bottom:8px">
|
||||
<summary class="text-xs text-dim" style="cursor:pointer;user-select:none">Or set as environment variable</summary>
|
||||
@@ -2838,13 +3175,17 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</template>
|
||||
<template x-if="installProgress.status === 'done' && !installProgress.error">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color:var(--green);font-size:16px">✓</span>
|
||||
<span style="color:var(--green);display:flex">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span class="text-sm font-bold" style="color:var(--green)">Installation complete!</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="installProgress.error">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color:var(--red);font-size:16px">✗</span>
|
||||
<span style="color:var(--red);display:flex">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</span>
|
||||
<span class="text-sm" style="color:var(--red)" x-text="installProgress.error"></span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3059,7 +3400,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm" @click="refreshDashboard()">Refresh</button>
|
||||
<button class="modal-close" @click="closeDashboard()">×</button>
|
||||
<button class="modal-close" @click="closeDashboard()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3230,11 +3573,47 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div x-show="tab === 'providers'">
|
||||
<div class="info-card">
|
||||
<h4>LLM Providers</h4>
|
||||
<p>OpenFang supports 12 LLM providers out of the box. Configure API keys to unlock models from each provider. Set environment variables and restart, or use the form below to save keys directly.</p>
|
||||
<p>OpenFang ships with <span x-text="providers.length"></span> built-in providers and you can add unlimited custom ones. <span x-text="configuredProviderCount"></span> currently configured. Filter, search, or jump to a category below — only providers with a saved key (or that need none) light up models for your agents.</p>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<template x-for="p in providers" :key="p.id">
|
||||
<div class="card provider-card" :class="providerCardClass(p)">
|
||||
<!-- Filter toolbar -->
|
||||
<div class="flex gap-2 mb-4" style="flex-wrap:wrap;align-items:center">
|
||||
<div class="search-input" style="flex:1;min-width:200px">
|
||||
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
|
||||
<input placeholder="Search providers..." x-model="providerSearch">
|
||||
</div>
|
||||
<select class="form-select" style="width:170px" x-model="providerStatusFilter">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="configured">Configured</option>
|
||||
<option value="unconfigured">Needs Key</option>
|
||||
</select>
|
||||
<select class="form-select" style="width:200px" x-model="providerCategoryFilter">
|
||||
<option value="">All Categories</option>
|
||||
<option value="frontier">Frontier</option>
|
||||
<option value="oss">Open-Weight Hosts</option>
|
||||
<option value="aggregator">Aggregators</option>
|
||||
<option value="regional">Regional / China</option>
|
||||
<option value="local">Local / Self-Hosted</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost btn-sm" @click="clearProviderFilters()" x-show="providerSearch || providerStatusFilter || providerCategoryFilter">Clear</button>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredProviders.length + ' of ' + providers.length + ' providers'"></div>
|
||||
<!-- Empty state for filters -->
|
||||
<div x-show="!filteredProviders.length && providers.length" style="text-align:center;padding:32px 16px">
|
||||
<h3 style="margin:0 0 4px;font-size:14px">No providers match your filters</h3>
|
||||
<p class="text-xs text-dim">Try a different search term or category.</p>
|
||||
<button class="btn btn-ghost btn-sm mt-2" @click="clearProviderFilters()">Clear Filters</button>
|
||||
</div>
|
||||
<!-- Grouped provider sections -->
|
||||
<template x-for="group in providersGrouped" :key="group.category">
|
||||
<div style="margin-bottom:1.25rem">
|
||||
<div class="card-header" style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)">
|
||||
<span x-text="group.label"></span>
|
||||
<span class="text-xs text-dim" style="font-weight:normal;text-transform:none;letter-spacing:0" x-text="'(' + group.items.length + ')'"></span>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<template x-for="p in group.items" :key="p.id">
|
||||
<div class="card provider-card" :class="providerCardClass(p)">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="card-header" style="margin:0" x-text="p.display_name"></div>
|
||||
<span class="badge" :class="providerAuthClass(p)" x-text="providerAuthText(p)"></span>
|
||||
@@ -3293,9 +3672,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Add Custom Provider -->
|
||||
<div class="info-card mt-4" style="border:1px solid var(--border)">
|
||||
<h4 style="margin-top:0">Add Custom Provider</h4>
|
||||
@@ -3610,12 +3991,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="card mt-4" style="border-left:3px solid var(--accent)">
|
||||
<div class="font-bold" style="font-size:13px;margin-bottom:6px">Security Dependencies</div>
|
||||
<div class="text-xs text-dim" style="line-height:1.8">
|
||||
<code style="color:var(--accent-light)">sha2</code> SHA-256 ·
|
||||
<code style="color:var(--accent-light)">hmac</code> HMAC-SHA256 ·
|
||||
<code style="color:var(--accent-light)">subtle</code> constant-time ·
|
||||
<code style="color:var(--accent-light)">ed25519-dalek</code> signing ·
|
||||
<code style="color:var(--accent-light)">zeroize</code> secret wiping ·
|
||||
<code style="color:var(--accent-light)">rand</code> randomness ·
|
||||
<code style="color:var(--accent-light)">sha2</code> SHA-256 <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
||||
<code style="color:var(--accent-light)">hmac</code> HMAC-SHA256 <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
||||
<code style="color:var(--accent-light)">subtle</code> constant-time <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
||||
<code style="color:var(--accent-light)">ed25519-dalek</code> signing <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
||||
<code style="color:var(--accent-light)">zeroize</code> secret wiping <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
||||
<code style="color:var(--accent-light)">rand</code> randomness <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
||||
<code style="color:var(--accent-light)">governor</code> rate limiting
|
||||
</div>
|
||||
</div>
|
||||
@@ -3642,7 +4023,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<span class="badge" :class="netStatus && netStatus.enabled ? 'badge-success' : 'badge-muted'" x-text="netStatus && netStatus.enabled ? 'Enabled' : 'Disabled'" style="font-size:12px;padding:4px 10px"></span>
|
||||
</div>
|
||||
<div x-show="netStatus && netStatus.enabled" class="text-xs text-dim mb-2" style="margin-top:-8px">
|
||||
Node: <span x-text="netStatus?.node_id?.substring(0,8) + '...'" class="font-bold"></span> •
|
||||
Node: <span x-text="netStatus?.node_id?.substring(0,8) + '...'" class="font-bold"></span> <svg width="4" height="4" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.5"><circle cx="12" cy="12" r="10"/></svg>
|
||||
Listening on <span x-text="netStatus?.listen_address" class="font-bold"></span>
|
||||
</div>
|
||||
<div x-show="peersLoading" class="loading-state"><div class="spinner"></div><span>Loading peers...</span></div>
|
||||
@@ -4259,7 +4640,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<template x-if="showAdd">
|
||||
<div class="modal-overlay" @click.self="showAdd = false" @keydown.escape.window="showAdd = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>Add Key</h3><button class="modal-close" @click="showAdd = false">×</button></div>
|
||||
<div class="modal-header"><h3>Add Key</h3><button class="modal-close" @click="showAdd = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
||||
<div class="form-group"><label>Key</label><input class="form-input" x-model="newKey" placeholder="my_key"></div>
|
||||
<div class="form-group"><label>Value (JSON)</label><textarea class="form-textarea" x-model="newValue" placeholder='"hello"'></textarea></div>
|
||||
<button class="btn btn-primary btn-block" @click="addKey()">Save</button>
|
||||
@@ -4586,7 +4967,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="wizard-progress-step" :class="{ 'wiz-active': step === n, 'wiz-done': step > n }" @click="goToStep(n)">
|
||||
<div class="wizard-progress-circle">
|
||||
<span x-show="step <= n" x-text="n"></span>
|
||||
<span x-show="step > n">✓</span>
|
||||
<span x-show="step > n">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<span class="wizard-progress-label" x-text="stepLabel(n)"></span>
|
||||
</div>
|
||||
@@ -4727,7 +5110,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<template x-if="selectedProviderObj && providerIsConfigured(selectedProviderObj)">
|
||||
<div class="card" style="border-left:3px solid var(--success);margin-top:16px">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color:var(--success);font-size:18px">✓</span>
|
||||
<div style="color:var(--success);display:flex;align-items:center;justify-content:center;width:24px;height:24px">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold" style="font-size:13px" x-text="selectedProviderObj.display_name + ' is configured and ready'"></div>
|
||||
<div class="text-xs text-dim">You can test the connection or continue to the next step.</div>
|
||||
@@ -4896,7 +5281,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- Step 6: Done -->
|
||||
<div class="wizard-step" x-show="step === 6">
|
||||
<div class="wizard-card" style="text-align:center;max-width:560px;margin:0 auto">
|
||||
<div style="font-size:56px;margin-bottom:12px;color:var(--success)">✓</div>
|
||||
<div style="margin-bottom:12px;color:var(--success);display:flex;justify-content:center">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<h3 style="font-size:20px;font-weight:700;margin-bottom:8px;color:var(--accent)">You're All Set!</h3>
|
||||
<p style="font-size:13px;color:var(--text-dim);line-height:1.8;margin-bottom:24px">OpenFang is configured and ready to go. Here is a summary of what was set up:</p>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -337,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) {
|
||||
@@ -376,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;
|
||||
|
||||
@@ -143,6 +143,29 @@ function chatPage() {
|
||||
// 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);
|
||||
@@ -304,22 +338,38 @@ function chatPage() {
|
||||
this._slashCommandsLoaded = true;
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
// 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() {
|
||||
@@ -330,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;
|
||||
@@ -355,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':
|
||||
@@ -419,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;
|
||||
@@ -427,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;
|
||||
@@ -435,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;
|
||||
@@ -474,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':
|
||||
@@ -509,9 +598,27 @@ 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
|
||||
@@ -641,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) {
|
||||
@@ -1087,6 +1203,7 @@ function chatPage() {
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
OpenFangToast.success(t('chat.agent_stopped') + ' "' + name + '"');
|
||||
Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
@@ -1095,6 +1212,37 @@ function chatPage() {
|
||||
});
|
||||
},
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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,6 +30,16 @@ 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,
|
||||
@@ -101,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) {
|
||||
@@ -111,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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -362,6 +464,7 @@ async fn test_agent_session_filters_system_messages() {
|
||||
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"),
|
||||
@@ -803,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()
|
||||
};
|
||||
@@ -834,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()
|
||||
@@ -856,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),
|
||||
@@ -982,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
@@ -8,7 +8,7 @@ use crate::types::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -22,6 +22,10 @@ const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const DISCORD_MSG_LIMIT: usize = 2000;
|
||||
/// Maximum number of seen message IDs kept in the dedup set.
|
||||
/// MESSAGE_UPDATE (embed resolution) events arrive within seconds of the
|
||||
/// original CREATE; entries older than this cap are safe to discard.
|
||||
const MAX_DEDUP_MSG_IDS: usize = 2_000;
|
||||
|
||||
/// Discord Gateway opcodes.
|
||||
mod opcode {
|
||||
@@ -56,6 +60,8 @@ pub struct DiscordAdapter {
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
/// Auto-thread behavior: "true", "false", or "smart"
|
||||
auto_thread: String,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Bot's own user ID (populated after READY event).
|
||||
@@ -64,6 +70,13 @@ pub struct DiscordAdapter {
|
||||
session_id: Arc<RwLock<Option<String>>>,
|
||||
/// Resume gateway URL.
|
||||
resume_gateway_url: Arc<RwLock<Option<String>>>,
|
||||
/// Thread channel IDs created by this bot (thread_id → parent_channel_id).
|
||||
/// Used to detect when incoming messages are inside a bot-created thread.
|
||||
created_thread_ids: Arc<RwLock<HashMap<String, String>>>,
|
||||
/// Message IDs seen via MESSAGE_CREATE (used to drop duplicate MESSAGE_UPDATE events).
|
||||
/// Populated immediately when MESSAGE_CREATE is forwarded — before bridge processing —
|
||||
/// to eliminate the race window where MESSAGE_UPDATE arrives before thread creation completes.
|
||||
threaded_message_ids: Arc<RwLock<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl DiscordAdapter {
|
||||
@@ -73,6 +86,7 @@ impl DiscordAdapter {
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
auto_thread: String,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
@@ -82,11 +96,14 @@ impl DiscordAdapter {
|
||||
allowed_users,
|
||||
ignore_bots,
|
||||
intents,
|
||||
auto_thread,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
bot_user_id: Arc::new(RwLock::new(None)),
|
||||
session_id: Arc::new(RwLock::new(None)),
|
||||
resume_gateway_url: Arc::new(RwLock::new(None)),
|
||||
created_thread_ids: Arc::new(RwLock::new(HashMap::new())),
|
||||
threaded_message_ids: Arc::new(RwLock::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +164,79 @@ impl DiscordAdapter {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a thread from a message in a Discord channel.
|
||||
async fn api_create_thread(
|
||||
&self,
|
||||
channel_id: &str,
|
||||
message_id: &str,
|
||||
name: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/threads",
|
||||
channel_id = channel_id,
|
||||
message_id = message_id
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"auto_archive_duration": 1440 // 24 hours
|
||||
});
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bot {}", self.token.as_str()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Discord createThread failed: {}", body_text).into());
|
||||
}
|
||||
|
||||
let response: serde_json::Value = resp.json().await?;
|
||||
let thread_id = response["id"].as_str().unwrap_or("").to_string();
|
||||
|
||||
// Track thread_id → parent channel_id so we can recognise messages
|
||||
// that arrive inside this thread.
|
||||
if !thread_id.is_empty() {
|
||||
self.created_thread_ids
|
||||
.write()
|
||||
.await
|
||||
.insert(thread_id.clone(), channel_id.to_string());
|
||||
}
|
||||
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
/// Send a message to an existing thread.
|
||||
/// Discord threads are channels — post directly to channels/{thread_id}/messages.
|
||||
async fn api_send_thread_message(
|
||||
&self,
|
||||
_channel_id: &str,
|
||||
thread_id: &str,
|
||||
text: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!("{DISCORD_API_BASE}/channels/{thread_id}/messages");
|
||||
let chunks = split_message(text, DISCORD_MSG_LIMIT);
|
||||
|
||||
for chunk in chunks {
|
||||
let body = serde_json::json!({ "content": chunk });
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bot {}", self.token.as_str()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Discord sendThreadMessage failed: {body_text}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -159,6 +249,33 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
ChannelType::Discord
|
||||
}
|
||||
|
||||
async fn should_auto_thread(&self, message: &ChannelMessage) -> Option<String> {
|
||||
// Only auto-thread in group channels (servers), not DMs
|
||||
if !message.is_group {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check auto_thread mode
|
||||
match self.auto_thread.as_str() {
|
||||
"true" => Some(thread_name_from_message(message)),
|
||||
"false" => None,
|
||||
"smart" => {
|
||||
// Only create thread if bot was @mentioned
|
||||
let was_mentioned = message
|
||||
.metadata
|
||||
.get("was_mentioned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if was_mentioned {
|
||||
Some(thread_name_from_message(message))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
@@ -176,6 +293,8 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
let bot_user_id = self.bot_user_id.clone();
|
||||
let session_id_store = self.session_id.clone();
|
||||
let resume_url_store = self.resume_gateway_url.clone();
|
||||
let created_thread_ids = self.created_thread_ids.clone();
|
||||
let threaded_message_ids = self.threaded_message_ids.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -414,19 +533,66 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
&allowed_guilds,
|
||||
&allowed_users,
|
||||
ignore_bots,
|
||||
&created_thread_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// MESSAGE_UPDATE must be suppressed if we already
|
||||
// forwarded a MESSAGE_CREATE for this message ID.
|
||||
// The check uses `seen_message_ids` (tracked below)
|
||||
// which is populated the moment MESSAGE_CREATE is
|
||||
// forwarded — before the bridge even processes it.
|
||||
// This closes the race window where MESSAGE_UPDATE
|
||||
// arrives before adapter.create_thread() completes.
|
||||
if event_name == "MESSAGE_UPDATE"
|
||||
&& threaded_message_ids
|
||||
.read()
|
||||
.await
|
||||
.contains(&msg.platform_message_id)
|
||||
{
|
||||
debug!(
|
||||
"Discord MESSAGE_UPDATE skipped (already seen {})",
|
||||
msg.platform_message_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Discord {event_name} from {}: {:?}",
|
||||
msg.sender.display_name, msg.content
|
||||
);
|
||||
|
||||
// Mark this message as seen immediately so any
|
||||
// concurrent or subsequent MESSAGE_UPDATE is dropped.
|
||||
if event_name == "MESSAGE_CREATE" {
|
||||
threaded_message_ids
|
||||
.write()
|
||||
.await
|
||||
.insert(msg.platform_message_id.clone());
|
||||
}
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"THREAD_DELETE" | "CHANNEL_DELETE" => {
|
||||
// Clean up tracking when a thread is deleted so the
|
||||
// next message in the parent channel is treated fresh.
|
||||
if let Some(tid) = d["id"].as_str() {
|
||||
created_thread_ids.write().await.remove(tid);
|
||||
// Prune the dedup set to prevent unbounded growth.
|
||||
// Entries older than MAX_DEDUP_MSG_IDS are safe to
|
||||
// discard — embed UPDATE events arrive within seconds.
|
||||
let mut ids = threaded_message_ids.write().await;
|
||||
if ids.len() > MAX_DEDUP_MSG_IDS {
|
||||
ids.clear();
|
||||
}
|
||||
debug!("Discord thread/channel deleted: {tid}");
|
||||
}
|
||||
}
|
||||
|
||||
"RESUMED" => {
|
||||
info!("Discord session resumed successfully");
|
||||
}
|
||||
@@ -532,12 +698,123 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
self.api_send_typing(&user.platform_id).await
|
||||
}
|
||||
|
||||
async fn send_in_thread(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
thread_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_thread_message(channel_id, thread_id, &text)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_thread_message(channel_id, thread_id, "(Unsupported content type)")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_thread(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
message_id: &str,
|
||||
thread_name: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let channel_id = &user.platform_id;
|
||||
let thread_id = self
|
||||
.api_create_thread(channel_id, message_id, thread_name)
|
||||
.await?;
|
||||
// Also ensure the message_id is marked as seen (belt-and-suspenders:
|
||||
// the gateway loop already inserts on MESSAGE_CREATE, but keep this
|
||||
// in case create_thread is ever called from another path).
|
||||
self.threaded_message_ids
|
||||
.write()
|
||||
.await
|
||||
.insert(message_id.to_string());
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum byte size for an attachment to be classified as a vision-eligible
|
||||
/// image. Anthropic's image content blocks are capped at 5 MB; oversize images
|
||||
/// fall through to `File` so the bridge passes the URL as text instead of
|
||||
/// attempting an inline image block.
|
||||
const VISION_IMAGE_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
||||
|
||||
/// Best-effort MIME inference from a filename extension. Used as a fallback
|
||||
/// when Discord's `content_type` field is missing or empty (we've observed
|
||||
/// this on some bot-relayed attachments).
|
||||
fn mime_from_extension(filename: &str) -> Option<&'static str> {
|
||||
let ext = filename.rsplit('.').next()?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"png" => Some("image/png"),
|
||||
"gif" => Some("image/gif"),
|
||||
"webp" => Some("image/webp"),
|
||||
"heic" => Some("image/heic"),
|
||||
"heif" => Some("image/heif"),
|
||||
"pdf" => Some("application/pdf"),
|
||||
"txt" => Some("text/plain"),
|
||||
"md" => Some("text/markdown"),
|
||||
"json" => Some("application/json"),
|
||||
"mp4" => Some("video/mp4"),
|
||||
"mov" => Some("video/quicktime"),
|
||||
"mp3" => Some("audio/mpeg"),
|
||||
"wav" => Some("audio/wav"),
|
||||
"ogg" => Some("audio/ogg"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a single Discord attachment JSON object into a `ChannelContent`
|
||||
/// block. Vision-eligible image MIME types (jpeg/png/gif/webp) under
|
||||
/// `VISION_IMAGE_MAX_BYTES` become `Image`; everything else becomes `File`
|
||||
/// (URL-pass-through; the bridge will surface it as a text descriptor in v1).
|
||||
///
|
||||
/// MIME resolution chain: `attachments[].content_type` (if non-empty) →
|
||||
/// extension lookup → `application/octet-stream`.
|
||||
fn classify_discord_attachment(att: &serde_json::Value) -> ChannelContent {
|
||||
let url = att["url"].as_str().unwrap_or("").to_string();
|
||||
let filename = att["filename"].as_str().unwrap_or("file").to_string();
|
||||
let size = att["size"].as_u64();
|
||||
|
||||
let resolved_mime: String = att["content_type"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| mime_from_extension(&filename).map(str::to_string))
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let is_vision_mime = matches!(
|
||||
resolved_mime.as_str(),
|
||||
"image/jpeg" | "image/png" | "image/gif" | "image/webp"
|
||||
);
|
||||
// If size is unknown, optimistically allow the image — the bridge will
|
||||
// surface a 4xx if Anthropic rejects it, which is better than silently
|
||||
// demoting to a text URL.
|
||||
let within_vision_limit = size.map(|s| s <= VISION_IMAGE_MAX_BYTES).unwrap_or(true);
|
||||
|
||||
if is_vision_mime && within_vision_limit {
|
||||
ChannelContent::Image { url, caption: None }
|
||||
} else {
|
||||
ChannelContent::File {
|
||||
url,
|
||||
filename,
|
||||
mime: Some(resolved_mime),
|
||||
size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Discord MESSAGE_CREATE or MESSAGE_UPDATE payload into a `ChannelMessage`.
|
||||
async fn parse_discord_message(
|
||||
d: &serde_json::Value,
|
||||
@@ -545,7 +822,13 @@ async fn parse_discord_message(
|
||||
allowed_guilds: &[String],
|
||||
allowed_users: &[String],
|
||||
ignore_bots: bool,
|
||||
created_thread_ids: &Arc<RwLock<HashMap<String, String>>>,
|
||||
) -> Option<ChannelMessage> {
|
||||
// Diagnostic: dump the raw Discord payload so we can ground attachment
|
||||
// parsing in real JSON. Gated by RUST_LOG; silent at default `info` level.
|
||||
// Enable with: RUST_LOG=openfang_channels::discord=debug
|
||||
debug!(target: "openfang_channels::discord", payload = %d, "discord raw message payload");
|
||||
|
||||
let author = d.get("author")?;
|
||||
let author_id = author["id"].as_str()?;
|
||||
|
||||
@@ -577,12 +860,22 @@ async fn parse_discord_message(
|
||||
}
|
||||
|
||||
let content_text = d["content"].as_str().unwrap_or("");
|
||||
if content_text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let channel_id = d["channel_id"].as_str()?;
|
||||
let message_id = d["id"].as_str().unwrap_or("0");
|
||||
|
||||
// Detect if this message is inside a bot-created thread.
|
||||
// In Discord, a thread is its own channel — channel_id will be the thread's ID.
|
||||
// If so, use the parent channel as platform_id and set thread_id so that:
|
||||
// (a) auto-thread logic is skipped (message.thread_id.is_some())
|
||||
// (b) responses are sent back into the same thread
|
||||
let (effective_channel_id, parsed_thread_id) = {
|
||||
let threads = created_thread_ids.read().await;
|
||||
if let Some(parent_channel_id) = threads.get(channel_id) {
|
||||
(parent_channel_id.clone(), Some(channel_id.to_string()))
|
||||
} else {
|
||||
(channel_id.to_string(), None)
|
||||
}
|
||||
};
|
||||
let username = author["username"].as_str().unwrap_or("Unknown");
|
||||
let discriminator = author["discriminator"].as_str().unwrap_or("0000");
|
||||
let display_name = if discriminator == "0" {
|
||||
@@ -597,7 +890,8 @@ async fn parse_discord_message(
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
|
||||
// Parse commands (messages starting with /)
|
||||
// Parse commands (messages starting with /). Commands do not carry
|
||||
// attachments in v1; attachment processing only runs in the non-command path.
|
||||
let content = if content_text.starts_with('/') {
|
||||
let parts: Vec<&str> = content_text.splitn(2, ' ').collect();
|
||||
let cmd_name = &parts[0][1..];
|
||||
@@ -611,7 +905,50 @@ async fn parse_discord_message(
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(content_text.to_string())
|
||||
let attachment_blocks: Vec<ChannelContent> = d["attachments"]
|
||||
.as_array()
|
||||
.map(|arr| arr.iter().map(classify_discord_attachment).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
match (content_text.is_empty(), attachment_blocks.len()) {
|
||||
// No text, no attachments → nothing to ingest.
|
||||
(true, 0) => return None,
|
||||
// Text only.
|
||||
(false, 0) => ChannelContent::Text(content_text.to_string()),
|
||||
// Single attachment, no caption.
|
||||
(true, 1) => attachment_blocks.into_iter().next().unwrap(),
|
||||
// Single attachment + caption: emit Multipart with the caption as
|
||||
// a sibling Text block. This keeps the caption visible to providers
|
||||
// that flatten content to text only (e.g. claude-code/*, which
|
||||
// currently drops Image blocks) — the user gets a coherent
|
||||
// text-only response instead of a hallucination. Vision-capable
|
||||
// providers see the same blocks and dispatch multimodally.
|
||||
(false, 1) => {
|
||||
let block = attachment_blocks.into_iter().next().unwrap();
|
||||
let normalized = match block {
|
||||
// Drop any caption that classify_discord_attachment may have
|
||||
// attached; the sibling Text block is now the caption.
|
||||
ChannelContent::Image { url, caption: _ } => {
|
||||
ChannelContent::Image { url, caption: None }
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
ChannelContent::Multipart(vec![
|
||||
ChannelContent::Text(content_text.to_string()),
|
||||
normalized,
|
||||
])
|
||||
}
|
||||
// Multiple attachments, no caption.
|
||||
(true, _) => ChannelContent::Multipart(attachment_blocks),
|
||||
// Multiple attachments + caption: text first, then attachments
|
||||
// (matches Discord's visual ordering: text above attachments).
|
||||
(false, _) => {
|
||||
let mut blocks = Vec::with_capacity(attachment_blocks.len() + 1);
|
||||
blocks.push(ChannelContent::Text(content_text.to_string()));
|
||||
blocks.extend(attachment_blocks);
|
||||
ChannelContent::Multipart(blocks)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Determine if this is a group message (guild_id present = server channel)
|
||||
@@ -636,12 +973,15 @@ async fn parse_discord_message(
|
||||
if was_mentioned {
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
}
|
||||
// Stash the Discord author 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!(author_id));
|
||||
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Discord,
|
||||
platform_message_id: message_id.to_string(),
|
||||
sender: ChannelUser {
|
||||
platform_id: channel_id.to_string(),
|
||||
platform_id: effective_channel_id,
|
||||
display_name,
|
||||
openfang_user: None,
|
||||
},
|
||||
@@ -649,15 +989,50 @@ async fn parse_discord_message(
|
||||
target_agent: None,
|
||||
timestamp,
|
||||
is_group,
|
||||
thread_id: None,
|
||||
thread_id: parsed_thread_id,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Discord thread name from the message content.
|
||||
/// Strips @mention prefixes (`<@...>`), trims whitespace, and truncates to
|
||||
/// Discord's 100-character thread name limit. Falls back to the sender's
|
||||
/// display name if the message has no usable text (e.g. image-only).
|
||||
fn thread_name_from_message(message: &ChannelMessage) -> String {
|
||||
let raw = match &message.content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Image { caption, .. } => caption.clone().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Strip leading Discord mention tokens (<@id> / <@!id>)
|
||||
let stripped = regex_lite::Regex::new(r"^(<@!?\d+>\s*)+")
|
||||
.map(|re| re.replace(&raw, "").into_owned())
|
||||
.unwrap_or(raw);
|
||||
|
||||
let trimmed = stripped.trim().to_string();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return message.sender.display_name.clone();
|
||||
}
|
||||
|
||||
// Truncate to Discord's 100-char limit
|
||||
if trimmed.chars().count() <= 100 {
|
||||
trimmed
|
||||
} else {
|
||||
trimmed.chars().take(97).collect::<String>() + "…"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Convenience helper: empty thread-tracking map for tests that don't exercise threading.
|
||||
fn empty_threads() -> Arc<RwLock<HashMap<String, String>>> {
|
||||
Arc::new(RwLock::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_message_basic() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
@@ -674,7 +1049,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
@@ -698,7 +1073,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -718,7 +1093,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -739,7 +1114,7 @@ mod tests {
|
||||
});
|
||||
|
||||
// With ignore_bots=false, other bots' messages should be allowed
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
|
||||
assert!(msg.is_some());
|
||||
let msg = msg.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "somebot");
|
||||
@@ -763,7 +1138,7 @@ mod tests {
|
||||
});
|
||||
|
||||
// Even with ignore_bots=false, the bot's own messages must still be filtered
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -784,12 +1159,20 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg =
|
||||
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
let msg = parse_discord_message(
|
||||
&d,
|
||||
&bot_id,
|
||||
&["111".into(), "222".into()],
|
||||
&[],
|
||||
true,
|
||||
&empty_threads(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
|
||||
let msg =
|
||||
parse_discord_message(&d, &bot_id, &["999".into()], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -808,7 +1191,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
@@ -835,7 +1218,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -854,7 +1237,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "alice#1234");
|
||||
@@ -878,7 +1261,7 @@ mod tests {
|
||||
});
|
||||
|
||||
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
@@ -909,16 +1292,25 @@ mod tests {
|
||||
&[],
|
||||
&["user111".into(), "user222".into()],
|
||||
true,
|
||||
&empty_threads(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
|
||||
let msg = parse_discord_message(
|
||||
&d,
|
||||
&bot_id,
|
||||
&[],
|
||||
&["user999".into()],
|
||||
true,
|
||||
&empty_threads(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -941,7 +1333,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.is_group);
|
||||
@@ -964,7 +1356,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg2.is_group);
|
||||
@@ -986,7 +1378,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!msg.is_group);
|
||||
@@ -1025,8 +1417,219 @@ mod tests {
|
||||
vec![],
|
||||
true,
|
||||
37376,
|
||||
"true".to_string(),
|
||||
);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
}
|
||||
|
||||
// -- Multipart / attachment parsing tests (commit 4) ----------------------
|
||||
|
||||
fn att(filename: &str, content_type: Option<&str>, size: u64) -> serde_json::Value {
|
||||
let mut obj = serde_json::json!({
|
||||
"url": format!("https://cdn.discordapp.com/attachments/1/2/{filename}"),
|
||||
"filename": filename,
|
||||
"size": size,
|
||||
});
|
||||
if let Some(ct) = content_type {
|
||||
obj["content_type"] = serde_json::Value::String(ct.to_string());
|
||||
}
|
||||
obj
|
||||
}
|
||||
|
||||
fn payload_with(content: &str, attachments: Vec<serde_json::Value>) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"content": content,
|
||||
"author": {
|
||||
"id": "user456",
|
||||
"username": "alice",
|
||||
"discriminator": "0",
|
||||
"bot": false
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00",
|
||||
"attachments": attachments,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_image_only_no_caption() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with("", vec![att("photo.png", Some("image/png"), 100_000)]);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
ChannelContent::Image { caption, url } => {
|
||||
assert!(caption.is_none());
|
||||
assert!(url.contains("photo.png"));
|
||||
}
|
||||
other => panic!("expected Image, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_image_with_caption() {
|
||||
// Single image + caption is emitted as Multipart([Text, Image]) so the
|
||||
// caption survives providers that flatten content blocks to text only
|
||||
// (e.g. claude-code/*). The Image carries no caption of its own; the
|
||||
// sibling Text block IS the caption.
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with(
|
||||
"look at this",
|
||||
vec![att("photo.jpg", Some("image/jpeg"), 50_000)],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
ChannelContent::Multipart(parts) => {
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(matches!(&parts[0], ChannelContent::Text(t) if t == "look at this"));
|
||||
match &parts[1] {
|
||||
ChannelContent::Image { caption, url } => {
|
||||
assert!(
|
||||
caption.is_none(),
|
||||
"image caption should be None; the sibling Text block is the caption"
|
||||
);
|
||||
assert!(url.contains("photo.jpg"));
|
||||
}
|
||||
other => panic!("expected Image as second part, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Multipart, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_multi_image_no_caption() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with(
|
||||
"",
|
||||
vec![
|
||||
att("a.png", Some("image/png"), 10_000),
|
||||
att("b.png", Some("image/png"), 20_000),
|
||||
],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
ChannelContent::Multipart(parts) => {
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(parts
|
||||
.iter()
|
||||
.all(|p| matches!(p, ChannelContent::Image { .. })));
|
||||
}
|
||||
other => panic!("expected Multipart, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_multi_image_with_caption() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with(
|
||||
"two pics",
|
||||
vec![
|
||||
att("a.png", Some("image/png"), 10_000),
|
||||
att("b.png", Some("image/png"), 20_000),
|
||||
],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
ChannelContent::Multipart(parts) => {
|
||||
assert_eq!(parts.len(), 3);
|
||||
// Text first, then images.
|
||||
assert!(matches!(&parts[0], ChannelContent::Text(t) if t == "two pics"));
|
||||
assert!(matches!(&parts[1], ChannelContent::Image { .. }));
|
||||
assert!(matches!(&parts[2], ChannelContent::Image { .. }));
|
||||
}
|
||||
other => panic!("expected Multipart, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_heic_falls_to_file() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with("", vec![att("photo.heic", Some("image/heic"), 100_000)]);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
ChannelContent::File { mime, filename, .. } => {
|
||||
assert_eq!(filename, "photo.heic");
|
||||
assert_eq!(mime.as_deref(), Some("image/heic"));
|
||||
}
|
||||
other => panic!("expected File, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_oversize_image_falls_to_file() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
// 6 MB exceeds VISION_IMAGE_MAX_BYTES (5 MB).
|
||||
let d = payload_with(
|
||||
"",
|
||||
vec![att("huge.png", Some("image/png"), 6 * 1024 * 1024)],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
ChannelContent::File {
|
||||
filename,
|
||||
mime,
|
||||
size,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(filename, "huge.png");
|
||||
assert_eq!(mime.as_deref(), Some("image/png"));
|
||||
assert_eq!(size, Some(6 * 1024 * 1024));
|
||||
}
|
||||
other => panic!("expected File, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_file_with_caption_yields_multipart() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with(
|
||||
"see attached",
|
||||
vec![att("doc.pdf", Some("application/pdf"), 200_000)],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
ChannelContent::Multipart(parts) => {
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(matches!(&parts[0], ChannelContent::Text(t) if t == "see attached"));
|
||||
assert!(matches!(&parts[1], ChannelContent::File { .. }));
|
||||
}
|
||||
other => panic!("expected Multipart, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_extension_fallback_when_content_type_missing() {
|
||||
// Discord occasionally omits content_type on bot-relayed attachments;
|
||||
// we should fall back to the filename extension.
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with("", vec![att("pic.png", None, 50_000)]);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(msg.content, ChannelContent::Image { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_empty_message_with_no_attachments_returns_none() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with("", vec![]);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
+439
-20
@@ -440,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)]
|
||||
@@ -1010,6 +1031,13 @@ fn main() {
|
||||
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(),
|
||||
@@ -1448,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(
|
||||
@@ -2429,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;
|
||||
@@ -2465,7 +2515,9 @@ decay_rate = 0.05
|
||||
if !json {
|
||||
ui::check_ok("GitHub Copilot (authenticated via device flow)");
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "provider", "name": "GitHub Copilot", "status": "ok"}));
|
||||
checks.push(
|
||||
serde_json::json!({"check": "provider", "name": "GitHub Copilot", "status": "ok"}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4562,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4633,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
|
||||
};
|
||||
|
||||
@@ -4745,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");
|
||||
@@ -4767,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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4988,7 +5230,9 @@ fn cmd_config_set_key(provider: &str) {
|
||||
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)) {
|
||||
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");
|
||||
@@ -7017,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.
|
||||
@@ -1439,6 +1444,14 @@ pub fn spawn_fetch_skills(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
.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()
|
||||
})
|
||||
@@ -1604,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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2003,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();
|
||||
@@ -2185,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() {
|
||||
|
||||
@@ -109,19 +109,15 @@ 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() {
|
||||
@@ -157,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",
|
||||
@@ -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)]
|
||||
@@ -304,7 +337,10 @@ enum CopilotAuthStatus {
|
||||
}
|
||||
|
||||
enum CopilotAuthEvent {
|
||||
DeviceCode { user_code: String, verification_uri: String },
|
||||
DeviceCode {
|
||||
user_code: String,
|
||||
verification_uri: String,
|
||||
},
|
||||
Authenticated,
|
||||
Models(Vec<String>),
|
||||
}
|
||||
@@ -640,8 +676,7 @@ 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 (copilot_tx, copilot_rx) = std::sync::mpsc::channel::<Result<CopilotAuthEvent, String>>();
|
||||
|
||||
let result = loop {
|
||||
terminal
|
||||
@@ -652,7 +687,10 @@ pub fn run() -> InitResult {
|
||||
if state.step == Step::CopilotAuth {
|
||||
while let Ok(event) = copilot_rx.try_recv() {
|
||||
match event {
|
||||
Ok(CopilotAuthEvent::DeviceCode { user_code, verification_uri }) => {
|
||||
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;
|
||||
@@ -831,7 +869,8 @@ pub fn run() -> InitResult {
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx.send(Err(format!("Runtime error: {e}")));
|
||||
let _ = copilot_tx
|
||||
.send(Err(format!("Runtime error: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -843,21 +882,31 @@ pub fn run() -> InitResult {
|
||||
.map_err(|e| format!("HTTP error: {e}"));
|
||||
let http = match http {
|
||||
Ok(h) => h,
|
||||
Err(e) => { let _ = copilot_tx.send(Err(e)); return; }
|
||||
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; }
|
||||
};
|
||||
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(),
|
||||
}));
|
||||
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
|
||||
|
||||
@@ -866,9 +915,14 @@ pub fn run() -> InitResult {
|
||||
&http,
|
||||
&device.device_code,
|
||||
device.interval,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => { let _ = copilot_tx.send(Err(e)); return; }
|
||||
Err(e) => {
|
||||
let _ = copilot_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Save tokens
|
||||
@@ -877,16 +931,38 @@ pub fn run() -> InitResult {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = copilot_tx.send(Ok(CopilotAuthEvent::Authenticated));
|
||||
let _ = copilot_tx
|
||||
.send(Ok(CopilotAuthEvent::Authenticated));
|
||||
|
||||
// Step 3: fetch models
|
||||
let ct = match copilot::exchange_copilot_token(&http, &tokens.access_token).await {
|
||||
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; }
|
||||
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}"))); }
|
||||
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}")));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -915,14 +991,15 @@ pub fn run() -> InitResult {
|
||||
state.step = Step::Provider;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if matches!(state.copilot_auth_status, CopilotAuthStatus::WaitingForUser) {
|
||||
if !state.copilot_verification_uri.is_empty() {
|
||||
let _ = openfang_runtime::drivers::copilot::open_verification_url(
|
||||
&state.copilot_verification_uri,
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -937,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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1948,14 +2020,15 @@ fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
Constraint::Length(1), // code value
|
||||
Constraint::Length(1), // blank
|
||||
Constraint::Length(1), // url
|
||||
Constraint::Min(0), // spacer
|
||||
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)),
|
||||
]));
|
||||
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()];
|
||||
@@ -1977,9 +2050,7 @@ fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
]));
|
||||
f.render_widget(line1, chunks[2]);
|
||||
|
||||
let code_label = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" Enter this code:"),
|
||||
]));
|
||||
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![
|
||||
@@ -1999,9 +2070,10 @@ fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
]));
|
||||
f.render_widget(url, chunks[8]);
|
||||
|
||||
let hint = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" [Enter] Open browser", theme::dim_style()),
|
||||
]));
|
||||
let hint = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Enter] Open browser",
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
f.render_widget(hint, chunks[10]);
|
||||
}
|
||||
CopilotAuthStatus::FetchingModels => {
|
||||
@@ -2032,9 +2104,10 @@ fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
]));
|
||||
f.render_widget(line, chunks[2]);
|
||||
|
||||
let hint = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" Esc to go back", theme::dim_style()),
|
||||
]));
|
||||
let hint = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Esc to go back",
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
f.render_widget(hint, chunks[10]);
|
||||
}
|
||||
}
|
||||
@@ -2611,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.5.8",
|
||||
"version": "0.6.9",
|
||||
"identifier": "ai.openfang.desktop",
|
||||
"build": {},
|
||||
"app": {
|
||||
|
||||
@@ -16,6 +16,8 @@ uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -173,7 +223,8 @@ impl HandRegistry {
|
||||
match bundled::parse_bundled("custom", &contents, &skill_content) {
|
||||
Ok(def) => {
|
||||
let hand_id = def.id.clone();
|
||||
info!(hand = %hand_id, path = %path.display(), "Loaded workspace hand");
|
||||
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;
|
||||
}
|
||||
@@ -204,7 +255,8 @@ 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
|
||||
@@ -251,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)
|
||||
}
|
||||
@@ -269,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)
|
||||
}
|
||||
@@ -1144,4 +1198,168 @@ metrics = []
|
||||
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,9 +32,10 @@ futures = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
cron = "0.15"
|
||||
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
|
||||
|
||||
+2473
-115
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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ impl SessionStore {
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
let messages_blob = rmp_serde::to_vec(&canonical.messages)
|
||||
let messages_blob = rmp_serde::to_vec_named(&canonical.messages)
|
||||
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT INTO canonical_sessions (agent_id, messages, compaction_cursor, compacted_summary, updated_at)
|
||||
@@ -586,12 +586,15 @@ impl SessionStore {
|
||||
ContentBlock::Image { media_type, .. } => {
|
||||
text_parts.push(format!("[image: {media_type}]"));
|
||||
}
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
text_parts.push(format!(
|
||||
"[thinking: {}]",
|
||||
openfang_types::truncate_str(thinking, 200)
|
||||
));
|
||||
}
|
||||
ContentBlock::RedactedThinking { .. } => {
|
||||
text_parts.push("[redacted_thinking]".to_string());
|
||||
}
|
||||
ContentBlock::Unknown => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,10 +896,8 @@ fn derive_capabilities(tools: &[String]) -> AgentCapabilities {
|
||||
"shell_exec" => {
|
||||
caps.shell = vec!["*".to_string()];
|
||||
}
|
||||
"web_fetch" | "web_search" | "browser_navigate" => {
|
||||
if caps.network.is_empty() {
|
||||
caps.network = vec!["*".to_string()];
|
||||
}
|
||||
"web_fetch" | "web_search" | "browser_navigate" if caps.network.is_empty() => {
|
||||
caps.network = vec!["*".to_string()];
|
||||
}
|
||||
"agent_send" | "agent_list" => {
|
||||
if caps.agent_message.is_empty() {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Per-turn agent context loader for external `context.md` files.
|
||||
//!
|
||||
//! Some agents depend on a `context.md` file updated by external tools (e.g. a
|
||||
//! cron job that writes live market data, or a script that refreshes project
|
||||
//! state). Before issue #843 this file was read once when the session started
|
||||
//! and then cached for the lifetime of the conversation, so external updates
|
||||
//! never reached the LLM.
|
||||
//!
|
||||
//! The default behaviour is now a small disk read per turn when the prompt is
|
||||
//! assembled. Agents that depend on the old behaviour can opt back in via the
|
||||
//! `cache_context` flag on their manifest.
|
||||
//!
|
||||
//! This module intentionally does not participate in per-token streaming — it
|
||||
//! is called once per agent turn, right before the system prompt is built.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::{collections::HashMap, fs};
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Maximum size of `context.md` to inject into the prompt (32 KB).
|
||||
///
|
||||
/// Matches the cap used by [`crate::workspace_context`] and the kernel's
|
||||
/// identity-file reader so a runaway file cannot blow up the prompt.
|
||||
const MAX_CONTEXT_BYTES: u64 = 32_768;
|
||||
|
||||
/// Filename that agents use for per-turn refreshable context.
|
||||
pub const CONTEXT_FILENAME: &str = "context.md";
|
||||
|
||||
/// In-memory cache of the last successful read for each workspace.
|
||||
///
|
||||
/// Used for two purposes:
|
||||
/// 1. When `cache_context = true`, the first successful read is returned on
|
||||
/// every subsequent call.
|
||||
/// 2. When `cache_context = false` and a re-read fails on disk (e.g. the file
|
||||
/// was temporarily replaced by an external writer), we fall back to the
|
||||
/// previous content instead of dropping context mid-conversation.
|
||||
fn cache() -> &'static Mutex<HashMap<PathBuf, String>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<PathBuf, String>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Load the agent's `context.md` for this turn.
|
||||
///
|
||||
/// Returns the current on-disk content, or — if the read fails after a
|
||||
/// previous success — the cached content with a warning. Returns `None` when
|
||||
/// no context.md has ever been seen for this workspace.
|
||||
///
|
||||
/// When `cache_context` is true the first successful read is stored and
|
||||
/// returned verbatim on every future call. Callers pass the flag straight from
|
||||
/// `AgentManifest::cache_context`.
|
||||
pub fn load_context_md(workspace: &Path, cache_context: bool) -> Option<String> {
|
||||
let path = workspace.join(CONTEXT_FILENAME);
|
||||
|
||||
if cache_context {
|
||||
if let Some(cached) = get_cached(&path) {
|
||||
return Some(cached);
|
||||
}
|
||||
}
|
||||
|
||||
match read_capped(&path) {
|
||||
Ok(Some(content)) => {
|
||||
store_cached(&path, &content);
|
||||
Some(content)
|
||||
}
|
||||
Ok(None) => {
|
||||
// File is absent or empty — do not serve a stale cache for a
|
||||
// deleted file unless the caller explicitly opted into caching.
|
||||
if cache_context {
|
||||
get_cached(&path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(prev) = get_cached(&path) {
|
||||
warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"Failed to re-read context.md; falling back to cached content"
|
||||
);
|
||||
Some(prev)
|
||||
} else {
|
||||
debug!(path = %path.display(), error = %e, "context.md unreadable and no cache");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_cached(path: &Path) -> Option<String> {
|
||||
cache()
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.get(path).cloned())
|
||||
}
|
||||
|
||||
fn store_cached(path: &Path, content: &str) {
|
||||
if let Ok(mut guard) = cache().lock() {
|
||||
guard.insert(path.to_path_buf(), content.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the file, returning Ok(None) if it is missing or empty, and
|
||||
/// Ok(Some(...)) if it has usable content. Oversized files are truncated to
|
||||
/// [`MAX_CONTEXT_BYTES`] so prompt size remains bounded.
|
||||
fn read_capped(path: &Path) -> std::io::Result<Option<String>> {
|
||||
let meta = match fs::metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if !meta.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(path)?;
|
||||
if content.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if meta.len() > MAX_CONTEXT_BYTES {
|
||||
let truncated = crate::str_utils::safe_truncate_str(&content, MAX_CONTEXT_BYTES as usize);
|
||||
return Ok(Some(truncated.to_string()));
|
||||
}
|
||||
Ok(Some(content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn fresh_workspace(tag: &str) -> PathBuf {
|
||||
// Unique temp dir per test to avoid cross-test cache pollution.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openfang_ctx_{}_{}",
|
||||
tag,
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reread_picks_up_external_update() {
|
||||
let ws = fresh_workspace("reread");
|
||||
let path = ws.join(CONTEXT_FILENAME);
|
||||
|
||||
fs::write(&path, "initial content A").unwrap();
|
||||
let first = load_context_md(&ws, false).unwrap();
|
||||
assert!(first.contains("initial content A"));
|
||||
|
||||
// External writer updates the file (simulates the cron case from #843).
|
||||
{
|
||||
let mut f = fs::File::create(&path).unwrap();
|
||||
f.write_all(b"updated content B").unwrap();
|
||||
}
|
||||
|
||||
let second = load_context_md(&ws, false).unwrap();
|
||||
assert!(second.contains("updated content B"));
|
||||
assert!(!second.contains("initial content A"));
|
||||
|
||||
let _ = fs::remove_dir_all(&ws);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_context_true_freezes_first_read() {
|
||||
let ws = fresh_workspace("cache");
|
||||
let path = ws.join(CONTEXT_FILENAME);
|
||||
|
||||
fs::write(&path, "frozen A").unwrap();
|
||||
let first = load_context_md(&ws, true).unwrap();
|
||||
assert!(first.contains("frozen A"));
|
||||
|
||||
fs::write(&path, "never seen B").unwrap();
|
||||
let second = load_context_md(&ws, true).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert!(!second.contains("never seen B"));
|
||||
|
||||
let _ = fs::remove_dir_all(&ws);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_returns_none() {
|
||||
let ws = fresh_workspace("missing");
|
||||
assert!(load_context_md(&ws, false).is_none());
|
||||
assert!(load_context_md(&ws, true).is_none());
|
||||
let _ = fs::remove_dir_all(&ws);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_failure_falls_back_to_cache() {
|
||||
let ws = fresh_workspace("fallback");
|
||||
let path = ws.join(CONTEXT_FILENAME);
|
||||
|
||||
fs::write(&path, "cached payload").unwrap();
|
||||
let first = load_context_md(&ws, false).unwrap();
|
||||
assert!(first.contains("cached payload"));
|
||||
|
||||
// Write bytes that are not valid UTF-8 so read_to_string returns an
|
||||
// IO error. This simulates a transient read failure while the cron
|
||||
// job is mid-rewrite.
|
||||
{
|
||||
let mut f = fs::File::create(&path).unwrap();
|
||||
f.write_all(&[0xff, 0xfe, 0xfd, 0x80, 0x81]).unwrap();
|
||||
}
|
||||
|
||||
let second = load_context_md(&ws, false);
|
||||
assert_eq!(second.as_deref(), Some("cached payload"));
|
||||
|
||||
let _ = fs::remove_dir_all(&ws);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_treated_as_absent() {
|
||||
let ws = fresh_workspace("empty");
|
||||
let path = ws.join(CONTEXT_FILENAME);
|
||||
fs::write(&path, " \n\n ").unwrap();
|
||||
assert!(load_context_md(&ws, false).is_none());
|
||||
let _ = fs::remove_dir_all(&ws);
|
||||
}
|
||||
}
|
||||
@@ -40,21 +40,43 @@ const MAX_RETRIES: u32 = 3;
|
||||
/// Base delay for exponential backoff (milliseconds).
|
||||
const BASE_RETRY_DELAY_MS: u64 = 1000;
|
||||
|
||||
/// Timeout for individual tool executions (seconds).
|
||||
/// Default timeout for individual tool executions (seconds).
|
||||
/// Raised from 60s to 120s for browser automation and long-running builds.
|
||||
/// Overridable via `OPENFANG_TOOL_TIMEOUT_SECS` env var. Set to `0` to disable
|
||||
/// the timeout entirely (useful for slow local inference like vLLM on old GPUs).
|
||||
const TOOL_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Timeout for inter-agent tool calls (seconds).
|
||||
/// Default timeout for inter-agent tool calls (seconds).
|
||||
/// Agent delegation (agent_send, agent_spawn) can involve a full agent loop on the
|
||||
/// target, so these need a significantly longer timeout than regular tools.
|
||||
/// Overridable via `OPENFANG_AGENT_TOOL_TIMEOUT_SECS` env var. Set to `0` to
|
||||
/// disable (issue #1125: slow vLLM rigs running Hands need unbounded waits).
|
||||
const AGENT_TOOL_TIMEOUT_SECS: u64 = 600;
|
||||
|
||||
/// Parse a u64 env var, returning `None` when unset or unparseable so the
|
||||
/// caller falls back to the compiled-in default.
|
||||
fn env_timeout_secs(var: &str) -> Option<u64> {
|
||||
std::env::var(var).ok().and_then(|s| s.trim().parse().ok())
|
||||
}
|
||||
|
||||
/// Returns the appropriate timeout duration for a given tool name.
|
||||
/// Inter-agent calls get a longer timeout since they may trigger full agent loops.
|
||||
fn tool_timeout_for(tool_name: &str) -> Duration {
|
||||
match tool_name {
|
||||
"agent_send" | "agent_spawn" => Duration::from_secs(AGENT_TOOL_TIMEOUT_SECS),
|
||||
_ => Duration::from_secs(TOOL_TIMEOUT_SECS),
|
||||
///
|
||||
/// Returns `None` when the operator opted out by setting the relevant env var
|
||||
/// to `0`. In that case the tool runs with no upper bound, which is what users
|
||||
/// on slow local inference (vLLM on old GPUs) want for Hands and inter-agent
|
||||
/// delegation (issue #1125).
|
||||
fn tool_timeout_for(tool_name: &str) -> Option<Duration> {
|
||||
let secs = match tool_name {
|
||||
"agent_send" | "agent_spawn" => {
|
||||
env_timeout_secs("OPENFANG_AGENT_TOOL_TIMEOUT_SECS").unwrap_or(AGENT_TOOL_TIMEOUT_SECS)
|
||||
}
|
||||
_ => env_timeout_secs("OPENFANG_TOOL_TIMEOUT_SECS").unwrap_or(TOOL_TIMEOUT_SECS),
|
||||
};
|
||||
if secs == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Duration::from_secs(secs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +84,10 @@ fn tool_timeout_for(tool_name: &str) -> Duration {
|
||||
/// Raised from 3 to 5 to allow longer-form generation.
|
||||
const MAX_CONTINUATIONS: u32 = 5;
|
||||
|
||||
/// Maximum message history size before auto-trimming to prevent context overflow.
|
||||
const MAX_HISTORY_MESSAGES: usize = 20;
|
||||
/// Default maximum message history size before auto-trimming to prevent context overflow.
|
||||
/// Per-agent overrides come from `AgentManifest::max_history_messages` (issue #871).
|
||||
#[allow(dead_code)]
|
||||
const MAX_HISTORY_MESSAGES: usize = openfang_types::agent::DEFAULT_MAX_HISTORY_MESSAGES;
|
||||
|
||||
/// Detect when the LLM claims to have performed an action (sent, posted, emailed)
|
||||
/// without actually calling any tools. Prevents hallucinated completions.
|
||||
@@ -109,6 +133,78 @@ fn append_tool_error_guidance(tool_result_blocks: &mut Vec<ContentBlock>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an assistant message that preserves Thinking blocks alongside the
|
||||
/// final visible text.
|
||||
///
|
||||
/// Issue #1098 — thinking-model state preservation. When the LLM response
|
||||
/// contains `ContentBlock::Thinking` (Anthropic extended thinking with
|
||||
/// signatures, Gemini 2.5+ thoughts, OpenAI-compat reasoning_content,
|
||||
/// MiniMax/Qwen inline `<think>` blocks), the prior code stored only the
|
||||
/// final text via `Message::assistant(text)` — discarding all reasoning
|
||||
/// state. On the next turn the model re-derived its answer from scratch
|
||||
/// and quality degraded.
|
||||
///
|
||||
/// This helper preserves the full block list whenever any Thinking block is
|
||||
/// present, otherwise returns the legacy `Message::assistant(text)` form so
|
||||
/// downstream consumers (channel formatters, JSONL mirrors, embeddings) keep
|
||||
/// working without changes.
|
||||
///
|
||||
/// Note: we deliberately replace any visible Text blocks in `response_blocks`
|
||||
/// with `final_text` so that any post-processing the agent loop applied
|
||||
/// (phantom-action recovery, accumulated_text fallback, EmptyResponse guard
|
||||
/// stub) is reflected in the persisted message.
|
||||
fn build_assistant_message_preserving_thinking(
|
||||
response_blocks: &[ContentBlock],
|
||||
final_text: &str,
|
||||
) -> Message {
|
||||
// Key on either Thinking or RedactedThinking — Anthropic/Bedrock both
|
||||
// reject extended-thinking history that drops the redacted variant, so a
|
||||
// turn that contains only RedactedThinking must still be preserved.
|
||||
let has_reasoning = response_blocks.iter().any(|b| {
|
||||
matches!(
|
||||
b,
|
||||
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. }
|
||||
)
|
||||
});
|
||||
if !has_reasoning {
|
||||
return Message::assistant(final_text.to_string());
|
||||
}
|
||||
|
||||
// Preserve order: Thinking / RedactedThinking blocks first (in original
|
||||
// order), then a single Text block carrying `final_text`. Tool blocks
|
||||
// aren't expected here (StopReason::EndTurn path), but copy them through
|
||||
// if present so we don't drop information.
|
||||
let mut blocks: Vec<ContentBlock> = Vec::with_capacity(response_blocks.len() + 1);
|
||||
let mut emitted_text = false;
|
||||
for b in response_blocks {
|
||||
match b {
|
||||
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
|
||||
blocks.push(b.clone())
|
||||
}
|
||||
ContentBlock::Text { .. } if !emitted_text => {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: final_text.to_string(),
|
||||
provider_metadata: None,
|
||||
});
|
||||
emitted_text = true;
|
||||
}
|
||||
ContentBlock::Text { .. } => {
|
||||
// Drop additional text blocks — final_text already captures
|
||||
// the canonical visible message.
|
||||
}
|
||||
other => blocks.push(other.clone()),
|
||||
}
|
||||
}
|
||||
if !emitted_text && !final_text.is_empty() {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: final_text.to_string(),
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
|
||||
Message::assistant_with_blocks(blocks)
|
||||
}
|
||||
|
||||
/// Strip a provider prefix from a model ID before sending to the API.
|
||||
///
|
||||
/// Many models are stored as `provider/org/model` (e.g. `openrouter/google/gemini-2.5-flash`)
|
||||
@@ -165,6 +261,30 @@ pub struct AgentLoopResult {
|
||||
pub directives: openfang_types::message::ReplyDirectives,
|
||||
}
|
||||
|
||||
/// Build the user-turn message, combining text with any image content blocks.
|
||||
///
|
||||
/// When the turn has both text and image blocks the text is emitted as the
|
||||
/// first block followed by the images so the LLM sees the full multimodal
|
||||
/// turn. When only one is present the single-mode representation is used.
|
||||
fn build_user_turn_message(user_message: &str, blocks: Option<Vec<ContentBlock>>) -> Message {
|
||||
match blocks {
|
||||
Some(blocks) if !blocks.is_empty() => {
|
||||
if user_message.trim().is_empty() {
|
||||
Message::user_with_blocks(blocks)
|
||||
} else {
|
||||
let mut combined = Vec::with_capacity(blocks.len() + 1);
|
||||
combined.push(ContentBlock::Text {
|
||||
text: user_message.to_string(),
|
||||
provider_metadata: None,
|
||||
});
|
||||
combined.extend(blocks);
|
||||
Message::user_with_blocks(combined)
|
||||
}
|
||||
}
|
||||
_ => Message::user(user_message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the agent execution loop for a single user message.
|
||||
///
|
||||
/// This is the core of OpenFang: it loads session context, recalls memories,
|
||||
@@ -278,12 +398,10 @@ pub async fn run_agent_loop(
|
||||
|
||||
// Add the user message to session history.
|
||||
// When content blocks are provided (e.g. text + image from a channel),
|
||||
// use multimodal message format so the LLM receives the image for vision.
|
||||
if let Some(blocks) = user_content_blocks {
|
||||
session.messages.push(Message::user_with_blocks(blocks));
|
||||
} else {
|
||||
session.messages.push(Message::user(user_message));
|
||||
}
|
||||
// combine them with the user text so the LLM sees the full multimodal turn.
|
||||
session
|
||||
.messages
|
||||
.push(build_user_turn_message(user_message, user_content_blocks));
|
||||
|
||||
// Build the messages for the LLM, filtering system messages
|
||||
// System prompt goes into the separate `system` field.
|
||||
@@ -340,12 +458,15 @@ pub async fn run_agent_loop(
|
||||
// Safety valve: trim excessively long message histories to prevent context overflow.
|
||||
// The full compaction system handles sophisticated summarization, but this prevents
|
||||
// the catastrophic case where 200+ messages cause instant context overflow.
|
||||
if messages.len() > MAX_HISTORY_MESSAGES {
|
||||
let trim_count = messages.len() - MAX_HISTORY_MESSAGES;
|
||||
// Per-agent cap: manifest override -> runtime default (issue #871).
|
||||
let max_history = manifest.effective_max_history_messages();
|
||||
if messages.len() > max_history {
|
||||
let trim_count = messages.len() - max_history;
|
||||
warn!(
|
||||
agent = %manifest.name,
|
||||
total_messages = messages.len(),
|
||||
trimming = trim_count,
|
||||
max_history = max_history,
|
||||
"Trimming old messages to prevent context overflow"
|
||||
);
|
||||
messages.drain(..trim_count);
|
||||
@@ -583,7 +704,16 @@ pub async fn run_agent_loop(
|
||||
};
|
||||
|
||||
final_response = text.clone();
|
||||
session.messages.push(Message::assistant(text));
|
||||
// Issue #1098: persist Thinking blocks alongside the text so
|
||||
// reasoning models retain state across turns. When the
|
||||
// response carries any Thinking content (Anthropic extended
|
||||
// thinking, Gemini 2.5 thought signatures, DeepSeek-R1/Qwen3
|
||||
// `reasoning_content`, MiniMax inline `<think>`), save the
|
||||
// full content blocks; otherwise fall back to the legacy
|
||||
// Text shape so existing sessions/snapshots stay readable.
|
||||
let assistant_msg =
|
||||
build_assistant_message_preserving_thinking(&response.content, &text);
|
||||
session.messages.push(assistant_msg);
|
||||
|
||||
// Prune NO_REPLY heartbeat turns to save context budget
|
||||
crate::session_repair::prune_heartbeat_turns(&mut session.messages, 10);
|
||||
@@ -697,10 +827,12 @@ pub async fn run_agent_loop(
|
||||
session.messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(assistant_blocks.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(assistant_blocks),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Build allowed tool names list for capability enforcement
|
||||
@@ -791,49 +923,51 @@ pub async fn run_agent_loop(
|
||||
// Resolve effective exec policy (per-agent override or global)
|
||||
let effective_exec_policy = manifest.exec_policy.as_ref();
|
||||
|
||||
// Timeout-wrapped execution
|
||||
let timeout = tool_timeout_for(&tool_call.name);
|
||||
let timeout_secs = timeout.as_secs();
|
||||
let result = match tokio::time::timeout(
|
||||
timeout,
|
||||
tool_runner::execute_tool(
|
||||
&tool_call.id,
|
||||
&tool_call.name,
|
||||
&tool_call.input,
|
||||
kernel.as_ref(),
|
||||
Some(&allowed_tool_names),
|
||||
Some(&caller_id_str),
|
||||
skill_registry,
|
||||
mcp_connections,
|
||||
web_ctx,
|
||||
browser_ctx,
|
||||
if hand_allowed_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&hand_allowed_env)
|
||||
},
|
||||
workspace_root,
|
||||
media_engine,
|
||||
effective_exec_policy,
|
||||
tts_engine,
|
||||
docker_config,
|
||||
process_manager,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", timeout_secs);
|
||||
openfang_types::tool::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
content: format!(
|
||||
"Tool '{}' timed out after {}s.",
|
||||
tool_call.name, timeout_secs
|
||||
),
|
||||
is_error: true,
|
||||
// Timeout-wrapped execution. `tool_timeout_for` returns None
|
||||
// when the operator disabled the timeout (issue #1125).
|
||||
let timeout_opt = tool_timeout_for(&tool_call.name);
|
||||
let exec_fut = tool_runner::execute_tool(
|
||||
&tool_call.id,
|
||||
&tool_call.name,
|
||||
&tool_call.input,
|
||||
kernel.as_ref(),
|
||||
Some(&allowed_tool_names),
|
||||
Some(&caller_id_str),
|
||||
skill_registry,
|
||||
mcp_connections,
|
||||
web_ctx,
|
||||
browser_ctx,
|
||||
if hand_allowed_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&hand_allowed_env)
|
||||
},
|
||||
workspace_root,
|
||||
media_engine,
|
||||
effective_exec_policy,
|
||||
tts_engine,
|
||||
docker_config,
|
||||
process_manager,
|
||||
);
|
||||
let result = match timeout_opt {
|
||||
Some(timeout) => {
|
||||
let timeout_secs = timeout.as_secs();
|
||||
match tokio::time::timeout(timeout, exec_fut).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", timeout_secs);
|
||||
openfang_types::tool::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
content: format!(
|
||||
"Tool '{}' timed out after {}s.",
|
||||
tool_call.name, timeout_secs
|
||||
),
|
||||
is_error: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => exec_fut.await,
|
||||
};
|
||||
|
||||
// Fire AfterToolCall hook
|
||||
@@ -917,6 +1051,7 @@ pub async fn run_agent_loop(
|
||||
let tool_results_msg = Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(tool_result_blocks.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
session.messages.push(tool_results_msg.clone());
|
||||
messages.push(tool_results_msg);
|
||||
@@ -936,7 +1071,12 @@ pub async fn run_agent_loop(
|
||||
} else {
|
||||
text
|
||||
};
|
||||
session.messages.push(Message::assistant(&text));
|
||||
// Issue #1148: preserve Thinking / RedactedThinking blocks
|
||||
// present in the response so reasoning state survives
|
||||
// MaxTokens truncation — same as the EndTurn branch.
|
||||
let assistant_msg =
|
||||
build_assistant_message_preserving_thinking(&response.content, &text);
|
||||
session.messages.push(assistant_msg);
|
||||
if let Err(e) = memory.save_session_async(session).await {
|
||||
warn!("Failed to save session on max continuations: {e}");
|
||||
}
|
||||
@@ -967,10 +1107,15 @@ pub async fn run_agent_loop(
|
||||
directives: Default::default(),
|
||||
});
|
||||
}
|
||||
// Model hit token limit — add partial response and continue
|
||||
// Model hit token limit — add partial response and continue.
|
||||
// Issue #1148: preserve full response content (Thinking,
|
||||
// RedactedThinking, etc.) so reasoning state is not dropped
|
||||
// when continuing across the token-limit boundary.
|
||||
let text = response.text();
|
||||
session.messages.push(Message::assistant(&text));
|
||||
messages.push(Message::assistant(&text));
|
||||
let assistant_msg =
|
||||
build_assistant_message_preserving_thinking(&response.content, &text);
|
||||
session.messages.push(assistant_msg.clone());
|
||||
messages.push(assistant_msg);
|
||||
session.messages.push(Message::user("Please continue."));
|
||||
messages.push(Message::user("Please continue."));
|
||||
warn!(iteration, "Max tokens hit, continuing");
|
||||
@@ -1121,6 +1266,7 @@ async fn call_with_retry(
|
||||
api_key,
|
||||
base_url: fb.base_url.clone(),
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let fb_driver = match crate::drivers::create_driver(&fb_config) {
|
||||
Ok(d) => d,
|
||||
@@ -1304,6 +1450,7 @@ async fn stream_with_retry(
|
||||
api_key,
|
||||
base_url: fb.base_url.clone(),
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let fb_driver = match crate::drivers::create_driver(&fb_config) {
|
||||
Ok(d) => d,
|
||||
@@ -1479,12 +1626,10 @@ pub async fn run_agent_loop_streaming(
|
||||
|
||||
// Add the user message to session history.
|
||||
// When content blocks are provided (e.g. text + image from a channel),
|
||||
// use multimodal message format so the LLM receives the image for vision.
|
||||
if let Some(blocks) = user_content_blocks {
|
||||
session.messages.push(Message::user_with_blocks(blocks));
|
||||
} else {
|
||||
session.messages.push(Message::user(user_message));
|
||||
}
|
||||
// combine them with the user text so the LLM sees the full multimodal turn.
|
||||
session
|
||||
.messages
|
||||
.push(build_user_turn_message(user_message, user_content_blocks));
|
||||
|
||||
let llm_messages: Vec<Message> = session
|
||||
.messages
|
||||
@@ -1532,12 +1677,15 @@ pub async fn run_agent_loop_streaming(
|
||||
let mut accumulated_text = String::new();
|
||||
|
||||
// Safety valve: trim excessively long message histories to prevent context overflow.
|
||||
if messages.len() > MAX_HISTORY_MESSAGES {
|
||||
let trim_count = messages.len() - MAX_HISTORY_MESSAGES;
|
||||
// Per-agent cap: manifest override -> runtime default (issue #871).
|
||||
let max_history = manifest.effective_max_history_messages();
|
||||
if messages.len() > max_history {
|
||||
let trim_count = messages.len() - max_history;
|
||||
warn!(
|
||||
agent = %manifest.name,
|
||||
total_messages = messages.len(),
|
||||
trimming = trim_count,
|
||||
max_history = max_history,
|
||||
"Trimming old messages to prevent context overflow (streaming)"
|
||||
);
|
||||
messages.drain(..trim_count);
|
||||
@@ -1637,6 +1785,12 @@ pub async fn run_agent_loop_streaming(
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp last_active before the (potentially long) LLM call so the
|
||||
// heartbeat monitor doesn't flag us as unresponsive mid-iteration.
|
||||
if let Some(k) = &kernel {
|
||||
k.touch_agent(&agent_id_str);
|
||||
}
|
||||
|
||||
// Stream LLM call with retry, error classification, and circuit breaker
|
||||
let provider_name = manifest.model.provider.as_str();
|
||||
let mut response = stream_with_retry(
|
||||
@@ -1770,7 +1924,13 @@ pub async fn run_agent_loop_streaming(
|
||||
text
|
||||
};
|
||||
final_response = text.clone();
|
||||
session.messages.push(Message::assistant(text));
|
||||
// Issue #1098: preserve Thinking blocks (with Anthropic
|
||||
// signatures / Gemini thought signatures / inline-think /
|
||||
// reasoning_content) on the persisted assistant turn. See
|
||||
// build_assistant_message_preserving_thinking for details.
|
||||
let assistant_msg =
|
||||
build_assistant_message_preserving_thinking(&response.content, &text);
|
||||
session.messages.push(assistant_msg);
|
||||
|
||||
// Prune NO_REPLY heartbeat turns to save context budget
|
||||
crate::session_repair::prune_heartbeat_turns(&mut session.messages, 10);
|
||||
@@ -1878,10 +2038,12 @@ pub async fn run_agent_loop_streaming(
|
||||
session.messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(assistant_blocks.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(assistant_blocks),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let allowed_tool_names: Vec<String> =
|
||||
@@ -1970,49 +2132,51 @@ pub async fn run_agent_loop_streaming(
|
||||
// Resolve effective exec policy (per-agent override or global)
|
||||
let effective_exec_policy = manifest.exec_policy.as_ref();
|
||||
|
||||
// Timeout-wrapped execution
|
||||
let timeout = tool_timeout_for(&tool_call.name);
|
||||
let timeout_secs = timeout.as_secs();
|
||||
let result = match tokio::time::timeout(
|
||||
timeout,
|
||||
tool_runner::execute_tool(
|
||||
&tool_call.id,
|
||||
&tool_call.name,
|
||||
&tool_call.input,
|
||||
kernel.as_ref(),
|
||||
Some(&allowed_tool_names),
|
||||
Some(&caller_id_str),
|
||||
skill_registry,
|
||||
mcp_connections,
|
||||
web_ctx,
|
||||
browser_ctx,
|
||||
if hand_allowed_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&hand_allowed_env)
|
||||
},
|
||||
workspace_root,
|
||||
media_engine,
|
||||
effective_exec_policy,
|
||||
tts_engine,
|
||||
docker_config,
|
||||
process_manager,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", timeout_secs);
|
||||
openfang_types::tool::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
content: format!(
|
||||
"Tool '{}' timed out after {}s.",
|
||||
tool_call.name, timeout_secs
|
||||
),
|
||||
is_error: true,
|
||||
// Timeout-wrapped execution. `tool_timeout_for` returns None
|
||||
// when the operator disabled the timeout (issue #1125).
|
||||
let timeout_opt = tool_timeout_for(&tool_call.name);
|
||||
let exec_fut = tool_runner::execute_tool(
|
||||
&tool_call.id,
|
||||
&tool_call.name,
|
||||
&tool_call.input,
|
||||
kernel.as_ref(),
|
||||
Some(&allowed_tool_names),
|
||||
Some(&caller_id_str),
|
||||
skill_registry,
|
||||
mcp_connections,
|
||||
web_ctx,
|
||||
browser_ctx,
|
||||
if hand_allowed_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&hand_allowed_env)
|
||||
},
|
||||
workspace_root,
|
||||
media_engine,
|
||||
effective_exec_policy,
|
||||
tts_engine,
|
||||
docker_config,
|
||||
process_manager,
|
||||
);
|
||||
let result = match timeout_opt {
|
||||
Some(timeout) => {
|
||||
let timeout_secs = timeout.as_secs();
|
||||
match tokio::time::timeout(timeout, exec_fut).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", timeout_secs);
|
||||
openfang_types::tool::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
content: format!(
|
||||
"Tool '{}' timed out after {}s.",
|
||||
tool_call.name, timeout_secs
|
||||
),
|
||||
is_error: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => exec_fut.await,
|
||||
};
|
||||
|
||||
// Fire AfterToolCall hook
|
||||
@@ -2110,6 +2274,7 @@ pub async fn run_agent_loop_streaming(
|
||||
let tool_results_msg = Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(tool_result_blocks.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
session.messages.push(tool_results_msg.clone());
|
||||
messages.push(tool_results_msg);
|
||||
@@ -2127,7 +2292,12 @@ pub async fn run_agent_loop_streaming(
|
||||
} else {
|
||||
text
|
||||
};
|
||||
session.messages.push(Message::assistant(&text));
|
||||
// Issue #1148: preserve Thinking / RedactedThinking blocks
|
||||
// present in the response so reasoning state survives
|
||||
// MaxTokens truncation — same as the EndTurn branch.
|
||||
let assistant_msg =
|
||||
build_assistant_message_preserving_thinking(&response.content, &text);
|
||||
session.messages.push(assistant_msg);
|
||||
if let Err(e) = memory.save_session_async(session).await {
|
||||
warn!("Failed to save session on max continuations: {e}");
|
||||
}
|
||||
@@ -2158,9 +2328,14 @@ pub async fn run_agent_loop_streaming(
|
||||
directives: Default::default(),
|
||||
});
|
||||
}
|
||||
// Issue #1148: preserve full response content (Thinking,
|
||||
// RedactedThinking, etc.) so reasoning state is not dropped
|
||||
// when continuing across the token-limit boundary.
|
||||
let text = response.text();
|
||||
session.messages.push(Message::assistant(&text));
|
||||
messages.push(Message::assistant(&text));
|
||||
let assistant_msg =
|
||||
build_assistant_message_preserving_thinking(&response.content, &text);
|
||||
session.messages.push(assistant_msg.clone());
|
||||
messages.push(assistant_msg);
|
||||
session.messages.push(Message::user("Please continue."));
|
||||
messages.push(Message::user("Please continue."));
|
||||
warn!(iteration, "Max tokens hit (streaming), continuing");
|
||||
@@ -3064,6 +3239,189 @@ mod tests {
|
||||
assert_eq!(MAX_ITERATIONS, 50);
|
||||
}
|
||||
|
||||
/// Issue #1098: when a response carries Thinking blocks, the persisted
|
||||
/// assistant turn must keep them so the next turn round-trips reasoning
|
||||
/// state to the model.
|
||||
#[test]
|
||||
fn test_build_assistant_message_preserves_thinking() {
|
||||
let response_blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "Let me reason carefully...".to_string(),
|
||||
signature: Some("sig_anthropic_xyz".to_string()),
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "anthropic_extended_thinking"
|
||||
})),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Initial response text".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
// Final text might differ from the original Text block (phantom-action
|
||||
// recovery / synthesis fallback rewrites it). The helper should adopt
|
||||
// final_text into the persisted Text block.
|
||||
let final_text = "Initial response text";
|
||||
let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text);
|
||||
assert_eq!(msg.role, Role::Assistant);
|
||||
let blocks = match &msg.content {
|
||||
MessageContent::Blocks(b) => b,
|
||||
other => panic!("expected blocks, got {other:?}"),
|
||||
};
|
||||
assert_eq!(blocks.len(), 2, "must preserve thinking + text");
|
||||
match &blocks[0] {
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking, "Let me reason carefully...");
|
||||
assert_eq!(signature.as_deref(), Some("sig_anthropic_xyz"));
|
||||
}
|
||||
_ => panic!("expected Thinking first"),
|
||||
}
|
||||
match &blocks[1] {
|
||||
ContentBlock::Text { text, .. } => assert_eq!(text, "Initial response text"),
|
||||
_ => panic!("expected Text second"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Without thinking, fall back to the legacy `Message::assistant(text)`
|
||||
/// shape so existing JSONL mirrors and embeddings keep working.
|
||||
#[test]
|
||||
fn test_build_assistant_message_no_thinking_is_plain_text() {
|
||||
let response_blocks = vec![ContentBlock::Text {
|
||||
text: "Hi.".to_string(),
|
||||
provider_metadata: None,
|
||||
}];
|
||||
let msg = build_assistant_message_preserving_thinking(&response_blocks, "Hi.");
|
||||
match msg.content {
|
||||
MessageContent::Text(t) => assert_eq!(t, "Hi."),
|
||||
_ => panic!("expected plain text content for non-thinking responses"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Final text supplied by the loop (e.g. recovery stub) must replace
|
||||
/// the original text part — the persisted message reflects what was
|
||||
/// actually returned to the user, not the raw LLM output.
|
||||
#[test]
|
||||
fn test_build_assistant_message_final_text_replaces_original_text() {
|
||||
let response_blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "deliberation".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "inline_think"})),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "raw LLM output".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let final_text = "[Task completed — recovered after empty response.]";
|
||||
let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text);
|
||||
let blocks = match &msg.content {
|
||||
MessageContent::Blocks(b) => b,
|
||||
_ => panic!("expected blocks"),
|
||||
};
|
||||
let saved_text = blocks.iter().find_map(|b| match b {
|
||||
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(saved_text, Some(final_text));
|
||||
}
|
||||
|
||||
/// Issue #1148 — when the LLM hits MaxTokens, the persisted assistant
|
||||
/// turn must keep `Thinking` and `RedactedThinking` blocks so reasoning
|
||||
/// state survives across the token-limit boundary. The helper used by
|
||||
/// the MaxTokens branches is the same `build_assistant_message_preserving_thinking`
|
||||
/// that EndTurn uses; this test pins that contract for both block types
|
||||
/// so the four MaxTokens persistence sites stay correct.
|
||||
#[test]
|
||||
fn test_build_assistant_message_preserves_redacted_thinking_for_max_tokens() {
|
||||
let response_blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "Mid-stream reasoning".to_string(),
|
||||
signature: Some("sig_xyz".to_string()),
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "anthropic_extended_thinking"
|
||||
})),
|
||||
},
|
||||
ContentBlock::RedactedThinking {
|
||||
data: "encrypted_blob_abc".to_string(),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Partial answer before token limit".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let final_text = "Partial answer before token limit";
|
||||
let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text);
|
||||
let blocks = match &msg.content {
|
||||
MessageContent::Blocks(b) => b,
|
||||
other => panic!("expected Blocks content for MaxTokens persistence, got {other:?}"),
|
||||
};
|
||||
|
||||
// All reasoning blocks must survive the persistence step so the
|
||||
// follow-up "Please continue." turn carries them back to the model.
|
||||
let has_thinking = blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
|
||||
let has_redacted = blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::RedactedThinking { .. }));
|
||||
assert!(
|
||||
has_thinking,
|
||||
"Thinking block must be preserved on MaxTokens"
|
||||
);
|
||||
assert!(
|
||||
has_redacted,
|
||||
"RedactedThinking block must be preserved on MaxTokens"
|
||||
);
|
||||
|
||||
// Verify the opaque blob is byte-identical (Anthropic rejects altered data).
|
||||
for b in blocks {
|
||||
if let ContentBlock::RedactedThinking { data } = b {
|
||||
assert_eq!(data, "encrypted_blob_abc");
|
||||
}
|
||||
}
|
||||
|
||||
// Final text reflects what the user will see.
|
||||
let saved_text = blocks.iter().find_map(|b| match b {
|
||||
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(saved_text, Some(final_text));
|
||||
}
|
||||
|
||||
/// Issue #1187 — a turn that contains only `RedactedThinking` (no
|
||||
/// `Thinking` block) must still trigger the block-preserving path. The
|
||||
/// previous gate keyed solely on `Thinking`, so redacted-only turns were
|
||||
/// downgraded to plain text and the encrypted blob was lost on the next
|
||||
/// request, which Anthropic/Bedrock reject.
|
||||
#[test]
|
||||
fn test_build_assistant_message_preserves_redacted_only() {
|
||||
let response_blocks = vec![
|
||||
ContentBlock::RedactedThinking {
|
||||
data: "encrypted_only".to_string(),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Answer".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let msg = build_assistant_message_preserving_thinking(&response_blocks, "Answer");
|
||||
let blocks = match &msg.content {
|
||||
MessageContent::Blocks(b) => b,
|
||||
other => panic!("expected Blocks content for redacted-only turn, got {other:?}"),
|
||||
};
|
||||
let has_redacted = blocks.iter().any(
|
||||
|b| matches!(b, ContentBlock::RedactedThinking { data } if data == "encrypted_only"),
|
||||
);
|
||||
assert!(
|
||||
has_redacted,
|
||||
"RedactedThinking-only turn must be preserved as Blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_constants() {
|
||||
assert_eq!(MAX_RETRIES, 3);
|
||||
@@ -3115,17 +3473,200 @@ mod tests {
|
||||
assert_eq!(AGENT_TOOL_TIMEOUT_SECS, 600);
|
||||
}
|
||||
|
||||
/// All `tool_timeout_for` cases live in one test (defaults plus env
|
||||
/// overrides) to avoid env-var races between parallel test threads.
|
||||
/// Issue #1125: operators on slow local inference (vLLM on old GPUs) need
|
||||
/// to disable or extend the inter-agent timeout via env var.
|
||||
#[test]
|
||||
fn test_tool_timeout_for_agent_tools() {
|
||||
assert_eq!(tool_timeout_for("agent_send"), Duration::from_secs(600));
|
||||
assert_eq!(tool_timeout_for("agent_spawn"), Duration::from_secs(600));
|
||||
assert_eq!(tool_timeout_for("file_read"), Duration::from_secs(120));
|
||||
assert_eq!(tool_timeout_for("shell_exec"), Duration::from_secs(120));
|
||||
// Baseline: no env overrides → compiled-in defaults.
|
||||
std::env::remove_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS");
|
||||
std::env::remove_var("OPENFANG_TOOL_TIMEOUT_SECS");
|
||||
assert_eq!(
|
||||
tool_timeout_for("agent_send"),
|
||||
Some(Duration::from_secs(600))
|
||||
);
|
||||
assert_eq!(
|
||||
tool_timeout_for("agent_spawn"),
|
||||
Some(Duration::from_secs(600))
|
||||
);
|
||||
assert_eq!(
|
||||
tool_timeout_for("file_read"),
|
||||
Some(Duration::from_secs(120))
|
||||
);
|
||||
assert_eq!(
|
||||
tool_timeout_for("shell_exec"),
|
||||
Some(Duration::from_secs(120))
|
||||
);
|
||||
|
||||
// Override: set to 0 → timeout disabled.
|
||||
std::env::set_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS", "0");
|
||||
std::env::set_var("OPENFANG_TOOL_TIMEOUT_SECS", "0");
|
||||
assert_eq!(tool_timeout_for("agent_send"), None);
|
||||
assert_eq!(tool_timeout_for("agent_spawn"), None);
|
||||
assert_eq!(tool_timeout_for("file_read"), None);
|
||||
|
||||
// Override: custom positive values are honored verbatim.
|
||||
std::env::set_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS", "1800");
|
||||
std::env::set_var("OPENFANG_TOOL_TIMEOUT_SECS", "300");
|
||||
assert_eq!(
|
||||
tool_timeout_for("agent_send"),
|
||||
Some(Duration::from_secs(1800))
|
||||
);
|
||||
assert_eq!(
|
||||
tool_timeout_for("file_read"),
|
||||
Some(Duration::from_secs(300))
|
||||
);
|
||||
|
||||
// Override: unparseable values fall back to compiled-in defaults.
|
||||
std::env::set_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS", "not-a-number");
|
||||
std::env::set_var("OPENFANG_TOOL_TIMEOUT_SECS", "");
|
||||
assert_eq!(
|
||||
tool_timeout_for("agent_send"),
|
||||
Some(Duration::from_secs(600))
|
||||
);
|
||||
assert_eq!(
|
||||
tool_timeout_for("file_read"),
|
||||
Some(Duration::from_secs(120))
|
||||
);
|
||||
|
||||
std::env::remove_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS");
|
||||
std::env::remove_var("OPENFANG_TOOL_TIMEOUT_SECS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_history_messages() {
|
||||
assert_eq!(MAX_HISTORY_MESSAGES, 20);
|
||||
assert_eq!(
|
||||
openfang_types::agent::DEFAULT_MAX_HISTORY_MESSAGES,
|
||||
MAX_HISTORY_MESSAGES
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #871: an agent with a manifest override uses that value.
|
||||
#[test]
|
||||
fn test_effective_max_history_uses_manifest_override() {
|
||||
let mut manifest = openfang_types::agent::AgentManifest {
|
||||
max_history_messages: Some(40),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(manifest.effective_max_history_messages(), 40);
|
||||
|
||||
manifest.max_history_messages = Some(6);
|
||||
assert_eq!(manifest.effective_max_history_messages(), 6);
|
||||
}
|
||||
|
||||
/// Issue #871: an agent without an override falls back to the runtime
|
||||
/// default. `Some(0)` is also treated as the default to avoid an agent
|
||||
/// accidentally disabling history entirely.
|
||||
#[test]
|
||||
fn test_effective_max_history_falls_back_to_default() {
|
||||
let mut manifest = openfang_types::agent::AgentManifest {
|
||||
max_history_messages: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
manifest.effective_max_history_messages(),
|
||||
MAX_HISTORY_MESSAGES
|
||||
);
|
||||
|
||||
manifest.max_history_messages = Some(0);
|
||||
assert_eq!(
|
||||
manifest.effective_max_history_messages(),
|
||||
MAX_HISTORY_MESSAGES
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #871: `max_history_messages` round-trips through serde with
|
||||
/// `#[serde(default)]`, so manifests without the field still deserialize.
|
||||
#[test]
|
||||
fn test_manifest_max_history_round_trip_json() {
|
||||
let json_no_override = r#"{"name":"worker","module":"builtin:chat"}"#;
|
||||
let manifest: openfang_types::agent::AgentManifest =
|
||||
serde_json::from_str(json_no_override).unwrap();
|
||||
assert_eq!(manifest.max_history_messages, None);
|
||||
assert_eq!(
|
||||
manifest.effective_max_history_messages(),
|
||||
MAX_HISTORY_MESSAGES
|
||||
);
|
||||
|
||||
let json_with_override =
|
||||
r#"{"name":"orchestrator","module":"builtin:chat","max_history_messages":40}"#;
|
||||
let manifest: openfang_types::agent::AgentManifest =
|
||||
serde_json::from_str(json_with_override).unwrap();
|
||||
assert_eq!(manifest.max_history_messages, Some(40));
|
||||
assert_eq!(manifest.effective_max_history_messages(), 40);
|
||||
}
|
||||
|
||||
fn sample_image_block() -> ContentBlock {
|
||||
ContentBlock::Image {
|
||||
media_type: "image/png".to_string(),
|
||||
data: "aGVsbG8=".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_user_turn_text_only() {
|
||||
let msg = build_user_turn_message("hello", None);
|
||||
assert_eq!(msg.role, Role::User);
|
||||
match msg.content {
|
||||
MessageContent::Text(text) => assert_eq!(text, "hello"),
|
||||
MessageContent::Blocks(_) => panic!("expected Text content for text-only turn"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_user_turn_images_only() {
|
||||
let msg = build_user_turn_message("", Some(vec![sample_image_block()]));
|
||||
assert_eq!(msg.role, Role::User);
|
||||
match msg.content {
|
||||
MessageContent::Blocks(blocks) => {
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert!(matches!(blocks[0], ContentBlock::Image { .. }));
|
||||
}
|
||||
MessageContent::Text(_) => panic!("expected Blocks content for images-only turn"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_user_turn_text_and_images_combined() {
|
||||
let msg =
|
||||
build_user_turn_message("what is in this image?", Some(vec![sample_image_block()]));
|
||||
assert_eq!(msg.role, Role::User);
|
||||
match msg.content {
|
||||
MessageContent::Blocks(blocks) => {
|
||||
assert_eq!(blocks.len(), 2, "text must be combined with images");
|
||||
match &blocks[0] {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
assert_eq!(text, "what is in this image?");
|
||||
}
|
||||
_ => panic!("expected first block to be user text"),
|
||||
}
|
||||
assert!(matches!(blocks[1], ContentBlock::Image { .. }));
|
||||
}
|
||||
MessageContent::Text(_) => panic!("expected Blocks content for multimodal turn"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_user_turn_whitespace_text_treated_as_empty() {
|
||||
let msg = build_user_turn_message(" \n", Some(vec![sample_image_block()]));
|
||||
match msg.content {
|
||||
MessageContent::Blocks(blocks) => {
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert!(matches!(blocks[0], ContentBlock::Image { .. }));
|
||||
}
|
||||
MessageContent::Text(_) => panic!("expected Blocks content"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_user_turn_empty_blocks_falls_back_to_text() {
|
||||
let msg = build_user_turn_message("hi", Some(Vec::new()));
|
||||
match msg.content {
|
||||
MessageContent::Text(text) => assert_eq!(text, "hi"),
|
||||
MessageContent::Blocks(_) => panic!("expected Text content when blocks are empty"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integration tests for empty response guards ---
|
||||
|
||||
@@ -404,6 +404,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
|
||||
conversation_text.push_str(&format!("[Image: {media_type}]\n\n"));
|
||||
}
|
||||
ContentBlock::Thinking { .. } => {}
|
||||
ContentBlock::RedactedThinking { .. } => {}
|
||||
ContentBlock::Unknown => {}
|
||||
}
|
||||
}
|
||||
@@ -435,10 +436,10 @@ async fn summarize_messages(
|
||||
let safe_start = if conversation_text.is_char_boundary(start) {
|
||||
start
|
||||
} else {
|
||||
// Find the nearest valid character boundary moving upward
|
||||
(start..conversation_text.len())
|
||||
.find(|&i| conversation_text.is_char_boundary(i))
|
||||
.unwrap_or(conversation_text.len())
|
||||
// Find the nearest valid character boundary moving upward
|
||||
(start..conversation_text.len())
|
||||
.find(|&i| conversation_text.is_char_boundary(i))
|
||||
.unwrap_or(conversation_text.len())
|
||||
};
|
||||
conversation_text = conversation_text[safe_start..].to_string();
|
||||
}
|
||||
@@ -457,6 +458,7 @@ async fn summarize_messages(
|
||||
text: summarize_prompt,
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
}],
|
||||
tools: vec![],
|
||||
max_tokens: config.max_summary_tokens,
|
||||
@@ -575,6 +577,7 @@ async fn summarize_in_chunks(
|
||||
text: merge_prompt,
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
}],
|
||||
tools: vec![],
|
||||
max_tokens: config.max_summary_tokens,
|
||||
@@ -912,6 +915,7 @@ mod tests {
|
||||
input: serde_json::json!({"query": "test"}),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
messages[2] = Message {
|
||||
role: Role::User,
|
||||
@@ -921,6 +925,7 @@ mod tests {
|
||||
content: "Search results here".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let session = Session {
|
||||
@@ -1251,6 +1256,7 @@ mod tests {
|
||||
provider_metadata: None,
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
@@ -1260,6 +1266,7 @@ mod tests {
|
||||
content: "Results found".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
@@ -1267,6 +1274,7 @@ mod tests {
|
||||
media_type: "image/png".to_string(),
|
||||
data: "base64data".to_string(),
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1401,6 +1409,7 @@ mod tests {
|
||||
content: tool_content,
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
}];
|
||||
let text = build_conversation_text(&messages, &config);
|
||||
// The base64 blob should be stripped/replaced by session_repair
|
||||
@@ -1421,6 +1430,7 @@ mod tests {
|
||||
content: large_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
}];
|
||||
let text = build_conversation_text(&messages, &config);
|
||||
// Should be capped at ~2000 chars (plus the "..." suffix)
|
||||
@@ -1445,6 +1455,7 @@ mod tests {
|
||||
content: short_result.to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
}];
|
||||
let text = build_conversation_text(&messages, &config);
|
||||
assert!(text.contains(short_result));
|
||||
@@ -1464,6 +1475,7 @@ mod tests {
|
||||
input: serde_json::json!({}),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
@@ -1473,6 +1485,7 @@ mod tests {
|
||||
content: "file contents".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
Message::assistant("Done reading."),
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! - Layer 2: Context guard that scans all tool results before LLM calls
|
||||
//! and compacts oldest results when total exceeds 75% headroom.
|
||||
|
||||
use crate::str_utils::safe_truncate_str;
|
||||
use openfang_types::message::{ContentBlock, Message, MessageContent};
|
||||
use openfang_types::tool::ToolDefinition;
|
||||
use tracing::debug;
|
||||
@@ -217,11 +218,14 @@ fn truncate_to(content: &str, max_chars: usize) -> String {
|
||||
.rfind('\n')
|
||||
.map(|pos| search_start + pos)
|
||||
.unwrap_or(keep);
|
||||
|
||||
// Use safe_truncate_str as an extra layer of safety
|
||||
let safe_content = safe_truncate_str(content, break_point);
|
||||
format!(
|
||||
"{}\n\n[COMPACTED: {} → {} chars by context guard]",
|
||||
&content[..break_point],
|
||||
safe_content,
|
||||
content.len(),
|
||||
break_point
|
||||
safe_content.len()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -286,6 +290,7 @@ mod tests {
|
||||
content: big_result.clone(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: openfang_types::message::Role::User,
|
||||
@@ -295,6 +300,7 @@ mod tests {
|
||||
content: big_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
@@ -346,6 +352,7 @@ mod tests {
|
||||
content: big_chinese,
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
}];
|
||||
// Must not panic on multi-byte content
|
||||
let compacted = apply_context_guard(&mut messages, &budget, &[]);
|
||||
|
||||
@@ -237,6 +237,7 @@ mod tests {
|
||||
Role::Assistant
|
||||
},
|
||||
content: MessageContent::Text(text),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -295,6 +296,7 @@ mod tests {
|
||||
content: big_result.clone(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
@@ -304,6 +306,7 @@ mod tests {
|
||||
content: big_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
// Tiny context window to force all stages
|
||||
@@ -342,6 +345,7 @@ mod tests {
|
||||
content: chinese_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
// Tiny context window to force stage 3 tool truncation
|
||||
@@ -365,6 +369,7 @@ mod tests {
|
||||
input: serde_json::json!({}),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
@@ -374,6 +379,7 @@ mod tests {
|
||||
content: "file contents".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
Message::user("thanks"),
|
||||
];
|
||||
|
||||
@@ -84,6 +84,22 @@ enum ApiContentBlock {
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
is_error: bool,
|
||||
},
|
||||
/// Extended-thinking block echoed back to the API.
|
||||
///
|
||||
/// Anthropic requires the original `signature` to be returned verbatim
|
||||
/// alongside the `thinking` text on subsequent turns; otherwise the
|
||||
/// model loses its prior reasoning state. Without `signature` the API
|
||||
/// rejects the block, so we omit thinking blocks that arrive without
|
||||
/// one (e.g. legacy sessions saved before this field was tracked).
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking { thinking: String, signature: String },
|
||||
/// Redacted (encrypted) thinking block echoed back to the API.
|
||||
///
|
||||
/// Anthropic returns these when the model decides to hide reasoning;
|
||||
/// the `data` blob is opaque and MUST be echoed verbatim on the next
|
||||
/// turn or the API rejects the resubmitted history.
|
||||
#[serde(rename = "redacted_thinking")]
|
||||
RedactedThinking { data: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -120,8 +136,21 @@ enum ResponseContentBlock {
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
/// Extended-thinking block from Anthropic. The `signature` is opaque
|
||||
/// to us but MUST be persisted and echoed back on the next request,
|
||||
/// otherwise the API rejects the resubmitted thinking block and the
|
||||
/// model loses its reasoning state.
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking { thinking: String },
|
||||
Thinking {
|
||||
thinking: String,
|
||||
#[serde(default)]
|
||||
signature: Option<String>,
|
||||
},
|
||||
/// Redacted (encrypted) thinking block. The `data` blob is opaque to
|
||||
/// us and must be persisted as-is so we can echo it back on the next
|
||||
/// request — Anthropic rejects history that strips these blocks.
|
||||
#[serde(rename = "redacted_thinking")]
|
||||
RedactedThinking { data: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -144,12 +173,25 @@ struct ApiErrorDetail {
|
||||
/// Accumulator for content blocks during streaming.
|
||||
enum ContentBlockAccum {
|
||||
Text(String),
|
||||
Thinking(String),
|
||||
/// Extended thinking — text plus an opaque signature delivered as
|
||||
/// `signature_delta` events (or as a single field on `content_block_stop`
|
||||
/// for older API versions). The signature is required to round-trip
|
||||
/// thinking blocks on subsequent turns.
|
||||
Thinking {
|
||||
thinking: String,
|
||||
signature: String,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input_json: String,
|
||||
},
|
||||
/// Redacted (encrypted) thinking block streamed from Anthropic.
|
||||
/// The opaque `data` blob arrives on `content_block_start` and must be
|
||||
/// persisted so the next turn can echo it back verbatim.
|
||||
RedactedThinking {
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -412,7 +454,24 @@ impl LlmDriver for AnthropicDriver {
|
||||
});
|
||||
}
|
||||
"thinking" => {
|
||||
blocks.push(ContentBlockAccum::Thinking(String::new()));
|
||||
// Some API versions ship the signature on
|
||||
// content_block_start instead of as a delta.
|
||||
let initial_sig =
|
||||
block["signature"].as_str().unwrap_or("").to_string();
|
||||
blocks.push(ContentBlockAccum::Thinking {
|
||||
thinking: String::new(),
|
||||
signature: initial_sig,
|
||||
});
|
||||
}
|
||||
"redacted_thinking" => {
|
||||
// Anthropic delivers redacted_thinking
|
||||
// as a single block_start with the opaque
|
||||
// `data` blob (no delta events). Store it
|
||||
// verbatim so we can echo it back on the
|
||||
// next request — API rejects history
|
||||
// that strips redacted_thinking blocks.
|
||||
let data = block["data"].as_str().unwrap_or("").to_string();
|
||||
blocks.push(ContentBlockAccum::RedactedThinking { data });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -452,11 +511,33 @@ impl LlmDriver for AnthropicDriver {
|
||||
}
|
||||
}
|
||||
"thinking_delta" => {
|
||||
if let Some(thinking) = delta["thinking"].as_str() {
|
||||
if let Some(ContentBlockAccum::Thinking(ref mut t)) =
|
||||
blocks.get_mut(block_idx)
|
||||
if let Some(t) = delta["thinking"].as_str() {
|
||||
if let Some(ContentBlockAccum::Thinking {
|
||||
thinking: ref mut buf,
|
||||
..
|
||||
}) = blocks.get_mut(block_idx)
|
||||
{
|
||||
t.push_str(thinking);
|
||||
buf.push_str(t);
|
||||
}
|
||||
// Forward to UI as ThinkingDelta event so dashboards can show reasoning.
|
||||
let _ = tx
|
||||
.send(StreamEvent::ThinkingDelta {
|
||||
text: t.to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"signature_delta" => {
|
||||
// Anthropic streams the thinking signature
|
||||
// as its own delta type; concatenate any
|
||||
// partial pieces into the accumulator.
|
||||
if let Some(sig) = delta["signature"].as_str() {
|
||||
if let Some(ContentBlockAccum::Thinking {
|
||||
ref mut signature,
|
||||
..
|
||||
}) = blocks.get_mut(block_idx)
|
||||
{
|
||||
signature.push_str(sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,8 +593,26 @@ impl LlmDriver for AnthropicDriver {
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
ContentBlockAccum::Thinking(thinking) => {
|
||||
content.push(ContentBlock::Thinking { thinking });
|
||||
ContentBlockAccum::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
// Drop empty thinking blocks (rare, but happens if the
|
||||
// stream is interrupted mid-block). Always keep the
|
||||
// signature when present — it's required to round-trip.
|
||||
if !thinking.is_empty() || !signature.is_empty() {
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature: if signature.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(signature)
|
||||
},
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "anthropic_extended_thinking"
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
ContentBlockAccum::ToolUse {
|
||||
id,
|
||||
@@ -530,6 +629,11 @@ impl LlmDriver for AnthropicDriver {
|
||||
});
|
||||
tool_calls.push(ToolCall { id, name, input });
|
||||
}
|
||||
ContentBlockAccum::RedactedThinking { data } => {
|
||||
if !data.is_empty() {
|
||||
content.push(ContentBlock::RedactedThinking { data });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +719,38 @@ fn convert_message(msg: &Message) -> ApiMessage {
|
||||
content: content.clone(),
|
||||
is_error: *is_error,
|
||||
}),
|
||||
ContentBlock::Thinking { .. } => None,
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
// Anthropic's extended-thinking spec requires the
|
||||
// verbatim `signature` to accompany any thinking block
|
||||
// resubmitted in conversation history. Without one,
|
||||
// the API rejects the request, so we silently drop
|
||||
// legacy thinking blocks (saved before signature
|
||||
// tracking) instead of round-tripping them.
|
||||
signature.as_ref().and_then(|sig| {
|
||||
if sig.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ApiContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
signature: sig.clone(),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
ContentBlock::RedactedThinking { data } => {
|
||||
// Echo the encrypted blob verbatim. Anthropic
|
||||
// rejects history that drops redacted_thinking
|
||||
// blocks, so always include them on resubmission.
|
||||
if data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ApiContentBlock::RedactedThinking { data: data.clone() })
|
||||
}
|
||||
}
|
||||
ContentBlock::Unknown => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -651,8 +786,20 @@ fn convert_response(api: ApiResponse) -> CompletionResponse {
|
||||
});
|
||||
tool_calls.push(ToolCall { id, name, input });
|
||||
}
|
||||
ResponseContentBlock::Thinking { thinking } => {
|
||||
content.push(ContentBlock::Thinking { thinking });
|
||||
ResponseContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "anthropic_extended_thinking"
|
||||
})),
|
||||
});
|
||||
}
|
||||
ResponseContentBlock::RedactedThinking { data } => {
|
||||
content.push(ContentBlock::RedactedThinking { data });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -758,6 +905,7 @@ mod tests {
|
||||
input: serde_json::Value::String(r#"{"query": "test"}"#.to_string()),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
let api_msg = convert_message(&msg);
|
||||
if let ApiContent::Blocks(blocks) = api_msg.content {
|
||||
@@ -772,4 +920,267 @@ mod tests {
|
||||
panic!("Expected Blocks content");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #1098: Anthropic extended-thinking blocks must round-trip
|
||||
/// through the driver — the inbound response carries a `signature` that
|
||||
/// MUST be echoed verbatim on the next request, otherwise the API
|
||||
/// rejects the resubmitted thinking block and the model loses prior
|
||||
/// reasoning state.
|
||||
#[test]
|
||||
fn test_thinking_block_signature_round_trip() {
|
||||
// Step 1: API delivers a thinking block with signature
|
||||
let api_response = ApiResponse {
|
||||
content: vec![
|
||||
ResponseContentBlock::Thinking {
|
||||
thinking: "Let me carefully consider this problem...".to_string(),
|
||||
signature: Some("WaUjzkypQ2mUEVM36O2TxuC".to_string()),
|
||||
},
|
||||
ResponseContentBlock::Text {
|
||||
text: "The answer is 42.".to_string(),
|
||||
},
|
||||
],
|
||||
stop_reason: "end_turn".to_string(),
|
||||
usage: ApiUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
},
|
||||
};
|
||||
let response = convert_response(api_response);
|
||||
assert_eq!(response.content.len(), 2);
|
||||
|
||||
// Step 2: Verify the signature reached the ContentBlock
|
||||
let thinking_block = &response.content[0];
|
||||
match thinking_block {
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking, "Let me carefully consider this problem...");
|
||||
assert_eq!(signature.as_deref(), Some("WaUjzkypQ2mUEVM36O2TxuC"));
|
||||
}
|
||||
_ => panic!("expected Thinking content block"),
|
||||
}
|
||||
|
||||
// Step 3: Now feed the assistant turn back into the driver as if
|
||||
// it were prior conversation history (next user turn). The signature
|
||||
// must survive into the outbound API request.
|
||||
let assistant_msg = Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(response.content.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let api_msg = convert_message(&assistant_msg);
|
||||
let blocks = match api_msg.content {
|
||||
ApiContent::Blocks(b) => b,
|
||||
_ => panic!("expected Blocks content"),
|
||||
};
|
||||
|
||||
// The Thinking block must appear in the outbound payload with its signature.
|
||||
let mut found_thinking = false;
|
||||
for block in &blocks {
|
||||
if let ApiContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} = block
|
||||
{
|
||||
assert_eq!(thinking, "Let me carefully consider this problem...");
|
||||
assert_eq!(signature, "WaUjzkypQ2mUEVM36O2TxuC");
|
||||
found_thinking = true;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_thinking,
|
||||
"outbound API request must include the thinking block with signature"
|
||||
);
|
||||
|
||||
// Step 4: Verify on-the-wire JSON shape (`type=thinking`, `signature` present).
|
||||
let outbound_json = serde_json::to_value(&blocks).unwrap();
|
||||
let arr = outbound_json.as_array().unwrap();
|
||||
let thinking_json = arr
|
||||
.iter()
|
||||
.find(|v| v["type"] == "thinking")
|
||||
.expect("thinking block in JSON");
|
||||
assert_eq!(thinking_json["signature"], "WaUjzkypQ2mUEVM36O2TxuC");
|
||||
assert_eq!(
|
||||
thinking_json["thinking"],
|
||||
"Let me carefully consider this problem..."
|
||||
);
|
||||
}
|
||||
|
||||
/// Legacy thinking blocks saved before signature tracking should NOT
|
||||
/// be replayed — Anthropic rejects thinking blocks without signatures.
|
||||
#[test]
|
||||
fn test_thinking_block_without_signature_dropped_outbound() {
|
||||
let assistant_msg = Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "old reasoning from before sig tracking".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Hello.".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
let api_msg = convert_message(&assistant_msg);
|
||||
let blocks = match api_msg.content {
|
||||
ApiContent::Blocks(b) => b,
|
||||
_ => panic!("expected Blocks content"),
|
||||
};
|
||||
// The legacy thinking block must be dropped (no sig = API would 400).
|
||||
for block in &blocks {
|
||||
assert!(
|
||||
!matches!(block, ApiContentBlock::Thinking { .. }),
|
||||
"thinking block without signature must be dropped"
|
||||
);
|
||||
}
|
||||
// The text part is still preserved.
|
||||
assert!(blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ApiContentBlock::Text { .. })));
|
||||
}
|
||||
|
||||
/// Streaming path: signature_delta events accumulate into the final block.
|
||||
#[test]
|
||||
fn test_thinking_block_serde_with_signature_field() {
|
||||
// Verify the API response wire format is parsed correctly.
|
||||
let json = serde_json::json!({
|
||||
"type": "thinking",
|
||||
"thinking": "step 1, step 2",
|
||||
"signature": "abc123"
|
||||
});
|
||||
let block: ResponseContentBlock = serde_json::from_value(json).unwrap();
|
||||
match block {
|
||||
ResponseContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
assert_eq!(thinking, "step 1, step 2");
|
||||
assert_eq!(signature.as_deref(), Some("abc123"));
|
||||
}
|
||||
_ => panic!("expected Thinking response block"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #1148 — Anthropic `redacted_thinking` blocks must survive the
|
||||
/// full driver round-trip. The opaque `data` blob is required verbatim
|
||||
/// on resubmission; dropping or mutating it causes the API to reject
|
||||
/// the assistant turn on the next request.
|
||||
#[test]
|
||||
fn test_redacted_thinking_round_trip() {
|
||||
// Step 1: API delivers a response with a redacted_thinking block.
|
||||
let api_response = ApiResponse {
|
||||
content: vec![
|
||||
ResponseContentBlock::RedactedThinking {
|
||||
data: "EncRyPt3D_BLO8".to_string(),
|
||||
},
|
||||
ResponseContentBlock::Text {
|
||||
text: "The answer is 42.".to_string(),
|
||||
},
|
||||
],
|
||||
stop_reason: "end_turn".to_string(),
|
||||
usage: ApiUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
},
|
||||
};
|
||||
let response = convert_response(api_response);
|
||||
assert_eq!(response.content.len(), 2);
|
||||
|
||||
// Step 2: The opaque blob must reach the ContentBlock layer.
|
||||
match &response.content[0] {
|
||||
ContentBlock::RedactedThinking { data } => {
|
||||
assert_eq!(data, "EncRyPt3D_BLO8");
|
||||
}
|
||||
other => panic!("expected RedactedThinking content block, got {other:?}"),
|
||||
}
|
||||
|
||||
// Step 3: Resubmit the assistant turn as conversation history.
|
||||
let assistant_msg = Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(response.content.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let api_msg = convert_message(&assistant_msg);
|
||||
let blocks = match api_msg.content {
|
||||
ApiContent::Blocks(b) => b,
|
||||
_ => panic!("expected Blocks content"),
|
||||
};
|
||||
|
||||
// The redacted_thinking block must appear in the outbound payload.
|
||||
let mut found_redacted = false;
|
||||
for block in &blocks {
|
||||
if let ApiContentBlock::RedactedThinking { data } = block {
|
||||
assert_eq!(data, "EncRyPt3D_BLO8");
|
||||
found_redacted = true;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_redacted,
|
||||
"outbound API request must include the redacted_thinking block"
|
||||
);
|
||||
|
||||
// Step 4: On-the-wire JSON shape (`type=redacted_thinking`, `data` present).
|
||||
let outbound_json = serde_json::to_value(&blocks).unwrap();
|
||||
let arr = outbound_json.as_array().unwrap();
|
||||
let redacted_json = arr
|
||||
.iter()
|
||||
.find(|v| v["type"] == "redacted_thinking")
|
||||
.expect("redacted_thinking block in JSON");
|
||||
assert_eq!(redacted_json["data"], "EncRyPt3D_BLO8");
|
||||
}
|
||||
|
||||
/// API response wire format for `redacted_thinking` is parsed correctly.
|
||||
#[test]
|
||||
fn test_redacted_thinking_serde() {
|
||||
let json = serde_json::json!({
|
||||
"type": "redacted_thinking",
|
||||
"data": "opaque_blob_xyz"
|
||||
});
|
||||
let block: ResponseContentBlock = serde_json::from_value(json).unwrap();
|
||||
match block {
|
||||
ResponseContentBlock::RedactedThinking { data } => {
|
||||
assert_eq!(data, "opaque_blob_xyz");
|
||||
}
|
||||
_ => panic!("expected RedactedThinking response block"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty redacted_thinking blocks (e.g. interrupted stream) must be
|
||||
/// dropped on outbound to avoid sending malformed history.
|
||||
#[test]
|
||||
fn test_redacted_thinking_empty_dropped_outbound() {
|
||||
let assistant_msg = Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(vec![
|
||||
ContentBlock::RedactedThinking {
|
||||
data: String::new(),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Hello.".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
let api_msg = convert_message(&assistant_msg);
|
||||
let blocks = match api_msg.content {
|
||||
ApiContent::Blocks(b) => b,
|
||||
_ => panic!("expected Blocks content"),
|
||||
};
|
||||
for block in &blocks {
|
||||
assert!(
|
||||
!matches!(block, ApiContentBlock::RedactedThinking { .. }),
|
||||
"empty redacted_thinking block must be dropped"
|
||||
);
|
||||
}
|
||||
assert!(blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ApiContentBlock::Text { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
||||
use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent};
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use openfang_types::message::{ContentBlock, Role, StopReason, TokenUsage};
|
||||
use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt};
|
||||
@@ -130,6 +130,14 @@ impl ClaudeCodeDriver {
|
||||
}
|
||||
|
||||
/// Build a text prompt from the completion request messages.
|
||||
///
|
||||
/// The Claude Code CLI is text-only (`-p <prompt>`), so non-text content
|
||||
/// blocks (images, etc.) cannot be sent natively. Rather than dropping
|
||||
/// them silently — which causes the model to hallucinate about content
|
||||
/// it can't see — we render each non-text block as a synthetic
|
||||
/// `[attachment: ...]` marker. The model still can't *view* the
|
||||
/// attachment, but it knows the attachment exists and can acknowledge
|
||||
/// it coherently instead of confabulating.
|
||||
fn build_prompt(request: &CompletionRequest) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
@@ -139,15 +147,53 @@ impl ClaudeCodeDriver {
|
||||
Role::Assistant => "Assistant",
|
||||
Role::System => "System",
|
||||
};
|
||||
let text = msg.content.text_content();
|
||||
if !text.is_empty() {
|
||||
parts.push(format!("[{role_label}]\n{text}"));
|
||||
let rendered = Self::render_content(&msg.content);
|
||||
if !rendered.is_empty() {
|
||||
parts.push(format!("[{role_label}]\n{rendered}"));
|
||||
}
|
||||
}
|
||||
|
||||
parts.join("\n\n")
|
||||
}
|
||||
|
||||
/// Render message content for the text-only CLI prompt.
|
||||
///
|
||||
/// Text blocks pass through verbatim. Image blocks are rendered as
|
||||
/// `[attachment: <media_type> image, ~N KB — not viewable on this
|
||||
/// provider]` so the model receives a positive signal that an
|
||||
/// attachment arrived. ToolUse/ToolResult/Thinking are omitted —
|
||||
/// the CLI manages its own tool loop.
|
||||
fn render_content(content: &MessageContent) -> String {
|
||||
match content {
|
||||
MessageContent::Text(s) => s.clone(),
|
||||
MessageContent::Blocks(blocks) => blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text.clone())
|
||||
}
|
||||
}
|
||||
ContentBlock::Image { media_type, data } => {
|
||||
// base64 → ~3/4 the length in decoded bytes.
|
||||
let approx_kb = (data.len().saturating_mul(3) / 4) / 1024;
|
||||
Some(format!(
|
||||
"[attachment: {media_type} image, ~{approx_kb} KB — not viewable on this provider]"
|
||||
))
|
||||
}
|
||||
ContentBlock::ToolUse { .. }
|
||||
| ContentBlock::ToolResult { .. }
|
||||
| ContentBlock::Thinking { .. }
|
||||
| ContentBlock::RedactedThinking { .. }
|
||||
| ContentBlock::Unknown => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a model ID like "claude-code/opus" to CLI --model flag value.
|
||||
fn model_flag(model: &str) -> Option<String> {
|
||||
let stripped = model.strip_prefix("claude-code/").unwrap_or(model);
|
||||
@@ -711,6 +757,7 @@ mod tests {
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::text("Hello"),
|
||||
..Default::default()
|
||||
}],
|
||||
tools: vec![],
|
||||
max_tokens: 1024,
|
||||
@@ -726,6 +773,79 @@ mod tests {
|
||||
assert!(prompt.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_prompt_renders_image_attachment_marker() {
|
||||
use openfang_types::message::{ContentBlock, Message, MessageContent};
|
||||
|
||||
// ~12 KB of base64 — decoded ~9 KB.
|
||||
let fake_b64 = "A".repeat(12 * 1024);
|
||||
let request = CompletionRequest {
|
||||
model: "claude-code/sonnet".to_string(),
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![
|
||||
ContentBlock::Text {
|
||||
text: "what's in this?".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
ContentBlock::Image {
|
||||
media_type: "image/png".to_string(),
|
||||
data: fake_b64,
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
}],
|
||||
tools: vec![],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
system: None,
|
||||
thinking: None,
|
||||
};
|
||||
|
||||
let prompt = ClaudeCodeDriver::build_prompt(&request);
|
||||
assert!(prompt.contains("what's in this?"), "text preserved");
|
||||
assert!(
|
||||
prompt.contains("[attachment: image/png image"),
|
||||
"image rendered as synthetic attachment marker, got: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("not viewable on this provider"),
|
||||
"marker explains the limitation, got: {prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_prompt_image_only_still_emits_marker() {
|
||||
use openfang_types::message::{ContentBlock, Message, MessageContent};
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: "claude-code/sonnet".to_string(),
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::Image {
|
||||
media_type: "image/jpeg".to_string(),
|
||||
data: "Zm9v".to_string(),
|
||||
}]),
|
||||
..Default::default()
|
||||
}],
|
||||
tools: vec![],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
system: None,
|
||||
thinking: None,
|
||||
};
|
||||
|
||||
let prompt = ClaudeCodeDriver::build_prompt(&request);
|
||||
assert!(
|
||||
prompt.contains("[User]"),
|
||||
"user role label emitted even with image-only content, got: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("[attachment: image/jpeg image"),
|
||||
"bare image renders marker, got: {prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_flag_mapping() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! `config.toml`. The driver handles the rest — device flow, token persistence,
|
||||
//! refresh, and Copilot API token exchange — automatically.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -55,6 +55,7 @@ const OAUTH_SCOPES: &str = "copilot";
|
||||
const TOKEN_FILE_NAME: &str = ".copilot-tokens.json";
|
||||
|
||||
/// Device flow polling interval (seconds) — GitHub default is 5.
|
||||
#[allow(dead_code)]
|
||||
const DEVICE_FLOW_POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Maximum time to wait for user to authorize the device flow.
|
||||
@@ -83,14 +84,14 @@ impl PersistedTokens {
|
||||
}
|
||||
|
||||
/// Load from the OpenFang data directory.
|
||||
pub fn load(openfang_dir: &PathBuf) -> Option<Self> {
|
||||
pub fn load(openfang_dir: &Path) -> Option<Self> {
|
||||
let path = openfang_dir.join(TOKEN_FILE_NAME);
|
||||
let data = std::fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
/// Persist to the OpenFang data directory with restricted permissions.
|
||||
pub fn save(&self, openfang_dir: &PathBuf) -> Result<(), String> {
|
||||
pub fn save(&self, openfang_dir: &Path) -> Result<(), String> {
|
||||
let path = openfang_dir.join(TOKEN_FILE_NAME);
|
||||
let json = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| format!("Failed to serialize tokens: {e}"))?;
|
||||
@@ -138,6 +139,7 @@ impl CachedCopilotToken {
|
||||
#[derive(Clone)]
|
||||
struct CachedModels {
|
||||
models: Vec<String>,
|
||||
#[allow(dead_code)]
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
@@ -169,6 +171,7 @@ struct OAuthTokenResponse {
|
||||
#[serde(default)]
|
||||
expires_in: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
refresh_token_expires_in: Option<i64>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
@@ -177,9 +180,7 @@ struct OAuthTokenResponse {
|
||||
}
|
||||
|
||||
/// Request a device code from GitHub using the Copilot client ID.
|
||||
pub async fn request_device_code(
|
||||
client: &reqwest::Client,
|
||||
) -> Result<DeviceCodeResponse, String> {
|
||||
pub async fn request_device_code(client: &reqwest::Client) -> Result<DeviceCodeResponse, String> {
|
||||
let resp = client
|
||||
.post(GITHUB_DEVICE_CODE_URL)
|
||||
.header("Accept", "application/json")
|
||||
@@ -259,9 +260,7 @@ pub async fn poll_for_token(
|
||||
let access_token = token_resp
|
||||
.access_token
|
||||
.ok_or("Missing access_token in response")?;
|
||||
let refresh_token = token_resp
|
||||
.refresh_token
|
||||
.unwrap_or_default(); // Empty if token expiration is disabled on the OAuth App
|
||||
let refresh_token = token_resp.refresh_token.unwrap_or_default(); // Empty if token expiration is disabled on the OAuth App
|
||||
let expires_in = token_resp.expires_in.unwrap_or(0); // 0 = non-expiring
|
||||
|
||||
return Ok(PersistedTokens {
|
||||
@@ -361,7 +360,7 @@ pub async fn exchange_copilot_token(
|
||||
.ok_or("Missing 'token' field in Copilot response")?;
|
||||
|
||||
let expires_at_unix = body.get("expires_at").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let ttl_secs = (expires_at_unix - unix_now() as i64).max(60) as u64;
|
||||
let ttl_secs = (expires_at_unix - unix_now()).max(60) as u64;
|
||||
|
||||
// Extract base URL from endpoints.api or proxy-ep in the token.
|
||||
let base_url = body
|
||||
@@ -444,7 +443,11 @@ pub async fn fetch_models(
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| m.get("id").and_then(|id| id.as_str()).map(|s| s.to_string()))
|
||||
.filter_map(|m| {
|
||||
m.get("id")
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -514,12 +517,7 @@ impl CopilotDriver {
|
||||
};
|
||||
|
||||
if let Some(ref rt) = refresh_token {
|
||||
match refresh_access_token(
|
||||
&self.http_client,
|
||||
rt,
|
||||
)
|
||||
.await
|
||||
{
|
||||
match refresh_access_token(&self.http_client, rt).await {
|
||||
Ok(new_tokens) => {
|
||||
info!("Copilot access token refreshed successfully");
|
||||
if let Err(e) = new_tokens.save(&self.openfang_dir) {
|
||||
@@ -547,8 +545,9 @@ impl CopilotDriver {
|
||||
}
|
||||
|
||||
/// Ensure we have a valid Copilot API token (tid=…).
|
||||
async fn ensure_copilot_token(&self) -> Result<CachedCopilotToken, crate::llm_driver::LlmError>
|
||||
{
|
||||
async fn ensure_copilot_token(
|
||||
&self,
|
||||
) -> Result<CachedCopilotToken, crate::llm_driver::LlmError> {
|
||||
// Check cache.
|
||||
{
|
||||
let lock = self.copilot_token.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -595,13 +594,16 @@ impl CopilotDriver {
|
||||
&self,
|
||||
copilot_token: &CachedCopilotToken,
|
||||
) -> Result<Vec<String>, crate::llm_driver::LlmError> {
|
||||
let models =
|
||||
fetch_models(&self.http_client, &copilot_token.base_url, &copilot_token.token)
|
||||
.await
|
||||
.map_err(|e| crate::llm_driver::LlmError::Api {
|
||||
status: 500,
|
||||
message: format!("Failed to fetch model list: {e}"),
|
||||
})?;
|
||||
let models = fetch_models(
|
||||
&self.http_client,
|
||||
&copilot_token.base_url,
|
||||
&copilot_token.token,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| crate::llm_driver::LlmError::Api {
|
||||
status: 500,
|
||||
message: format!("Failed to fetch model list: {e}"),
|
||||
})?;
|
||||
|
||||
let mut lock = self.models.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*lock = Some(CachedModels {
|
||||
@@ -638,10 +640,7 @@ impl CopilotDriver {
|
||||
execute: F,
|
||||
) -> Result<crate::llm_driver::CompletionResponse, crate::llm_driver::LlmError>
|
||||
where
|
||||
F: Fn(
|
||||
super::openai::OpenAIDriver,
|
||||
crate::llm_driver::CompletionRequest,
|
||||
) -> Fut,
|
||||
F: Fn(super::openai::OpenAIDriver, crate::llm_driver::CompletionRequest) -> Fut,
|
||||
Fut: std::future::Future<
|
||||
Output = Result<crate::llm_driver::CompletionResponse, crate::llm_driver::LlmError>,
|
||||
>,
|
||||
@@ -652,9 +651,10 @@ impl CopilotDriver {
|
||||
|
||||
match execute(driver, request.clone()).await {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(crate::llm_driver::LlmError::Api { status, ref message })
|
||||
if status == 400 && message.contains("model_not_supported") =>
|
||||
{
|
||||
Err(crate::llm_driver::LlmError::Api {
|
||||
status,
|
||||
ref message,
|
||||
}) if status == 400 && message.contains("model_not_supported") => {
|
||||
// Refresh model list so subsequent calls have updated info.
|
||||
warn!(
|
||||
model = %request.model,
|
||||
@@ -683,9 +683,10 @@ impl crate::llm_driver::LlmDriver for CopilotDriver {
|
||||
&self,
|
||||
request: crate::llm_driver::CompletionRequest,
|
||||
) -> Result<crate::llm_driver::CompletionResponse, crate::llm_driver::LlmError> {
|
||||
self.execute_with_model_retry(request, |driver, req| async move {
|
||||
driver.complete(req).await
|
||||
})
|
||||
self.execute_with_model_retry(
|
||||
request,
|
||||
|driver, req| async move { driver.complete(req).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -700,9 +701,10 @@ impl crate::llm_driver::LlmDriver for CopilotDriver {
|
||||
|
||||
match driver.stream(request.clone(), tx.clone()).await {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(crate::llm_driver::LlmError::Api { status, ref message })
|
||||
if status == 400 && message.contains("model_not_supported") =>
|
||||
{
|
||||
Err(crate::llm_driver::LlmError::Api {
|
||||
status,
|
||||
ref message,
|
||||
}) if status == 400 && message.contains("model_not_supported") => {
|
||||
warn!(
|
||||
model = %request.model,
|
||||
"Model not supported — refreshing model catalog"
|
||||
@@ -732,9 +734,7 @@ impl crate::llm_driver::LlmDriver for CopilotDriver {
|
||||
///
|
||||
/// Called from `openfang config set-key github-copilot`, `openfang init`,
|
||||
/// `openfang onboard`, and `openfang configure`.
|
||||
pub async fn run_interactive_setup(
|
||||
openfang_dir: &PathBuf,
|
||||
) -> Result<PersistedTokens, String> {
|
||||
pub async fn run_interactive_setup(openfang_dir: &Path) -> Result<PersistedTokens, String> {
|
||||
run_device_flow(openfang_dir).await
|
||||
}
|
||||
|
||||
@@ -742,9 +742,7 @@ pub async fn run_interactive_setup(
|
||||
///
|
||||
/// Prints the user code and verification URL, attempts to open the browser,
|
||||
/// then polls until the user authorizes.
|
||||
pub async fn run_device_flow(
|
||||
openfang_dir: &PathBuf,
|
||||
) -> Result<PersistedTokens, String> {
|
||||
pub async fn run_device_flow(openfang_dir: &Path) -> Result<PersistedTokens, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
@@ -767,12 +765,7 @@ pub async fn run_device_flow(
|
||||
println!(" Waiting for authorization...");
|
||||
|
||||
// Step 3: Poll for authorization.
|
||||
let tokens = poll_for_token(
|
||||
&client,
|
||||
&device.device_code,
|
||||
device.interval,
|
||||
)
|
||||
.await?;
|
||||
let tokens = poll_for_token(&client, &device.device_code, device.interval).await?;
|
||||
|
||||
// Step 4: Persist.
|
||||
tokens.save(openfang_dir)?;
|
||||
@@ -782,6 +775,7 @@ pub async fn run_device_flow(
|
||||
}
|
||||
|
||||
/// Read a line from stdin with a prompt. Used during interactive setup.
|
||||
#[allow(dead_code)]
|
||||
fn prompt_line(prompt: &str) -> Result<String, String> {
|
||||
use std::io::{self, BufRead, Write};
|
||||
print!("{prompt}");
|
||||
@@ -825,7 +819,7 @@ pub fn open_verification_url(url: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Check if Copilot OAuth tokens exist on disk.
|
||||
pub fn copilot_auth_available(openfang_dir: &PathBuf) -> bool {
|
||||
pub fn copilot_auth_available(openfang_dir: &Path) -> bool {
|
||||
openfang_dir.join(TOKEN_FILE_NAME).exists()
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,39 @@ fn convert_messages(
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking { .. } => {}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
provider_metadata,
|
||||
..
|
||||
} => {
|
||||
// Issue #1098: preserve Gemini 2.5+ thought parts
|
||||
// when the upstream model originally emitted them.
|
||||
// Most Gemini state actually rides on the
|
||||
// thoughtSignature attached to text/tool_use
|
||||
// parts above, but we round-trip the visible
|
||||
// thinking text + sig as a `Thought` part too
|
||||
// so the model's internal state is fully
|
||||
// preserved. Other providers' thinking blocks
|
||||
// are dropped here (they have their own
|
||||
// outbound paths in the OpenAI/Anthropic
|
||||
// drivers).
|
||||
let format = provider_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("format"))
|
||||
.and_then(|v| v.as_str());
|
||||
if format == Some("gemini_thought") && !thinking.is_empty() {
|
||||
let sig = provider_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("thought_signature"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
parts.push(GeminiPart::Thought {
|
||||
text: thinking.clone(),
|
||||
thought: true,
|
||||
thought_signature: sig,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -561,12 +593,29 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
|
||||
input: function_call.args,
|
||||
});
|
||||
}
|
||||
GeminiPart::Thought { text, .. } => {
|
||||
GeminiPart::Thought {
|
||||
text,
|
||||
thought_signature,
|
||||
..
|
||||
} => {
|
||||
// Gemini 2.5+ thinking parts — internal reasoning.
|
||||
// Store as Thinking content block so the UI can
|
||||
// optionally display it (like <think> blocks).
|
||||
// optionally display it. Issue #1098: preserve the
|
||||
// part-level `thoughtSignature` in `provider_metadata`
|
||||
// (and on subsequent text/tool_use parts) so the
|
||||
// model retains state across turns.
|
||||
if !text.is_empty() {
|
||||
content.push(ContentBlock::Thinking { thinking: text });
|
||||
let provider_metadata = thought_signature.map(|sig| {
|
||||
serde_json::json!({
|
||||
"format": "gemini_thought",
|
||||
"thought_signature": sig,
|
||||
})
|
||||
});
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: text,
|
||||
signature: None,
|
||||
provider_metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
|
||||
@@ -788,6 +837,10 @@ impl LlmDriver for GeminiDriver {
|
||||
let mut text_content = String::new();
|
||||
// Thought signature for accumulated text content (last one wins)
|
||||
let mut text_thought_sig: Option<String> = None;
|
||||
// Accumulated thought (Gemini 2.5+) text + signature, for
|
||||
// round-tripping reasoning state across turns (issue #1098).
|
||||
let mut thought_text = String::new();
|
||||
let mut thought_sig: Option<String> = None;
|
||||
// Track function calls: (name, args_json, thought_signature)
|
||||
let mut fn_calls: Vec<(String, serde_json::Value, Option<String>)> = Vec::new();
|
||||
let mut finish_reason: Option<String> = None;
|
||||
@@ -894,17 +947,26 @@ impl LlmDriver for GeminiDriver {
|
||||
thought_signature.clone(),
|
||||
));
|
||||
}
|
||||
GeminiPart::Thought { ref text, .. } => {
|
||||
GeminiPart::Thought {
|
||||
ref text,
|
||||
ref thought_signature,
|
||||
..
|
||||
} => {
|
||||
// Gemini 2.5+ thinking chunk — emit as
|
||||
// thinking delta so UIs can optionally
|
||||
// show it; do NOT mix into text_content.
|
||||
// show it; accumulate the text + sig
|
||||
// for later persistence (issue #1098).
|
||||
if !text.is_empty() {
|
||||
thought_text.push_str(text);
|
||||
let _ = tx
|
||||
.send(StreamEvent::ThinkingDelta {
|
||||
text: text.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if thought_signature.is_some() {
|
||||
thought_sig = thought_signature.clone();
|
||||
}
|
||||
}
|
||||
GeminiPart::InlineData { .. }
|
||||
| GeminiPart::FunctionResponse { .. } => {}
|
||||
@@ -985,14 +1047,24 @@ impl LlmDriver for GeminiDriver {
|
||||
thought_signature.clone(),
|
||||
));
|
||||
}
|
||||
GeminiPart::Thought { ref text, .. } => {
|
||||
GeminiPart::Thought {
|
||||
ref text,
|
||||
ref thought_signature,
|
||||
..
|
||||
} if !text.is_empty()
|
||||
|| thought_signature.is_some() =>
|
||||
{
|
||||
if !text.is_empty() {
|
||||
thought_text.push_str(text);
|
||||
let _ = tx
|
||||
.send(StreamEvent::ThinkingDelta {
|
||||
text: text.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if thought_signature.is_some() {
|
||||
thought_sig = thought_signature.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1034,6 +1106,25 @@ impl LlmDriver for GeminiDriver {
|
||||
let mut content = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
// Issue #1098: persist any accumulated Thought parts (Gemini
|
||||
// 2.5+ thinking) so reasoning state round-trips on the next
|
||||
// turn. The thoughtSignature also rides on text/tool_use
|
||||
// parts below; this Thinking block carries the human-readable
|
||||
// reasoning text for UI display + audit.
|
||||
if !thought_text.is_empty() || thought_sig.is_some() {
|
||||
let provider_metadata = thought_sig.as_ref().map(|sig| {
|
||||
serde_json::json!({
|
||||
"format": "gemini_thought",
|
||||
"thought_signature": sig,
|
||||
})
|
||||
});
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: thought_text,
|
||||
signature: None,
|
||||
provider_metadata,
|
||||
});
|
||||
}
|
||||
|
||||
if !text_content.is_empty() {
|
||||
let provider_metadata =
|
||||
text_thought_sig.map(|sig| serde_json::json!({ "thought_signature": sig }));
|
||||
@@ -1362,6 +1453,7 @@ mod tests {
|
||||
Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text("System prompt here.".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
Message::user("Hi"),
|
||||
];
|
||||
@@ -1495,6 +1587,7 @@ mod tests {
|
||||
"thought_signature": "sig_xyz789"
|
||||
})),
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
@@ -1504,6 +1597,7 @@ mod tests {
|
||||
content: "Results about Rust programming".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1541,6 +1635,7 @@ mod tests {
|
||||
"thought_signature": "text_sig_abc"
|
||||
})),
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1612,6 +1707,7 @@ mod tests {
|
||||
input: serde_json::json!({"path": "/tmp/test"}),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
@@ -1621,6 +1717,7 @@ mod tests {
|
||||
content: "file contents".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1864,6 +1961,7 @@ mod tests {
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(completion.content),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
@@ -1873,6 +1971,7 @@ mod tests {
|
||||
content: "search results".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let (contents, _) = convert_messages(&messages, &None);
|
||||
@@ -1994,7 +2093,7 @@ mod tests {
|
||||
// Should have a Thinking block and a Text block
|
||||
assert_eq!(completion.content.len(), 2);
|
||||
match &completion.content[0] {
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
assert_eq!(thinking, "Let me reason...");
|
||||
}
|
||||
_ => panic!("Expected Thinking block, got {:?}", completion.content[0]),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Mistral, Fireworks, Ollama, vLLM, Chutes.ai, and any OpenAI-compatible endpoint.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod bedrock;
|
||||
pub mod claude_code;
|
||||
pub mod copilot;
|
||||
pub mod fallback;
|
||||
@@ -18,11 +19,11 @@ use openfang_types::model_catalog::{
|
||||
AI21_BASE_URL, ANTHROPIC_BASE_URL, AZURE_OPENAI_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL,
|
||||
COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL,
|
||||
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL,
|
||||
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
|
||||
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NOVITA_BASE_URL, NVIDIA_NIM_BASE_URL,
|
||||
OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL,
|
||||
QWEN_BASE_URL, REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL,
|
||||
VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL,
|
||||
ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -34,6 +35,64 @@ struct ProviderDefaults {
|
||||
key_required: bool,
|
||||
}
|
||||
|
||||
/// Resolve an OpenAI-compatible base URL for a local/self-hosted provider from
|
||||
/// well-known environment variables. Returns `None` if no override is set.
|
||||
///
|
||||
/// This lets users point Ollama / LM Studio / vLLM / Lemonade at a remote host
|
||||
/// (VPS, LXC, another box on the LAN) without editing `~/.openfang/config.toml`.
|
||||
///
|
||||
/// Recognised variables:
|
||||
/// - `ollama` → `OLLAMA_BASE_URL`, then `OLLAMA_HOST` (Ollama CLI convention)
|
||||
/// - `lmstudio` → `LMSTUDIO_BASE_URL`, then `LMSTUDIO_HOST`
|
||||
/// - `vllm` → `VLLM_BASE_URL`, then `VLLM_HOST`
|
||||
/// - `lemonade` → `LEMONADE_BASE_URL`, then `LEMONADE_HOST`
|
||||
///
|
||||
/// `*_HOST` values may omit the scheme and the `/v1` suffix
|
||||
/// (e.g. `OLLAMA_HOST=192.168.1.50:11434`); both are normalised.
|
||||
pub fn local_provider_url_from_env(provider: &str) -> Option<String> {
|
||||
fn read(var: &str) -> Option<String> {
|
||||
std::env::var(var)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Normalise a host-style value into a full OpenAI-compatible base URL.
|
||||
/// - Adds `http://` if no scheme is present.
|
||||
/// - Appends `/v1` if not already present in the path.
|
||||
fn normalize(raw: &str) -> String {
|
||||
let mut url = if raw.contains("://") {
|
||||
raw.trim_end_matches('/').to_string()
|
||||
} else {
|
||||
format!("http://{}", raw.trim_end_matches('/'))
|
||||
};
|
||||
// Add /v1 suffix if missing (OpenAI-compatible endpoints expect it).
|
||||
// Be lenient: accept either `/v1` or `/v1/` already in place, and also
|
||||
// `/openai/v1` style proxies.
|
||||
let lower = url.to_lowercase();
|
||||
if !lower.ends_with("/v1") && !lower.contains("/v1/") {
|
||||
url.push_str("/v1");
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
let (primary, host_fallback) = match provider {
|
||||
"ollama" => ("OLLAMA_BASE_URL", "OLLAMA_HOST"),
|
||||
"lmstudio" => ("LMSTUDIO_BASE_URL", "LMSTUDIO_HOST"),
|
||||
"vllm" => ("VLLM_BASE_URL", "VLLM_HOST"),
|
||||
"lemonade" => ("LEMONADE_BASE_URL", "LEMONADE_HOST"),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if let Some(v) = read(primary) {
|
||||
return Some(normalize(&v));
|
||||
}
|
||||
if let Some(v) = read(host_fallback) {
|
||||
return Some(normalize(&v));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get defaults for known providers.
|
||||
fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
match provider {
|
||||
@@ -47,6 +106,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "OPENROUTER_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"requesty" => Some(ProviderDefaults {
|
||||
base_url: REQUESTY_BASE_URL,
|
||||
api_key_env: "REQUESTY_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"deepseek" => Some(ProviderDefaults {
|
||||
base_url: DEEPSEEK_BASE_URL,
|
||||
api_key_env: "DEEPSEEK_API_KEY",
|
||||
@@ -222,6 +286,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "NVIDIA_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"novita" | "novita-ai" => Some(ProviderDefaults {
|
||||
base_url: NOVITA_BASE_URL,
|
||||
api_key_env: "NOVITA_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"azure" | "azure-openai" => Some(ProviderDefaults {
|
||||
base_url: AZURE_OPENAI_BASE_URL,
|
||||
api_key_env: "AZURE_OPENAI_API_KEY",
|
||||
@@ -319,10 +388,28 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
// Claude Code CLI — subprocess-based, no API key needed
|
||||
if provider == "claude-code" {
|
||||
let cli_path = config.base_url.clone();
|
||||
return Ok(Arc::new(claude_code::ClaudeCodeDriver::new(
|
||||
cli_path,
|
||||
config.skip_permissions,
|
||||
)));
|
||||
// Timeout precedence (highest wins):
|
||||
// 1. OPENFANG_SUBPROCESS_TIMEOUT_SECS env var (no-rebuild override for emergencies)
|
||||
// 2. DriverConfig.subprocess_timeout_secs, populated upstream from
|
||||
// config.toml — `default_model.subprocess_timeout_secs` for the
|
||||
// primary driver, `[[fallback_providers]].subprocess_timeout_secs`
|
||||
// for global fallbacks. See kernel.rs::resolve_driver and
|
||||
// kernel.rs::create_drivers for the wiring.
|
||||
// 3. Driver default (currently 300s, set inside ClaudeCodeDriver::new)
|
||||
// NOTE: The field and env var are scope-named to apply to any subprocess
|
||||
// driver, but today only `provider = "claude-code"` reads them. Other
|
||||
// drivers accept the field silently (forward-compat); future subprocess
|
||||
// drivers (qwen-code, etc.) will opt in here individually.
|
||||
let timeout = std::env::var("OPENFANG_SUBPROCESS_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.or(config.subprocess_timeout_secs);
|
||||
return Ok(Arc::new(match timeout {
|
||||
Some(secs) => {
|
||||
claude_code::ClaudeCodeDriver::with_timeout(cli_path, config.skip_permissions, secs)
|
||||
}
|
||||
None => claude_code::ClaudeCodeDriver::new(cli_path, config.skip_permissions),
|
||||
}));
|
||||
}
|
||||
|
||||
// Qwen Code CLI — subprocess-based, uses Qwen OAuth (free tier)
|
||||
@@ -406,6 +493,18 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
return Ok(Arc::new(vertex::VertexAIDriver::new(project_id, region)));
|
||||
}
|
||||
|
||||
// AWS Bedrock — Converse API with Bedrock API Key (Bearer token)
|
||||
if provider == "bedrock" {
|
||||
let bedrock_api_key = config.api_key.clone();
|
||||
let region = std::env::var("AWS_REGION")
|
||||
.or_else(|_| std::env::var("AWS_DEFAULT_REGION"))
|
||||
.ok();
|
||||
return Ok(Arc::new(bedrock::BedrockDriver::new_with_credentials(
|
||||
bedrock_api_key,
|
||||
region,
|
||||
)?));
|
||||
}
|
||||
|
||||
// Kimi for Code — Anthropic-compatible endpoint
|
||||
if provider == "kimi_coding" {
|
||||
let api_key = config
|
||||
@@ -437,9 +536,14 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
)));
|
||||
}
|
||||
|
||||
// Precedence for the base URL:
|
||||
// 1. Explicit `DriverConfig.base_url` (from config.toml or `[provider_urls]`)
|
||||
// 2. Well-known env vars for local providers (`OLLAMA_HOST`, etc.) — issue #1154
|
||||
// 3. Hard-coded provider default (localhost for ollama/lmstudio/vllm/lemonade)
|
||||
let base_url = config
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| local_provider_url_from_env(provider))
|
||||
.unwrap_or_else(|| defaults.base_url.to_string());
|
||||
|
||||
return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
|
||||
@@ -482,10 +586,11 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
Err(LlmError::Api {
|
||||
status: 0,
|
||||
message: format!(
|
||||
"Unknown provider '{}'. Supported: anthropic, gemini, openai, azure, groq, openrouter, \
|
||||
deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \
|
||||
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \
|
||||
chutes, venice, nvidia, codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
"Unknown provider '{}'. Supported: anthropic, gemini, openai, azure, bedrock, groq, \
|
||||
openrouter, deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, \
|
||||
perplexity, cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, \
|
||||
github-copilot, chutes, venice, nvidia, codex, claude-code. \
|
||||
Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
provider
|
||||
),
|
||||
})
|
||||
@@ -526,6 +631,7 @@ pub fn detect_available_provider() -> Option<(&'static str, &'static str, &'stat
|
||||
"PERPLEXITY_API_KEY",
|
||||
),
|
||||
("cohere", "command-r-plus", "COHERE_API_KEY"),
|
||||
("novita", "moonshotai/kimi-k2.5", "NOVITA_API_KEY"),
|
||||
];
|
||||
for &(provider, model, env_var) in PROBE_ORDER {
|
||||
if std::env::var(env_var)
|
||||
@@ -583,6 +689,7 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
"chutes",
|
||||
"venice",
|
||||
"nvidia",
|
||||
"novita",
|
||||
"codex",
|
||||
"claude-code",
|
||||
"qwen-code",
|
||||
@@ -590,9 +697,53 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
]
|
||||
}
|
||||
|
||||
/// Cross-module env-var serialisation lock for tests that mutate process env.
|
||||
///
|
||||
/// Several tests in this crate (drivers, model_catalog) set/unset the same
|
||||
/// `OLLAMA_*` / `LMSTUDIO_*` env vars and would race under cargo's parallel
|
||||
/// test runner. Anything that mutates those vars must hold this lock.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn env_lock_for_tests() -> &'static std::sync::Mutex<()> {
|
||||
use std::ops::Deref;
|
||||
tests::ENV_LOCK.deref()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
pub(super) static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &str) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
std::env::set_var(key, value);
|
||||
Self { key, original }
|
||||
}
|
||||
|
||||
fn remove(key: &'static str) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
std::env::remove_var(key);
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = &self.original {
|
||||
std::env::set_var(self.key, value);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_defaults_groq() {
|
||||
@@ -627,6 +778,7 @@ mod tests {
|
||||
api_key: Some("test".to_string()),
|
||||
base_url: Some("http://localhost:9999/v1".to_string()),
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_ok());
|
||||
@@ -639,6 +791,7 @@ mod tests {
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_err());
|
||||
@@ -687,11 +840,12 @@ mod tests {
|
||||
assert!(providers.contains(&"volcengine"));
|
||||
assert!(providers.contains(&"chutes"));
|
||||
assert!(providers.contains(&"nvidia"));
|
||||
assert!(providers.contains(&"novita"));
|
||||
assert!(providers.contains(&"codex"));
|
||||
assert!(providers.contains(&"claude-code"));
|
||||
assert!(providers.contains(&"qwen-code"));
|
||||
assert!(providers.contains(&"azure"));
|
||||
assert_eq!(providers.len(), 37);
|
||||
assert_eq!(providers.len(), 38);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -732,33 +886,87 @@ mod tests {
|
||||
assert!(d.key_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_defaults_novita() {
|
||||
let d = provider_defaults("novita").unwrap();
|
||||
assert_eq!(d.base_url, "https://api.novita.ai/openai/v1");
|
||||
assert_eq!(d.api_key_env, "NOVITA_API_KEY");
|
||||
assert!(d.key_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_defaults_novita_ai_alias() {
|
||||
let d = provider_defaults("novita-ai").unwrap();
|
||||
assert_eq!(d.base_url, "https://api.novita.ai/openai/v1");
|
||||
assert_eq!(d.api_key_env, "NOVITA_API_KEY");
|
||||
assert!(d.key_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_novita_provider_with_env_key() {
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let unique_key = "test-novita-key-12345";
|
||||
let _env = EnvVarGuard::set("NOVITA_API_KEY", unique_key);
|
||||
let config = DriverConfig {
|
||||
provider: "novita".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(
|
||||
driver.is_ok(),
|
||||
"Novita provider with env var should succeed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_novita_provider_no_key_errors() {
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let _env = EnvVarGuard::remove("NOVITA_API_KEY");
|
||||
let config = DriverConfig {
|
||||
provider: "novita".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nvidia_provider_with_env_key() {
|
||||
// NVIDIA NIM is a known provider — set API key and verify driver creation succeeds.
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let unique_key = "test-nvidia-key-12345";
|
||||
std::env::set_var("NVIDIA_API_KEY", unique_key);
|
||||
let _env = EnvVarGuard::set("NVIDIA_API_KEY", unique_key);
|
||||
let config = DriverConfig {
|
||||
provider: "nvidia".to_string(),
|
||||
api_key: None, // picked up from env via provider_defaults
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(
|
||||
driver.is_ok(),
|
||||
"NVIDIA provider with env var should succeed"
|
||||
);
|
||||
std::env::remove_var("NVIDIA_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nvidia_provider_no_key_errors() {
|
||||
// NVIDIA NIM provider with no API key should error.
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let _env = EnvVarGuard::remove("NVIDIA_API_KEY");
|
||||
let config = DriverConfig {
|
||||
provider: "nvidia".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_err());
|
||||
@@ -767,13 +975,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_custom_provider_key_no_url_helpful_error() {
|
||||
// Custom provider with key set (via env) but no base_url should give helpful error.
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let unique_key = "test-custom-key-67890";
|
||||
std::env::set_var("MYCUSTOM_API_KEY", unique_key);
|
||||
let _env = EnvVarGuard::set("MYCUSTOM_API_KEY", unique_key);
|
||||
let config = DriverConfig {
|
||||
provider: "mycustom".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let result = create_driver(&config);
|
||||
assert!(result.is_err());
|
||||
@@ -783,7 +993,6 @@ mod tests {
|
||||
"Error should mention base_url: {}",
|
||||
err
|
||||
);
|
||||
std::env::remove_var("MYCUSTOM_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -802,6 +1011,7 @@ mod tests {
|
||||
api_key: Some("explicit-key".to_string()),
|
||||
base_url: Some("https://api.example.com/v1".to_string()),
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_ok());
|
||||
@@ -829,6 +1039,7 @@ mod tests {
|
||||
api_key: Some("test-azure-key".to_string()),
|
||||
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_ok(), "Azure driver with key + URL should succeed");
|
||||
@@ -841,6 +1052,7 @@ mod tests {
|
||||
api_key: None,
|
||||
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let result = create_driver(&config);
|
||||
assert!(result.is_err(), "Azure driver without key should error");
|
||||
@@ -859,6 +1071,7 @@ mod tests {
|
||||
api_key: Some("test-azure-key".to_string()),
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let result = create_driver(&config);
|
||||
assert!(result.is_err(), "Azure driver without URL should error");
|
||||
@@ -877,6 +1090,7 @@ mod tests {
|
||||
api_key: Some("test-azure-key".to_string()),
|
||||
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(
|
||||
@@ -884,4 +1098,213 @@ mod tests {
|
||||
"azure-openai alias should create driver successfully"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bedrock_not_in_provider_defaults() {
|
||||
// Bedrock is special-cased in create_driver(), not in provider_defaults()
|
||||
assert!(provider_defaults("bedrock").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bedrock_driver_requires_credentials() {
|
||||
// With no credentials in env, bedrock creation should fail gracefully
|
||||
// (We can't easily test this without mucking with env, so just verify
|
||||
// that with an explicit api_key it succeeds at construction)
|
||||
let config = DriverConfig {
|
||||
provider: "bedrock".to_string(),
|
||||
api_key: Some("test-bedrock-api-key".to_string()),
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
// Should succeed because api_key is provided
|
||||
let driver = create_driver(&config);
|
||||
assert!(
|
||||
driver.is_ok(),
|
||||
"Bedrock with explicit api_key should construct successfully"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_driver_constructs_with_default_timeout() {
|
||||
// No timeout in config and no env override → driver uses its built-in default.
|
||||
std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS");
|
||||
let config = DriverConfig {
|
||||
provider: "claude-code".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_ok(), "claude-code driver should construct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_driver_constructs_with_config_timeout() {
|
||||
// Timeout set via config field → with_timeout path is exercised.
|
||||
std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS");
|
||||
let config = DriverConfig {
|
||||
provider: "claude-code".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: Some(480),
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(
|
||||
driver.is_ok(),
|
||||
"claude-code driver should construct with custom timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_driver_constructs_with_env_timeout_override() {
|
||||
// Env var present → wins over config field. We can't read the timeout off the
|
||||
// trait object here, but at minimum the construction path must not panic
|
||||
// when both are set and the env var parses cleanly.
|
||||
std::env::set_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS", "600");
|
||||
let config = DriverConfig {
|
||||
provider: "claude-code".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: Some(120),
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS");
|
||||
assert!(
|
||||
driver.is_ok(),
|
||||
"claude-code driver should construct when env override is set"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_driver_ignores_unparseable_env_timeout() {
|
||||
// Garbage env var → falls through to config field, doesn't error.
|
||||
std::env::set_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS", "not-a-number");
|
||||
let config = DriverConfig {
|
||||
provider: "claude-code".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: Some(420),
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS");
|
||||
assert!(
|
||||
driver.is_ok(),
|
||||
"unparseable env override should fall through to config field"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Issue #1154: env-var URL overrides for local providers ──
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_ollama_host_normalised() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
|
||||
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "192.168.1.50:11434");
|
||||
let url = local_provider_url_from_env("ollama").expect("env should resolve");
|
||||
assert_eq!(url, "http://192.168.1.50:11434/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_ollama_base_url_wins() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::set("OLLAMA_BASE_URL", "https://llm.example.com/v1");
|
||||
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "should-be-ignored:11434");
|
||||
let url = local_provider_url_from_env("ollama").expect("env should resolve");
|
||||
assert_eq!(url, "https://llm.example.com/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_lmstudio() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("LMSTUDIO_BASE_URL");
|
||||
let _g2 = EnvVarGuard::set("LMSTUDIO_HOST", "http://10.0.0.5:1234");
|
||||
let url = local_provider_url_from_env("lmstudio").expect("env should resolve");
|
||||
assert_eq!(url, "http://10.0.0.5:1234/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_vllm() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("VLLM_BASE_URL");
|
||||
let _g2 = EnvVarGuard::set("VLLM_HOST", "vps.internal:8000");
|
||||
let url = local_provider_url_from_env("vllm").expect("env should resolve");
|
||||
assert_eq!(url, "http://vps.internal:8000/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_unset_returns_none() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
|
||||
let _g2 = EnvVarGuard::remove("OLLAMA_HOST");
|
||||
assert!(local_provider_url_from_env("ollama").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_only_for_local_providers() {
|
||||
// Cloud providers should never resolve via these helpers — they have
|
||||
// their own *_API_KEY conventions and a fixed cloud base URL.
|
||||
assert!(local_provider_url_from_env("openai").is_none());
|
||||
assert!(local_provider_url_from_env("anthropic").is_none());
|
||||
assert!(local_provider_url_from_env("groq").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_preserves_existing_v1_suffix() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::set("OLLAMA_BASE_URL", "http://1.2.3.4:11434/v1");
|
||||
let _g2 = EnvVarGuard::remove("OLLAMA_HOST");
|
||||
let url = local_provider_url_from_env("ollama").expect("env should resolve");
|
||||
assert_eq!(url, "http://1.2.3.4:11434/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_driver_ollama_uses_env_host() {
|
||||
// End-to-end: when no explicit base_url and no OLLAMA_API_KEY, the
|
||||
// driver should be constructed pointed at the env-supplied host.
|
||||
// (We can't introspect the OpenAIDriver's base_url directly, but
|
||||
// construction succeeds — separate unit covers URL resolution.)
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
|
||||
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "10.20.30.40:11434");
|
||||
let _g3 = EnvVarGuard::remove("OLLAMA_API_KEY");
|
||||
|
||||
let config = DriverConfig {
|
||||
provider: "ollama".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(
|
||||
driver.is_ok(),
|
||||
"ollama with OLLAMA_HOST set and no API key should construct: {:?}",
|
||||
driver.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_driver_lmstudio_no_key_no_env_still_works() {
|
||||
// Pre-#1154 regression guard: lmstudio with no env vars and no API key
|
||||
// should still construct (falls back to localhost default).
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("LMSTUDIO_BASE_URL");
|
||||
let _g2 = EnvVarGuard::remove("LMSTUDIO_HOST");
|
||||
let _g3 = EnvVarGuard::remove("LMSTUDIO_API_KEY");
|
||||
|
||||
let config = DriverConfig {
|
||||
provider: "lmstudio".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_ok(), "lmstudio default should construct");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,9 +190,14 @@ struct OaiMessage {
|
||||
tool_calls: Option<Vec<OaiToolCall>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
/// Moonshot Kimi: sent as empty string on assistant messages with tool_calls when using Kimi (thinking is disabled for multi-turn compatibility).
|
||||
/// Legacy reasoning field. Pre-vLLM 0.19, DeepSeek, Moonshot/Kimi (empty string when thinking is disabled for tool_calls multi-turn).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning_content: Option<String>,
|
||||
/// New reasoning field per OpenAI GPT-OSS Responses-API convention.
|
||||
/// vLLM 0.19+ (PR #33402) renamed `reasoning_content` to `reasoning`.
|
||||
/// Issue #1157: emit both for backward compat across servers.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning: Option<String>,
|
||||
}
|
||||
|
||||
/// Content can be a plain string or an array of content parts (for images).
|
||||
@@ -263,8 +268,23 @@ struct OaiResponseMessage {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OaiToolCall>>,
|
||||
/// Reasoning/thinking content returned by some models (DeepSeek-R1, Qwen3, etc.)
|
||||
/// via LM Studio, Ollama, and other local inference servers.
|
||||
/// via LM Studio, Ollama, and pre-0.19 vLLM.
|
||||
reasoning_content: Option<String>,
|
||||
/// New reasoning field per OpenAI GPT-OSS Responses-API convention.
|
||||
/// vLLM 0.19+ (PR #33402) emits this name instead of `reasoning_content`.
|
||||
/// Issue #1157.
|
||||
reasoning: Option<String>,
|
||||
}
|
||||
|
||||
impl OaiResponseMessage {
|
||||
/// Return whichever reasoning field the server populated.
|
||||
/// vLLM ≥ 0.19 → `reasoning`. Older servers / DeepSeek / Qwen → `reasoning_content`.
|
||||
fn reasoning_text(&self) -> Option<&str> {
|
||||
self.reasoning
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| self.reasoning_content.as_deref().filter(|s| !s.is_empty()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -276,7 +296,7 @@ struct OaiUsage {
|
||||
/// Strip trailing empty assistant messages without tool calls.
|
||||
/// Some API proxies reject empty assistant messages as "prefill".
|
||||
fn strip_trailing_empty_assistant(messages: &mut Vec<OaiMessage>) {
|
||||
while messages.last().map_or(false, |m| {
|
||||
while messages.last().is_some_and(|m| {
|
||||
m.role == "assistant"
|
||||
&& m.tool_calls.is_none()
|
||||
&& match &m.content {
|
||||
@@ -289,6 +309,144 @@ fn strip_trailing_empty_assistant(messages: &mut Vec<OaiMessage>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble an outbound assistant `OaiMessage` from `ContentBlock`s, replaying
|
||||
/// any `Thinking` blocks in the format the upstream model originally emitted.
|
||||
///
|
||||
/// This is the fix for issue #1098 — thinking-model state preservation.
|
||||
/// Without this, `<think>...</think>` and `reasoning_content` are stripped on
|
||||
/// the next turn so the model loses its prior reasoning trace and re-derives
|
||||
/// the answer (degrading quality). We honour `provider_metadata.format`:
|
||||
///
|
||||
/// - `"reasoning_content"` → emitted on the OpenAI `reasoning_content` field
|
||||
/// (DeepSeek-R1, Qwen3, MiniMax M2 via LM Studio/Ollama)
|
||||
/// - `"inline_think"` → wrapped in `<think>...</think>` and prepended to
|
||||
/// the visible content (MiniMax M2.5, Llama-3.3-think variants)
|
||||
/// - missing/other → fall back to the legacy Moonshot/Kimi behaviour
|
||||
/// (only emit `reasoning_content` when `needs_reasoning_content()` is true)
|
||||
fn assemble_assistant_message(
|
||||
blocks: &[ContentBlock],
|
||||
model: &str,
|
||||
driver: &OpenAIDriver,
|
||||
) -> OaiMessage {
|
||||
let mut text_parts: Vec<String> = Vec::new();
|
||||
let mut tool_calls: Vec<OaiToolCall> = Vec::new();
|
||||
let mut reasoning_field: Option<String> = None;
|
||||
let mut inline_think: Option<String> = None;
|
||||
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_calls.push(OaiToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: OaiFunction {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
provider_metadata,
|
||||
..
|
||||
} => {
|
||||
if thinking.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let format = provider_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("format"))
|
||||
.and_then(|v| v.as_str());
|
||||
match format {
|
||||
Some("inline_think") => {
|
||||
// MiniMax / models trained to expect `<think>` in
|
||||
// historical assistant messages. Concatenate
|
||||
// multiple thinking blocks if present.
|
||||
let entry = format!("<think>{thinking}</think>");
|
||||
match &mut inline_think {
|
||||
Some(existing) => existing.push_str(&entry),
|
||||
None => inline_think = Some(entry),
|
||||
}
|
||||
}
|
||||
Some("reasoning_content") => {
|
||||
// DeepSeek-R1 / Qwen3 / OpenAI-compat servers that
|
||||
// expose a separate `reasoning_content` field.
|
||||
match &mut reasoning_field {
|
||||
Some(existing) => existing.push_str(thinking),
|
||||
None => reasoning_field = Some(thinking.clone()),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unknown format — preserve as inline_think since it's
|
||||
// safe (visible to the model as ordinary text). The
|
||||
// legacy Moonshot path overrides this below.
|
||||
let entry = format!("<think>{thinking}</think>");
|
||||
match &mut inline_think {
|
||||
Some(existing) => existing.push_str(&entry),
|
||||
None => inline_think = Some(entry),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the visible content by prepending inline_think (if any).
|
||||
let mut visible = String::new();
|
||||
if let Some(it) = inline_think.as_ref() {
|
||||
visible.push_str(it);
|
||||
}
|
||||
if !text_parts.is_empty() {
|
||||
visible.push_str(&text_parts.join(""));
|
||||
}
|
||||
|
||||
let has_tool_calls = !tool_calls.is_empty();
|
||||
let needs_reasoning = driver.needs_reasoning_content(model);
|
||||
|
||||
// Final reasoning fields: the per-block format hint wins; otherwise
|
||||
// fall back to legacy Moonshot/Kimi behaviour (empty string when needed).
|
||||
//
|
||||
// Issue #1157: vLLM ≥ 0.19 renamed `reasoning_content` to `reasoning`.
|
||||
// Emit BOTH fields so the persisted thinking trace reaches the model
|
||||
// regardless of which server version we're talking to. Old servers
|
||||
// ignore `reasoning`; new vLLM ignores `reasoning_content` (and would
|
||||
// otherwise silently strip our thinking, see PR vllm#33402).
|
||||
let (reasoning_content, reasoning) = if let Some(text) = reasoning_field {
|
||||
(Some(text.clone()), Some(text))
|
||||
} else if needs_reasoning {
|
||||
// Moonshot/Kimi legacy contract: empty `reasoning_content` to disable
|
||||
// thinking on tool-call multi-turn. The `reasoning` field stays unset.
|
||||
(Some(String::new()), None)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
OaiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: if visible.is_empty() {
|
||||
if has_tool_calls {
|
||||
Some(OaiMessageContent::Text(String::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(OaiMessageContent::Text(visible))
|
||||
},
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
tool_call_id: None,
|
||||
reasoning_content,
|
||||
reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for OpenAIDriver {
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
@@ -302,22 +460,22 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
for msg in &request.messages {
|
||||
match (&msg.role, &msg.content) {
|
||||
(Role::System, MessageContent::Text(text)) => {
|
||||
if request.system.is_none() {
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OaiMessageContent::Text(text.clone())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
(Role::System, MessageContent::Text(text)) if request.system.is_none() => {
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OaiMessageContent::Text(text.clone())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Text(text)) => {
|
||||
oai_messages.push(OaiMessage {
|
||||
@@ -326,6 +484,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::Assistant, MessageContent::Text(text)) => {
|
||||
@@ -335,6 +494,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Blocks(blocks)) => {
|
||||
@@ -359,6 +519,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id.clone()),
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
ContentBlock::Text { text, .. } => {
|
||||
@@ -382,63 +543,13 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
(Role::Assistant, MessageContent::Blocks(blocks)) => {
|
||||
let mut text_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut reasoning_text = String::new();
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_calls.push(OaiToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: OaiFunction {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
reasoning_text = thinking.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_tool_calls = !tool_calls.is_empty();
|
||||
let needs_reasoning = self.needs_reasoning_content(&request.model);
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: if text_parts.is_empty() {
|
||||
if has_tool_calls {
|
||||
Some(OaiMessageContent::Text(String::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(OaiMessageContent::Text(text_parts.join("")))
|
||||
},
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
tool_call_id: None,
|
||||
reasoning_content: if needs_reasoning {
|
||||
Some(if reasoning_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
reasoning_text
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
let assembled = assemble_assistant_message(blocks, &request.model, self);
|
||||
oai_messages.push(assembled);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -600,7 +711,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model doesn't support function calling — retry without tools
|
||||
// Model doesn't support function calling — retry without tools
|
||||
// (e.g. GLM-5 on DashScope returns 500 "internal error" when tools are sent)
|
||||
let body_lower = body.to_lowercase();
|
||||
if !oai_request.tools.is_empty()
|
||||
@@ -644,30 +755,46 @@ impl LlmDriver for OpenAIDriver {
|
||||
let mut content = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
// Capture reasoning_content from models that use a separate field
|
||||
// (DeepSeek-R1, Qwen3, etc. via LM Studio/Ollama)
|
||||
if let Some(ref reasoning) = choice.message.reasoning_content {
|
||||
// Capture reasoning text from models that use a separate field.
|
||||
// Issue #1098 (legacy `reasoning_content`) + #1157 (vLLM ≥ 0.19
|
||||
// renamed it to `reasoning`). Accept either.
|
||||
if let Some(reasoning) = choice.message.reasoning_text() {
|
||||
if !reasoning.is_empty() {
|
||||
debug!(
|
||||
len = reasoning.len(),
|
||||
"Captured reasoning_content from response"
|
||||
);
|
||||
debug!(len = reasoning.len(), "Captured reasoning from response");
|
||||
// Mark the format so the outbound path knows to re-emit
|
||||
// this on the reasoning field rather than as inline
|
||||
// `<think>` tags. The outbound assembler writes BOTH
|
||||
// `reasoning` and `reasoning_content` for cross-server
|
||||
// compat.
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: reasoning.clone(),
|
||||
thinking: reasoning.to_string(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "reasoning_content"
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let already_has_reasoning = choice.message.reasoning_text().is_some();
|
||||
if let Some(text) = choice.message.content {
|
||||
if !text.is_empty() {
|
||||
// Extract <think>...</think> blocks that some local models
|
||||
// embed directly in the content field.
|
||||
let (cleaned, thinking) = extract_think_tags(&text);
|
||||
if let Some(think_text) = thinking {
|
||||
// Only add if we didn't already get reasoning_content
|
||||
if choice.message.reasoning_content.is_none() {
|
||||
// Only add if we didn't already get a reasoning field
|
||||
// (either legacy `reasoning_content` or new vLLM 0.19+
|
||||
// `reasoning`). Issue #1157.
|
||||
if !already_has_reasoning {
|
||||
// Mark the format so we re-emit as inline `<think>`
|
||||
// tags on the next turn (MiniMax/M2.5 style).
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: think_text,
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "inline_think"
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -694,7 +821,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
let thinking_text = content
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
|
||||
ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or("");
|
||||
@@ -754,7 +881,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
// this as a "silent failure" and loop unnecessarily.
|
||||
if !content.is_empty() && usage.input_tokens == 0 && usage.output_tokens == 0 {
|
||||
debug!(
|
||||
"Response has content but no usage stats — setting synthetic output_tokens=1"
|
||||
"Response has content but no usage stats — setting synthetic output_tokens=1"
|
||||
);
|
||||
usage.output_tokens = 1;
|
||||
}
|
||||
@@ -788,21 +915,21 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
|
||||
for msg in &request.messages {
|
||||
match (&msg.role, &msg.content) {
|
||||
(Role::System, MessageContent::Text(text)) => {
|
||||
if request.system.is_none() {
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OaiMessageContent::Text(text.clone())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
(Role::System, MessageContent::Text(text)) if request.system.is_none() => {
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OaiMessageContent::Text(text.clone())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Text(text)) => {
|
||||
oai_messages.push(OaiMessage {
|
||||
@@ -811,6 +938,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::Assistant, MessageContent::Text(text)) => {
|
||||
@@ -820,6 +948,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Blocks(blocks)) => {
|
||||
@@ -840,64 +969,14 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id.clone()),
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
(Role::Assistant, MessageContent::Blocks(blocks)) => {
|
||||
let mut text_parts = Vec::new();
|
||||
let mut tool_calls_out = Vec::new();
|
||||
let mut reasoning_text = String::new();
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_calls_out.push(OaiToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: OaiFunction {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
reasoning_text = thinking.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_tool_calls = !tool_calls_out.is_empty();
|
||||
let needs_reasoning = self.needs_reasoning_content(&request.model);
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: if text_parts.is_empty() {
|
||||
if has_tool_calls {
|
||||
Some(OaiMessageContent::Text(String::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(OaiMessageContent::Text(text_parts.join("")))
|
||||
},
|
||||
tool_calls: if tool_calls_out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls_out)
|
||||
},
|
||||
tool_call_id: None,
|
||||
reasoning_content: if needs_reasoning {
|
||||
Some(if reasoning_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
reasoning_text
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
let assembled = assemble_assistant_message(blocks, &request.model, self);
|
||||
oai_messages.push(assembled);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1055,7 +1134,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Provider doesn't support stream_options — retry without it
|
||||
// Provider doesn't support stream_options — retry without it
|
||||
if status == 400
|
||||
&& oai_request.stream_options.is_some()
|
||||
&& attempt < max_retries
|
||||
@@ -1068,7 +1147,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model doesn't support function calling — retry without tools
|
||||
// Model doesn't support function calling — retry without tools
|
||||
let body_lower = body.to_lowercase();
|
||||
if !oai_request.tools.is_empty()
|
||||
&& attempt < max_retries
|
||||
@@ -1157,7 +1236,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
for choice in choices {
|
||||
let delta = &choice["delta"];
|
||||
|
||||
// Text content delta — route through think filter to
|
||||
// Text content delta — route through think filter to
|
||||
// strip <think>...</think> tags before they reach the client.
|
||||
if let Some(text) = delta["content"].as_str() {
|
||||
if !text.is_empty() {
|
||||
@@ -1273,7 +1352,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
sse_lines = sse_line_count,
|
||||
finish = ?finish_reason,
|
||||
buffer_remaining = buffer.len(),
|
||||
"SSE stream returned empty: 0 content, 0 tokens — likely a silently failed request"
|
||||
"SSE stream returned empty: 0 content, 0 tokens — likely a silently failed request"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
@@ -1296,8 +1375,15 @@ impl LlmDriver for OpenAIDriver {
|
||||
|
||||
// Add reasoning/thinking content if present
|
||||
if !reasoning_content.is_empty() {
|
||||
// Mark format so outbound path replays this as
|
||||
// `reasoning_content` (DeepSeek-R1, Qwen3, MiniMax via
|
||||
// LM Studio/Ollama). Issue #1098.
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: reasoning_content.clone(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "reasoning_content"
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1307,8 +1393,14 @@ impl LlmDriver for OpenAIDriver {
|
||||
if let Some(think_text) = thinking {
|
||||
// Only add if we didn't already get reasoning_content
|
||||
if reasoning_content.is_empty() {
|
||||
// Mark as inline-think so the next outbound turn
|
||||
// re-emits the content wrapped in `<think>...</think>`.
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: think_text,
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "inline_think"
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1333,7 +1425,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
let thinking_text = content
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
|
||||
ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or("");
|
||||
@@ -1400,7 +1492,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
// non-zero output_tokens so the agent loop doesn't misclassify
|
||||
// this as a "silent failure" and loop unnecessarily.
|
||||
if !content.is_empty() && usage.input_tokens == 0 && usage.output_tokens == 0 {
|
||||
debug!("Stream has content but no usage stats — setting synthetic output_tokens=1");
|
||||
debug!(
|
||||
"Stream has content but no usage stats — setting synthetic output_tokens=1"
|
||||
);
|
||||
usage.output_tokens = 1;
|
||||
}
|
||||
|
||||
@@ -1453,7 +1547,7 @@ fn extract_think_tags(text: &str) -> (String, Option<String>) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Unclosed <think> tag — treat everything after as thinking
|
||||
// Unclosed <think> tag — treat everything after as thinking
|
||||
let thought = cleaned[start + "<think>".len()..].trim().to_string();
|
||||
if !thought.is_empty() {
|
||||
thinking_parts.push(thought);
|
||||
@@ -1560,7 +1654,7 @@ fn parse_groq_failed_tool_call(body: &str) -> Option<CompletionResponse> {
|
||||
let args = &call_content[brace_pos..];
|
||||
(name, args)
|
||||
} else {
|
||||
// No args — just a tool name
|
||||
// No args — just a tool name
|
||||
(call_content.trim(), "{}")
|
||||
};
|
||||
|
||||
@@ -1576,7 +1670,7 @@ fn parse_groq_failed_tool_call(body: &str) -> Option<CompletionResponse> {
|
||||
}
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
// No tool calls found — the model generated plain text but Groq rejected it.
|
||||
// No tool calls found — the model generated plain text but Groq rejected it.
|
||||
// Return it as a normal text response instead of failing.
|
||||
if !failed.trim().is_empty() {
|
||||
warn!("Recovering plain text from Groq failed_generation (no tool calls)");
|
||||
@@ -1821,9 +1915,124 @@ mod tests {
|
||||
let msg: OaiResponseMessage = serde_json::from_str(json).unwrap();
|
||||
assert!(msg.content.is_none());
|
||||
assert!(msg.reasoning_content.is_none());
|
||||
assert!(msg.reasoning.is_none());
|
||||
}
|
||||
|
||||
// ── Azure OpenAI tests ──────────────────────────────────────────
|
||||
// ── Issue #1157: vLLM ≥ 0.19 reasoning field rename ─────────────────
|
||||
|
||||
/// vLLM 0.19+ (PR #33402) returns `reasoning` instead of
|
||||
/// `reasoning_content`. We must accept the new name on ingress.
|
||||
#[test]
|
||||
fn test_oai_response_message_with_vllm_reasoning_field() {
|
||||
let json =
|
||||
r#"{"content": "Answer.", "reasoning": "I weighed A vs B.", "tool_calls": null}"#;
|
||||
let msg: OaiResponseMessage = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.content.as_deref(), Some("Answer."));
|
||||
assert!(msg.reasoning_content.is_none());
|
||||
assert_eq!(msg.reasoning.as_deref(), Some("I weighed A vs B."));
|
||||
// reasoning_text() must surface the new field transparently.
|
||||
assert_eq!(msg.reasoning_text(), Some("I weighed A vs B."));
|
||||
}
|
||||
|
||||
/// If a server sends both fields (during the transition), prefer the
|
||||
/// new `reasoning` name since that's what vLLM 0.19+ writes natively.
|
||||
#[test]
|
||||
fn test_reasoning_text_prefers_new_field() {
|
||||
let json = r#"{"content": null, "reasoning": "new", "reasoning_content": "old"}"#;
|
||||
let msg: OaiResponseMessage = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.reasoning_text(), Some("new"));
|
||||
}
|
||||
|
||||
/// If only the legacy field is set (older vLLM, DeepSeek, Ollama),
|
||||
/// `reasoning_text()` must still return it.
|
||||
#[test]
|
||||
fn test_reasoning_text_falls_back_to_legacy_field() {
|
||||
let json = r#"{"content": null, "reasoning_content": "legacy thinking"}"#;
|
||||
let msg: OaiResponseMessage = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.reasoning_text(), Some("legacy thinking"));
|
||||
}
|
||||
|
||||
/// Outbound assembler must emit BOTH `reasoning` and `reasoning_content`
|
||||
/// when a `Thinking` block carries the `reasoning_content` format hint,
|
||||
/// so the persisted thinking reaches the model regardless of whether
|
||||
/// the upstream server is pre- or post-vLLM 0.19.
|
||||
#[test]
|
||||
fn test_assemble_emits_both_reasoning_fields_for_vllm_compat() {
|
||||
let driver = OpenAIDriver::new("test".to_string(), "http://localhost:8000/v1".to_string());
|
||||
let blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "MARKER-vllm-019".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "reasoning_content"})),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "final".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let msg = assemble_assistant_message(&blocks, "minimax-m2", &driver);
|
||||
assert_eq!(
|
||||
msg.reasoning_content.as_deref(),
|
||||
Some("MARKER-vllm-019"),
|
||||
"legacy reasoning_content field required for pre-0.19 vLLM and DeepSeek"
|
||||
);
|
||||
assert_eq!(
|
||||
msg.reasoning.as_deref(),
|
||||
Some("MARKER-vllm-019"),
|
||||
"new reasoning field required for vLLM ≥ 0.19 (PR #33402)"
|
||||
);
|
||||
|
||||
// Serialize and confirm the wire shape has both keys at top level.
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
assert_eq!(json["reasoning_content"], "MARKER-vllm-019");
|
||||
assert_eq!(json["reasoning"], "MARKER-vllm-019");
|
||||
}
|
||||
|
||||
/// Non-reasoning models (gpt-4o, claude, …) must NOT carry either
|
||||
/// reasoning field on the wire. Regression guard for the dual-emit
|
||||
/// change in #1157.
|
||||
#[test]
|
||||
fn test_assemble_no_reasoning_fields_for_plain_model() {
|
||||
let driver = OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
|
||||
let blocks = vec![ContentBlock::Text {
|
||||
text: "hi".to_string(),
|
||||
provider_metadata: None,
|
||||
}];
|
||||
let msg = assemble_assistant_message(&blocks, "gpt-4o", &driver);
|
||||
assert!(msg.reasoning_content.is_none());
|
||||
assert!(msg.reasoning.is_none());
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
assert!(json.get("reasoning").is_none());
|
||||
assert!(json.get("reasoning_content").is_none());
|
||||
}
|
||||
|
||||
/// Moonshot/Kimi legacy contract: emit empty `reasoning_content` to
|
||||
/// disable thinking on tool-call multi-turn. Issue #1157 must not
|
||||
/// regress this — `reasoning` stays absent because Moonshot doesn't
|
||||
/// understand the new name.
|
||||
#[test]
|
||||
fn test_assemble_moonshot_keeps_legacy_field_only() {
|
||||
let driver =
|
||||
OpenAIDriver::new("test".to_string(), "https://api.moonshot.cn/v1".to_string());
|
||||
let blocks = vec![ContentBlock::ToolUse {
|
||||
id: "call_1".to_string(),
|
||||
name: "search".to_string(),
|
||||
input: serde_json::json!({"q": "x"}),
|
||||
provider_metadata: None,
|
||||
}];
|
||||
let msg = assemble_assistant_message(&blocks, "kimi-k2", &driver);
|
||||
assert_eq!(
|
||||
msg.reasoning_content.as_deref(),
|
||||
Some(""),
|
||||
"Moonshot Kimi requires empty reasoning_content on tool_calls turns"
|
||||
);
|
||||
assert!(
|
||||
msg.reasoning.is_none(),
|
||||
"Moonshot does not understand the new vLLM `reasoning` field"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Azure OpenAI tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_azure_driver_creation() {
|
||||
@@ -1898,4 +2107,139 @@ mod tests {
|
||||
let url = driver.chat_url("moonshot-v1-128k");
|
||||
assert_eq!(url, "https://api.moonshot.ai/v1/chat/completions");
|
||||
}
|
||||
|
||||
// ── issue #1098: thinking-block round-trip ────────────────────────
|
||||
|
||||
/// Inline `<think>` blocks captured on ingress must be re-emitted in
|
||||
/// historical assistant turns so MiniMax-style models retain reasoning
|
||||
/// state across turns.
|
||||
#[test]
|
||||
fn test_assemble_assistant_replays_inline_think() {
|
||||
let driver = OpenAIDriver::new(
|
||||
"test".to_string(),
|
||||
"https://api.minimax.chat/v1".to_string(),
|
||||
);
|
||||
let blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "step-by-step reasoning".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "inline_think"})),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Hello, user.".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let msg = assemble_assistant_message(&blocks, "minimax-m2.5", &driver);
|
||||
let content = match msg.content {
|
||||
Some(OaiMessageContent::Text(t)) => t,
|
||||
_ => panic!("expected text content"),
|
||||
};
|
||||
assert_eq!(
|
||||
content, "<think>step-by-step reasoning</think>Hello, user.",
|
||||
"inline_think must be re-emitted as <think> wrapping prepended to text"
|
||||
);
|
||||
// No reasoning_content field should be set for non-Moonshot models.
|
||||
assert!(msg.reasoning_content.is_none());
|
||||
}
|
||||
|
||||
/// `reasoning_content`-flavoured Thinking blocks must re-emit on the
|
||||
/// `reasoning_content` field, NOT inline (DeepSeek-R1, Qwen3, MiniMax M2
|
||||
/// via LM Studio/Ollama).
|
||||
#[test]
|
||||
fn test_assemble_assistant_replays_reasoning_content_field() {
|
||||
let driver = OpenAIDriver::new(
|
||||
"test".to_string(),
|
||||
"https://api.deepseek.com/v1".to_string(),
|
||||
);
|
||||
let blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "internal chain-of-thought".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "reasoning_content"})),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "answer".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let msg = assemble_assistant_message(&blocks, "deepseek-reasoner", &driver);
|
||||
let content = match msg.content {
|
||||
Some(OaiMessageContent::Text(t)) => t,
|
||||
_ => panic!("expected text content"),
|
||||
};
|
||||
assert_eq!(
|
||||
content, "answer",
|
||||
"visible content must not include <think>"
|
||||
);
|
||||
assert_eq!(
|
||||
msg.reasoning_content.as_deref(),
|
||||
Some("internal chain-of-thought"),
|
||||
"reasoning_content field must carry the reasoning text"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without thinking blocks, the outbound message should be a plain
|
||||
/// assistant message — preserve the legacy shape.
|
||||
#[test]
|
||||
fn test_assemble_assistant_no_thinking_is_plain() {
|
||||
let driver = OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
|
||||
let blocks = vec![ContentBlock::Text {
|
||||
text: "Hi.".to_string(),
|
||||
provider_metadata: None,
|
||||
}];
|
||||
let msg = assemble_assistant_message(&blocks, "gpt-4o", &driver);
|
||||
match msg.content {
|
||||
Some(OaiMessageContent::Text(t)) => assert_eq!(t, "Hi."),
|
||||
_ => panic!("expected text content"),
|
||||
}
|
||||
assert!(msg.reasoning_content.is_none());
|
||||
}
|
||||
|
||||
/// Issue #1098 round-trip: parse a wire response with `reasoning_content`,
|
||||
/// then feed the parsed assistant turn back through the outbound path
|
||||
/// and confirm the reasoning is replayed.
|
||||
#[test]
|
||||
fn test_reasoning_content_full_round_trip() {
|
||||
// Step 1: parse server response shape.
|
||||
let json = serde_json::json!({
|
||||
"content": "Final answer.",
|
||||
"reasoning_content": "I considered options A, B, and C…",
|
||||
"tool_calls": null
|
||||
});
|
||||
let server_msg: OaiResponseMessage = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(server_msg.content.as_deref(), Some("Final answer."));
|
||||
assert_eq!(
|
||||
server_msg.reasoning_content.as_deref(),
|
||||
Some("I considered options A, B, and C…")
|
||||
);
|
||||
|
||||
// Step 2: simulate the driver building blocks (mirrors the live
|
||||
// path in `complete()`).
|
||||
let mut content = Vec::new();
|
||||
if let Some(ref reasoning) = server_msg.reasoning_content {
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: reasoning.clone(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "reasoning_content"})),
|
||||
});
|
||||
}
|
||||
content.push(ContentBlock::Text {
|
||||
text: server_msg.content.unwrap(),
|
||||
provider_metadata: None,
|
||||
});
|
||||
|
||||
// Step 3: replay through the outbound path.
|
||||
let driver = OpenAIDriver::new(
|
||||
"test".to_string(),
|
||||
"https://api.deepseek.com/v1".to_string(),
|
||||
);
|
||||
let outbound = assemble_assistant_message(&content, "deepseek-reasoner", &driver);
|
||||
// The reasoning_content field must round-trip verbatim.
|
||||
assert_eq!(
|
||||
outbound.reasoning_content.as_deref(),
|
||||
Some("I considered options A, B, and C…"),
|
||||
"issue #1098 regression: reasoning was stripped on resubmission"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +457,7 @@ mod tests {
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::text("Hello"),
|
||||
..Default::default()
|
||||
}],
|
||||
tools: vec![],
|
||||
max_tokens: 1024,
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
//! They receive `&GuestState` (not `&mut`) and return JSON values.
|
||||
|
||||
use crate::sandbox::GuestState;
|
||||
use crate::web_fetch;
|
||||
use openfang_types::capability::{capability_matches, Capability};
|
||||
use serde_json::json;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::path::{Component, Path};
|
||||
use tracing::debug;
|
||||
|
||||
@@ -117,64 +117,9 @@ fn safe_resolve_parent(path: &str) -> Result<std::path::PathBuf, serde_json::Val
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF protection
|
||||
// SSRF protection — delegates to the canonical implementation in web_fetch.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SSRF protection: check if a hostname resolves to a private/internal IP.
|
||||
/// This defeats DNS rebinding by checking the RESOLVED address, not the hostname.
|
||||
fn is_ssrf_target(url: &str) -> Result<(), serde_json::Value> {
|
||||
// Only allow http:// and https:// schemes (block file://, gopher://, ftp://)
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err(json!({"error": "Only http:// and https:// URLs are allowed"}));
|
||||
}
|
||||
|
||||
let host = extract_host_from_url(url);
|
||||
let hostname = host.split(':').next().unwrap_or(&host);
|
||||
|
||||
// Check hostname-based blocklist first (catches metadata endpoints)
|
||||
let blocked_hostnames = [
|
||||
"localhost",
|
||||
"metadata.google.internal",
|
||||
"metadata.aws.internal",
|
||||
"instance-data",
|
||||
"169.254.169.254",
|
||||
];
|
||||
if blocked_hostnames.contains(&hostname) {
|
||||
return Err(json!({"error": format!("SSRF blocked: {hostname} is a restricted hostname")}));
|
||||
}
|
||||
|
||||
// Resolve DNS and check every returned IP
|
||||
let port = if url.starts_with("https") { 443 } else { 80 };
|
||||
let socket_addr = format!("{hostname}:{port}");
|
||||
if let Ok(addrs) = socket_addr.to_socket_addrs() {
|
||||
for addr in addrs {
|
||||
let ip = addr.ip();
|
||||
if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) {
|
||||
return Err(json!({"error": format!(
|
||||
"SSRF blocked: {hostname} resolves to private IP {ip}"
|
||||
)}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_private_ip(ip: &std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
matches!(
|
||||
octets,
|
||||
[10, ..] | [172, 16..=31, ..] | [192, 168, ..] | [169, 254, ..]
|
||||
)
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let segments = v6.segments();
|
||||
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Always-allowed functions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -279,13 +224,15 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
|
||||
.unwrap_or("GET");
|
||||
let body = params.get("body").and_then(|b| b.as_str()).unwrap_or("");
|
||||
|
||||
// SECURITY: SSRF protection — check resolved IP against private ranges
|
||||
if let Err(e) = is_ssrf_target(url) {
|
||||
return e;
|
||||
// SECURITY: SSRF protection — delegates to the canonical check in web_fetch
|
||||
// which includes the full blocklist, metadata IP detection, IPv6 support,
|
||||
// and respects the ssrf_allowed_hosts configuration.
|
||||
if let Err(msg) = web_fetch::check_ssrf(url, &state.ssrf_allowed_hosts) {
|
||||
return json!({"error": msg});
|
||||
}
|
||||
|
||||
// Extract host:port from URL for capability check
|
||||
let host = extract_host_from_url(url);
|
||||
let host = web_fetch::extract_host(url);
|
||||
if let Err(e) = check_capability(&state.capabilities, &Capability::NetConnect(host)) {
|
||||
return e;
|
||||
}
|
||||
@@ -311,22 +258,6 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract host:port from a URL for capability checking.
|
||||
fn extract_host_from_url(url: &str) -> String {
|
||||
if let Some(after_scheme) = url.split("://").nth(1) {
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
if host_port.contains(':') {
|
||||
host_port.to_string()
|
||||
} else if url.starts_with("https") {
|
||||
format!("{host_port}:443")
|
||||
} else {
|
||||
format!("{host_port}:80")
|
||||
}
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell (capability-checked)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -501,6 +432,7 @@ mod tests {
|
||||
kernel: None,
|
||||
agent_id: "test-agent".to_string(),
|
||||
tokio_handle: tokio::runtime::Handle::current(),
|
||||
ssrf_allowed_hosts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,51 +550,68 @@ mod tests {
|
||||
assert!(safe_resolve_parent("/tmp/../../etc/shadow").is_err());
|
||||
}
|
||||
|
||||
// SSRF tests now exercise the canonical implementation in web_fetch.rs,
|
||||
// which is the same code path used by host_net_fetch at runtime.
|
||||
// This verifies the integration works end-to-end for WASM host calls.
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_private_ips_blocked() {
|
||||
assert!(is_ssrf_target("http://127.0.0.1:8080/secret").is_err());
|
||||
assert!(is_ssrf_target("http://localhost:3000/api").is_err());
|
||||
assert!(is_ssrf_target("http://169.254.169.254/metadata").is_err());
|
||||
assert!(is_ssrf_target("http://metadata.google.internal/v1/instance").is_err());
|
||||
let no_allow: Vec<String> = vec![];
|
||||
assert!(web_fetch::check_ssrf("http://127.0.0.1:8080/secret", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://localhost:3000/api", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://169.254.169.254/metadata", &no_allow).is_err());
|
||||
assert!(
|
||||
web_fetch::check_ssrf("http://metadata.google.internal/v1/instance", &no_allow)
|
||||
.is_err()
|
||||
);
|
||||
// These were previously missing from host_functions — now covered:
|
||||
assert!(web_fetch::check_ssrf("http://[::1]:8080/secret", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://100.100.100.200/metadata", &no_allow).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_public_ips_allowed() {
|
||||
assert!(is_ssrf_target("https://api.openai.com/v1/chat").is_ok());
|
||||
assert!(is_ssrf_target("https://google.com").is_ok());
|
||||
let no_allow: Vec<String> = vec![];
|
||||
assert!(web_fetch::check_ssrf("https://api.openai.com/v1/chat", &no_allow).is_ok());
|
||||
assert!(web_fetch::check_ssrf("https://google.com", &no_allow).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_scheme_validation() {
|
||||
assert!(is_ssrf_target("file:///etc/passwd").is_err());
|
||||
assert!(is_ssrf_target("gopher://evil.com").is_err());
|
||||
assert!(is_ssrf_target("ftp://example.com").is_err());
|
||||
let no_allow: Vec<String> = vec![];
|
||||
assert!(web_fetch::check_ssrf("file:///etc/passwd", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("gopher://evil.com", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("ftp://example.com", &no_allow).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_private_ip() {
|
||||
use std::net::IpAddr;
|
||||
assert!(is_private_ip(&"10.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(&"172.16.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(&"192.168.1.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(&"169.254.169.254".parse::<IpAddr>().unwrap()));
|
||||
assert!(!is_private_ip(&"8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
assert!(!is_private_ip(&"1.1.1.1".parse::<IpAddr>().unwrap()));
|
||||
fn test_ssrf_allowlist_respected() {
|
||||
let allowed = vec!["192.168.1.0/24".to_string()];
|
||||
// Private IP that matches allowlist — should pass
|
||||
assert!(web_fetch::check_ssrf("http://192.168.1.100:8080/api", &allowed).is_ok());
|
||||
// Private IP outside allowlist — should still block
|
||||
let no_allow: Vec<String> = vec![];
|
||||
assert!(web_fetch::check_ssrf("http://192.168.1.100:8080/api", &no_allow).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_host_from_url() {
|
||||
fn test_extract_host_delegates_to_web_fetch() {
|
||||
assert_eq!(
|
||||
extract_host_from_url("https://api.openai.com/v1/chat"),
|
||||
web_fetch::extract_host("https://api.openai.com/v1/chat"),
|
||||
"api.openai.com:443"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_host_from_url("http://localhost:8080/api"),
|
||||
web_fetch::extract_host("http://localhost:8080/api"),
|
||||
"localhost:8080"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_host_from_url("http://example.com"),
|
||||
web_fetch::extract_host("http://example.com"),
|
||||
"example.com:80"
|
||||
);
|
||||
// IPv6 — previously not handled by host_functions
|
||||
assert_eq!(
|
||||
web_fetch::extract_host("http://[::1]:9090/test"),
|
||||
"[::1]:9090"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@ use tracing::warn;
|
||||
/// Generate images via OpenAI's image generation API.
|
||||
///
|
||||
/// Requires OPENAI_API_KEY to be set.
|
||||
pub async fn generate_image(request: &ImageGenRequest) -> Result<ImageGenResult, String> {
|
||||
///
|
||||
/// `base_url_override` (sourced from `MediaConfig.image_gen_base_url`) lets
|
||||
/// callers redirect the request to a local OpenAI-compatible image service
|
||||
/// (e.g. Lemonade/Flux, LM Studio). When `None`, the hardcoded
|
||||
/// `https://api.openai.com/v1/images/generations` endpoint is used. Closes #1051.
|
||||
pub async fn generate_image(
|
||||
request: &ImageGenRequest,
|
||||
base_url_override: Option<&str>,
|
||||
) -> Result<ImageGenResult, String> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
@@ -30,9 +38,19 @@ pub async fn generate_image(request: &ImageGenRequest) -> Result<ImageGenResult,
|
||||
body["quality"] = serde_json::json!(request.quality);
|
||||
}
|
||||
|
||||
// `image_gen_base_url` (config.media.image_gen_base_url) overrides the
|
||||
// hardcoded provider URL when set, allowing the same OpenAI-compat JSON
|
||||
// wire format to be sent to a local image generation service
|
||||
// (Lemonade/Flux, LM Studio, etc.) instead of the cloud provider. The
|
||||
// Authorization header is still built from `OPENAI_API_KEY`; local
|
||||
// services typically accept any non-empty bearer token. Closes #1051.
|
||||
let url = base_url_override
|
||||
.map(|base| format!("{}/v1/images/generations", base.trim_end_matches('/')))
|
||||
.unwrap_or_else(|| "https://api.openai.com/v1/images/generations".to_string());
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.openai.com/v1/images/generations")
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
@@ -201,6 +219,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes #1051: when `image_gen_base_url` is set, the URL building
|
||||
/// logic must use the override (with `/v1/images/generations` appended)
|
||||
/// and strip any trailing slash from the user-supplied base. When unset,
|
||||
/// the hardcoded provider URL is used.
|
||||
#[test]
|
||||
fn test_image_gen_base_url_override_logic() {
|
||||
// Helper mirroring the URL construction in `generate_image`.
|
||||
fn build(base: Option<&str>) -> String {
|
||||
base.map(|b| format!("{}/v1/images/generations", b.trim_end_matches('/')))
|
||||
.unwrap_or_else(|| "https://api.openai.com/v1/images/generations".to_string())
|
||||
}
|
||||
|
||||
// Default: hardcoded URL preserved (backward compatibility).
|
||||
assert_eq!(build(None), "https://api.openai.com/v1/images/generations");
|
||||
|
||||
// Override applied.
|
||||
assert_eq!(
|
||||
build(Some("http://127.0.0.1:7000")),
|
||||
"http://127.0.0.1:7000/v1/images/generations"
|
||||
);
|
||||
|
||||
// Trailing slash on the user-supplied base is stripped.
|
||||
assert_eq!(
|
||||
build(Some("http://127.0.0.1:7000/")),
|
||||
"http://127.0.0.1:7000/v1/images/generations"
|
||||
);
|
||||
assert_eq!(
|
||||
build(Some("https://images.example.com/")),
|
||||
"https://images.example.com/v1/images/generations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_images_creates_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user