Compare commits

...
307 Commits
Author SHA1 Message Date
jaberjaber23 a26f762635 v0.5.7: multi-instance hands + 8 critical fixes
## Headline feature
- Multi-instance Hands via optional instance_name (customer ask + #878).
  Web UI, CLI (--name / -n), API, kernel, registry all threaded. Two
  clip-youtube + clip-tiktok instances now coexist. Backward compatible
  when instance_name is omitted.

## Critical bug fixes
- #919 [SECURITY] rm bypass closed. process_start tool now validates against
  exec_policy allowlist and rejects shell metacharacters in both command
  and args. Added 5 regression tests.
- #1013 session_repair phase ordering — dedup now runs BEFORE synthetic
  result insertion, fixing Moonshot's non-unique tool_call_id format
  (function_name:index). Added regression test.
- #1003 global [[fallback_providers]] now actually used at runtime.
  resolve_driver wraps primary in FallbackDriver with global fallback
  chain. Network errors escalate to fallback instead of infinite retry.
- #937 Discord gateway heartbeat. Spawns interval task, tracks sequence,
  handles ACKs, detects zombie connections, force-closes on missing ACK.
  Credits @hello-world-bfree (PR #938) for the diagnosis.
- #935 System prompt leak in Web UI. get_agent_session now filters
  Role::System by default (?include_system=true for debug). Defense in
  depth client-side filter too.
- #984 Custom hands persistence. install_from_path copies to
  ~/.openfang/hands/. Kernel loads them on startup.
- #884 Workspace version bump 0.5.5 -> 0.5.7. Binaries now correctly
  report --version as 0.5.7 instead of stale 0.5.5.

## Cleanup
- rmcp 1.3 builder API adopted (credits @jefflower PR #986) for
  StreamableHttpClientTransportConfig. Drops unused Arc import.

## Stats
- 22 files changed, all workspace tests passing (1800+)
- Live-tested with daemon: v0.5.7 reported, multi-instance hands
  verified end-to-end, Groq round-trip PONG confirmed
2026-04-08 22:59:56 +03:00
jaberjaber23 07963779be fix: install script skips empty releases, finds latest with binary assets 2026-04-03 03:52:46 +03:00
Jaber Jaber a78299ed3d Merge pull request #753 from RamXX/fix/dashboard-password-argon2
Replace SHA256 password hashing with Argon2id for dashboard auth
2026-03-31 02:22:02 +03:00
Jaber Jaber eebb83c79a Merge pull request #877 from pbranchu/fix/silent-reinforcement
Fix silent reinforcement: recognize [SILENT] token
2026-03-31 02:21:59 +03:00
Jaber Jaber 0b59205b0c Merge pull request #881 from pbranchu/fix/token-estimation-tooluse
Fix token estimation: include ToolUse arguments in text_length
2026-03-31 02:21:55 +03:00
Jaber Jaber 3f8ceabc51 Merge pull request #897 from tytsxai/pr/upstream-nested-xml
feat(runtime): recover nested XML tool call parameters
2026-03-31 02:21:51 +03:00
Jaber Jaber 4921ee5ece Merge pull request #898 from tytsxai/pr/upstream-generic-fixes
channels: add startup timeout for Telegram control-plane calls
2026-03-31 02:21:47 +03:00
Jaber Jaber 0c4769a07f Merge pull request #900 from neo-wanderer/fix/agent-skills-reload
Fix/agent skills reload
2026-03-31 02:21:44 +03:00
Jaber Jaber 167b37f10e Merge pull request #917 from lc-soft/fix/alpine-exp-error
fix: Alpine Expression Error in settings page caused by x-show
2026-03-31 02:21:40 +03:00
Jaber Jaber 545e710abb Merge pull request #920 from norci/add-searxng-search-provider
feat: add SearXNG search provider with custom URL and JSON output
2026-03-31 02:21:36 +03:00
jaberjaber23 618e83714c fix: version bump to 0.5.5, SSRF allowlist, Ollama context, embedding detection
- Bump workspace version and Tauri config to 0.5.5 (fixes users stuck on 0.5.1)
- Add ssrf_allowed_hosts config for self-hosted K8s environments (Jerry Jaz)
- Raise Ollama discovered model defaults to 128K context / 16K output (Cureator)
- Expand embedding auto-detection: OpenAI, Groq, Mistral, Together, Fireworks, Cohere, then local providers (Thunder Guardian)

All tests passing. 9 files changed, 272 insertions.
2026-03-30 21:30:48 +03:00
beann 46eac44635 feat: add searxng search specialist skill 2026-03-30 19:35:53 +08:00
beann 9372cc6ff2 docs: add SearXNG search provider configuration 2026-03-30 18:57:06 +08:00
beann 9cf37eab22 feat: SearXNG pagination support 2026-03-30 18:40:26 +08:00
beann 79ca1cda32 feat: SearXNG dynamic category support with validation 2026-03-30 18:32:57 +08:00
beann 50c51dd6b7 refactor: remove redundant max_results from SearxngSearchConfig 2026-03-30 18:26:11 +08:00
beann d75a56a0f6 fix: filter SearXNG noise fields, only expose title/url/content/published_date to LLM 2026-03-30 18:15:35 +08:00
beann ce3344a994 fix: SearXNG does not support limit param, truncate results client-side 2026-03-30 18:12:16 +08:00
beann 656e2734ce feat: add SearXNG search provider with custom URL and JSON output 2026-03-30 18:04:39 +08:00
Liu cfda9b9bfc fix: Alpine Expression Error in settings page caused by x-show 2026-03-30 13:52:10 +08:00
vigneshnrfs a428b1cd66 test: add test for agent skills/mcp_servers TOML parsing
The skills and mcp_servers fields must be at the top level of the
agent.toml, not after [capabilities], due to TOML implicit table
ordering rules.
2026-03-29 09:12:25 +05:30
vigneshnrfs 51d358f9d9 fix: detect skills and mcp_servers changes in agent config reload
The agent config reload logic was missing skills and mcp_servers from
the change detection, so edits to these fields in agent.toml weren't
being picked up when loading agents from SQLite.

Added both fields to the comparison to ensure proper hot-reload.
2026-03-29 09:03:51 +05:30
ww 1c61b869c0 feat(runtime): recover nested XML tool call parameters
(cherry picked from commit 336240b28996bcd4c6a823d5d0a45efe4a6aaba3)
2026-03-29 04:56:03 +08:00
ww fc902a9ceb channels: add startup timeout for telegram control-plane calls
(cherry picked from commit 7432086493)
2026-03-29 04:55:15 +08:00
Philippe BranchuandClaude Opus 4.6 a3cefa424c Fix clippy: remove needless borrow in line.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:22:10 +00:00
Philippe BranchuandClaude Opus 4.6 06d0479419 Fix clippy: remove needless borrow in line.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:22:06 +00:00
Philippe BranchuandClaude Opus 4.6 613a7d4a3b Fix corrupt Cargo.lock (resolve merge conflict markers)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:19:43 +00:00
Philippe BranchuandClaude Opus 4.6 6ed6d3ac3b Fix cargo fmt formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:15:46 +00:00
Philippe BranchuandClaude Opus 4.6 9f72d921c3 Fix cargo fmt formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:14:01 +00:00
Philippe BranchuandClaude Opus 4.6 4b5aba28cf Revert unrelated mcp.rs rewrite and Cargo.lock changes
The previous commit accidentally included a complete MCP module rewrite
that removed Http transport and headers support, breaking compilation
against upstream kernel.rs tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 00:42:52 +00:00
Philippe BranchuandClaude Opus 4.6 bbed72b491 Fix cargo fmt: join Image/Unknown match arms on single line
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:18:49 +00:00
Philippe BranchuandClaude Opus 4.6 55395c80db Fix token estimation: include ToolUse arguments in text_length
`MessageContent::text_length()` returned 0 for `ToolUse` blocks,
ignoring the tool name and JSON input arguments. This caused the
compactor's `estimate_token_count()` (which uses `text_length()`)
to massively undercount tokens when conversations contained tool
calls with large arguments (e.g. web_search results, page content).

The result: compaction never triggered despite the session exceeding
the context window, leading to "Token limit exceeded" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:15:57 +00:00
Philippe BranchuandClaude Opus 4.6 946363e919 Fix silent reinforcement: recognize [SILENT] token in agent replies
Extract is_silent_token() helper for case-insensitive [SILENT] detection.
Revert unrelated Cargo.lock and formatting changes. Add unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:15:05 +00:00
jaberjaber23 64631a31e6 fix: resolve 5 bugs + close 1 resolved (#771, #811, #752, #772, #661)
- #771: Fix Qwen tool_calls orphaning after context overflow. Added safe drain boundaries
  in compactor and context_overflow to avoid splitting tool pairs. Added missing
  validate_and_repair call in streaming loop.
- #811: LINE webhook signature now uses raw request bytes (not re-serialized JSON) for
  HMAC. Channel secret is trimmed. Debug logging added for mismatches.
- #752: Local skill install now hot-reloads kernel via POST /api/skills/reload. TUI skill
  list fixed to parse wrapper object. ClawHub install also triggers reload.
- #772: exec_policy mode=full now bypasses approval gate for shell_exec tools. Non-shell
  tools like file_delete still respect approval settings.
- #661: Closed as resolved by #770 splice() reactivity fix and #836 tool ID fix.

All tests passing. 10 files changed, 436 insertions.
2026-03-28 00:44:12 +03:00
RamXX cf38b49e4d Add openfang auth hash-password CLI command and startup warning
Addresses review feedback:
- Add `openfang auth hash-password` subcommand so users can generate
  Argon2id hashes after upgrading (the command referenced in docs).
- Emit a tracing::warn at daemon startup when auth is enabled but the
  password_hash is not in Argon2id format, so users know why login fails.
2026-03-27 13:03:52 -07:00
RamXX 8ba84ada9d Replace SHA256 password hashing with Argon2id for dashboard auth
Dashboard passwords were hashed with plain SHA256 (no salt), vulnerable
to rainbow tables and GPU brute force. Switch to Argon2id with random
per-hash salts. Breaking change: existing SHA256 hashes in config.toml
must be regenerated with `openfang auth hash-password`.
2026-03-27 13:03:52 -07:00
jaberjaber23 9fef6d6c91 fix: resolve 5 bugs + close 1 resolved (#875, #872, #867, #824, #833, #766)
- #875: Install script uses robust sed parsing instead of fragile cut for version detection
- #872: Session endpoint returns full tool results (removed 2000-char truncation)
- #867: agent_send/agent_spawn get 600s timeout (was 120s), regular tools keep 120s
- #824: Doctor workspace skills count uses direct return value from load_workspace_skills
- #833: Model switching respects provider via new find_model_for_provider() lookup
- #766: Closed as resolved by combined heartbeat fixes (v0.5.3 + merged PRs)

All tests passing. Live tested with daemon.
2026-03-27 22:42:24 +03:00
Jaber Jaber f98bc330d4 Merge pull request #859 from RightNow-AI/dependabot/cargo/governor-0.10.4
build(deps): bump governor from 0.8.1 to 0.10.4
2026-03-27 22:04:59 +03:00
Jaber Jaber 86694dd926 Merge pull request #862 from RightNow-AI/dependabot/cargo/toml-0.9.12spec-1.1.0
Bump toml from 0.8.2 to 0.9.12+spec-1.1.0
2026-03-27 22:04:55 +03:00
dependabot[bot] f8da17719e Bump governor from 0.8.1 to 0.10.4
Bumps [governor](https://github.com/boinkor-net/governor) from 0.8.1 to 0.10.4.
- [Release notes](https://github.com/boinkor-net/governor/releases)
- [Changelog](https://github.com/boinkor-net/governor/blob/master/release.toml)
- [Commits](https://github.com/boinkor-net/governor/compare/v0.8.1...v0.10.4)

---
updated-dependencies:
- dependency-name: governor
  dependency-version: 0.10.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 19:02:27 +00:00
Jaber Jaber a72e6087d8 Merge pull request #665 from tianrking/main
feat(channels): add MQTT pub/sub channel adapter
2026-03-27 22:00:33 +03:00
Jaber Jaber 3a64e322ad Merge pull request #778 from BaseDatum/feat/rmcp-protocol
feat: replace hand-rolled MCP transport with rmcp SDK
2026-03-27 22:00:18 +03:00
Sky Moore 9e2853a5f8 fix: resolve CI failures after rebase on upstream/main
- Add missing budget_config field to AppState in all 3 test files
- Fix redundant closures and unwrap_or_else in openfang-memory semantic.rs
- Fix needless_borrow in openfang-api routes.rs (toml::from_str)
- Update parse_researcher_hand test to match new max_iterations = 25
- Update tar to 0.4.45 to fix RUSTSEC-2026-0067 and RUSTSEC-2026-0068
- Apply cargo fmt fixes in ws.rs and feishu.rs
2026-03-27 18:40:33 +00:00
Sky Moore feecb60442 fix: use specific capability-denial assertion to avoid OS error false positive on Linux CI 2026-03-27 16:47:37 +00:00
Sky Moore e53e238e81 fix: cargo fmt and update rustls-webpki to 0.103.10 (RUSTSEC-2026-0049) 2026-03-27 16:47:35 +00:00
Sky Moore 991aea85ee feat: use rmcp for mcp protocol instead of hand rolled
Replace the custom JSON-RPC + stdio/SSE transport layer with the rmcp
SDK (crate 'rmcp').  This gives us spec-compliant Streamable-HTTP
transport, automatic Mcp-Session-Id tracking, SSE stream parsing, and
content-type negotiation out of the box while deleting ~300 lines of
hand-rolled plumbing.

Key changes:
- Add rmcp dependency with transport feature
- Replace McpTransportHandle enum with rmcp RunningService
- Replace manual JSON-RPC send_request/send_notification with rmcp client calls
- Add custom HTTP headers support for authenticated remote MCP servers
- Simplify tool discovery and invocation through rmcp's typed API
2026-03-27 16:47:32 +00:00
w0x7ce bfbf0bb892 feat(channels): add MQTT pub/sub channel adapter
Add generic MQTT 3.1.1/5.0 support for IoT and messaging integration:

- MqttConfig with broker_url, TLS, QoS, auth via env vars
- MqttAdapter implementing ChannelAdapter trait
- Support for text and JSON {"text": "..."} payloads
- Command messages via /command args syntax
- Auto-reconnect with exponential backoff
- Message chunking for long responses

Configuration example:
  [channels.mqtt]
  broker_url = "tcp://broker.hivemq.com:1883"
  subscribe_topic = "openfang/inbox"
  publish_topic = "openfang/outbox"
2026-03-27 22:08:18 +08:00
Jaber Jaber b6cb4cc2d9 Merge pull request #657 from xinuxZ/feat/feishu-websocket-receive-mode
feat(feishu): add WebSocket receive mode with protobuf framing
2026-03-27 16:44:31 +03:00
Jaber Jaber 827481633c Merge pull request #662 from lizekai-hash/feat/langchain-code-reviewer
feat(agents): add LangChain code review agent with A2A protocol
2026-03-27 16:44:22 +03:00
Jaber Jaber ad780b9cb4 Merge pull request #667 from bobbiejaxn/feat/http-memory-backend
feat: HTTP memory backend for SemanticStore
2026-03-27 16:44:17 +03:00
Jaber Jaber 4582ed16b0 Merge pull request #659 from zamal-db/feat/vertex-ai-oauth-v2
feat(drivers): add Vertex AI driver with OAuth authentication
2026-03-27 16:44:09 +03:00
Jaber Jaber f56505258d Merge pull request #673 from vnz/feat/cron-run-now
Implement "Run Now" for cron jobs
2026-03-27 16:42:47 +03:00
Jaber Jaber ddd1536bcb Merge pull request #702 from yaroslavyaroslav/codex/tlg-chat-enhancements
Expose Telegram slash commands via setMyCommands
2026-03-27 16:42:42 +03:00
Jaber Jaber 9fa5234061 Merge pull request #705 from apestchanker/fix/claude-code-system-prompt
fix(claude-code): pass system prompt via --system-prompt flag instead…
2026-03-27 16:42:33 +03:00
Jaber Jaber e21efa61ef Merge pull request #685 from Fail-Safe/fix/researcher-hand-defaults
fix: make heartbeat interval configurable and reduce researcher max_iterations
2026-03-27 16:39:31 +03:00
Jaber Jaber 3f72c5d918 Merge pull request #701 from Fail-Safe/fix/agent-modal-ui
fix: improve agent detail modal layout and fallback chain display
2026-03-27 16:39:23 +03:00
Jaber Jaber c286b88d54 Merge pull request #703 from Fail-Safe/fix/heartbeat-startup-false-positive
fix: reset last_active on agent restore to prevent heartbeat false-positives on startup
2026-03-27 16:39:19 +03:00
Jaber Jaber 22c08c2325 Merge pull request #668 from lc-soft/fix-runtime-page-style
fix runtime page stat card layout
2026-03-27 16:39:13 +03:00
Jaber Jaber f6493e8843 Merge pull request #682 from Fail-Safe/fix/tool-filter-case-insensitive
fix: make tool allowlist/blocklist matching case-insensitive
2026-03-27 16:39:08 +03:00
Jaber Jaber 1d2bfff8ea Merge pull request #680 from Fail-Safe/fix/docs-search-provider-duck-duck-go
fix(docs): correct search_provider value for DuckDuckGo
2026-03-27 16:39:02 +03:00
Jaber Jaber b967852891 Merge pull request #690 from lc-soft/fix-list-style
fix list style in message bubble
2026-03-27 16:38:59 +03:00
Jaber Jaber d95d9583b0 Merge pull request #696 from Abhishek21k/fix(#660)/notion-api-token-fix
Fix Notion MCP server env var name (NOTION_API_KEY → NOTION_TOKEN)
2026-03-27 16:38:53 +03:00
Jaber Jaber 8c0cce3ac5 Merge pull request #737 from octo-patch/feature/add-minimax-m2.7
feat: add MiniMax-M2.7 as new flagship model
2026-03-27 16:25:35 +03:00
Jaber Jaber f036bd54e3 Merge pull request #710 from Reaster0/fix/fallback-default-provider-resolution
fix(kernel): resolve "default" provider in fallback_models before driver init
2026-03-27 16:25:30 +03:00
Jaber Jaber 77da90f3f8 Merge pull request #709 from Fail-Safe/fix/touch-agent-before-llm-call
fix: stamp last_active before LLM call to prevent mid-iteration heartbeat timeouts
2026-03-27 16:25:26 +03:00
Jaber Jaber b0b6f84492 Merge pull request #762 from lc-soft/fix/mobile-menu-btn-overlap
fix: resolve page-header overlap and overflow
2026-03-27 16:25:23 +03:00
Jaber Jaber 0da8e32a51 Merge pull request #870 from lc-soft/fix/wizard-provider-api-key-test
Clean fix for provider reset during API key test. Reviewed and approved.
2026-03-27 16:16:49 +03:00
Liu 7410faa96d fix(wizard): prevent provider reset to first item during API_KEY test 2026-03-27 10:57:40 +08:00
Jaber Jaber e880dfa3e7 Merge pull request #777 from ANierbeck/main
Expose all agent templates in the web interface
2026-03-27 05:37:24 +03:00
Jaber Jaber 7791b3f170 Merge pull request #768 from voidborne-d/fix/matrix-self-message-loop
fix(matrix): prevent bot self-reply loop with user_id mismatch and event dedup
2026-03-27 05:37:19 +03:00
Jaber Jaber e58039c83e Merge pull request #789 from pbranchu/fix/mcp-response-matching
Fix MCP bridge dropping tool results when servers send notifications
2026-03-27 05:37:15 +03:00
Jaber Jaber 9b0a7d2f61 Merge pull request #790 from pbranchu/fix/sender-identity
Prepend sender identity to channel messages for agent context
2026-03-27 05:37:10 +03:00
Jaber Jaber 86fe4929e9 Merge pull request #775 from pbranchu/config-heartbeat-timeout
Expose heartbeat default_timeout_secs in config.toml
2026-03-27 05:36:32 +03:00
Jaber Jaber 9993718d9c Merge pull request #779 from Mohl/fix/streamable-http-mcp
fix(mcp): handle Streamable HTTP MCP responses with SSE framing
2026-03-27 05:36:28 +03:00
Jaber Jaber 0bf2f61ab1 Merge pull request #782 from rager306/fix/safe-budget-mutation
fix: replace unsafe Arc mutation in PUT /api/budget with RwLock
2026-03-27 05:36:24 +03:00
Jaber Jaber 51eff0d75f Merge pull request #783 from rager306/fix/csp-nonce
fix: replace unsafe-inline CSP with per-request nonce
2026-03-27 05:36:21 +03:00
Jaber Jaber a30cce129e Merge pull request #788 from pbranchu/fix/gemini-empty-parts
Fix Gemini driver crash on content entries without parts
2026-03-27 05:35:02 +03:00
Jaber Jaber 54885d8a1c Merge pull request #765 from felix307253927/pr-main-0320
fix: Fix the issue of duplicate tool calls with identical arguments i…
2026-03-27 05:34:59 +03:00
Jaber Jaber 617b4f81d8 Merge pull request #764 from felix307253927/pr-main-320
fix: The command succeeded, yet the model keeps calling it repeatedly.
2026-03-27 05:34:55 +03:00
Jaber Jaber a0f829383c Merge pull request #776 from felix307253927/pr-main-321
fix: Empty string IDs are overwritten, leading to inconsistencies in …
2026-03-27 05:34:51 +03:00
Jaber Jaber 6083c24484 Merge pull request #801 from b4iterdev/main
feat: add statically compiled native-tls to binary
2026-03-27 05:34:48 +03:00
Jaber Jaber 1964545f35 Merge pull request #814 from szponeczek/feat/infisical-sync-hand-clean
Adds infisical-sync hand. Declarative only, strong security posture.
2026-03-27 04:53:23 +03:00
Jaber Jaber fc7e971d7e Merge pull request #806 from ilteoood/main
Adds NVIDIA NIM support to CLI wizard.
2026-03-27 04:53:20 +03:00
Jaber Jaber 86309c8e40 Merge pull request #838 from turbolego/fix_clippy_linting_errors
Trivial lint and clippy fixes.
2026-03-27 04:52:53 +03:00
Jaber Jaber 282ad3a960 Merge pull request #832 from felix307253927/pr-main-324
Fixes unicode filename upload via multipart/form-data.
2026-03-27 04:52:50 +03:00
Jaber Jaber d6f857eee2 Merge pull request #830 from lc-soft/fix/tool-input-json-format
Clean 3-line fix for object-type tool input in formatToolJson.
2026-03-27 04:52:46 +03:00
Jaber Jaber 751b420b39 Merge pull request #796 from pbranchu/fix/gemini-turn-sanitization
Fixes Gemini INVALID_ARGUMENT crash after message trimming.
2026-03-27 04:51:28 +03:00
Jaber Jaber 5b2be80399 Merge pull request #803 from jam676767/fix/claude-code-empty-response-295
Critical fix for claude-code driver deadlock and empty responses.
2026-03-27 04:51:26 +03:00
Jaber Jaber 6ae8dd4cfd Merge pull request #860 from RightNow-AI/dependabot/cargo/clap_complete-4.6.0
Minor version bump. CI passes.
2026-03-27 04:50:13 +03:00
Jaber Jaber 25a66df41b Merge pull request #861 from RightNow-AI/dependabot/cargo/openssl-0.10.76
Security patch. Reviewed and approved.
2026-03-27 04:50:10 +03:00
dependabot[bot] 5212730773 Bump toml from 0.8.2 to 0.9.12+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.2 to 0.9.12+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.2...toml-v0.9.12)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.12+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 01:10:12 +00:00
jaberjaber23andClaude Opus 4.6 9b7496947b fix: resolve 7 more bugs (#825, #828, #856, #770, #774, #851/#808, #785)
- #825: Doctor now surfaces blocked workspace skills count in injection scan
- #828: Skill install detects Git URLs (https://, git@) and clones before install
- #856: Custom model names preserved — user-defined models take priority over builtins
- #770: Dashboard WS streaming now triggers Alpine.js reactivity via splice()
- #774: tool_use.input always normalized to JSON object (fixes Anthropic API errors)
- #851/#808: Global skills loaded for all agents; workspace skills properly override globals
- #785: Gemini streaming SSE parser handles \r\n line endings (fixes empty response loop)

All 2,186 tests passing. Live tested with daemon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 04:04:17 +03:00
dependabot[bot] 895e94ccac Bump openssl from 0.10.75 to 0.10.76
Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.75 to 0.10.76.
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.75...openssl-v0.10.76)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.76
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 08:19:40 +00:00
dependabot[bot] da9d8a84f1 Bump clap_complete from 4.5.66 to 4.6.0
Bumps [clap_complete](https://github.com/clap-rs/clap) from 4.5.66 to 4.6.0.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.66...clap_complete-v4.6.0)

---
updated-dependencies:
- dependency-name: clap_complete
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 08:19:25 +00:00
jaberjaber23 e7b9143423 fix: resolve 6 more bugs (#845, #844, #823, #767, #802, #816)
- #845: Model fallback chain now retries with fallback_models on ModelNotFound
- #844: Heartbeat skips idle agents that never received a message (no more crash loops)
- #823: Doctor --json outputs clean JSON to stdout, tracing to stderr, BrokenPipe handled
- #767: Workflows page scrollable with flex layout fix
- #802: Model dropdown handles object options (no more [object Object] for Ollama)
- #816: Spawn wizard provider dropdown loads dynamically from /api/providers (43 providers)

All 829+ tests passing. Live tested with daemon.
2026-03-26 05:26:57 +03:00
jaberjaber23 604e4ea7e3 fix: resolve 6 open bugs (#834, #805, #820, #848, #826, #836)
- #834: Remove 3 decommissioned Groq models (gemma2-9b-it, llama-3.2-1b/3b-preview)
- #805: Ollama streaming parser now checks both reasoning_content and reasoning fields
- #820: Browser Hand checks python3 before python, fix optional dep logic
- #848: Hand continuous interval changed from 60s to 3600s to prevent credit waste
- #826: Doctor command no longer reports all_ok when provider key is rejected
- #836: WebSocket tool events now include tool call ID for concurrent call correlation

All 825+ tests passing. Verified live with daemon.
2026-03-26 03:33:07 +03:00
turbolego c926372d81 lint fixes for 'cargo clippy --workspace --all-targets -- -D warnings' and 'cargo test --workspace' 2026-03-24 22:23:06 +01:00
Felix da12f47369 fix: Fix the error when uploading files with Unicode characters in filenames 2026-03-24 19:33:12 +08:00
Liu b7c81965a1 fix: format object-type tool input correctly in formatToolJson 2026-03-24 17:05:44 +08:00
Claw Kowalski f65dc775eb fix(hands): remove deployment-specific language from infisical-sync 2026-03-23 15:01:29 -04:00
Claw Kowalski c59041a09d fix(hands): workspaceId → projectId in list query string 2026-03-23 14:59:47 -04:00
Claw Kowalski 715f37effc feat(hands): add infisical-sync Hand
- Implement create-with-PATCH-on-conflict push pattern (POST 409 → PATCH)
- Migrate push and delete endpoints from deprecated v3 to v4 API
- Replace workspaceId with projectId in push/delete API calls
2026-03-23 14:50:22 -04:00
Matteo Pietro Dazzi 570e1941b2 Merge pull request #1 from ilteoood/copilot/implement-nvidia-provider-functionality
feat: add NVIDIA NIM to CLI provider selection wizards
2026-03-23 09:47:36 +01:00
copilot-swe-agent[bot]andilteoood 4c700c8d2d fix: use existing nvidia/llama-3.1-nemotron-70b-instruct as default model
Use a model that already exists in the catalog instead of the
non-existent meta/llama-3.3-70b-instruct.

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/541bf2ad-f8d8-488c-84a2-4ca71e66730f
2026-03-23 08:36:36 +00:00
copilot-swe-agent[bot]andilteoood 5ae554ed51 feat: add NVIDIA NIM to CLI provider selection wizards
Add NVIDIA NIM as a selectable provider in both the setup wizard
and init wizard CLI screens, using NVIDIA_API_KEY env var and
meta/llama-3.3-70b-instruct as the default model.

Closes #787

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/541bf2ad-f8d8-488c-84a2-4ca71e66730f
2026-03-23 08:35:33 +00:00
jam 62b697c90e fix(claude-code): extract assistant text from nested message.content in stream()
Claude CLI ≥2.x emits type=assistant events where the response text is
inside message.content[{"type":"text","text":"..."}] rather than a flat
content string. The old handler only checked event.content, so every
token was silently dropped and streaming always returned an empty response.

The handler now checks the flat content field first (backward-compatible),
then falls back to joining all text blocks from message.content[].

Refs: RightNow-AI/openfang#295
2026-03-23 09:35:22 +01:00
jam 4b3b602457 fix(claude-code): inject HOME and null stdin in stream() subprocess
Mirror the same environment fixes applied to complete(): inject HOME so
the CLI locates ~/.claude/credentials when running as a service, and set
stdin to null so the process does not block on interactive input.

Refs: RightNow-AI/openfang#295
2026-03-23 09:35:05 +01:00
jam d7bd5c6636 fix(claude-code): prevent pipe-buffer deadlock in complete() via concurrent drain
When complete() called child.wait() before reading stdout/stderr, large
responses (>64 KB) caused a deadlock: the subprocess blocked on write()
because the OS pipe buffer was full, and wait() never returned.

Fix by spawning two tokio tasks to drain stdout/stderr concurrently with
child.wait(), then collecting after the process exits.

Also inject HOME from home_dir() so the CLI finds ~/.claude/credentials
when OpenFang runs as a service, and set stdin to null so the CLI does
not stall waiting for interactive input.

Refs: RightNow-AI/openfang#295
2026-03-23 09:34:55 +01:00
jam 66e6eb2509 fix(claude-code): add message field to ClaudeStreamEvent for nested assistant content
Newer Claude CLI versions (≥2.x) emit assistant responses inside a nested
`message.content[].text` structure in stream-json events, rather than a
flat `content` string.

Add ClaudeMessageBlock and ClaudeAssistantMessage structs, plus a new
`message` field on ClaudeStreamEvent, so the stream handler can extract
text from both layouts.

Refs: RightNow-AI/openfang#295
2026-03-23 09:33:35 +01:00
jamandClaude 1365fc9635 fix(claude-code): add #[serde(default)] to ClaudeJsonOutput.result
Without this attribute, serde treats a missing `result` field as a
deserialization error even though `Option<T>` implies the field is
optional.  Some Claude CLI versions emit the response in `content` or
`text` rather than `result`; the silent parse failure caused the
driver to fall through to a plain-text read which could be empty,
triggering the "model returned an empty response" guard in the agent
loop.

Closes #295.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-23 09:31:37 +01:00
b4iterdev 78669863b7 feat: add statically compiled native-tls to binary 2026-03-23 14:32:48 +07:00
Philippe BranchuandClaude Opus 4.6 316bbe11c3 Add tests for sanitize_gemini_turns
- test_sanitize_drops_orphaned_function_call
- test_sanitize_keeps_valid_function_call_response_pair
- test_sanitize_drops_orphaned_function_response
- test_sanitize_merges_consecutive_same_role
- test_sanitize_empty_input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:43:03 +00:00
Philippe BranchuandClaude Opus 4.6 ff00499e8e Fix Gemini INVALID_ARGUMENT crash after message trimming
Add sanitize_gemini_turns() to enforce Gemini's strict turn-ordering
constraints after message history is trimmed. This merges consecutive
same-role turns, drops orphaned functionCall/functionResponse parts,
and removes empty turns. Also adds #[serde(default)] on GeminiContent.parts
and fixes two tests that were missing required ToolResult messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:31:40 +00:00
Philippe BranchuandClaude Opus 4.6 1a5ae4e3ce Prepend sender identity to channel messages for agent context
The bridge now prefixes messages with [From: Name <email>] so agents
know who is speaking. Essential for multi-user rooms and for agents
that need to act on behalf of specific users (e.g., checking the
correct email account or calendar).

Updated bridge integration tests to match the new format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:49:37 +00:00
Philippe BranchuandClaude Opus 4.6 bf9066a602 Fix MCP bridge dropping tool results from servers that send notifications
Read response lines until finding a JSON-RPC response matching the
request ID. Previously, the bridge read one line and assumed it was
the response, causing "No result from MCP tools/call" when MCP
servers send notifications or log lines before the actual result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:49:28 +00:00
Philippe BranchuandClaude Opus 4.6 acf51e02a8 Fix Gemini driver crash on content entries without parts
Add #[serde(default)] to GeminiContent.parts so responses with
empty or missing parts arrays deserialize as empty Vec instead
of failing with "missing field parts".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:47:56 +00:00
copilot-swe-agent[bot]andilteoood 1c9d53df11 chore: remove generated linux-schema.json build artifact
Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/7944f50c-bf8e-47ca-a936-cc6c562e36ca
2026-03-22 15:23:09 +00:00
copilot-swe-agent[bot]andilteoood b298c4c273 feat: add NVIDIA NIM provider with ZeroClaw-recommended models
- Add NVIDIA_API_KEY to .env.example
- Add 3 new ZeroClaw-recommended models: meta/llama-3.3-70b-instruct,
  nvidia/llama-3.3-nemotron-super-49b-v1.5,
  nvidia/llama-3.1-nemotron-ultra-253b-v1
- Add nemotron, nemotron-super, nemotron-ultra aliases
- Add NVIDIA NIM provider section to docs/providers.md (provider #21)
- Add NVIDIA NIM models to Model Catalog table
- Add aliases to Aliases table
- Add NVIDIA NIM to Environment Variables Summary
- Update provider/model/alias counts

Closes #787

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/7944f50c-bf8e-47ca-a936-cc6c562e36ca
2026-03-22 15:22:34 +00:00
copilot-swe-agent[bot] f407a41a98 Initial plan 2026-03-22 15:02:53 +00:00
rager306 173c843107 fix: replace unsafe-inline CSP with per-request nonce
The dashboard CSP uses 'unsafe-inline' for script-src, which permits
any inline <script> block to execute — including attacker-injected
scripts if any endpoint reflects user input (agent names, message
content, channel descriptions, etc.).

Replace with a per-request cryptographic nonce (UUID v4):
- webchat_page generates a unique nonce on every request
- All <script> tags embed the nonce at compile time via __NONCE__ placeholder
- CSP becomes: script-src 'self' 'nonce-{nonce}' 'unsafe-eval'
  ('unsafe-eval' is still required for Alpine.js x-data expressions)
- API endpoints receive a strict default-src 'none'; frame-ancestors 'none'
  policy instead of the permissive dashboard policy

This is a standard CSP Level 2 hardening; all modern browsers support nonces.
2026-03-22 16:18:15 +07:00
rager306 b3787e07ea fix: replace unsafe Arc mutation in update_budget with RwLock
PUT /api/budget casts &Arc<AppState> to *mut KernelConfig and mutates
the budget fields through a raw pointer. This is unsound: AppState is
shared across Tokio worker threads, so two concurrent PUT /api/budget
requests cause a data race on the same memory location.

Replace with Arc<tokio::sync::RwLock<BudgetConfig>> stored on AppState,
initialized from kernel.config.budget at startup. All readers use
.read().await and all writers use .write().await. No unsafe code remains
in the budget update path.

Fixes: data race / undefined behaviour under concurrent budget updates
2026-03-22 16:16:09 +07:00
Alaundo 935c8cad88 fix(mcp): handle Streamable HTTP MCP responses with SSE framing
MCP servers using Streamable HTTP (e.g., Hindsight) wrap JSON-RPC
responses in SSE framing (event: message\ndata: {...}\n\n). The SSE
transport handler expected raw JSON, causing 'Invalid MCP SSE JSON-RPC
response' errors when connecting to these servers.

Extract the JSON payload from SSE data: lines before deserializing.
Falls back to raw body parsing for servers that return plain JSON.

Fixes connection to MCP servers implementing the Streamable HTTP
transport (MCP spec 2025-03-26).
2026-03-21 20:15:54 +01:00
anierbeck d95270da5a Fix agent template spawning
- Ensure all templates have manifest_toml field
- Use spawnFromTemplate for templates with manifest_toml
- Fix spawnBuiltin to handle missing fields gracefully
- Update HTML template to call correct spawn method
2026-03-21 18:16:16 +01:00
anierbeck 7a2211d0f4 Expose agent templates in web interface
- Replace hardcoded list of 6 templates with all 30+ available templates
- Add category information to templates
- Combine static and dynamic templates with static templates displayed first
- Add loading and error states for template list
- Fix showDetail method for agent configuration
2026-03-21 17:03:09 +01:00
Felix e14885fa80 fix: Empty string IDs are overwritten, leading to inconsistencies in certain models. 2026-03-21 21:02:59 +08:00
pbranchuandClaude Opus 4.6 ccbaf90a24 Expose heartbeat default_timeout_secs in config.toml
Add a [heartbeat] section to KernelConfig so users can tune the
inactivity timeout that determines when agents are marked unresponsive.
Reactive agents (hands) that sit idle between infrequent requests were
getting marked as crashed after the hardcoded 180s default, causing
the first request after idle to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:45:47 +00:00
anierbeck f66c2525cb Expose agent templates in web interface
- Modified agents.js to fetch templates dynamically from /api/templates endpoint
- Updated API endpoint to include category information for templates
- Added category mapping logic for proper template categorization
- Updated HTML template to handle loading and error states
- Implemented fallback to hardcoded templates if API fails

This change replaces the hardcoded list of 6 templates with all 32 available
agent templates from the agents/ directory, making the web interface more
dynamic and maintainable.
2026-03-21 12:14:48 +01:00
d 🔹 842d932ae6 fix(matrix): prevent bot self-reply loop with user_id mismatch and event dedup
Two fixes for the Matrix bot stuck in infinite reply loop (#757):

1. Use validated user ID from /whoami instead of config value for
   self-message filtering. Matrix server delegation or casing
   differences can cause the configured user_id to not match the
   sender field in timeline events, so the bot processes its own
   replies and enters an infinite loop.

2. Add event ID dedup set to prevent re-processing the same event
   on sync token races or reconnects. This is a defense-in-depth
   measure that also protects against edge cases where /sync returns
   overlapping event windows.

Fixes #757
2026-03-20 16:13:22 +00:00
Felix 37d1c822f2 fix: Fix the issue of duplicate tool calls with identical arguments in certain scenarios. 2026-03-20 19:31:37 +08:00
Felix 865fd28704 fix: The command succeeded, yet the model keeps calling it repeatedly. 2026-03-20 18:22:08 +08:00
Liu 43a92a764f fix: resolve page-header overlap and overflow 2026-03-20 16:07:40 +08:00
PR Bot 17f783073e fix: correct MiniMax M2.7 model specifications per official docs
- Pricing: $0.30/$1.20 per 1M tokens (was $1.10/$4.40)
- Context window: 204,800 tokens (was 1,048,576)
- Max output: 131,072 tokens (was 16,384)
- Vision: false — M2.7 is text-only (was true)
2026-03-20 13:11:34 +08:00
jaberjaber23 db86ff4ce3 bump v0.5.1 2026-03-20 03:48:59 +03:00
Jaber Jaber ee042769e2 Merge pull request #711 from Reaster0/fix/matrix-configurable-auto-accept-invites
fix(matrix): make auto_accept_invites configurable, default to false
2026-03-20 03:13:33 +03:00
Jaber Jaber 41ffb8537a Merge pull request #742 from RightNow-AI/dependabot/cargo/zip-4.6.1
Bump zip from 2.4.2 to 4.6.1
2026-03-20 03:13:22 +03:00
Jaber Jaber 80658c94e3 Merge pull request #744 from RightNow-AI/dependabot/cargo/roxmltree-0.21.1
Bump roxmltree from 0.20.0 to 0.21.1
2026-03-20 03:13:11 +03:00
Jaber Jaber c35301e155 Merge pull request #740 from RightNow-AI/dependabot/github_actions/docker/setup-buildx-action-4
Bump docker/setup-buildx-action from 3 to 4
2026-03-20 03:13:02 +03:00
Jaber Jaber 14f0421e7b Merge pull request #741 from RightNow-AI/dependabot/github_actions/docker/build-push-action-7
Bump docker/build-push-action from 6 to 7
2026-03-20 03:12:52 +03:00
Jaber Jaber 3f772b5b27 Merge pull request #713 from CastleOneX/pr/approvals-visibility
Fix invisible approval requests in dashboard
2026-03-20 03:12:42 +03:00
Jaber Jaber a12547081a Merge pull request #714 from CastleOneX/pr/provider-model-normalization
Normalize provider-backed model updates
2026-03-20 03:12:32 +03:00
Jaber Jaber 63f4befe80 Merge pull request #748 from lc-soft/fix/katex-load
Load KaTeX on demand to prevent first-paint blocking
2026-03-20 03:12:21 +03:00
Jaber Jaber 0f25386e2e Merge pull request #750 from lc-soft/fix/settings-page-error
fix: settingsLoading -> loading
2026-03-20 03:12:11 +03:00
jaberjaber23 7f752dde99 bump v0.5.0 2026-03-20 00:46:15 +03:00
jaberjaber23 93ef98a429 bug fixes 2026-03-20 00:33:20 +03:00
Liu b71bd801fb fix(api): settingsLoading -> loading 2026-03-19 19:29:09 +08:00
Liu 9badeb243e fix(api): load KaTeX on demand to prevent first-paint blocking 2026-03-19 19:12:25 +08:00
dependabot[bot] 9a683ec511 Bump roxmltree from 0.20.0 to 0.21.1
Bumps [roxmltree](https://github.com/RazrFalcon/roxmltree) from 0.20.0 to 0.21.1.
- [Changelog](https://github.com/RazrFalcon/roxmltree/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RazrFalcon/roxmltree/compare/v0.20.0...v0.21.1)

---
updated-dependencies:
- dependency-name: roxmltree
  dependency-version: 0.21.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:18:28 +00:00
dependabot[bot] eaa89defd1 Bump zip from 2.4.2 to 4.6.1
Bumps [zip](https://github.com/zip-rs/zip2) from 2.4.2 to 4.6.1.
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v2.4.2...v4.6.1)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 4.6.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:17:54 +00:00
dependabot[bot] d245059a01 Bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:17:10 +00:00
dependabot[bot] c30bf3e557 Bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:17:06 +00:00
PR Bot dd95f24980 feat: add MiniMax-M2.7 as new flagship model and update default alias
- Add MiniMax-M2.7 model entry (Frontier tier, 1M context, vision+tools)
- Update default 'minimax' alias to resolve to MiniMax-M2.7
- Add 'minimax-m2.7' alias for explicit model selection
- Add M2.7 pricing in metering (same as M2.5: $1.10/$4.40 per 1M tokens)
- Update model catalog tests for M2.7 as new default
- Increment MiniMax model count from 6 to 7
2026-03-19 11:48:22 +08:00
vnzandClaude Opus 4.6 1cf36241e4 Apply rustfmt to kernel.rs (fixes CI format check)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 2ab31f3d3e Apply rustfmt to changed files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 2915cb2113 Fix failed manual run pushing next_run and premature last_run in UI
- record_failure() now only recomputes next_run when the job is already
  overdue (next_run <= now), preserving the scheduled fire time when a
  manual run fails before the job's natural next_run
- Remove premature job.last_run update in scheduler.js — the job runs
  asynchronously so last_run should only reflect the server-side
  completion timestamp on the next data refresh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 7b1057df0c Add complete JSON response examples to cron endpoint docs
- GET /api/cron/jobs: show actual {jobs: [...], total} wrapper and
  document the ?agent_id query filter
- POST /api/cron/jobs: fix status code to 201 Created, show the actual
  {result: "<stringified-json>"} response shape
- GET /api/cron/jobs/{id}/status: show full JobMeta structure with
  nested job object, one_shot, last_status, consecutive_errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 0b99ac4071 Replace racy get_job + reserve_run with atomic try_claim_for_run
The previous sequence — get_job (read lock), check enabled, reserve_run
(write lock) — had a TOCTOU window where another request could disable
or delete the job between the check and the reservation.

Replace with CronScheduler::try_claim_for_run() which holds a single
DashMap write lock for the existence check, enabled guard, and next_run
advancement. Returns a typed ClaimError (NotFound | Disabled) so the
route handler maps directly to HTTP status codes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 19260945bd Implement "Run Now" for cron jobs
Add POST /api/cron/jobs/{id}/run endpoint that triggers a cron job
immediately without waiting for its next scheduled fire time. The job
executes asynchronously in the background and its status can be polled
via the existing /status endpoint.

Key changes:
- Extract per-job execution logic from the inline cron tick loop into
  a reusable `cron_run_job()` method on OpenFangKernel, called by both
  the background scheduler and the new API endpoint
- Add `reserve_run()` on CronScheduler to pre-advance next_run for
  overdue jobs before spawning manual runs, preventing duplicate
  execution from the scheduler tick (only advances when next_run <= now
  to avoid skipping imminent scheduled runs)
- Fix dashboard scheduler.js to call the correct cron API endpoint
  instead of the legacy /api/schedules/ path
- Document all cron/scheduler endpoints in api-reference.md

Partially addresses upstream issue #634.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
jaberjaber23 93ea832394 bump v0.4.9 2026-03-19 02:04:30 +03:00
jaberjaber23 38d42c4d9b image pipeline 2026-03-19 01:34:31 +03:00
jaberjaber23 2d02ba22fb lockfile sync 2026-03-19 01:03:35 +03:00
jaberjaber23 91d8734198 community docs 2026-03-19 00:26:41 +03:00
jaberjaber23 b676b2975a bump v0.4.8 2026-03-19 00:04:29 +03:00
jaberjaber23 44f37711cb bug fixes 2026-03-18 23:00:36 +03:00
jaberjaber23 cea4c3f452 bug fixes 2026-03-18 22:55:36 +03:00
Irwin 12ab5f1a93 Normalize provider-backed model updates
Resolve catalog display names and aliases to canonical model IDs when spawning or updating agents, keep provider-specific api_key_env hints in sync when switching providers, and route explicit provider model changes through kernel model normalization instead of directly mutating the registry. This fixes cases like xAI agents carrying stale OpenAI auth hints or UI labels such as 'Grok 4.20' being treated as raw model IDs.

(cherry picked from commit 51ee6a5a927208ab0301a29fd2e40b29c9dfaf7d)
2026-03-18 16:37:54 +13:00
Irwin a5bc9f916a Fix invisible approval requests in dashboard
Keep a bounded recent approval history instead of dropping timed-out or resolved requests on the floor, return recent approvals from /api/approvals, and make the dashboard poll and badge pending approvals so shell_exec prompts do not disappear before the user ever sees them.

(cherry picked from commit 78dd9f99cc835e85e452889bf6cfced5137f8a4a)
2026-03-18 16:34:34 +13:00
jaberjaber23 ad472d657e bug fixes 2026-03-18 06:23:12 +03:00
jaberjaber23 b4383b1626 bug fixes 2026-03-18 06:20:37 +03:00
jaberjaber23 ea287093c4 bug fixes 2026-03-18 05:58:22 +03:00
jaberjaber23 3688d86ef8 bug fixes 2026-03-18 05:49:42 +03:00
jaberjaber23 9f9903797e bump v0.4.5 2026-03-18 05:42:11 +03:00
jaberjaber23 3cd8847a95 bug fixes 2026-03-18 05:37:32 +03:00
jaberjaber23 88bb55c8f2 bug fixes 2026-03-18 05:08:12 +03:00
reasterandClaude Opus 4.6 935f3fac8e fix(matrix): make auto_accept_invites configurable, default to false
MatrixAdapter hardcoded `auto_accept_invites: true`, meaning any
Matrix-connected instance would blindly join every room it was invited
to. This is a security concern for public-facing homeservers — a
malicious user could invite the bot into an arbitrary room and interact
with the agent without the operator's consent.

Changes:
- Add `auto_accept_invites: bool` to `MatrixConfig` in openfang-types,
  with `#[serde(default)]` defaulting to `false`.
- Thread the field through `MatrixAdapter::new()` instead of hardcoding.
- Wire it in `channel_bridge.rs` from `mx_config.auto_accept_invites`.
- Update tests to pass the new parameter.

Operators who want the old behaviour can set:
```toml
[channels.matrix]
auto_accept_invites = true
```

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:43:47 +01:00
reasterandClaude Opus 4.6 ad90d417cc fix(kernel): resolve "default" provider in fallback_models before driver init
The fallback model loop passed `provider = "default"` verbatim to
`create_driver()`, which only recognises real provider names (ollama,
openai, anthropic, …).  The primary model overlay at spawn_agent()
already resolves "default" → kernel config, but fallback_models was
skipped, causing every bundled agent with a "default" fallback to log:

    Fallback driver 'default' failed to init: Unknown provider 'default'

This meant agents had zero fallback drivers, silently degrading
resilience for anyone whose config.toml sets a non-standard default
provider (e.g. ollama pointing at a local proxy).

Changes:
- Mirror the primary-model overlay logic for fallback entries:
  resolve provider, model, api_key_env, and base_url from
  `config.default_model` when the fallback specifies "default" or empty.
- Inherit `base_url` from default_model before falling back to
  `lookup_provider_url()`, so custom endpoints propagate correctly.
- Use resolved values in `strip_provider_prefix()` and warn messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:42:09 +01:00
Alex Li 4eae7502d2 fix(claude-code): pass system prompt via --system-prompt flag instead of inlining in -p 2026-03-17 18:22:24 -03:00
Yaroslav Yashin d6326d8967 Expose Telegram slash commands 2026-03-17 22:53:49 +02:00
Abhishek Kumar 8ec3766da3 fix(notion): fixed the notion api call to mcp 2026-03-17 16:36:57 +05:30
Liu 90fc171e26 fix list style in message bubble 2026-03-17 17:06:43 +08:00
Mark BakerandClaude Sonnet 4.6 972a52ff9c fix: make heartbeat interval configurable and reduce researcher max_iterations
Two related issues with autonomous Hand agents:

1. heartbeat_interval_secs was hardcoded at 30s (the AutonomousConfig default)
   for all Hands, with no way to override it from HAND.toml. For agents that
   make long LLM calls, 30s causes false-positive recovery triggers during
   normal operation. Add heartbeat_interval_secs to HandAgentConfig so each
   Hand can declare an appropriate interval.

2. The researcher Hand shipped with max_iterations = 80 and a system prompt
   instructing exhaustive research (50+ sources). This combination was designed
   for cloud LLMs with 200K context windows. On any model with a 32K or smaller
   context window, 80 iterations × growing history guarantees context overflow
   before the task completes. Reduce to 25, which is sufficient for thorough
   research within a 32K budget.

researcher/HAND.toml changes:
- max_iterations: 80 → 25
- heartbeat_interval_secs: 120 (new field; 30s default was triggering false
  recovery during normal multi-minute LLM calls)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 6b78838416 fix: improve agent detail modal layout and fallback chain display
- Widen agent detail modal from 600px to 700px to better accommodate
  longer model names and the fallback chain editor
- Restructure the Fallbacks section in the Info tab: content div is now
  a column flex container (gap:6px) with margin-left:16px to create a
  clear visual column between the label and its content
- Prevent long provider/model badge strings from overflowing the right
  edge of the modal (word-break:break-all; white-space:normal on badge)
- Add flex-shrink:0 to the × delete button so it never gets squashed
  when a badge is long
- Wrap the "+ Add" button in a div so it stays left-aligned (column
  flex would otherwise stretch a bare button to full width)
- Replace margin-top with gap-based spacing on the fallback edit form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 13051b2f06 fix: reset last_active on agent restore to prevent heartbeat false-positives
When agents are loaded from persistent storage on daemon startup, their
last_active timestamp reflects when they were last active before the
previous shutdown. If the daemon was down for longer than the heartbeat
timeout (default 180 s), the first heartbeat tick immediately marks every
restored agent as unresponsive and triggers crash recovery — even though
all agents just started and haven't had a chance to run.

Fix: stamp last_active = Utc::now() alongside the state = Running reset
in the restore loop. This is consistent with how new agent spawns work
(they also set last_active to now) and gives each restored agent a clean
baseline from which the heartbeat can accurately track responsiveness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 aed4bf62ae fix: stamp last_active before LLM call to prevent mid-iteration heartbeat timeouts
Slow local models (e.g. 27B quantised MLX models) can take 3–4+ minutes
per iteration, well beyond the default 180s heartbeat timeout. Because
last_active was only updated at the end of an iteration — never during it —
the heartbeat monitor would flag the agent as unresponsive mid-call and
initiate crash/recovery while the loop was still running correctly.

Changes:
- Add `touch()` to `AgentRegistry`: refreshes `last_active` with no other
  side-effects.
- Add `touch_agent(&self, agent_id: &str)` to `KernelHandle` trait with a
  default no-op, so existing mock implementations require no changes.
- Implement `touch_agent` on `OpenFangKernel`: parses the UUID and
  delegates to `registry.touch()`.
- Call `kernel.touch_agent(agent_id)` at the top of each agent loop
  iteration, immediately before the `call_with_retry` LLM call. This
  resets the inactivity clock at the start of every iteration rather than
  only at completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Liu a039e395f5 fix runtime page stat card layout 2026-03-16 11:44:22 +00:00
Your NameandClaude Opus 4.6 67b30c1549 feat: HTTP memory backend for shared memory infrastructure
Route SemanticStore remember/recall operations to the memory-api gateway
(PostgreSQL + pgvector + Jina AI embeddings) when backend=http is configured.

- Add backend, http_url, http_token_env fields to MemoryConfig
- Create http_client module with MemoryApiClient (reqwest::blocking)
- Add HTTP dispatch to SemanticStore with graceful SQLite fallback
- Wire MemoryConfig through MemorySubstrate::open() and kernel boot
- Add reqwest as optional dependency behind http-memory feature flag

Sessions, KV store, and knowledge graph remain local SQLite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:44:06 +01:00
Tsukimaru Oshawott 40bdf4316b feat(agents): add LangChain code review agent with A2A protocol
Add a Python-based code review agent powered by LangChain that
integrates with OpenFang via the A2A (Agent-to-Agent) protocol.

- agent.py: Core review logic with structured Chinese SYSTEM_PROMPT
  covering 6 dimensions (correctness, security, performance,
  maintainability, testing, style) and 4 severity levels
- server.py: FastAPI server exposing A2A-compatible endpoints
  (/.well-known/agent.json and /a2a JSON-RPC)
- workflow.json: OpenFang workflow definition for the review pipeline
- config.example.toml: Example A2A config for ~/.openfang/config.toml
- Supports OpenAI, DeepSeek, and Ollama backends

Made-with: Cursor
2026-03-16 14:33:41 +08:00
at384 e3c05a9d47 feat(drivers): add Vertex AI driver with OAuth authentication
Rebased on latest main (f1ca527) after codebase changes. This is a
fresh submission after PR #22 was closed as stale.

## Why This Feature

Enables enterprise GCP deployments using existing service accounts
instead of requiring separate Gemini API keys. Many organizations
already have GCP infrastructure and prefer OAuth-based auth.

## What's New

- VertexAIDriver with full streaming support
- OAuth 2.0 token caching (50 min TTL) with auto-refresh via gcloud
- Auto-detection of project_id from service account JSON
- Security: tokens stored with Zeroizing<String>
- Provider aliases: vertex-ai, vertex, google-vertex
- Compatible with new ContentBlock::provider_metadata field

## Testing

- 6 unit tests passing
- Clippy clean (no warnings)
- End-to-end tested with real GCP service account + gemini-2.0-flash
- Both streaming and non-streaming paths verified

## Usage

export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json
# Set provider=vertex-ai, model=gemini-2.0-flash in config.toml
2026-03-16 06:50:35 +01:00
Mark BakerandClaude Sonnet 4.6 6ab77612f5 fix: make tool allowlist/blocklist matching case-insensitive
Tool names stored via the dashboard can arrive in any case (e.g. uppercase
FILE_READ vs registered name file_read). The previous case-sensitive
comparison caused allowlisted tools to silently match nothing, giving the
agent an empty effective tool set with no error or warning.

Normalise both sides with to_lowercase() so the filter works regardless of
how the names were entered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 a3073007a1 fix(docs): correct search_provider value for DuckDuckGo
The docs listed `duckduckgo` as the config value but the actual serde
deserialization produces `duck_duck_go` — serde's rename_all = "snake_case"
on the DuckDuckGo enum variant inserts underscores at each word boundary.

Updated all three occurrences in configuration.md to match the real value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:00:00 -04:00
xinuxz a95fb4a96b feat(feishu): add WebSocket receive mode with protobuf framing
Add WebSocket long-connection receive mode for the Feishu/Lark adapter
as an alternative to webhook callbacks. WebSocket mode is enabled by
default, requiring no public IP or domain.

- FeishuConnectionMode enum (Webhook/WebSocket) with mode dispatch
- Protobuf binary frame parsing (prost) based on Feishu pbbp2 protocol
- Auto-reconnect, ping/pong heartbeat, ACK, multi-part payload combine
- handle_data_frame reuses parse_event() pipeline (dedup, group filter)
- FeishuMode config enum with bridge-layer adapter creation per mode
2026-03-16 10:37:01 +08:00
jaberjaber23 f1ca52714d feature batch 2026-03-15 20:23:30 +03:00
Evan HuandClaude Opus 4.6 77ed954d18 wecom channel adapter
* feat: Add WeCom (WeChat Work) channel adapter

- Add wecom.rs channel adapter implementation
- Add WeComConfig in config.rs
- Register WeCom adapter in channel_bridge.rs

WeCom channel supports:
- Inbound messages via callback webhook
- Outbound messages via WeCom API
- Access token caching and auto-refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle WeCom callbacks and preserve hand extension tools

* fix: render WeCom replies as plain text

* fix: resolve clippy warnings in wecom adapter

- Remove unused WECOM_API_HOST constant
- Fix needless borrow in send_text call
- Replace assert_eq!(bool, true) with assert!()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt for wecom-related files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt --all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: upgrade quinn-proto and add cargo audit ignore list

- Upgrade quinn-proto 0.11.13 → 0.11.14 (RUSTSEC-2026-0037 DoS fix)
- Add .cargo/audit.toml to ignore unmaintainable transitive deps
  (tauri GTK3 bindings, time pinned by mac-notification-sys, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:43 +03:00
jaberjaber23 4fa2f9474b bug fixes 2026-03-15 19:39:39 +03:00
jaberjaber23 75c9c80679 bug fixes 2026-03-15 18:26:53 +03:00
jaberjaber23 3eaa9e02c9 community fixes 2026-03-15 17:48:09 +03:00
Tilman Baumann f165263296 test merge
* Feat: Add Nix support

Adding Nix support. Nixos modules may follow...

Run directly with `nix run github:RightNow-AI/openfang`

There are a bunch of flake outputs (based on cargo workspace)
Focus on these:
* openfang-cli (default)
* openfang-desktop

* nix: cmake depdencency was introduced via llama

* Follow upstream style
2026-03-15 17:40:22 +03:00
NextDoorLaoHuang-HFandroot c122e1ddd7 test merge
* Improve OpenClaw provider alias migration compatibility

* Fix local provider env mapping regression in migration

* test(migrate): cover json5 default_model provider/env mapping

* test(migrate): add JSON5 agent provider mapping integration tests

* test(migrate): add legacy YAML provider alias integration coverage

* fix(migrate): harden JSON5 provider catalog resolution

* chore(migrate): scope split_model_ref helper to tests

---------

Co-authored-by: root <root@LAPTOP-NGAQG9OH.localdomain>
2026-03-15 17:40:09 +03:00
pluginmdandClaude Opus 4.6 d2ea030f03 test merge
Merge lark.rs features (dedup, encryption, group filtering, rich text parsing)
into feishu.rs with FeishuRegion toggle (cn/intl). Single [channels.feishu]
config handles both domestic Feishu and international Lark via region field.

- Expand FeishuConfig: region, webhook_path, verification_token, encrypt_key_env, bot_names
- Add FeishuRegion enum with domain switching (open.feishu.cn / open.larksuite.com)
- Add AES-256-CBC event decryption, message/event dedup, group chat filtering
- Update channel_bridge.rs wiring for full config
- Update routes.rs ChannelMeta with new UI fields (region basic, rest advanced)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 17:39:56 +03:00
tuzkierandWang Hanbin eb87e3fd42 test merge
Adds a WebSocket-based DingTalk Stream channel adapter as an alternative
to the existing webhook-based DingTalk adapter.

DingTalk Stream Mode uses a long-lived WebSocket connection to the
DingTalk Gateway, eliminating the need for a public webhook endpoint.

Changes:
- `openfang-types`: add `DingTalkStreamConfig` struct and wire into
  `ChannelsConfig` alongside the existing `DingTalkConfig`
- `openfang-channels`: implement `DingTalkStreamAdapter` (WebSocket
  connection management, ping/pong, token refresh, send via batchSend API)
- `openfang-api`: register `dingtalk_stream` in the channel registry,
  `is_channel_configured`, and `channel_config_values`
- `openfang-api`: wire adapter startup in `channel_bridge.rs`
- `openfang-cli`: add `dingtalk_stream` entry to the TUI channels list

Configuration:
```toml
[channels.dingtalk_stream]
app_key_env = "DINGTALK_APP_KEY"      # Enterprise Internal App Key
app_secret_env = "DINGTALK_APP_SECRET" # Enterprise Internal App Secret
robot_code_env = "DINGTALK_ROBOT_CODE" # optional, defaults to app_key
```

Requires an Enterprise Internal App in the DingTalk Open Platform with
Stream Mode enabled. No public endpoint needed.

Made-with: Cursor

Co-authored-by: Wang Hanbin <wanghb@best-inc.com>
2026-03-15 17:39:52 +03:00
6d742e9081 test merge
* feat: heartbeat auto-recovery for crashed agents

Extend the heartbeat monitor to detect and automatically recover crashed
agents, reducing operator intervention for 24/7 autonomous deployments:

- Add RecoveryTracker: per-agent failure count with configurable cooldown
- Heartbeat now monitors both Running and Crashed agents
- Crashed agents auto-recover up to max_recovery_attempts (default 3)
- After exhausting attempts, agents are marked Terminated
- Unresponsive Running agents marked Crashed for next-cycle recovery
- Increase default timeout from 60s to 180s (browser/LLM tasks need time)
- Add HeartbeatStatus.state field for downstream consumers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: claude code driver — PID tracking and message timeout

Add subprocess lifecycle management to prevent hung CLI processes from
blocking agents indefinitely:

- Track active subprocess PIDs in a concurrent DashMap for external monitoring
- Enforce configurable message timeout (default 300s) with automatic process kill
- Return proper LlmError::Api on non-zero exit in streaming mode (was silently ignored)
- Add with_timeout(), active_pids(), pid_map() public methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: /restart endpoint — manual per-agent recovery without daemon bounce

POST /api/agents/{id}/restart and /api/agents/{id}/start both:
- Cancel any active task via stop_agent_run()
- Reset agent state to Running (updates last_active)
- Return JSON with previous state and whether a task was cancelled

Enables operators to recover individual crashed/stuck agents through the
API without restarting the entire daemon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ZiLLA Dev <dev@zilla.wtf>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 17:39:26 +03:00
Victor Duarte 317b947608 docker runtimes 2026-03-15 17:27:13 +03:00
jaberjaber23 1cb8b989d1 community fixes 2026-03-15 16:59:21 +03:00
psumotek bde1f5414c channel agent reresolution
When an agent is restarted, its UUID changes but the channel bridge still
holds the old UUID from startup. This causes "Agent not found" errors.

This fix stores the agent *name* alongside the cached UUID at bridge
startup and, on "Agent not found" errors, re-resolves the name to a
fresh UUID via find_agent_by_name(), updates the cache, and retries the
message — all transparently to the end user.

Changes:
- router.rs: add channel_default_names DashMap, set_channel_default_with_name(),
  channel_default_name(), update_channel_default()
- channel_bridge.rs: use set_channel_default_with_name() at startup
- bridge.rs: add try_reresolution() helper, integrate retry logic into
  dispatch_message() and dispatch_with_blocks() error paths with proper
  lifecycle_reactions guards and sanitize_agent_error() usage
2026-03-15 16:55:08 +03:00
TJUEZandTJUEZ d15207fa51 shell skill runtime
Add Shell runtime type to SkillRuntime enum and implement
execute_shell function for running Bash scripts as skills.

This allows skills to use Bash script files, which many
existing skills rely on.

Ref: RightNow-AI/openfang/issues/620

Co-authored-by: TJUEZ <tjuez@email.com>
2026-03-15 16:54:57 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e808ca0d08 bump mailparse
Bumps [mailparse](https://github.com/staktrace/mailparse) from 0.15.0 to 0.16.1.
- [Commits](https://github.com/staktrace/mailparse/compare/v0.15.0...v0.16.1)

---
updated-dependencies:
- dependency-name: mailparse
  dependency-version: 0.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:54:06 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c554adae0d bump ci action
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:33 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 590121d5f3 bump ci action
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:31 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 58a7a07942 bump ci action
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:28 +03:00
Nahuel Gonzalez 72b87dfaa9 chromium no-sandbox root
Chromium refuses to launch without --no-sandbox when the process is
running as UID 0. This causes the browser hand to fail immediately with
'Chromium exited before printing DevTools URL' on any server-based
OpenFang installation that runs as root (the default install).

Added is_running_as_root() which reads /proc/self/status on Linux to
detect UID 0 without requiring a libc dependency, with a fallback to
the HOME env var for other Unix systems. When root is detected,
--no-sandbox is appended to the Chromium launch args automatically.
2026-03-15 15:30:29 +03:00
Frankandtsubasakong b8fb6987e0 tool error guidance
Co-authored-by: tsubasakong <185121705+tsubasakong@users.noreply.github.com>
2026-03-15 15:08:19 +03:00
Vincent LeraitreandClaude Opus 4.6 dec081a326 slack unfurl links
* Add unfurl_links config for Slack channel

Add unfurl_links: bool (default true) to SlackConfig to control
Slack's automatic URL preview expansion. When set to false, links
in agent messages are not unfurled, keeping output compact.

Applied to SlackAdapter's chat.postMessage payload via unfurl_links
and unfurl_media parameters, affecting both real-time and cron
delivery paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Rename test per review: clarify it tests explicit true, not default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:44:53 +03:00
Mark BandClaude Sonnet 4.6 52647b2996 agent rename fix
* feat: add release-fast Cargo profile for faster dev builds

Introduces a `release-fast` profile that inherits from `release` but
uses thin LTO and 8 codegen units instead of full LTO + 1, cutting
link time significantly while remaining fast enough for integration
testing. Documents usage in CONTRIBUTING.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: allow renaming an agent to its current name

AgentRegistry::update_name was calling name_index.contains_key()
without excluding the agent being renamed. Renaming to the same name
always returned AgentAlreadyExists instead of succeeding silently.

Fix: only error when a *different* agent owns the target name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:44:22 +03:00
Frank 905cfd2c21 docs link fix 2026-03-15 14:43:55 +03:00
jaberjaber23 7ad7489860 community fixes 2026-03-15 14:43:10 +03:00
Jones Fernandes 77c4c9add8 whatsapp setup docs 2026-03-15 14:34:31 +03:00
jaberjaber23 07019f764e community fixes 2026-03-15 06:28:23 +03:00
jaberjaber23 80eed53305 community fixes 2026-03-15 06:23:04 +03:00
Evan Hu 14c4c1d1f5 stable hand agent IDs
* fix: use fixed agent ID for hand agents based on hand_id

This ensures triggers and cron jobs continue to work after daemon restart,
as hand agents now have stable IDs instead of generating a new UUID each time.

Changes:
- Add AgentId::from_string() method for deterministic ID generation
- Modify spawn_agent_with_parent() to accept optional fixed_id
- Use hand_id-based fixed ID in activate_hand()

See: #519

* remove: remove serena local config from commit

* chore: ignore .serena directory
2026-03-15 06:18:48 +03:00
jaberjaber23 fa7dd277e6 community fixes 2026-03-15 06:16:06 +03:00
jaberjaber23 0e589e3f9a community fixes 2026-03-15 06:12:33 +03:00
Daniel.Chung 59703d50d6 codex id_token
Signed-off-by: zhong <zdianjiang@gmail.com>
2026-03-15 06:05:52 +03:00
Sky Moore 5413269943 async session save
* fix: use async save_session to avoid blocking tokio runtime

save_session() was synchronous, holding a Mutex<Connection> on the
tokio worker thread during SQLite writes. On pods with 1 CPU core
(1 tokio worker thread), this starved the entire runtime — including
health check endpoints — causing K8s to mark the pod not-ready and
return 504 on all subsequent requests.

Add save_session_async() that wraps the SQLite write in
spawn_blocking, matching the pattern already used by other memory
operations (recall, remember, etc.). Update all 12 call sites in
the agent loop.

* fix: move health check DB query to spawn_blocking and add SSE keep_alive

The health endpoint called structured_get() synchronously on the tokio
async runtime, acquiring the shared std::sync::Mutex<Connection> on a
worker thread. When the agent loop held this mutex during session saves,
the health check blocked the tokio thread, starving the SSE stream and
causing Kubernetes probe timeouts.

- Health and health_detail now run the DB check via spawn_blocking
- SSE message/stream endpoint now includes keep_alive to flush periodic
  heartbeats even during contention

* feat: add hands upsert API for idempotent hand definition updates

Add upsert_from_content() to HandRegistry that overwrites existing
definitions instead of rejecting duplicates. Exposed as POST
/api/hands/upsert for use by the shard manager to keep hand definitions
up to date across pod restarts.

* fix: websocket streaming delays

* fix: get response immediately
2026-03-15 06:05:19 +03:00
mdrissel c5582ceb1e docker build args
Adds LTO and CODEGEN_UNITS arguments that default to optimized prod settings but can be overridden (e.g., LTO=false, CODEGEN_UNITS=16) by developers for faster iteration.
2026-03-15 06:04:37 +03:00
Mark BandClaude Sonnet 4.6 36dc62745c mastodon polling fix
The polling loop was updating last_notification_id on every iteration,
leaving it set to the oldest (smallest) ID in the batch after the loop
completed. On the next poll, since_id was set to that oldest ID, causing
Mastodon to return all previously seen notifications again.

Re-delivered notifications caused the bot to respond to the same user
mention repeatedly. Combined with api_post_status chaining each response
chunk as a reply to the previous chunk, this produced long self-reply
threads that appeared to be the bot conversing with itself.

Fix: capture the first (newest) notification ID before processing the
batch, so since_id always advances correctly on each poll cycle.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 06:04:16 +03:00
JingyiQiu cb6e6909d4 telegram formatting 2026-03-15 06:01:45 +03:00
jaberjaber23 b3be3f4940 community fixes 2026-03-15 05:58:00 +03:00
Mark BandClaude Sonnet 4.6 7505007d8c release-fast profile
Introduces a `release-fast` profile that inherits from `release` but
uses thin LTO and 8 codegen units instead of full LTO + 1, cutting
link time significantly while remaining fast enough for integration
testing. Documents usage in CONTRIBUTING.md.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 05:57:33 +03:00
jaberjaber23 fdd6c1a1f7 vault wiring 2026-03-15 05:48:09 +03:00
jaberjaber23 135c37fbf7 community fixes 2026-03-15 01:25:15 +03:00
jaberjaber23 a7a96a7b0f community fixes 2026-03-15 00:18:34 +03:00
jaberjaber23 52bacf0946 community fixes 2026-03-14 22:49:43 +03:00
jaberjaber23 d55e1b8545 community batch v0.4.0 2026-03-12 23:33:19 +03:00
jaberjaber23 0c059d1dc1 bump v0.3.49 2026-03-12 18:34:16 +03:00
jaberjaber23 b6b8b4ebe1 fix community issues 2026-03-12 16:42:39 +03:00
jaberjaber23 14f4845170 trader dashboard 2026-03-12 06:20:46 +03:00
jaberjaber23 be8a589986 bump v0.3.47 2026-03-12 01:23:35 +03:00
jaberjaber23 951e8d0feb fix 11 issues 2026-03-12 01:22:45 +03:00
jaberjaber23 98f8d1ca79 fix community PRs (inspired by #438 @pandego, #433 @ozekimasaki, #417 @f-liva, #392 @cryptonahue, #410 @hobostay, #413 @castorinop, #275 @woodcoal, #464 @citadelgrad, #419 @shipdocs, #480 @skeltavik, #439 @modship) 2026-03-11 03:25:16 +03:00
jaberjaber23 24f5717ae9 fix streaming-think, cron-orphans 2026-03-10 18:24:33 +03:00
jaberjaber23 f10eefdc0e fix 6 issues 2026-03-10 17:28:06 +03:00
jaberjaber23 edd0fed518 fix 7 issues 2026-03-10 16:53:04 +03:00
jaberjaber23 86b50070e8 fix gemini-schema 2026-03-10 04:09:26 +03:00
jaberjaber23 c6aab08faa fix temperature, free-models 2026-03-10 04:06:24 +03:00
jaberjaber23 62ec09d0ae bump version 2026-03-10 02:16:05 +03:00
jaberjaber23 f6f9cf7e9f fix claude-code 2026-03-10 02:14:49 +03:00
jaberjaber23 56aeb499c9 fix tool-schema 2026-03-10 01:41:33 +03:00
jaberjaber23 b4e6a693f5 version bump 2026-03-10 01:27:39 +03:00
jaberjaber23 48d5418c91 claude code fix
Fix Claude Code provider setup flow: wizard now shows Detect button instead of API key input for keyless providers, TUI wizards include claude-code in provider list, credentials detection checks both .credentials.json paths, subprocess env_clear prevents API key leaks. Fixes #376 #303.
2026-03-10 01:19:02 +03:00
jaberjaber23 cc93ef4571 community fixes
Fix tool name mapping so LLM-hallucinated aliases (fs-write, fsRead, writeFile, etc.) normalize to canonical names before capability check and dispatch (#349). Fix provider keys not loading after dashboard save by creating fresh drivers that read current env vars instead of stale boot-time cache (#465, #458, #355). Fix Moonshot/kimi model IDs and provider inference (#428). Add Telegram message reactions for agent lifecycle feedback (#435). Add configurable api_url for Telegram proxy support (#477). Add Discord ignore_bots config option (#403). Fix openfang init EPERM crash with 7-browser fallback on Linux (#389). Add text-based tool call parsing for models without native function calling — [TOOL_CALL], <tool_call>, bare JSON patterns (#354, #332). Fix pre-existing Windows test failures with cross-platform paths. 1948 tests pass, 0 clippy warnings.
2026-03-10 00:40:45 +03:00
jaberjaber23 3e069798f9 community fixes
Fix 8 issues: empty LLM response after ~4 rounds by re-validating message pairs after history trim (#460), MCP tools permission denied by bypassing ToolInvoke capability filter for extension tools (#352), Telegram photos silently dropped now downloaded and passed as multimodal ContentBlock::Image (#362), workflow visual builder double-click editing and live property updates (#357), Claude Code provider card reflects actual install/auth status (#376), Python 3 detection runs actual command instead of path lookup (#405), hand agent_id persisted for cron job reassignment on restart (#402), CLI sends auth headers on all commands not just stop (#478). 1921 tests pass, 0 clippy warnings.
2026-03-09 23:07:08 +03:00
jaberjaber23 ad10aa5e80 community fixes
Fix 12 GitHub issues: SSE streaming token counts (#stream_options), UTF-8 boundary panics (#472), cron timezone scheduling (#473), TOML multiline system_prompt (#463), dashboard 401 auth interceptor (#468), custom provider env var convention (#471), cron stale agent_id reassignment (#461), concurrent provider probing with cache (#474), model switch provider sync (#466/#387), OpenRouter real models (#385), embedding URL normalization (#395), ZHIPU content format (#384), Fish shell PATH detection (#372). 1915 tests pass, 0 clippy warnings.
2026-03-09 21:19:50 +03:00
jaberjaber23 385aee8e56 fix streaming
- Add stream_options (include_usage) for accurate token counts in streaming mode
- Add fallback for providers that don't support stream_options
- Add SSE stream diagnostic logging
2026-03-09 04:57:04 +03:00
jaberjaber23 a00327abe9 fix auth 2026-03-09 03:19:14 +03:00
jaberjaber23 487555a5e5 bump version 2026-03-09 02:18:51 +03:00
jaberjaber23 9d51426cb4 fix bugs 2026-03-09 02:16:36 +03:00
jaberjaber23 6fab720843 bump version 2026-03-08 22:53:48 +03:00
jaberjaber23 4667f497ef fix csp 2026-03-08 22:51:21 +03:00
jaberjaber23 eba9198827 community fixes 2026-03-08 22:29:54 +03:00
jaberjaber23 f2413949bc shell hardening 2026-03-08 20:20:34 +03:00
jaberjaber23 9e230f423e security hardening 2026-03-08 16:59:48 +03:00
jaberjaber23 8138b7e0e8 version bump 2026-03-08 04:05:39 +03:00
jaberjaber23 cfae867908 batch fixes 2026-03-08 04:04:37 +03:00
jaberjaber23 6857e3cf06 issue fixes 2026-03-08 01:25:20 +03:00
jaberjaber23 772cbdbe38 community fixes 2026-03-07 23:31:38 +03:00
jaberjaber23 b2e2b1a038 version bump 2026-03-07 05:29:52 +03:00
jaberjaber23 d237ecf161 community batch 2026-03-07 04:22:16 +03:00
jaberjaber23 4a3d570155 community hardening 2026-03-07 00:22:25 +03:00
jaberjaber23 ebcdc17c13 default resilience 2026-03-05 22:57:08 +03:00
jaberjaber23 45e06b9bad channel resilience 2026-03-05 21:35:37 +03:00
jaberjaber23 c6b46ccbe1 catalog composite 2026-03-05 20:31:49 +03:00
jaberjaber23 9fc0fe71bf driver resilience 2026-03-05 20:21:03 +03:00
jaberjaber23 06df0795c8 think stripping 2026-03-05 15:41:08 +03:00
jaberjaber23 eafeb6a012 bugfix batch 2026-03-05 15:27:10 +03:00
jaberjaber23 05431509be bugfix batch 2026-03-05 14:53:22 +03:00
jaberjaber23 9d3136e512 bugfix batch 2026-03-05 14:39:43 +03:00
jaberjaber23 60566f22fb bugfix batch 2026-03-05 03:17:37 +03:00
jaberjaber23 50440e4047 bugfix batch 2026-03-05 02:13:48 +03:00
jaberjaber23 f45268aedc stress hardening 2026-03-05 01:21:32 +03:00
jaberjaber23 cc54e14114 bugfix batch 2026-03-05 00:25:25 +03:00
jaberjaber23 1037ef768d bugfix batch 2026-03-04 15:22:10 +03:00
jaberjaber23 c3dcf02e3c bugfix batch 2026-03-04 05:43:38 +03:00
jaberjaber23 b157e3c7e6 issue fixes 2026-03-04 04:11:07 +03:00
jaberjaber23 74ac992420 issue fixes 2026-03-04 03:34:12 +03:00
jaberjaber23 53e1b31777 version bump 2026-03-04 02:08:52 +03:00
jaberjaber23 603a94e560 issue fixes 2026-03-04 02:08:32 +03:00
jaberjaber23 fac4ad33e5 discord bugfixes 2026-03-04 01:17:37 +03:00
jaberjaber23 fe96cd1004 bugfixes batch 2026-03-03 21:26:06 +03:00
jaberjaber23 7c85308cf6 bugfixes batch 2026-03-03 20:28:46 +03:00
jaberjaber23 a4a83b1699 bugfixes batch 2026-03-03 16:54:30 +03:00
jaberjaber23 8942d8c2b6 bugfixes release 2026-03-03 05:20:05 +03:00
jaberjaber23 260dd7a125 version bump 2026-03-03 01:18:02 +03:00
jaberjaber23 294f0e7af8 batch fixes 2026-03-03 01:17:34 +03:00
jaberjaber23 444d82e4d6 batch fixes 2026-03-02 23:49:44 +03:00
jaberjaber23 62e6e0f088 batch fixes 2026-03-02 23:12:19 +03:00
jaberjaber23 d3385f2cdc batch fixes 2026-03-02 20:52:57 +03:00
jaberjaber23 516f163dfb batch fixes 2026-03-02 18:37:56 +03:00
jaberjaber23 a54bb1cd4f batch fixes 2026-03-02 15:14:58 +03:00
jaberjaber23 6f3c4e7778 batch fixes 2026-03-02 05:08:42 +03:00
jaberjaber23 73ad49a3a1 batch fixes 2026-03-02 03:31:59 +03:00
jaberjaber23 7ec0e024c7 v0.2.5 release 2026-03-01 22:27:42 +03:00
jaberjaber23 7c81c187c4 batch fixes 2026-03-01 20:38:12 +03:00
jaberjaber23 7ae80b1b9f batch fixes 2026-03-01 04:16:17 +03:00
jaberjaber23 e58ae3e304 fix providers 2026-03-01 00:26:36 +03:00
jaberjaber23 74f5a91fdd bug fixes 2026-02-28 21:20:48 +03:00
jaberjaber23 6de0447e8c coffee badge 2026-02-28 15:42:03 +03:00
jaberjaber23 45dbf617a7 bump v0.2.0 2026-02-28 15:39:20 +03:00
jaberjaber23 b416bf417f critical fixes 2026-02-28 15:36:46 +03:00
296 changed files with 54037 additions and 5226 deletions
+34
View File
@@ -0,0 +1,34 @@
# Ignored advisories — all are transitive dependencies we cannot upgrade directly.
#
# time 0.3.45: pinned by mac-notification-sys (tauri dependency), awaiting upstream fix
# GTK3/glib/pango/etc: tauri uses gtk-rs GTK3 bindings which are unmaintained
# paste, proc-macro-error, fxhash: unmaintained transitive deps
# lexical-core: unmaintained, pulled by tauri dep chain
# serde_cbor: unmaintained, pulled by tao (tauri)
# cocoa/cocoa-foundation: unmaintained, pulled by tauri/tao
[advisories]
ignore = [
"RUSTSEC-2026-0009", # time DoS — pinned by mac-notification-sys
"RUSTSEC-2024-0370", # proc-macro-error unmaintained
"RUSTSEC-2024-0411", # gtk-rs GTK3 unmaintained (gdk-pixbuf)
"RUSTSEC-2024-0412", # gtk-rs GTK3 unmaintained (gdk)
"RUSTSEC-2024-0413", # gtk-rs GTK3 unmaintained (atk)
"RUSTSEC-2024-0414", # gtk-rs GTK3 unmaintained (pango)
"RUSTSEC-2024-0415", # gtk-rs GTK3 unmaintained (gio)
"RUSTSEC-2024-0416", # gtk-rs GTK3 unmaintained (atk-sys)
"RUSTSEC-2024-0417", # gtk-rs GTK3 unmaintained (gdk-pixbuf-sys)
"RUSTSEC-2024-0418", # gtk-rs GTK3 unmaintained (gdk-sys)
"RUSTSEC-2024-0419", # gtk-rs GTK3 unmaintained (gtk3-macros)
"RUSTSEC-2024-0420", # gtk-rs GTK3 unmaintained (pango-sys)
"RUSTSEC-2024-0429", # gtk-rs GTK3 unmaintained (gtk-sys)
"RUSTSEC-2024-0436", # paste unmaintained
"RUSTSEC-2025-0057", # fxhash unmaintained
"RUSTSEC-2025-0075", # glib unmaintained
"RUSTSEC-2025-0080", # cocoa unmaintained
"RUSTSEC-2025-0081", # cocoa-foundation unmaintained
"RUSTSEC-2025-0098", # lexical-core unmaintained
"RUSTSEC-2025-0100", # gio-sys unmaintained
"RUSTSEC-2026-0002", # serde_cbor unmaintained
"RUSTSEC-2023-0086", # lexopt unmaintained (if present)
]
+62
View File
@@ -0,0 +1,62 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: Description
description: What happened?
placeholder: Describe the bug clearly and concisely.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: How can we reproduce this?
placeholder: |
1. Run `openfang start`
2. Open dashboard at http://localhost:4200
3. Click ...
validations:
required: true
- type: input
id: version
attributes:
label: OpenFang Version
description: Output of `openfang -V`
placeholder: "0.3.23"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- Linux (x86_64)
- Linux (aarch64/ARM64)
- macOS (Apple Silicon)
- macOS (Intel)
- Windows
- Android (Termux)
- Other
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Screenshots
description: Paste relevant logs or attach screenshots.
@@ -0,0 +1,24 @@
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: Description
description: What feature would you like?
placeholder: Describe the feature and why it would be useful.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you tried any workarounds?
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other context, screenshots, or references.
+17
View File
@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
labels:
- "ci"
+19
View File
@@ -0,0 +1,19 @@
## Summary
<!-- What does this PR do? Link related issues with "Fixes #123". -->
## Changes
<!-- Brief list of what changed. -->
## Testing
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
- [ ] `cargo test --workspace` passes
- [ ] Live integration tested (if applicable)
## Security
- [ ] No new unsafe code
- [ ] No secrets or API keys in diff
- [ ] User input validated at boundaries
+6 -6
View File
@@ -20,7 +20,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
@@ -45,7 +45,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
@@ -67,7 +67,7 @@ jobs:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@@ -87,7 +87,7 @@ jobs:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
@@ -97,7 +97,7 @@ jobs:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-audit
@@ -109,7 +109,7 @@ jobs:
name: Secrets Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install trufflehog
+12 -7
View File
@@ -49,7 +49,7 @@ jobs:
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install system deps (Linux)
if: runner.os == 'Linux'
@@ -162,7 +162,7 @@ jobs:
archive: zip
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -181,6 +181,11 @@ jobs:
- name: Build CLI
if: matrix.target != 'aarch64-unknown-linux-gnu'
run: cargo build --release --target ${{ matrix.target }} --bin openfang
- name: Ad-hoc codesign CLI binary (macOS)
if: runner.os == 'macOS'
run: |
xattr -cr target/${{ matrix.target }}/release/openfang || true
codesign --force --sign - target/${{ matrix.target }}/release/openfang
- name: Package (Unix)
if: matrix.archive == 'tar.gz'
run: |
@@ -207,22 +212,22 @@ jobs:
name: Docker Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU (for arm64 emulation)
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract version
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Build and push (multi-arch)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
push: true
+2
View File
@@ -34,6 +34,7 @@ BUILD_LOG.md
# OS
.DS_Store
._*
Thumbs.db
# IDE & tools
@@ -43,3 +44,4 @@ Thumbs.db
*.swp
*.swo
*~
.serena/
+10
View File
@@ -5,6 +5,16 @@ All notable changes to OpenFang will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- **BREAKING:** Dashboard password hashing switched from SHA256 to Argon2id. Existing `password_hash` values in `config.toml` must be regenerated with `openfang auth hash-password`. Only affects users with `[auth] enabled = true`.
### Fixed
- Dashboard passwords were hashed with plain SHA256 (no salt), making them vulnerable to rainbow table and GPU-accelerated brute force attacks. Now uses Argon2id with random salts.
## [0.1.0] - 2026-02-24
### Added
+10
View File
@@ -56,6 +56,16 @@ Tests that require a real LLM key will skip gracefully if the env var is absent.
cargo build --workspace
```
### Fast Release Build (for development)
The default `--release` profile uses full LTO and single-codegen-unit, which produces the smallest/fastest binary but is slow to compile. For iterating locally, use the `release-fast` profile instead:
```bash
cargo build --profile release-fast -p openfang-cli
```
This cuts link time significantly (thin LTO, 8 codegen units, `opt-level=2`) while still producing a binary fast enough to run integration tests against. Use `--release` only for final binaries or CI.
### Run All Tests
```bash
Generated
+1110 -510
View File
File diff suppressed because it is too large Load Diff
+38 -8
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.1.9"
version = "0.5.7"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -32,7 +32,7 @@ tokio-stream = "0.1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
toml = "0.9"
rmp-serde = "1"
# Error handling
@@ -49,9 +49,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Time
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
# IDs
uuid = { version = "1", features = ["v4", "serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
# Database
rusqlite = { version = "0.31", features = ["bundled", "serde_json"] }
@@ -61,7 +62,7 @@ clap = { version = "4", features = ["derive"] }
clap_complete = "4"
# HTTP client (for LLM drivers)
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
# Async trait
async-trait = "0.1"
@@ -74,6 +75,7 @@ bytes = "1"
# Futures
futures = "0.3"
prost = "0.13"
# WebSocket client (for Discord/Slack gateway)
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
@@ -83,7 +85,7 @@ url = "2"
wasmtime = "41"
# HTTP server (for API daemon)
axum = { version = "0.8", features = ["ws"] }
axum = { version = "0.8", features = ["ws", "multipart"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "compression-br"] }
@@ -101,6 +103,9 @@ walkdir = "2"
# Security
sha2 = "0.10"
sha1 = "0.10"
aes = "0.8"
cbc = "0.1"
hmac = "0.12"
hex = "0.4"
subtle = "2"
@@ -109,7 +114,7 @@ rand = "0.8"
zeroize = { version = "1", features = ["derive"] }
# Rate limiting
governor = "0.8"
governor = "0.10"
# Interactive CLI
ratatui = "0.29"
@@ -119,14 +124,32 @@ colored = "3"
aes-gcm = "0.10"
argon2 = "0.5"
# HTML entity decoding
html-escape = "0.2"
# Lightweight regex
regex-lite = "0.1"
# MCP SDK (official Rust implementation)
rmcp = { version = "1.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] }
# Socket options (SO_REUSEADDR)
socket2 = "0.5"
# Zip archive extraction
zip = { version = "4", default-features = false, features = ["deflate"] }
# Email (SMTP + IMAP)
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
imap = "2"
native-tls = "0.2"
mailparse = "0.15"
native-tls = { version = "0.2", features = ["vendored"] }
mailparse = "0.16"
# MQTT client
rumqttc = "0.24"
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
openssl = { version = "0.10", features = ["vendored"] }
# Testing
tokio-test = "0.4"
@@ -137,3 +160,10 @@ lto = true
codegen-units = 1
strip = true
opt-level = 3
[profile.release-fast]
inherits = "release"
lto = "thin"
codegen-units = 8
opt-level = 2
strip = false
+16 -2
View File
@@ -7,10 +7,24 @@ COPY crates ./crates
COPY xtask ./xtask
COPY agents ./agents
COPY packages ./packages
# Optional build args for dev environments to speed up compilation
# Example: docker build --build-arg LTO=false --build-arg CODEGEN_UNITS=16 .
ARG LTO=true
ARG CODEGEN_UNITS=1
ENV CARGO_PROFILE_RELEASE_LTO=${LTO} \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CODEGEN_UNITS}
RUN cargo build --release --bin openfang
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
FROM rust:1-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
python3-pip \
python3-venv \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/openfang /usr/local/bin/
COPY --from=builder /build/agents /opt/openfang/agents
EXPOSE 4200
+103 -5
View File
@@ -19,16 +19,17 @@
<p align="center">
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
<img src="https://img.shields.io/badge/version-0.3.30-green?style=flat-square" alt="v0.3.30" />
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
</p>
---
> **v0.1.0 — First Release (February 2026)**
> **v0.3.30 — Security Hardening Release (March 2026)**
>
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
---
@@ -263,6 +264,97 @@ Connect your agents to every platform your users are on.
Each adapter supports per-channel model overrides, DM/group policies, rate limiting, and output formatting.
---
## WhatsApp Web Gateway (QR Code)
Connect your personal WhatsApp account to OpenFang via QR code — just like WhatsApp Web. No Meta Business account required.
### Prerequisites
- **Node.js >= 18** installed ([download](https://nodejs.org/))
- OpenFang installed and initialized
### Setup
**1. Install the gateway dependencies:**
```bash
cd packages/whatsapp-gateway
npm install
```
**2. Configure `config.toml`:**
```toml
[channels.whatsapp]
mode = "web"
default_agent = "assistant"
```
**3. Set the gateway URL (choose one):**
Add to your shell profile for persistence:
```bash
# macOS / Linux
echo 'export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"' >> ~/.zshrc
source ~/.zshrc
```
Or set it inline when starting the gateway:
```bash
export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"
```
**4. Start the gateway:**
```bash
node packages/whatsapp-gateway/index.js
```
The gateway listens on port `3009` by default. Override with `WHATSAPP_GATEWAY_PORT`.
**5. Start OpenFang:**
```bash
openfang start
# Dashboard at http://localhost:4200
```
**6. Scan the QR code:**
Open the dashboard → **Channels****WhatsApp**. A QR code will appear. Scan it with your phone:
> **WhatsApp** → **Settings** → **Linked Devices** → **Link a Device**
Once scanned, the status changes to `connected` and incoming messages are routed to your configured agent.
### Gateway Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `WHATSAPP_WEB_GATEWAY_URL` | Gateway URL for OpenFang to connect to | _(empty = disabled)_ |
| `WHATSAPP_GATEWAY_PORT` | Port the gateway listens on | `3009` |
| `OPENFANG_URL` | OpenFang API URL the gateway reports to | `http://127.0.0.1:4200` |
| `OPENFANG_DEFAULT_AGENT` | Agent that handles incoming messages | `assistant` |
### Gateway API Endpoints
| Method | Route | Description |
|--------|-------|-------------|
| `POST` | `/login/start` | Generate QR code (returns base64 PNG) |
| `GET` | `/login/status` | Connection status (`disconnected`, `qr_ready`, `connected`) |
| `POST` | `/message/send` | Send a message (`{ "to": "5511999999999", "text": "Hello" }`) |
| `GET` | `/health` | Health check |
### Alternative: WhatsApp Cloud API
For production workloads, use the [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api) with a Meta Business account. See the [Cloud API configuration docs](https://openfang.sh/docs/channels/whatsapp).
---
## 27 LLM Providers — 123+ Models
@@ -370,7 +462,7 @@ cargo fmt --all -- --check
## Stability Notice
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
- **Breaking changes** may occur between minor versions until v1.0
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
@@ -381,6 +473,12 @@ We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
---
## Security
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
---
## License
MIT — use it however you want.
@@ -412,7 +510,7 @@ MIT — use it however you want.
<p align="center">
<a href="https://www.rightnowai.co/">Website</a> &bull;
<a href="https://x.com/Akashi203">Twitter / X</a> &bull;
<a href="https://github.com/sponsors/RightNow-AI">Sponsor</a>
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p>
---
+2 -2
View File
@@ -4,7 +4,7 @@
| Version | Supported |
|---------|--------------------|
| 0.1.x | :white_check_mark: |
| 0.3.x | :white_check_mark: |
## Reporting a Vulnerability
@@ -14,7 +14,7 @@ If you discover a security vulnerability in OpenFang, please report it responsib
### How to Report
1. Email: **security@openfang.ai**
1. Email: **jaber@rightnowai.co**
2. Include:
- Description of the vulnerability
- Steps to reproduce
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.4
@@ -34,8 +34,8 @@ OUTPUT FORMAT:
- Caveats and limitations"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["architecture", "design", "planning"]
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -31,8 +31,8 @@ Output format: Use clear headings, diagrams (ASCII), and structured reasoning.
When asked to review, be honest about weaknesses."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+6 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["general", "assistant", "default", "multipurpose", "conversation", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Assistant, a specialist agent in the OpenFang Agent OS. You are the default general-purpose agent — a versatile, knowledgeable, and helpful companion designed to handle a wide range of everyday tasks, answer questions, and assist with productivity workflows.
@@ -61,7 +61,7 @@ TOOLS AVAILABLE:
You are reliable, adaptable, and genuinely helpful. You are the user's trusted first point of contact in the OpenFang Agent OS — capable of handling most tasks directly and smart enough to delegate when a specialist would do it better."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
@@ -76,3 +76,6 @@ memory_read = ["*"]
memory_write = ["self.*", "shared.*"]
agent_message = ["*"]
shell = ["python *", "cargo *", "git *", "npm *"]
[autonomous]
max_iterations = 100
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["review", "code-quality", "best-practices"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -34,8 +34,8 @@ Rules:
- Focus on things that matter for production"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["coding", "implementation", "rust", "python"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -31,8 +31,8 @@ RESEARCH:
- Check official documentation before guessing at API usage."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["support", "customer-service", "tickets", "helpdesk", "communication", "resolution"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.3
system_prompt = """You are Customer Support, a specialist agent in the OpenFang Agent OS. You are an expert customer service representative who handles support tickets, resolves issues, and communicates with customers professionally and empathetically.
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
You are patient, empathetic, and solutions-focused. You turn frustrated customers into satisfied advocates."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -36,8 +36,8 @@ Output format:
- Caveats and limitations"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.2
@@ -37,8 +37,8 @@ OUTPUT FORMAT:
- Prevention: Test or pattern to prevent recurrence"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.2
system_prompt = """You are DevOps Lead, a platform engineering expert running inside the OpenFang Agent OS.
@@ -35,7 +35,7 @@ When designing pipelines:
5. Automated rollback on failure"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.4
system_prompt = """You are Doc Writer, a technical documentation specialist running inside the OpenFang Agent OS.
@@ -33,7 +33,7 @@ Style guide:
- Consistent formatting and structure"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["email", "communication", "triage", "drafting", "scheduling", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.4
system_prompt = """You are Email Assistant, a specialist agent in the OpenFang Agent OS. Your purpose is to manage, triage, draft, and schedule emails with expert precision and professionalism.
@@ -47,7 +47,7 @@ TOOLS AVAILABLE:
You are thorough, discreet, and efficient. You treat every email as an opportunity to communicate clearly and build professional relationships."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["health", "wellness", "fitness", "medication", "habits", "tracking"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.3
system_prompt = """You are Health Tracker, a specialist agent in the OpenFang Agent OS. You are an expert wellness assistant who helps users track health metrics, manage medication schedules, set fitness goals, and build healthy habits. You are NOT a medical professional and you always make this clear.
+2 -2
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.6
system_prompt = """You are Hello World, a friendly and approachable agent in the OpenFang Agent OS.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["smart-home", "iot", "automation", "devices", "monitoring", "home"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.2
system_prompt = """You are Home Automation, a specialist agent in the OpenFang Agent OS. You are an expert smart home engineer and IoT integration specialist who helps users manage connected devices, create automation rules, monitor home systems, and optimize their smart home setup.
@@ -0,0 +1 @@
__pycache__/
+187
View File
@@ -0,0 +1,187 @@
"""
LangChain Code Review Agent — core review logic.
Supports OpenAI, Ollama, and any LangChain-compatible LLM.
"""
import os
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
SYSTEM_PROMPT = """\
You are a principal-level code reviewer with 15+ years of production experience \
across multiple languages (Python, Rust, TypeScript, Java, Go, C/C++).
You receive code snippets, diffs, or pull request descriptions and produce a \
structured, actionable review report.
You MUST respond in **中文**, but keep code snippets, variable names, and \
technical terms in their original language.
# ── 审核维度(按优先级排序) ──────────────────────────────
## 1. 正确性 (Correctness)
- 逻辑错误、off-by-one、边界条件
- 空指针 / None / undefined 未处理
- 错误处理不完整(吞异常、漏 catch、panic 路径)
- 并发问题:竞态条件、死锁、数据竞争
- 类型安全:隐式转换、溢出、精度丢失
- 资源泄漏:未关闭的文件/连接/锁
## 2. 安全性 (Security)
- SQL / NoSQL / OS 命令注入
- XSS、CSRF、SSRF
- 硬编码密钥、token、密码
- 不安全的反序列化
- 路径穿越(Path Traversal
- 缺少输入校验 / 输出编码
- 权限检查缺失或绕过
- 敏感数据明文日志
## 3. 性能 (Performance)
- 算法复杂度不合理(O(n²) 可优化为 O(n))
- 不必要的内存分配 / 拷贝
- N+1 查询、缺少批量操作
- 阻塞 I/O 在异步上下文中
- 缺少缓存 / 索引
- 热路径上的正则编译 / 反射
## 4. 可维护性 (Maintainability)
- 命名不清晰、缩写歧义
- 函数过长(>50行建议拆分)
- 重复代码(DRY 违反)
- 职责不单一(SRP 违反)
- 缺少必要注释(复杂业务逻辑、非显而易见的决策)
- 魔法数字 / 字符串
- 耦合过紧、依赖方向不合理
## 5. 测试 (Testing)
- 关键路径缺少单元测试
- 测试覆盖了 happy path 但遗漏了 edge case
- 测试中有硬编码依赖(时间、文件路径、网络)
- Mock 过度导致测试失去意义
## 6. 风格 (Style)
- 不符合语言惯例(Pythonic、Rust idiom 等)
- 格式不一致(应由 formatter 处理的除外)
- 不必要的复杂写法
# ── 严重级别 ──────────────────────────────────────────
| 级别 | 含义 | 是否阻塞合并 |
|------|------|-------------|
| 🔴 **[必须修复]** | 存在 bug、安全漏洞或数据丢失风险 | 是 |
| 🟡 **[建议修复]** | 不影响功能但会影响可维护性或性能 | 否,但强烈建议 |
| 🔵 **[小建议]** | 风格、命名等微小改进 | 否 |
| 🟢 **[亮点]** | 写得好的地方,值得肯定 | — |
# ── 输出格式 ──────────────────────────────────────────
严格按以下 Markdown 格式输出:
```
## 📋 总结
**结论**: [✅ 通过 / ⚠️ 需要修改 / 💬 仅评论]
**概述**: [1-2 句话总体评价]
**发现统计**: 🔴 X 个必须修复 | 🟡 X 个建议修复 | 🔵 X 个小建议 | 🟢 X 个亮点
---
## 🔍 详细发现
### 🔴 [必须修复] 问题标题
- **位置**: `文件名` 第 X-Y 行
- **问题**: 具体描述
- **原因**: 为什么这是个问题,可能造成什么后果
- **修复建议**:
(给出修复后的代码)
### 🟡 [建议修复] 问题标题
...
### 🔵 [小建议] 问题标题
...
### 🟢 [亮点] 优点标题
- **位置**: `文件名` 第 X-Y 行
- **说明**: 为什么这段代码写得好
---
## 📊 评分
| 维度 | 分数 | 说明 |
|------|------|------|
| 正确性 | X/10 | 一句话说明 |
| 安全性 | X/10 | 一句话说明 |
| 性能 | X/10 | 一句话说明 |
| 可维护性 | X/10 | 一句话说明 |
| 测试 | X/10 | 一句话说明 |
| **综合** | **X/10** | 一句话总结 |
```
# ── 审核原则 ──────────────────────────────────────────
1. **先肯定,再指出问题** — 不要只挑毛病,好的代码也要指出来
2. **解释 WHY,不仅是 WHAT** — 每个问题都要说清楚「为什么不好」和「可能导致什么后果」
3. **给出具体修复代码** — 不要只说"这里有问题",要给出改好后的写法
4. **区分严重级别** — 不要把小问题标成必须修复,也不要把严重 bug 标成小建议
5. **尊重作者** — 用建设性的语气,避免 "这是错的" 这种措辞,用 "这里可以改进为..."
6. **不纠结格式** — 如果项目有 formatter/linter,格式问题跳过
7. **关注变更本身** — 如果是 diff,只审核变更的部分,不要评论未修改的代码
8. **没有代码时** — 直接要求提交代码,不要编造审核结果"""
def _build_llm():
"""Build the LLM based on environment configuration."""
use_ollama = os.getenv("USE_OLLAMA", "").lower() in ("1", "true", "yes")
if use_ollama:
from langchain_ollama import ChatOllama
model = os.getenv("OLLAMA_MODEL", "qwen2.5")
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
return ChatOllama(model=model, base_url=base_url, temperature=0.2)
provider = os.getenv("LLM_PROVIDER", "openai").lower()
if provider == "deepseek":
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.2,
max_tokens=4096,
)
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
temperature=0.2,
max_tokens=4096,
)
class CodeReviewAgent:
"""LangChain-based code review agent."""
def __init__(self):
self.llm = _build_llm()
self.prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("human", "{input}"),
])
self.chain = self.prompt | self.llm | StrOutputParser()
def review(self, code_or_diff: str) -> str:
"""
Review the given code or diff.
Args:
code_or_diff: Source code, git diff, or PR description to review.
Returns:
Structured review report as markdown text.
"""
if not code_or_diff.strip():
return "No code provided. Please submit code or a diff to review."
return self.chain.invoke({"input": code_or_diff})
@@ -0,0 +1,10 @@
# Add this section to your ~/.openfang/config.toml
# to register the LangChain code review agent.
[a2a]
enabled = true
listen_path = "/a2a"
[[a2a.external_agents]]
name = "langchain-code-reviewer"
url = "http://127.0.0.1:9100"
@@ -0,0 +1,6 @@
langchain>=0.3
langchain-openai>=0.3
langchain-core>=0.3
langchain-ollama>=0.3
fastapi>=0.115
uvicorn>=0.34
+226
View File
@@ -0,0 +1,226 @@
"""
LangChain Code Review Agent — A2A-compatible server.
Exposes a code review agent via Google's A2A protocol so that
OpenFang workflows can call it as an external agent.
Start:
OPENAI_API_KEY=sk-xxx python server.py
# or with Ollama (no key needed):
USE_OLLAMA=1 python server.py
Endpoints:
GET /.well-known/agent.json — A2A Agent Card
POST /a2a — JSON-RPC task endpoint
"""
import os
import uuid
import asyncio
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
from agent import CodeReviewAgent
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "9100"))
BASE_URL = os.getenv("BASE_URL", f"http://127.0.0.1:{PORT}")
app = FastAPI(title="LangChain Code Review Agent")
agent = CodeReviewAgent()
# In-memory task store
tasks: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# A2A Agent Card
# ---------------------------------------------------------------------------
AGENT_CARD = {
"name": "langchain-code-reviewer",
"description": (
"LangChain-powered code review agent. "
"Analyzes code for bugs, security issues, performance problems, "
"and style violations. Returns structured review with severity levels."
),
"url": f"{BASE_URL}/a2a",
"version": "0.1.0",
"capabilities": {
"streaming": False,
"pushNotifications": False,
"stateTransitionHistory": True,
},
"skills": [
{
"id": "code-review",
"name": "Code Review",
"description": "Review code for correctness, security, performance, and style",
"tags": ["code", "review", "security", "quality"],
"examples": [
"Review this Python function for bugs",
"Check this Rust code for security issues",
"Analyze this PR diff for performance problems",
],
},
{
"id": "pr-review",
"name": "Pull Request Review",
"description": "Review a git diff / pull request",
"tags": ["pr", "diff", "git"],
"examples": [
"Review this PR diff",
"Analyze these changes",
],
},
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
}
@app.get("/.well-known/agent.json")
async def agent_card():
return JSONResponse(content=AGENT_CARD)
# ---------------------------------------------------------------------------
# A2A JSON-RPC Endpoint
# ---------------------------------------------------------------------------
@app.post("/a2a")
async def a2a_endpoint(request: Request):
body = await request.json()
jsonrpc = body.get("jsonrpc", "2.0")
req_id = body.get("id", 1)
method = body.get("method", "")
params = body.get("params", {})
if method == "tasks/send":
return await handle_tasks_send(jsonrpc, req_id, params)
elif method == "tasks/get":
return handle_tasks_get(jsonrpc, req_id, params)
elif method == "tasks/cancel":
return handle_tasks_cancel(jsonrpc, req_id, params)
else:
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
})
async def handle_tasks_send(jsonrpc: str, req_id: int, params: dict):
message = params.get("message", {})
session_id = params.get("sessionId")
task_id = str(uuid.uuid4())
text_parts = [
p["text"] for p in message.get("parts", []) if p.get("type") == "text"
]
user_input = "\n".join(text_parts)
task = {
"id": task_id,
"sessionId": session_id,
"status": {"state": "working", "message": None},
"messages": [message],
"artifacts": [],
}
tasks[task_id] = task
try:
review_result = await asyncio.to_thread(agent.review, user_input)
agent_message = {
"role": "agent",
"parts": [{"type": "text", "text": review_result}],
}
task["messages"].append(agent_message)
task["status"] = {"state": "completed", "message": None}
task["artifacts"] = [
{
"name": "code-review-report",
"description": "Structured code review report",
"parts": [{"type": "text", "text": review_result}],
"index": 0,
"lastChunk": True,
}
]
except Exception as e:
task["status"] = {"state": "failed", "message": str(e)}
task["messages"].append({
"role": "agent",
"parts": [{"type": "text", "text": f"Review failed: {e}"}],
})
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"result": task,
})
def handle_tasks_get(jsonrpc: str, req_id: int, params: dict):
task_id = params.get("id", "")
task = tasks.get(task_id)
if task is None:
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
})
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"result": task,
})
def handle_tasks_cancel(jsonrpc: str, req_id: int, params: dict):
task_id = params.get("id", "")
task = tasks.get(task_id)
if task is None:
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
})
task["status"] = {"state": "cancelled", "message": None}
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"result": task,
})
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "ok", "agent": "langchain-code-reviewer", "tasks": len(tasks)}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print(f"Starting LangChain Code Review Agent on {HOST}:{PORT}")
print(f"Agent Card: {BASE_URL}/.well-known/agent.json")
print(f"A2A endpoint: {BASE_URL}/a2a")
uvicorn.run(app, host=HOST, port=PORT)
@@ -0,0 +1,23 @@
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "langchain-code-review-pipeline",
"description": "Code review pipeline: uses LangChain external agent for deep review, then OpenFang Writer agent to format the final report.",
"created_at": "2026-03-16T00:00:00Z",
"steps": [
{
"name": "review-code",
"agent": { "name": "a2a-proxy" },
"prompt_template": "Use the a2a_send tool to send the following code to the external agent for code review. Set agent_name to langchain-code-reviewer and set message to the code below. Return the complete review result:\n\n{{input}}",
"mode": "sequential",
"timeout_secs": 300,
"output_var": "review_result"
},
{
"name": "format-report",
"agent": { "name": "Writer" },
"prompt_template": "Format the following code review into a clean, professional report. Preserve all severity levels and scores. Add a brief executive summary at the top:\n\n{{review_result}}",
"mode": "sequential",
"timeout_secs": 120
}
]
}
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["legal", "contracts", "compliance", "research", "review", "documents"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 8192
temperature = 0.2
@@ -58,8 +58,8 @@ DISCLAIMER: You are an AI assistant providing legal information for educational
You are meticulous, cautious, and precise. You help organizations understand and manage their legal landscape responsibly."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["meetings", "notes", "action-items", "agenda", "follow-up", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Meeting Assistant, a specialist agent in the OpenFang Agent OS. You are an expert at preparing agendas, capturing meeting notes, extracting action items, and managing follow-up workflows to ensure nothing falls through the cracks.
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
You are organized, detail-oriented, and relentlessly focused on accountability. You turn chaotic meetings into clear outcomes."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+2 -2
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.1-8b-instant"
provider = "default"
model = "default"
max_tokens = 2048
temperature = 0.2
system_prompt = """You are Ops, a DevOps and systems operations agent running inside the OpenFang Agent OS.
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -45,8 +45,8 @@ Always explain your delegation strategy before executing it.
Be thorough but efficient — don't delegate trivially simple tasks."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[schedule]
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["finance", "budget", "expenses", "savings", "planning", "money"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.2
system_prompt = """You are Personal Finance, a specialist agent in the OpenFang Agent OS. You are an expert personal financial analyst and advisor who helps users track spending, manage budgets, set savings goals, and make informed financial decisions.
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Planner, a project planning specialist running inside the OpenFang Agent OS.
@@ -37,7 +37,7 @@ Output format:
### Open Questions"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["recruiting", "hiring", "resume", "outreach", "talent", "hr"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.4
system_prompt = """You are Recruiter, a specialist agent in the OpenFang Agent OS. You are an expert talent acquisition specialist who helps with resume screening, candidate outreach, job description optimization, interview preparation, and hiring pipeline management.
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
You are thorough, fair, and people-oriented. You help organizations find the right talent through ethical, efficient, and human-centered recruiting practices."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["research", "analysis", "web"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.5
@@ -36,8 +36,8 @@ OUTPUT:
Always cite your sources. Never present uncertain information as fact."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["sales", "crm", "outreach", "pipeline", "prospecting", "deals"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.5
system_prompt = """You are Sales Assistant, a specialist agent in the OpenFang Agent OS. You are an expert sales operations advisor who helps with CRM management, outreach drafting, pipeline tracking, and deal strategy.
@@ -54,7 +54,7 @@ TOOLS AVAILABLE:
You are strategic, persuasive, and detail-oriented. You help sales teams work smarter and close more deals."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["security", "audit", "vulnerability"]
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 4096
temperature = 0.2
@@ -37,8 +37,8 @@ Severity levels: CRITICAL / HIGH / MEDIUM / LOW / INFO
Report format: Finding Impact Evidence Remediation"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[schedule]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["social-media", "content", "marketing", "engagement", "scheduling", "analytics"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.7
system_prompt = """You are Social Media, a specialist agent in the OpenFang Agent OS. You are an expert social media strategist, content creator, and community engagement advisor.
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
You are creative, culturally aware, and strategically minded. You balance creativity with data-driven decision-making."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["testing", "qa", "validation"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -39,8 +39,8 @@ When reviewing test coverage:
- Suggest mutation testing targets"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["translation", "languages", "localization", "multilingual", "communication", "i18n"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Translator, a specialist agent in the OpenFang Agent OS. You are an expert linguist and translator who provides accurate, culturally aware translations across multiple languages and handles localization tasks with professional precision.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["travel", "planning", "itinerary", "booking", "logistics", "vacation"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Travel Planner, a specialist agent in the OpenFang Agent OS. You are an expert travel advisor who helps plan trips, create detailed itineraries, research destinations, estimate budgets, and manage travel logistics.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["education", "teaching", "tutoring", "learning", "explanation", "knowledge"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Tutor, a specialist agent in the OpenFang Agent OS. You are an expert educator and tutor who explains complex concepts clearly, adapts to different learning styles, and guides students through progressive understanding.
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.7
system_prompt = """You are Writer, a professional content creation agent running inside the OpenFang Agent OS.
@@ -30,7 +30,7 @@ OUTPUT:
- Adapt formatting to the target platform when specified."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+7 -1
View File
@@ -33,9 +33,15 @@ governor = { workspace = true }
tokio-stream = { workspace = true }
subtle = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
hex = { workspace = true }
socket2 = { workspace = true }
reqwest = { workspace = true }
argon2 = { workspace = true }
rand = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
reqwest = { workspace = true }
tempfile = { workspace = true }
uuid = { workspace = true }
+300 -58
View File
@@ -30,6 +30,7 @@ use openfang_channels::messenger::MessengerAdapter;
use openfang_channels::reddit::RedditAdapter;
use openfang_channels::revolt::RevoltAdapter;
use openfang_channels::viber::ViberAdapter;
use openfang_types::config::FeishuMode;
// Wave 4
use openfang_channels::flock::FlockAdapter;
use openfang_channels::guilded::GuildedAdapter;
@@ -43,19 +44,24 @@ use openfang_channels::webex::WebexAdapter;
// Wave 5
use async_trait::async_trait;
use openfang_channels::dingtalk::DingTalkAdapter;
use openfang_channels::dingtalk_stream::DingTalkStreamAdapter;
use openfang_channels::discourse::DiscourseAdapter;
use openfang_channels::gitter::GitterAdapter;
use openfang_channels::gotify::GotifyAdapter;
use openfang_channels::linkedin::LinkedInAdapter;
use openfang_channels::mqtt::MqttAdapter;
use openfang_channels::mumble::MumbleAdapter;
use openfang_channels::ntfy::NtfyAdapter;
use openfang_channels::webhook::WebhookAdapter;
use openfang_channels::wecom::WeComAdapter;
use openfang_kernel::OpenFangKernel;
use openfang_types::agent::AgentId;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
use openfang_runtime::str_utils::safe_truncate_str;
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
pub struct KernelBridgeAdapter {
kernel: Arc<OpenFangKernel>,
@@ -70,6 +76,37 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.send_message(agent_id, message)
.await
.map_err(|e| format!("{e}"))?;
// Silent/NO_REPLY responses should not be forwarded to channels
if result.silent {
return Ok(String::new());
}
Ok(result.response)
}
async fn send_message_with_blocks(
&self,
agent_id: AgentId,
blocks: Vec<openfang_types::message::ContentBlock>,
) -> Result<String, String> {
// Extract text for the message parameter (used for memory recall / logging)
let text: String = blocks
.iter()
.filter_map(|b| match b {
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let text = if text.is_empty() {
"[Image]".to_string()
} else {
text
};
let result = self
.kernel
.send_message_with_blocks(agent_id, &text, blocks)
.await
.map_err(|e| format!("{e}"))?;
Ok(result.response)
}
@@ -351,7 +388,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| t.agent_id.to_string());
let status = if t.enabled { "on" } else { "off" };
let id_short = &t.id.0.to_string()[..8];
let id_str = t.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
msg.push_str(&format!(
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
id_short,
@@ -390,7 +428,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.kernel
.triggers
.register(agent.id, pattern, prompt.to_string(), 0);
let id_short = &trigger_id.0.to_string()[..8];
let id_str = trigger_id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
}
@@ -405,7 +444,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
1 => {
let t = matched[0];
if self.kernel.triggers.remove(t.id) {
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
let id_str = t.id.0.to_string();
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
} else {
"Failed to remove trigger.".to_string()
}
@@ -428,7 +468,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| job.agent_id.to_string());
let status = if job.enabled { "on" } else { "off" };
let id_short = &job.id.0.to_string()[..8];
let id_str = job.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let sched = match &job.schedule {
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
@@ -450,6 +491,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg
}
#[allow(dead_code)]
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
match action {
"add" => {
@@ -488,7 +530,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
match self.kernel.cron_scheduler.add_job(job, false) {
Ok(id) => {
let id_short = &id.0.to_string()[..8];
let id_str = id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
}
Err(e) => format!("Failed to create job: {e}"),
@@ -510,7 +553,12 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
let j = matched[0];
match self.kernel.cron_scheduler.remove_job(j.id) {
Ok(_) => {
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
let id_str = j.id.0.to_string();
format!(
"Job [{}] '{}' removed.",
safe_truncate_str(&id_str, 8),
j.name
)
}
Err(e) => format!("Failed to remove job: {e}"),
}
@@ -539,10 +587,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
openfang_types::scheduler::CronAction::SystemEvent { text } => {
text.clone()
}
openfang_types::scheduler::CronAction::WorkflowRun {
workflow_id,
input,
..
} => {
format!(
"Run workflow {workflow_id}{}",
input
.as_deref()
.map(|i| format!(" with input: {i}"))
.unwrap_or_default()
)
}
};
match self.kernel.send_message(j.agent_id, &message).await {
Ok(result) => {
let id_short = &j.id.0.to_string()[..8];
let id_str = j.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] ran:\n{}", result.response)
}
Err(e) => format!("Failed to run job: {e}"),
@@ -562,7 +624,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
let mut msg = format!("Pending approvals ({}):\n", pending.len());
for req in &pending {
let id_short = &req.id.to_string()[..8];
let id_str = req.id.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
let age = if age_secs >= 60 {
format!("{}m", age_secs / 60)
@@ -603,10 +666,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
) {
Ok(_) => {
let verb = if approve { "Approved" } else { "Rejected" };
let id_str = req.id.to_string();
format!(
"{} [{}] {} — {}",
verb,
&req.id.to_string()[..8],
safe_truncate_str(&id_str, 8),
req.tool_name,
req.agent_id
)
@@ -646,9 +710,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
));
}
self.kernel
.set_agent_model(agent_id, model)
.set_agent_model(agent_id, model, None)
.map_err(|e| format!("{e}"))?;
Ok(format!("Model switched to: {model}"))
// Read back resolved model+provider from registry
let entry = self
.kernel
.registry
.get(agent_id)
.ok_or_else(|| "Agent not found after model switch".to_string())?;
Ok(format!(
"Model switched to: {} (provider: {})",
entry.manifest.model.model, entry.manifest.model.provider
))
}
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
@@ -727,12 +800,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
// Wave 5
"mumble" => channels.mumble.as_ref().map(|c| c.overrides.clone()),
"dingtalk" => channels.dingtalk.as_ref().map(|c| c.overrides.clone()),
"dingtalk_stream" => channels
.dingtalk_stream
.as_ref()
.map(|c| c.overrides.clone()),
"discourse" => channels.discourse.as_ref().map(|c| c.overrides.clone()),
"gitter" => channels.gitter.as_ref().map(|c| c.overrides.clone()),
"ntfy" => channels.ntfy.as_ref().map(|c| c.overrides.clone()),
"gotify" => channels.gotify.as_ref().map(|c| c.overrides.clone()),
"webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()),
"linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()),
"wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()),
"mqtt" => channels.mqtt.as_ref().map(|c| c.overrides.clone()),
_ => None,
}
}
@@ -774,6 +853,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
recipient: &str,
success: bool,
error: Option<&str>,
thread_id: Option<&str>,
) {
let receipt = if success {
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
@@ -786,9 +866,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
};
self.kernel.delivery_tracker.record(agent_id, receipt);
// Persist last channel for cron CronDelivery::LastChannel
// Persist last channel for cron CronDelivery::LastChannel.
// Include thread_id when present so forum-topic context survives restarts.
if success {
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
if let Some(tid) = thread_id {
kv_val["thread_id"] = serde_json::json!(tid);
}
let _ = self
.kernel
.memory
@@ -859,7 +943,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
return "OFP peer network is disabled. Set network_enabled = true in config.toml."
.to_string();
}
match &self.kernel.peer_registry {
match self.kernel.peer_registry.get() {
Some(registry) => {
let peers = registry.all_peers();
if peers.is_empty() {
@@ -893,11 +977,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg.push_str(&format!(" {}{}\n", card.name, url));
let desc = &card.description;
if !desc.is_empty() {
let short = if desc.len() > 60 {
&desc[..60]
} else {
desc.as_str()
};
let short = openfang_types::truncate_str(desc, 60);
msg.push_str(&format!(" {short}\n"));
}
}
@@ -938,40 +1018,39 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
}
}
/// Resolve a default agent by name — find running or spawn from manifest.
async fn resolve_default_agent(
handle: &KernelBridgeAdapter,
name: &str,
router: &mut AgentRouter,
adapter_name: &str,
) {
match handle.find_agent_by_name(name).await {
Ok(Some(agent_id)) => {
router.set_default(agent_id);
info!("{adapter_name} default agent: {name} ({agent_id})");
}
_ => match handle.spawn_agent_by_name(name).await {
Ok(agent_id) => {
router.set_default(agent_id);
info!("{adapter_name}: spawned default agent {name} ({agent_id})");
}
Err(e) => {
warn!("{adapter_name}: could not find or spawn default agent '{name}': {e}");
}
},
}
}
/// Resolve a token: if the value looks like an actual secret (contains `:`,
/// starts with `xoxb-`, `xapp-`, `sk-`, etc.), use it directly.
/// Otherwise treat it as an env var name and look it up.
fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
// Heuristic: actual tokens contain `:` (Telegram, Discord) or start with
// known prefixes. Env var names are uppercase ASCII identifiers.
let looks_like_token = env_var_or_token.contains(':')
|| env_var_or_token.starts_with("xoxb-")
|| env_var_or_token.starts_with("xapp-")
|| env_var_or_token.starts_with("sk-")
|| env_var_or_token.starts_with("Bearer ")
|| env_var_or_token.len() > 80; // Long random strings are tokens, not env var names
/// Read a token from an env var, returning None with a warning if missing/empty.
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
match std::env::var(env_var) {
if looks_like_token {
warn!(
"{adapter_name}: config field contains what looks like an actual token \
rather than an env var name using it directly. \
Tip: store the token in an env var and use the var name instead for security."
);
return Some(env_var_or_token.to_string());
}
match std::env::var(env_var_or_token) {
Ok(t) if !t.is_empty() => Some(t),
Ok(_) => {
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping");
None
}
Err(_) => {
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
warn!(
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
Set it with: export {env_var_or_token}=<your-token>"
);
None
}
}
@@ -1031,6 +1110,7 @@ pub async fn start_channel_bridge_with_config(
// Wave 5
|| config.mumble.is_some()
|| config.dingtalk.is_some()
|| config.dingtalk_stream.is_some()
|| config.discourse.is_some()
|| config.gitter.is_some()
|| config.ntfy.is_some()
@@ -1058,6 +1138,7 @@ pub async fn start_channel_bridge_with_config(
token,
tg_config.allowed_users.clone(),
poll_interval,
tg_config.api_url.clone(),
));
adapters.push((adapter, tg_config.default_agent.clone()));
}
@@ -1069,6 +1150,8 @@ pub async fn start_channel_bridge_with_config(
let adapter = Arc::new(DiscordAdapter::new(
token,
dc_config.allowed_guilds.clone(),
dc_config.allowed_users.clone(),
dc_config.ignore_bots,
dc_config.intents,
));
adapters.push((adapter, dc_config.default_agent.clone()));
@@ -1083,6 +1166,9 @@ pub async fn start_channel_bridge_with_config(
app_token,
bot_token,
sl_config.allowed_channels.clone(),
sl_config.auto_thread_reply,
sl_config.thread_ttl_hours,
sl_config.unfurl_links,
));
adapters.push((adapter, sl_config.default_agent.clone()));
}
@@ -1092,7 +1178,9 @@ pub async fn start_channel_bridge_with_config(
// WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL)
if let Some(ref wa_config) = config.whatsapp {
let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp");
let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty());
let gateway_url = std::env::var(&wa_config.gateway_url_env)
.ok()
.filter(|u| !u.is_empty());
if cloud_token.is_some() || gateway_url.is_some() {
let token = cloud_token.unwrap_or_default();
@@ -1134,6 +1222,7 @@ pub async fn start_channel_bridge_with_config(
mx_config.user_id.clone(),
token,
mx_config.allowed_rooms.clone(),
mx_config.auto_accept_invites,
));
adapters.push((adapter, mx_config.default_agent.clone()));
}
@@ -1340,11 +1429,27 @@ pub async fn start_channel_bridge_with_config(
// Feishu/Lark
if let Some(ref fs_config) = config.feishu {
if let Some(secret) = read_token(&fs_config.app_secret_env, "Feishu") {
let adapter = Arc::new(FeishuAdapter::new(
fs_config.app_id.clone(),
secret,
fs_config.webhook_port,
));
let region = openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region);
let encrypt_key = fs_config
.encrypt_key_env
.as_ref()
.and_then(|env| read_token(env, "Feishu encrypt_key"));
let adapter = match fs_config.mode {
FeishuMode::Webhook => Arc::new(FeishuAdapter::with_config(
fs_config.app_id.clone(),
secret,
fs_config.webhook_port,
region,
Some(fs_config.webhook_path.clone()),
fs_config.verification_token.clone(),
encrypt_key,
fs_config.bot_names.clone(),
)),
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket(
fs_config.app_id.clone(),
secret,
)),
};
adapters.push((adapter, fs_config.default_agent.clone()));
}
}
@@ -1357,6 +1462,21 @@ pub async fn start_channel_bridge_with_config(
}
}
// WeCom/WeChat Work
if let Some(ref wc_config) = config.wecom {
if let Some(secret) = read_token(&wc_config.secret_env, "WeCom") {
let adapter = Arc::new(WeComAdapter::with_verification(
wc_config.corp_id.clone(),
wc_config.agent_id.clone(),
secret,
wc_config.webhook_port,
wc_config.encoding_aes_key.clone(),
wc_config.token.clone(),
));
adapters.push((adapter, wc_config.default_agent.clone()));
}
}
// ── Wave 4 ──────────────────────────────────────────────────
// Nextcloud Talk
@@ -1463,7 +1583,7 @@ pub async fn start_channel_bridge_with_config(
}
}
// DingTalk
// DingTalk (webhook mode)
if let Some(ref dt_config) = config.dingtalk {
if let Some(token) = read_token(&dt_config.access_token_env, "DingTalk") {
let secret = read_token(&dt_config.secret_env, "DingTalk (secret)").unwrap_or_default();
@@ -1472,6 +1592,21 @@ pub async fn start_channel_bridge_with_config(
}
}
// DingTalk (stream mode)
if let Some(ref ds_config) = config.dingtalk_stream {
if let Some(app_key) = read_token(&ds_config.app_key_env, "DingTalk Stream (app_key)") {
if let Some(app_secret) =
read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)")
{
let robot_code =
read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)")
.unwrap_or_else(|| app_key.clone());
let adapter = Arc::new(DingTalkStreamAdapter::new(app_key, app_secret, robot_code));
adapters.push((adapter, ds_config.default_agent.clone()));
}
}
}
// Discourse
if let Some(ref dc_config) = config.discourse {
if let Some(api_key) = read_token(&dc_config.api_key_env, "Discourse") {
@@ -1545,16 +1680,63 @@ pub async fn start_channel_bridge_with_config(
}
}
// MQTT
if let Some(ref mq_config) = config.mqtt {
let username = read_token(&mq_config.username_env, "MQTT (username)");
let password = read_token(&mq_config.password_env, "MQTT (password)");
let adapter = Arc::new(MqttAdapter::new(
mq_config.broker_url.clone(),
mq_config.client_id.clone(),
mq_config.subscribe_topic.clone(),
mq_config.publish_topic.clone(),
username,
password,
mq_config.use_tls,
mq_config.keep_alive_secs,
mq_config.clean_session,
mq_config.qos,
));
adapters.push((adapter, mq_config.default_agent.clone()));
}
if adapters.is_empty() {
return (None, Vec::new());
}
// Resolve default agent from first adapter that has one configured
// Resolve per-channel default agents AND set the first one as system-wide fallback
let mut router = AgentRouter::new();
for (_, default_agent) in &adapters {
let mut system_default_set = false;
for (adapter, default_agent) in &adapters {
if let Some(ref name) = default_agent {
resolve_default_agent(&handle, name, &mut router, "Channel bridge").await;
break; // Only need one default
// Resolve agent name to ID
let agent_id = match handle.find_agent_by_name(name).await {
Ok(Some(id)) => Some(id),
_ => match handle.spawn_agent_by_name(name).await {
Ok(id) => Some(id),
Err(e) => {
warn!(
"{}: could not find or spawn default agent '{}': {e}",
adapter.name(),
name
);
None
}
},
};
if let Some(agent_id) = agent_id {
// Register per-channel default
let channel_key = format!("{:?}", adapter.channel_type());
info!(
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
adapter.name()
);
router.set_channel_default_with_name(channel_key, agent_id, name.clone());
// First configured default also becomes system-wide fallback
if !system_default_set {
router.set_default(agent_id);
system_default_set = true;
}
}
}
}
@@ -1620,6 +1802,35 @@ pub async fn reload_channels_from_disk(
*guard = None;
}
// Re-read secrets.env so new API tokens are available in std::env
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
if secrets_path.exists() {
if let Ok(content) = std::fs::read_to_string(&secrets_path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim();
let mut value = trimmed[eq_pos + 1..].trim().to_string();
if !key.is_empty() {
// Strip matching quotes
if ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
&& value.len() >= 2
{
value = value[1..value.len() - 1].to_string();
}
// Always overwrite — the file is the source of truth after dashboard edits
std::env::set_var(key, &value);
}
}
}
info!("Reloaded secrets.env for channel hot-reload");
}
}
// Re-read config from disk
let config_path = state.kernel.config.home_dir.join("config.toml");
let fresh_config = openfang_kernel::config::load_config(Some(&config_path));
@@ -1692,4 +1903,35 @@ mod tests {
assert!(config.channels.webhook.is_none());
assert!(config.channels.linkedin.is_none());
}
#[test]
fn test_feishu_bridge_mode_defaults_to_websocket() {
let config: openfang_types::config::KernelConfig = toml::from_str(
r#"
[channels.feishu]
app_id = "cli_test"
app_secret_env = "FEISHU_APP_SECRET"
"#,
)
.unwrap();
let feishu = config.channels.feishu.expect("feishu config should exist");
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
}
#[test]
fn test_feishu_bridge_mode_supports_websocket() {
let config: openfang_types::config::KernelConfig = toml::from_str(
r#"
[channels.feishu]
app_id = "cli_test"
app_secret_env = "FEISHU_APP_SECRET"
mode = "websocket"
"#,
)
.unwrap();
let feishu = config.channels.feishu.expect("feishu config should exist");
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod openai_compat;
pub mod rate_limiter;
pub mod routes;
pub mod server;
pub mod session_auth;
pub mod stream_chunker;
pub mod stream_dedup;
pub mod types;
+126 -56
View File
@@ -43,84 +43,121 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
response
}
/// Authentication state passed to the auth middleware.
#[derive(Clone)]
pub struct AuthState {
pub api_key: String,
pub auth_enabled: bool,
pub session_secret: String,
}
/// Bearer token authentication middleware.
///
/// When `api_key` is non-empty, all requests must include
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
/// When `api_key` is non-empty (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 dashboard auth is enabled, session cookies are also accepted.
pub async fn auth(
axum::extract::State(api_key): axum::extract::State<String>,
axum::extract::State(auth_state): axum::extract::State<AuthState>,
request: Request<Body>,
next: Next,
) -> Response<Body> {
// If no API key configured, restrict to loopback addresses only.
if api_key.is_empty() {
// SECURITY: Capture method early for method-aware public endpoint checks.
let method = request.method().clone();
// Shutdown is loopback-only (CLI on same machine) — skip token auth
let path = request.uri().path();
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);
if !is_loopback {
tracing::warn!(
"Rejected non-localhost request: no API key configured. \
Set api_key in config.toml for remote access."
);
return Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
})
.to_string(),
))
.unwrap_or_default();
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
if is_loopback {
return next.run(request).await;
}
return next.run(request).await;
}
// Public endpoints that don't require auth (dashboard needs these)
let path = request.uri().path();
if path == "/"
// Public endpoints that don't require auth (dashboard needs these).
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
// SECURITY: Public endpoints are GET-only unless explicitly noted.
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
// unauthenticated writes (cron job creation, skill install, etc.).
let is_get = method == axum::http::Method::GET;
let is_public = path == "/"
|| path == "/logo.png"
|| path == "/favicon.ico"
|| (path == "/.well-known/agent.json" && is_get)
|| (path.starts_with("/a2a/") && is_get)
|| path == "/api/health"
|| path == "/api/health/detail"
|| path == "/api/status"
|| path == "/api/version"
|| path == "/api/agents"
|| path == "/api/profiles"
|| path == "/api/config"
|| path.starts_with("/api/uploads/")
|| (path == "/api/agents" && is_get)
|| (path == "/api/profiles" && is_get)
|| (path == "/api/config" && is_get)
|| (path == "/api/config/schema" && is_get)
|| (path.starts_with("/api/uploads/") && is_get)
// Dashboard read endpoints — allow unauthenticated so the SPA can
// render before the user enters their API key.
|| path == "/api/models"
|| path == "/api/models/aliases"
|| path == "/api/providers"
|| path == "/api/budget"
|| path == "/api/budget/agents"
|| path.starts_with("/api/budget/agents/")
|| path == "/api/network/status"
|| path == "/api/a2a/agents"
|| path == "/api/approvals"
|| path == "/api/channels"
|| path == "/api/skills"
|| path == "/api/sessions"
|| path == "/api/integrations"
|| path == "/api/integrations/available"
|| path == "/api/integrations/health"
|| path.starts_with("/api/cron/")
{
|| (path == "/api/models" && is_get)
|| (path == "/api/models/aliases" && is_get)
|| (path == "/api/providers" && is_get)
|| (path == "/api/budget" && is_get)
|| (path == "/api/budget/agents" && is_get)
|| (path.starts_with("/api/budget/agents/") && is_get)
|| (path == "/api/network/status" && is_get)
|| (path == "/api/a2a/agents" && is_get)
|| (path == "/api/approvals" && is_get)
|| (path.starts_with("/api/approvals/") && is_get)
|| (path == "/api/channels" && is_get)
|| (path == "/api/hands" && is_get)
|| (path == "/api/hands/active" && is_get)
|| (path.starts_with("/api/hands/") && is_get)
|| (path == "/api/skills" && is_get)
|| (path == "/api/sessions" && is_get)
|| (path == "/api/integrations" && is_get)
|| (path == "/api/integrations/available" && is_get)
|| (path == "/api/integrations/health" && is_get)
|| (path == "/api/workflows" && is_get)
|| path == "/api/logs/stream" // SSE stream, read-only
|| (path.starts_with("/api/cron/") && is_get)
|| path.starts_with("/api/providers/github-copilot/oauth/")
|| path == "/api/auth/login"
|| path == "/api/auth/logout"
|| (path == "/api/auth/check" && is_get);
if is_public {
return next.run(request).await;
}
// Check Authorization: Bearer <token> header
// 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.
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;
}
let api_key = api_key_trimmed.as_str();
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
let bearer_token = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
let api_token = bearer_token.or_else(|| {
request
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
});
// SECURITY: Use constant-time comparison to prevent timing attacks.
let header_auth = bearer_token.map(|token| {
let header_auth = api_token.map(|token| {
use subtle::ConstantTimeEq;
if token.len() != api_key.len() {
return false;
@@ -149,6 +186,17 @@ pub async fn auth(
return next.run(request).await;
}
// Check session cookie (dashboard login sessions)
if auth_state.auth_enabled {
if let Some(token) = extract_session_cookie(&request) {
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
.is_some()
{
return next.run(request).await;
}
}
}
// Determine error message: was a credential provided but wrong, or missing entirely?
let credential_provided = header_auth.is_some() || query_auth.is_some();
let error_msg = if credential_provided {
@@ -166,6 +214,21 @@ 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;
@@ -173,13 +236,16 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
headers.insert("x-frame-options", "DENY".parse().unwrap());
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
// All JS/CSS is bundled inline — only external resource is Google Fonts.
headers.insert(
"content-security-policy",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
.parse()
.unwrap(),
);
// The dashboard handler (webchat_page) sets its own nonce-based CSP.
// For all other responses (API endpoints), apply a strict default.
if !headers.contains_key("content-security-policy") {
headers.insert(
"content-security-policy",
"default-src 'none'; frame-ancestors 'none'"
.parse()
.unwrap(),
);
}
headers.insert(
"referrer-policy",
"strict-origin-when-cross-origin".parse().unwrap(),
@@ -188,6 +254,10 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
"cache-control",
"no-store, no-cache, must-revalidate".parse().unwrap(),
);
headers.insert(
"strict-transport-security",
"max-age=63072000; includeSubDomains".parse().unwrap(),
);
response
}
+9 -9
View File
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
return Some((entry.id, entry.name.clone()));
}
// 4. Fallback → first registered agent
let agents = state.kernel.registry.list();
agents.first().map(|e| (e.id, e.name.clone()))
// No match — return None so the caller returns a proper 404
None
}
// ── Message conversion ──────────────────────────────────────────────────────
@@ -203,9 +202,10 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
let blocks: Vec<ContentBlock> = parts
.iter()
.filter_map(|part| match part {
OaiContentPart::Text { text } => {
Some(ContentBlock::Text { text: text.clone() })
}
OaiContentPart::Text { text } => Some(ContentBlock::Text {
text: text.clone(),
provider_metadata: None,
}),
OaiContentPart::ImageUrl { image_url } => {
// Parse data URI: data:{media_type};base64,{data}
if let Some(rest) = image_url.url.strip_prefix("data:") {
@@ -323,7 +323,7 @@ pub async fn chat_completions(
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle))
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle), None, None)
.await
{
Ok(result) => {
@@ -336,7 +336,7 @@ pub async fn chat_completions(
index: 0,
message: ChoiceMessage {
role: "assistant",
content: Some(result.response),
content: Some(crate::ws::strip_think_tags(&result.response)),
tool_calls: None,
},
finish_reason: "stop",
@@ -379,7 +379,7 @@ async fn stream_response(
let (mut rx, _handle) = state
.kernel
.send_message_streaming(agent_id, message, Some(kernel_handle))
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None, None)
.map_err(|e| format!("Streaming setup failed: {e}"))?;
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
+1
View File
@@ -29,6 +29,7 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
("POST", p) if p.contains("/run") => NonZeroU32::new(100).unwrap(),
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
_ => NonZeroU32::new(5).unwrap(),
File diff suppressed because it is too large Load Diff
+158 -9
View File
@@ -45,15 +45,18 @@ pub async fn build_router(
let state = Arc::new(AppState {
kernel: kernel.clone(),
started_at: Instant::now(),
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
peer_registry: kernel.peer_registry.get().map(|r| Arc::new(r.clone())),
bridge_manager: tokio::sync::Mutex::new(bridge),
channels_config: tokio::sync::RwLock::new(channels_config),
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(kernel.config.budget.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.is_empty() {
let cors = if state.kernel.config.api_key.trim().is_empty() {
// No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost)
let port = listen_addr.port();
let mut origins: Vec<axum::http::HeaderValue> = vec![
@@ -101,13 +104,36 @@ pub async fn build_router(
.allow_headers(tower_http::cors::Any)
};
let api_key = state.kernel.config.api_key.clone();
// Warn if dashboard auth is enabled but the password hash is not Argon2id.
let ph = &state.kernel.config.auth.password_hash;
if state.kernel.config.auth.enabled && !ph.is_empty() && !ph.starts_with("$argon2") {
tracing::warn!(
"Dashboard auth password_hash is not in Argon2id format. \
Login will fail. Regenerate with: openfang auth hash-password"
);
}
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = crate::middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let gcra_limiter = rate_limiter::create_rate_limiter();
let app = Router::new()
.route("/", axum::routing::get(webchat::webchat_page))
.route("/logo.png", axum::routing::get(webchat::logo_png))
.route("/favicon.ico", axum::routing::get(webchat::favicon_ico))
.route("/manifest.json", axum::routing::get(webchat::manifest_json))
.route("/sw.js", axum::routing::get(webchat::sw_js))
.route(
"/api/metrics",
axum::routing::get(routes::prometheus_metrics),
@@ -125,13 +151,23 @@ pub async fn build_router(
)
.route(
"/api/agents/{id}",
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
axum::routing::get(routes::get_agent)
.delete(routes::kill_agent)
.patch(routes::patch_agent),
)
.route(
"/api/agents/{id}/mode",
axum::routing::put(routes::set_agent_mode),
)
.route("/api/profiles", axum::routing::get(routes::list_profiles))
.route(
"/api/agents/{id}/restart",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/start",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/message",
axum::routing::post(routes::send_message),
@@ -156,6 +192,10 @@ pub async fn build_router(
"/api/agents/{id}/session/reset",
axum::routing::post(routes::reset_session),
)
.route(
"/api/agents/{id}/history",
axum::routing::delete(routes::clear_agent_history),
)
.route(
"/api/agents/{id}/session/compact",
axum::routing::post(routes::compact_session),
@@ -168,6 +208,10 @@ pub async fn build_router(
"/api/agents/{id}/model",
axum::routing::put(routes::set_model),
)
.route(
"/api/agents/{id}/tools",
axum::routing::get(routes::get_agent_tools).put(routes::set_agent_tools),
)
.route(
"/api/agents/{id}/skills",
axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills),
@@ -277,6 +321,12 @@ pub async fn build_router(
"/api/workflows",
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
)
.route(
"/api/workflows/{id}",
axum::routing::get(routes::get_workflow)
.put(routes::update_workflow)
.delete(routes::delete_workflow),
)
.route(
"/api/workflows/{id}/run",
axum::routing::post(routes::run_workflow),
@@ -295,6 +345,10 @@ pub async fn build_router(
"/api/skills/uninstall",
axum::routing::post(routes::uninstall_skill),
)
.route(
"/api/skills/reload",
axum::routing::post(routes::reload_skills),
)
.route(
"/api/marketplace/search",
axum::routing::get(routes::marketplace_search),
@@ -312,12 +366,24 @@ pub async fn build_router(
"/api/clawhub/skill/{slug}",
axum::routing::get(routes::clawhub_skill_detail),
)
.route(
"/api/clawhub/skill/{slug}/code",
axum::routing::get(routes::clawhub_skill_code),
)
.route(
"/api/clawhub/install",
axum::routing::post(routes::clawhub_install),
)
// Hands endpoints
.route("/api/hands", axum::routing::get(routes::list_hands))
.route(
"/api/hands/install",
axum::routing::post(routes::install_hand),
)
.route(
"/api/hands/upsert",
axum::routing::post(routes::upsert_hand),
)
.route(
"/api/hands/active",
axum::routing::get(routes::list_active_hands),
@@ -335,6 +401,10 @@ pub async fn build_router(
"/api/hands/{hand_id}/install-deps",
axum::routing::post(routes::install_hand_deps),
)
.route(
"/api/hands/{hand_id}/settings",
axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings),
)
.route(
"/api/hands/instances/{id}/pause",
axum::routing::post(routes::pause_hand),
@@ -377,6 +447,24 @@ pub async fn build_router(
"/api/network/status",
axum::routing::get(routes::network_status),
)
// Agent communication (Comms) endpoints
.route(
"/api/comms/topology",
axum::routing::get(routes::comms_topology),
)
.route(
"/api/comms/events",
axum::routing::get(routes::comms_events),
)
.route(
"/api/comms/events/stream",
axum::routing::get(routes::comms_events_stream),
)
.route("/api/comms/send", axum::routing::post(routes::comms_send))
.route("/api/comms/task", axum::routing::post(routes::comms_task));
// Split into a second router chunk to stay within axum's type nesting limit.
let app = app
// Tools endpoint
.route("/api/tools", axum::routing::get(routes::list_tools))
// Config endpoints
@@ -421,7 +509,7 @@ pub async fn build_router(
)
.route(
"/api/budget/agents/{id}",
axum::routing::get(routes::agent_budget_status),
axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget),
)
// Session endpoints
.route("/api/sessions", axum::routing::get(routes::list_sessions))
@@ -450,8 +538,25 @@ pub async fn build_router(
"/api/models/aliases",
axum::routing::get(routes::list_aliases),
)
.route(
"/api/models/custom",
axum::routing::post(routes::add_custom_model),
)
.route(
"/api/models/custom/{*id}",
axum::routing::delete(routes::remove_custom_model),
)
.route("/api/models/{*id}", axum::routing::get(routes::get_model))
.route("/api/providers", axum::routing::get(routes::list_providers))
// Copilot OAuth (must be before parametric {name} routes)
.route(
"/api/providers/github-copilot/oauth/start",
axum::routing::post(routes::copilot_oauth_start),
)
.route(
"/api/providers/github-copilot/oauth/poll/{poll_id}",
axum::routing::get(routes::copilot_oauth_poll),
)
.route(
"/api/providers/{name}/key",
axum::routing::post(routes::set_provider_key).delete(routes::delete_provider_key),
@@ -495,6 +600,10 @@ pub async fn build_router(
"/api/cron/jobs/{id}/status",
axum::routing::get(routes::cron_job_status),
)
.route(
"/api/cron/jobs/{id}/run",
axum::routing::post(routes::run_cron_job),
)
// Webhook trigger endpoints (external event injection)
.route("/hooks/wake", axum::routing::post(routes::webhook_wake))
.route("/hooks/agent", axum::routing::post(routes::webhook_agent))
@@ -608,8 +717,12 @@ pub async fn build_router(
"/v1/models",
axum::routing::get(crate::openai_compat::list_models),
)
// Dashboard authentication endpoints
.route("/api/auth/login", axum::routing::post(routes::auth_login))
.route("/api/auth/logout", axum::routing::post(routes::auth_logout))
.route("/api/auth/check", axum::routing::get(routes::auth_check))
.layer(axum::middleware::from_fn_with_state(
api_key,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn_with_state(
@@ -679,7 +792,8 @@ pub async fn run_daemon(
if info_path.exists() {
if let Ok(existing) = std::fs::read_to_string(info_path) {
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&existing) {
if is_process_alive(info.pid) {
// PID alive AND the health endpoint responds → truly running
if is_process_alive(info.pid) && is_daemon_responding(&info.listen_addr) {
return Err(format!(
"Another daemon (PID {}) is already running at {}",
info.pid, info.listen_addr
@@ -688,7 +802,8 @@ pub async fn run_daemon(
}
}
}
// Stale PID file, remove it
// Stale PID file (process dead or different process reused PID), remove it
info!("Removing stale daemon info file");
let _ = std::fs::remove_file(info_path);
}
@@ -710,7 +825,21 @@ pub async fn run_daemon(
info!("WebChat UI available at http://{addr}/",);
info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",);
let listener = tokio::net::TcpListener::bind(addr).await?;
// Use SO_REUSEADDR to allow binding immediately after reboot (avoids TIME_WAIT).
let socket = socket2::Socket::new(
if addr.is_ipv4() {
socket2::Domain::IPV4
} else {
socket2::Domain::IPV6
},
socket2::Type::STREAM,
None,
)?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
// Run server with graceful shutdown.
// SECURITY: `into_make_service_with_connect_info` injects the peer
@@ -830,3 +959,23 @@ fn is_process_alive(pid: u32) -> bool {
false
}
}
/// Check if an OpenFang daemon is actually responding at the given address.
/// This avoids false positives where a different process reused the same PID
/// after a system reboot.
fn is_daemon_responding(addr: &str) -> bool {
// Quick TCP connect check — don't make a full HTTP request to avoid delays
let addr_only = addr
.strip_prefix("http://")
.or_else(|| addr.strip_prefix("https://"))
.unwrap_or(addr);
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500))
.is_ok()
} else {
// Fallback: try connecting to hostname
std::net::TcpStream::connect(addr_only)
.map(|_| true)
.unwrap_or(false)
}
}
+144
View File
@@ -0,0 +1,144 @@
//! Stateless session token authentication for the dashboard.
//! Tokens are HMAC-SHA256 signed and contain username + expiry.
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Create a session token: base64(username:expiry_unix:hmac_hex)
pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> String {
use base64::Engine;
let expiry = chrono::Utc::now().timestamp() + (ttl_hours as i64 * 3600);
let payload = format!("{username}:{expiry}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
mac.update(payload.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
}
/// 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;
let decoded = base64::engine::general_purpose::STANDARD
.decode(token)
.ok()?;
let decoded_str = String::from_utf8(decoded).ok()?;
let parts: Vec<&str> = decoded_str.splitn(3, ':').collect();
if parts.len() != 3 {
return None;
}
let (username, expiry_str, provided_sig) = (parts[0], parts[1], parts[2]);
let expiry: i64 = expiry_str.parse().ok()?;
if chrono::Utc::now().timestamp() > expiry {
return None;
}
let payload = format!("{username}:{expiry_str}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(payload.as_bytes());
let expected_sig = hex::encode(mac.finalize().into_bytes());
use subtle::ConstantTimeEq;
if provided_sig.len() != expected_sig.len() {
return None;
}
if provided_sig
.as_bytes()
.ct_eq(expected_sig.as_bytes())
.into()
{
Some(username.to_string())
} else {
None
}
}
/// Hash a password with Argon2id for config storage.
///
/// Returns a PHC-format string (e.g. `$argon2id$v=19$m=19456,t=2,p=1$...`).
pub fn hash_password(password: &str) -> String {
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
let salt = SaltString::generate(&mut rand::thread_rng());
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.expect("Argon2 hashing should not fail with valid inputs")
.to_string()
}
/// Verify a password against a stored Argon2id hash (PHC string format).
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
let Ok(parsed) = PasswordHash::new(stored_hash) else {
return false;
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let hash = hash_password("secret123");
assert!(
hash.starts_with("$argon2id$"),
"should produce Argon2id PHC string"
);
assert!(verify_password("secret123", &hash));
assert!(!verify_password("wrong", &hash));
}
#[test]
fn test_hash_produces_unique_salts() {
let h1 = hash_password("same");
let h2 = hash_password("same");
assert_ne!(h1, h2, "each hash should use a unique salt");
assert!(verify_password("same", &h1));
assert!(verify_password("same", &h2));
}
#[test]
fn test_rejects_non_argon2_hash() {
// A plain SHA256 hex string should no longer be accepted.
use sha2::Digest;
let sha256_hash = hex::encode(sha2::Sha256::digest(b"password"));
assert!(!verify_password("password", &sha256_hash));
}
#[test]
fn test_create_and_verify_token() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "my-secret");
assert_eq!(user, Some("admin".to_string()));
}
#[test]
fn test_token_wrong_secret() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "wrong-secret");
assert_eq!(user, None);
}
#[test]
fn test_token_invalid_base64() {
let user = verify_session_token("not-valid-base64!!!", "secret");
assert_eq!(user, None);
}
#[test]
fn test_rejects_garbage_input() {
assert!(!verify_password("x", "short"));
assert!(!verify_password("x", ""));
}
#[test]
fn test_verify_malformed_argon2_hash() {
// Starts with $argon2 but is not a valid PHC string.
assert!(!verify_password("x", "$argon2id$garbage"));
}
}
+24 -4
View File
@@ -102,9 +102,15 @@ impl StreamChunker {
}
}
// Priority 4: Forced break at max_chunk_chars
// Priority 4: Forced break at max_chunk_chars (char-boundary safe)
if self.buffer.len() >= self.max_chunk_chars {
let break_at = self.max_chunk_chars;
let mut break_at = self.max_chunk_chars;
while break_at > 0 && !self.buffer.is_char_boundary(break_at) {
break_at -= 1;
}
if break_at == 0 {
break_at = self.buffer.len();
}
let chunk = self.buffer[..break_at].to_string();
self.buffer = self.buffer[break_at..].to_string();
return Some(chunk);
@@ -134,9 +140,23 @@ impl StreamChunker {
}
/// Find the last occurrence of a pattern within a byte range.
///
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
/// char boundary so that slicing never panics on multi-byte content.
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
let search_text = &text[range.start..range.end.min(text.len())];
search_text.rfind(pattern).map(|pos| range.start + pos)
let len = text.len();
// Clamp end to text length and walk back to a char boundary
let mut end = range.end.min(len);
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
// Walk start forward to the nearest char boundary (never past end)
let mut start = range.start.min(end);
while start < end && !text.is_char_boundary(start) {
start += 1;
}
let search_text = &text[start..end];
search_text.rfind(pattern).map(|pos| start + pos)
}
#[cfg(test)]
+15 -2
View File
@@ -2,11 +2,16 @@
use serde::{Deserialize, Serialize};
/// Request to spawn an agent from a TOML manifest string.
/// Request to spawn an agent from a TOML manifest string or a template name.
#[derive(Debug, Deserialize)]
pub struct SpawnRequest {
/// Agent manifest as TOML string.
/// Agent manifest as TOML string (optional if `template` is provided).
#[serde(default)]
pub manifest_toml: String,
/// Template name from `~/.openfang/agents/{template}/agent.toml`.
/// When provided and `manifest_toml` is empty, the template is loaded automatically.
#[serde(default)]
pub template: Option<String>,
/// Optional Ed25519 signed manifest envelope (JSON).
/// When present, the signature is verified before spawning.
#[serde(default)]
@@ -37,6 +42,12 @@ pub struct MessageRequest {
/// Optional file attachments (uploaded via /upload endpoint).
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
/// Sender identity (e.g. WhatsApp phone number, Telegram user ID).
#[serde(default)]
pub sender_id: Option<String>,
/// Sender display name.
#[serde(default)]
pub sender_name: Option<String>,
}
/// Response from sending a message.
@@ -46,6 +57,8 @@ pub struct MessageResponse {
pub input_tokens: u64,
pub output_tokens: u64,
pub iterations: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
}
/// Request to install a skill from the marketplace.
+76 -16
View File
@@ -15,7 +15,13 @@
use axum::http::header;
use axum::response::IntoResponse;
/// Nonce placeholder in compile-time HTML, replaced at request time.
const NONCE_PLACEHOLDER: &str = "__NONCE__";
/// Compile-time ETag based on the crate version.
/// Not used for the dashboard page (nonce prevents caching) but retained
/// for potential future use by static asset handlers.
#[allow(dead_code)]
const ETAG: &str = concat!("\"openfang-", env!("CARGO_PKG_VERSION"), "\"");
/// Embedded logo PNG for single-binary deployment.
@@ -46,20 +52,65 @@ pub async fn favicon_ico() -> impl IntoResponse {
)
}
/// GET / — Serve the OpenFang Dashboard single-page application.
///
/// Returns the full SPA with ETag header based on package version for caching.
pub async fn webchat_page() -> impl IntoResponse {
/// Embedded PWA manifest for installable web app support.
const MANIFEST_JSON: &str = include_str!("../static/manifest.json");
/// Embedded service worker for PWA support.
const SW_JS: &str = include_str!("../static/sw.js");
/// GET /manifest.json — Serve the PWA web app manifest.
pub async fn manifest_json() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::ETAG, ETAG),
(
header::CACHE_CONTROL,
"public, max-age=3600, must-revalidate",
),
(header::CONTENT_TYPE, "application/manifest+json"),
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
],
WEBCHAT_HTML,
MANIFEST_JSON,
)
}
/// GET /sw.js — Serve the PWA service worker.
pub async fn sw_js() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
SW_JS,
)
}
/// GET / — Serve the OpenFang Dashboard single-page application.
///
/// Generates a unique CSP nonce on every request and injects it into both
/// the `<script>` tags and the `Content-Security-Policy` header. This
/// replaces `'unsafe-inline'` so only our own scripts execute.
pub async fn webchat_page() -> impl IntoResponse {
let nonce = uuid::Uuid::new_v4().to_string();
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; \
img-src 'self' data: blob:; \
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; \
font-src 'self' https://fonts.gstatic.com; \
media-src 'self' blob:; \
frame-src 'self' blob:; \
object-src 'none'; \
base-uri 'self'; \
form-action 'self'"
);
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
(
header::HeaderName::from_static("content-security-policy"),
csp,
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
html,
)
}
@@ -81,21 +132,26 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/vendor/github-dark.min.css"),
"\n</style>\n",
include_str!("../static/index_body.html"),
// Vendor libs: marked + highlight first (used by app.js)
"<script>\n",
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/marked.min.js"),
"\n</script>\n",
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/highlight.min.js"),
"\n</script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/chart.umd.min.js"),
"\n</script>\n",
// App code
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/js/api.js"),
"\n",
include_str!("../static/js/app.js"),
"\n",
include_str!("../static/js/pages/overview.js"),
"\n",
include_str!("../static/js/katex.js"),
"\n",
include_str!("../static/js/pages/chat.js"),
"\n",
include_str!("../static/js/pages/agents.js"),
@@ -123,9 +179,13 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/js/pages/wizard.js"),
"\n",
include_str!("../static/js/pages/approvals.js"),
"\n",
include_str!("../static/js/pages/comms.js"),
"\n",
include_str!("../static/js/pages/runtime.js"),
"\n</script>\n",
// Alpine.js MUST be last — it processes x-data and fires alpine:init
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/alpine.min.js"),
"\n</script>\n",
"</body></html>"
+223 -74
View File
@@ -146,19 +146,30 @@ pub async fn agent_ws(
uri: axum::http::Uri,
) -> impl IntoResponse {
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
let api_key = &state.kernel.config.api_key;
// Trim whitespace so empty/whitespace-only api_key disables auth.
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 header_auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|token| token == api_key)
.map(|token| ct_eq(token, api_key))
.unwrap_or(false);
let query_auth = uri
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(|token| token == api_key)
.map(|token| ct_eq(token, api_key))
.unwrap_or(false);
if !header_auth && !query_auth {
@@ -428,6 +439,7 @@ async fn handle_text_message(
// Resolve file attachments into image content blocks
let mut has_images = false;
let mut ws_content_blocks: Option<Vec<openfang_types::message::ContentBlock>> = None;
if let Some(attachments) = parsed["attachments"].as_array() {
let refs: Vec<crate::types::AttachmentRef> = attachments
.iter()
@@ -437,11 +449,7 @@ async fn handle_text_message(
let image_blocks = crate::routes::resolve_attachments(&refs);
if !image_blocks.is_empty() {
has_images = true;
crate::routes::inject_attachments_into_session(
&state.kernel,
agent_id,
image_blocks,
);
ws_content_blocks = Some(image_blocks);
}
}
}
@@ -491,16 +499,30 @@ async fn handle_text_message(
// Send message to agent with streaming
let kernel_handle: Arc<dyn KernelHandle> =
state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_streaming(agent_id, &content, Some(kernel_handle))
{
match state.kernel.send_message_streaming(
agent_id,
&content,
Some(kernel_handle),
None,
None,
ws_content_blocks,
) {
Ok((mut rx, handle)) => {
// Forward stream events to WebSocket with debouncing
// Forward stream events to WebSocket with debouncing.
//
// The stream_task also accumulates the full response text and
// captures ContentComplete usage data. This lets us send the
// `response` event immediately when the stream channel closes
// (after `drop(phase_cb)` in the kernel), WITHOUT waiting for
// post-processing (canonical session writes, JSONL, compaction)
// that happens in the kernel task after the loop.
let sender_stream = Arc::clone(sender);
let verbose_clone = Arc::clone(verbose);
let stream_task = tokio::spawn(async move {
let mut text_buffer = String::new();
let mut accumulated_text = String::new();
let mut stream_usage: Option<openfang_types::message::TokenUsage> = None;
let mut is_silent = false;
let far_future = tokio::time::Instant::now() + Duration::from_secs(86400);
let mut flush_deadline = far_future;
@@ -524,7 +546,15 @@ async fn handle_text_message(
break;
}
Some(ev) => {
// Capture ContentComplete for immediate response
if let StreamEvent::ContentComplete { usage, .. } = &ev {
stream_usage = Some(*usage);
// Don't forward — handled below
continue;
}
if let StreamEvent::TextDelta { ref text } = ev {
accumulated_text.push_str(text);
text_buffer.push_str(text);
if text_buffer.len() >= DEBOUNCE_CHARS {
let _ = flush_text_buffer(
@@ -589,14 +619,62 @@ async fn handle_text_message(
}
}
}
// Check if the agent signalled NO_REPLY via the stream
// (PhaseChange with a "silent" marker — currently the
// kernel sets result.silent after the loop, so we detect
// it from empty accumulated text when ContentComplete
// had no text deltas at all).
if accumulated_text.is_empty() && stream_usage.is_some() {
is_silent = true;
}
(accumulated_text, stream_usage, is_silent)
});
// Wait for the agent loop to complete
match handle.await {
Ok(Ok(result)) => {
// Cancel the stream forwarder (should be done by now)
stream_task.abort();
// Wait for the stream to finish (fast — closes as soon as
// drop(phase_cb) runs after the agent loop). This does NOT
// wait for post-processing.
let stream_result = stream_task.await;
// Spawn the kernel task in the background for cleanup
// (canonical session writes, JSONL mirror, compaction).
// We don't need its result for the response event.
let sender_bg = Arc::clone(sender);
tokio::spawn(async move {
match handle.await {
Ok(Err(e)) => {
warn!("Agent post-processing failed: {e}");
let user_msg = classify_streaming_error(&e);
let _ = send_json(
&sender_bg,
&serde_json::json!({
"type": "error",
"content": user_msg,
}),
)
.await;
}
Err(e) => {
warn!("Agent task panicked: {e}");
let _ = send_json(
&sender_bg,
&serde_json::json!({
"type": "error",
"content": "Internal error occurred",
}),
)
.await;
}
Ok(Ok(_)) => {
// Post-processing completed successfully — nothing to send
}
}
});
// Send the response immediately from stream data
match stream_result {
Ok((accumulated_text, stream_usage, is_silent)) => {
// Send typing lifecycle: stop
let _ = send_json(
sender,
@@ -607,39 +685,36 @@ async fn handle_text_message(
)
.await;
// NO_REPLY: agent intentionally chose not to reply
if result.silent {
let usage = stream_usage.unwrap_or_default();
if is_silent {
let _ = send_json(
sender,
&serde_json::json!({
"type": "silent_complete",
"input_tokens": result.total_usage.input_tokens,
"output_tokens": result.total_usage.output_tokens,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}),
)
.await;
return;
}
// Guard: ensure we never send an empty response
let content = if result.response.trim().is_empty() {
// Strip <think>...</think> blocks
let cleaned = strip_think_tags(&accumulated_text);
let content = if cleaned.trim().is_empty() {
format!(
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
result.total_usage.input_tokens,
result.total_usage.output_tokens,
result.iterations,
"[The agent completed processing but returned no text response. ({} in / {} out)]",
usage.input_tokens, usage.output_tokens,
)
} else {
result.response
cleaned
};
// Estimate context pressure from last call
let per_call = if result.iterations > 0 {
result.total_usage.input_tokens / result.iterations as u64
} else {
result.total_usage.input_tokens
};
let ctx_pct = (per_call as f64 / 200_000.0 * 100.0).min(100.0);
// Estimate context pressure
let ctx_pct =
(usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0);
let pressure = if ctx_pct > 85.0 {
"critical"
} else if ctx_pct > 70.0 {
@@ -655,38 +730,17 @@ async fn handle_text_message(
&serde_json::json!({
"type": "response",
"content": content,
"input_tokens": result.total_usage.input_tokens,
"output_tokens": result.total_usage.output_tokens,
"iterations": result.iterations,
"cost_usd": result.cost_usd,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"iterations": 0, // Not available from stream; handle updates later if needed
"cost_usd": null,
"context_pressure": pressure,
}),
)
.await;
}
Ok(Err(e)) => {
stream_task.abort();
warn!("Agent message failed: {e}");
let _ = send_json(
sender,
&serde_json::json!({
"type": "typing", "state": "stop",
}),
)
.await;
let user_msg = classify_streaming_error(&e);
let _ = send_json(
sender,
&serde_json::json!({
"type": "error",
"content": user_msg,
}),
)
.await;
}
Err(e) => {
stream_task.abort();
warn!("Agent task panicked: {e}");
warn!("Stream task panicked: {e}");
let _ = send_json(
sender,
&serde_json::json!({
@@ -794,9 +848,21 @@ async fn handle_command(
serde_json::json!({"type": "error", "content": "Agent not found"})
}
} else {
match state.kernel.set_agent_model(agent_id, args) {
match state.kernel.set_agent_model(agent_id, args, None) {
Ok(()) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
if let Some(entry) = state.kernel.registry.get(agent_id) {
let model = &entry.manifest.model.model;
let provider = &entry.manifest.model.provider;
serde_json::json!({
"type": "command_result",
"command": cmd,
"message": format!("Model switched to: {model} (provider: {provider})"),
"model": model,
"provider": provider
})
} else {
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
}
}
Err(e) => {
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
@@ -885,7 +951,7 @@ async fn handle_command(
let msg = if !state.kernel.config.network_enabled {
"OFP network disabled.".to_string()
} else {
match &state.kernel.peer_registry {
match state.kernel.peer_registry.get() {
Some(registry) => {
let peers = registry.all_peers();
if peers.is_empty() {
@@ -932,11 +998,14 @@ async fn handle_command(
fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_json::Value> {
match event {
StreamEvent::TextDelta { .. } => None, // Handled by debounce buffer
StreamEvent::ToolUseStart { name, .. } => Some(serde_json::json!({
StreamEvent::ToolUseStart { id, name, .. } => Some(serde_json::json!({
"type": "tool_start",
"id": id,
"tool": name,
})),
StreamEvent::ToolUseEnd { name, input, .. } if name == "canvas_present" => {
StreamEvent::ToolUseEnd {
id, name, input, ..
} if name == "canvas_present" => {
let html = input.get("html").and_then(|v| v.as_str()).unwrap_or("");
let title = input
.get("title")
@@ -944,12 +1013,15 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.unwrap_or("Canvas");
Some(serde_json::json!({
"type": "canvas",
"id": id,
"canvas_id": uuid::Uuid::new_v4().to_string(),
"html": html,
"title": title,
}))
}
StreamEvent::ToolUseEnd { name, input, .. } => match verbose {
StreamEvent::ToolUseEnd {
id, name, input, ..
} => match verbose {
VerboseLevel::Off => None,
VerboseLevel::On => {
let input_preview: String = serde_json::to_string(input)
@@ -959,6 +1031,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.collect();
Some(serde_json::json!({
"type": "tool_end",
"id": id,
"tool": name,
"input": input_preview,
}))
@@ -971,18 +1044,21 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.collect();
Some(serde_json::json!({
"type": "tool_end",
"id": id,
"tool": name,
"input": input_preview,
}))
}
},
StreamEvent::ToolExecutionResult {
id,
name,
result_preview,
is_error,
} => match verbose {
VerboseLevel::Off => Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"is_error": is_error,
})),
@@ -990,6 +1066,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
let truncated: String = result_preview.chars().take(200).collect();
Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"result": truncated,
"is_error": is_error,
@@ -997,6 +1074,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
}
VerboseLevel::Full => Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"result": result_preview,
"is_error": is_error,
@@ -1092,6 +1170,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
let status = extract_status_code(&inner);
let classified = llm_errors::classify_error(&inner, status);
// Build a user-facing message. The classified.sanitized_message now
// includes a redacted excerpt of the raw error (issue #493 fix), so we
// use it as the base and only override for cases that need extra context.
match classified.category {
llm_errors::LlmErrorCategory::ContextOverflow => {
"Context is full. Try /compact or /new.".to_string()
@@ -1099,20 +1180,36 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
llm_errors::LlmErrorCategory::RateLimit => {
if let Some(delay_ms) = classified.suggested_delay_ms {
let secs = (delay_ms / 1000).max(1);
format!("Provider rate limited. Wait ~{secs}s and try again.")
format!("Rate limited. Wait ~{secs}s and try again.")
} else {
"Provider rate limited. Wait a moment and try again.".to_string()
"Rate limited. Wait a moment and try again.".to_string()
}
}
llm_errors::LlmErrorCategory::Billing => {
"Check provider account status (billing issue detected).".to_string()
format!("Billing issue. {}", classified.sanitized_message)
}
llm_errors::LlmErrorCategory::Auth => {
// Show the actual error detail so users can diagnose (issue #493).
// The sanitized_message already redacts secrets.
classified.sanitized_message.clone()
}
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
llm_errors::LlmErrorCategory::ModelNotFound => {
"Model unavailable. Use /model to see options.".to_string()
if inner.contains("localhost:11434") || inner.contains("ollama") {
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
} else {
format!(
"{}. Use /model to see options.",
classified.sanitized_message
)
}
}
llm_errors::LlmErrorCategory::Format => {
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
// Claude Code CLI errors have actionable messages — pass them through
if inner.contains("Claude Code CLI") || inner.contains("claude auth") {
classified.raw_message.clone()
} else {
classified.sanitized_message.clone()
}
}
_ => classified.sanitized_message,
}
@@ -1120,6 +1217,14 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
/// Try to extract an HTTP status code from an error string.
fn extract_status_code(s: &str) -> Option<u16> {
// "API error (NNN):" — the format produced by LlmError::Api Display impl
if let Some(idx) = s.find("API error (") {
let after = &s[idx + 11..];
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(code) = num.parse::<u16>() {
return Some(code);
}
}
// "status: NNN"
if let Some(idx) = s.find("status: ") {
let after = &s[idx + 8..];
@@ -1147,6 +1252,27 @@ fn extract_status_code(s: &str) -> Option<u16> {
None
}
/// Strip `<think>...</think>` blocks from model output.
///
/// Some models (MiniMax, DeepSeek, etc.) wrap their reasoning in `<think>` tags.
/// These are internal chain-of-thought and shouldn't be shown to the user.
pub fn strip_think_tags(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut remaining = text;
while let Some(start) = remaining.find("<think>") {
result.push_str(&remaining[..start]);
if let Some(end) = remaining[start..].find("</think>") {
remaining = &remaining[(start + end + 8)..]; // 8 = "</think>".len()
} else {
// Unclosed <think> tag — strip to end
remaining = "";
break;
}
}
result.push_str(remaining);
result
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1217,10 +1343,33 @@ mod tests {
);
assert_eq!(extract_status_code("StatusCode(401)"), Some(401));
assert_eq!(extract_status_code("some random error"), None);
// LlmError::Api Display format (issue #493 fix)
assert_eq!(
extract_status_code("LLM driver error: API error (403): quota exceeded"),
Some(403)
);
assert_eq!(
extract_status_code("API error (401): invalid api key"),
Some(401)
);
}
#[test]
fn test_sanitize_trims_whitespace() {
assert_eq!(sanitize_user_input(" hello "), "hello");
}
#[test]
fn test_strip_think_tags() {
assert_eq!(
strip_think_tags("<think>reasoning here</think>The answer is 42."),
"The answer is 42."
);
assert_eq!(
strip_think_tags("Hello <think>\nsome thinking\n</think> world"),
"Hello world"
);
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
}
}
+380 -3
View File
@@ -69,6 +69,32 @@
gap: 16px;
}
/* Card-based flex containers for agent chips and similar inline layouts */
.card-flex {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
/* Nested list indentation inside cards, detail panels, and modals */
.card ul, .card ol,
.detail-grid ul, .detail-grid ol,
.modal ul, .modal ol,
.info-card ul, .info-card ol {
padding-left: 18px;
margin: 4px 0;
}
.card ul ul, .card ol ol,
.modal ul ul, .modal ol ol {
padding-left: 16px;
margin: 2px 0;
}
.card li, .modal li, .info-card li {
margin-bottom: 2px;
font-size: 12px;
line-height: 1.5;
}
/* Glow effect on card hover */
.card-glow {
overflow: hidden;
@@ -90,13 +116,17 @@
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
padding: 3px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
white-space: nowrap;
line-height: 1.2;
vertical-align: middle;
}
.badge + .badge { margin-left: 4px; }
.badge-running { background: rgba(74,222,128,0.12); color: var(--success); }
.badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); }
@@ -110,7 +140,7 @@
.badge-error { background: rgba(239,68,68,0.12); color: var(--error); }
.badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); }
.badge-info { background: rgba(59,130,246,0.12); color: var(--info); }
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; }
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; }
.text-danger { color: var(--error); }
/* Tables */
@@ -617,6 +647,11 @@ mark.search-highlight {
color: var(--text);
}
.message-bubble.markdown-body ul,
.message-bubble.markdown-body ol {
padding-left: 2em;
}
.copy-btn {
position: absolute;
top: 6px;
@@ -898,6 +933,129 @@ mark.search-highlight {
.slash-menu-item:last-child { border-bottom: none; }
.slash-menu-item:hover, .slash-menu-item.slash-active { background: var(--surface2); }
/* Model switcher dropdown */
.model-switcher-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
max-width: 200px;
transition: all 0.15s;
white-space: nowrap;
}
.model-switcher-btn:hover { border-color: var(--accent); color: var(--text); }
.model-switcher-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.model-switcher-btn:disabled:hover { border-color: var(--border); color: var(--text-dim); }
.model-switcher-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
}
.model-switcher-chevron {
transition: transform 0.2s;
flex-shrink: 0;
opacity: 0.5;
}
.model-switcher-chevron.open { transform: rotate(180deg); }
.model-switcher-dropdown {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
width: 340px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.model-switcher-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.model-switcher-search select {
max-width: 100px;
flex-shrink: 0;
}
.model-switcher-search select:focus {
outline: none;
border-color: var(--accent);
}
.model-switcher-search input {
flex: 1;
background: none;
border: none;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
}
.model-switcher-list {
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
}
.model-switcher-group-header {
position: sticky;
top: 0;
z-index: 1;
padding: 6px 12px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.model-switcher-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s;
}
.model-switcher-item:hover { background: var(--surface2); }
.model-switcher-item.active {
background: var(--accent-subtle, rgba(255,92,0,0.06));
cursor: default;
}
.model-switcher-item-name {
font-size: 12px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-switcher-tier {
display: inline-block;
padding: 1px 5px;
border-radius: 8px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
flex-shrink: 0;
}
.model-switcher-tier.tier-frontier { background: rgba(168,85,247,0.15); color: #a855f7; }
.model-switcher-tier.tier-smart { background: rgba(59,130,246,0.15); color: #3b82f6; }
.model-switcher-tier.tier-balanced { background: rgba(34,197,94,0.15); color: #22c55e; }
.model-switcher-tier.tier-fast { background: rgba(245,158,11,0.15); color: #f59e0b; }
.model-switcher-tier.tier-local { background: rgba(148,163,184,0.12); color: var(--text-dim); }
/* Sidebar footer */
.sidebar-footer {
padding: 8px 0;
@@ -1121,8 +1279,11 @@ mark.search-highlight {
/* Utility */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.grid { display: grid; }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
@@ -1130,6 +1291,7 @@ mark.search-highlight {
.mt-4 { margin-top: 16px; }
.mb-2 { margin-bottom: 8px; }
.mb-4 { margin-bottom: 16px; }
.mb-6 { margin-bottom: 24px; }
.text-dim { color: var(--text-dim); }
.text-sm { font-size: 11px; }
.text-xs { font-size: 10px; }
@@ -3072,4 +3234,219 @@ mark.search-highlight {
max-height: 400px;
overflow-y: auto;
}
.flex-col { flex-direction: column; }
/* Comms page */
.comms-topo-tree { padding: 4px 0 4px 8px; }
.comms-topo-child { padding: 0 0 0 20px; display: flex; align-items: center; gap: 4px; }
.comms-topo-branch { color: var(--text-dim); font-family: var(--font-mono); white-space: pre; }
.comms-topo-node { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
.comms-event-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; transition: background var(--transition-fast);
}
.comms-event-row:hover { background: var(--bg-hover); }
.comms-event-time { min-width: 50px; text-align: right; }
.comms-event-detail { margin-left: auto; }
/*
Trader Dashboard
*/
.trader-dashboard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
width: 96vw;
max-width: 1200px;
max-height: 92vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.trader-dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-card);
z-index: 10;
border-radius: 12px 12px 0 0;
}
.trader-dashboard-body {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* KPI Cards */
.trader-kpi-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
}
@media (max-width: 900px) {
.trader-kpi-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 540px) {
.trader-kpi-row { grid-template-columns: repeat(2, 1fr); }
}
.trader-kpi-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
text-align: center;
}
.trader-kpi-label {
font-size: 0.7rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.trader-kpi-value {
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
font-family: var(--font-mono);
}
.kpi-positive { color: var(--success) !important; }
.kpi-negative { color: var(--error) !important; }
/* Chart Rows */
.trader-chart-row {
display: flex;
gap: 12px;
}
@media (max-width: 768px) {
.trader-chart-row { flex-direction: column; }
}
.trader-chart-panel {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
min-width: 0;
}
.trader-chart-title {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
font-weight: 600;
}
.trader-chart-wrap {
position: relative;
width: 100%;
min-height: 180px;
}
.trader-chart-wrap canvas {
width: 100% !important;
height: 100% !important;
}
.trader-chart-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 0.85rem;
}
/* Heatmap Table */
.trader-heatmap-wrap {
overflow-x: auto;
}
.trader-heatmap-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-heatmap-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-heatmap-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
}
.heatmap-positive { color: var(--success); font-weight: 600; }
.heatmap-negative { color: var(--error); font-weight: 600; }
/* Signal Badges */
.signal-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.3px;
}
.signal-strong_buy, .signal-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.signal-sell, .signal-strong_sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.signal-hold { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
/* Confidence Bar */
.confidence-bar-wrap {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
}
.confidence-bar {
height: 6px;
border-radius: 3px;
transition: width 0.3s ease;
}
.conf-high { background: var(--success); }
.conf-mid { background: var(--warning); }
.conf-low { background: var(--error); }
.confidence-label {
font-size: 0.7rem;
color: var(--text-dim);
min-width: 32px;
font-family: var(--font-mono);
}
/* Trades Table */
.trader-trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-trades-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-trades-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.trade-side-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 0.68rem;
font-weight: 700;
}
.trade-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.trade-sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
+23 -1
View File
@@ -55,6 +55,11 @@
transform: scale(1.05);
}
[data-theme="light"] .sidebar-logo img,
[data-theme="light"] .message-avatar img {
filter: invert(1);
}
.sidebar-header h1 {
font-size: 14px;
font-weight: 700;
@@ -238,6 +243,14 @@
z-index: 99;
}
.mobile-menu-btn {
position: fixed !important;
top: 12px;
left: 16px;
z-index: 98;
padding: 6px 10px !important;
}
/* Wide desktop — larger card grids */
@media (min-width: 1400px) {
.card-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
@@ -272,6 +285,8 @@
left: -300px;
}
.mobile-menu-btn { display: flex !important; }
/* Offset header content so it does not overlap the fixed mobile menu button. */
.page-header > :first-child { margin-left: 52px; }
}
@media (min-width: 769px) {
@@ -280,7 +295,14 @@
/* Mobile small screen */
@media (max-width: 480px) {
.page-header { flex-direction: column; gap: 8px; align-items: flex-start; padding: 12px 16px; }
.page-header {
gap: 8px;
padding: 12px 16px;
flex-wrap: wrap;
}
.page-header h2 {
line-height: 44px;
}
.page-body { padding: 12px; }
.stats-row { flex-wrap: wrap; }
.stat-card { min-width: 80px; flex: 1 1 40%; }
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,8 @@
<title>OpenFang Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#6366f1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
+34 -12
View File
@@ -161,6 +161,17 @@ var OpenFangAPI = (function() {
return fetch(BASE + path, opts).then(function(r) {
if (_connectionState !== 'connected') setConnectionState('connected');
if (!r.ok) {
// On 401, auto-show auth prompt so the user can re-enter their key
if (r.status === 401 && typeof Alpine !== 'undefined') {
try {
var store = Alpine.store('app');
if (store && !store.showAuthPrompt) {
_authToken = '';
localStorage.removeItem('openfang-api-key');
store.showAuthPrompt = true;
}
} catch(e2) { /* ignore Alpine errors */ }
}
return r.text().then(function(text) {
var msg = '';
try {
@@ -213,9 +224,12 @@ var OpenFangAPI = (function() {
try {
var url = WS_BASE + '/api/agents/' + agentId + '/ws';
if (_authToken) url += '?token=' + encodeURIComponent(_authToken);
_ws = new WebSocket(url);
var socket = new WebSocket(url);
_ws = socket;
_ws.onopen = function() {
socket.onopen = function() {
// Guard: ignore if this socket was superseded by a newer connection
if (_ws !== socket) return;
_wsConnected = true;
_reconnectAttempts = 0;
setConnectionState('connected');
@@ -226,14 +240,20 @@ var OpenFangAPI = (function() {
if (_wsCallbacks.onOpen) _wsCallbacks.onOpen();
};
_ws.onmessage = function(e) {
socket.onmessage = function(e) {
try {
var data = JSON.parse(e.data);
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
} catch(err) { /* ignore parse errors */ }
} catch(parseErr) {
return; // Ignore malformed JSON frames
}
// Dispatch outside try/catch so handler errors are not swallowed
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
};
_ws.onclose = function(e) {
socket.onclose = function(e) {
// Guard: only update state if this is still the active socket.
// A superseded socket closing must not null-out the new connection.
if (_ws !== socket) return;
_wsConnected = false;
_ws = null;
if (_wsAgentId && _reconnectAttempts < MAX_RECONNECT && e.code !== 1000) {
@@ -254,7 +274,9 @@ var OpenFangAPI = (function() {
if (_wsCallbacks.onClose) _wsCallbacks.onClose();
};
_ws.onerror = function() {
socket.onerror = function() {
// Guard: ignore errors from superseded sockets
if (_ws !== socket) return;
_wsConnected = false;
if (_wsCallbacks.onError) _wsCallbacks.onError();
};
@@ -286,15 +308,15 @@ var OpenFangAPI = (function() {
function getToken() { return _authToken; }
function upload(agentId, file) {
var hdrs = {
'Content-Type': file.type || 'application/octet-stream',
'X-Filename': file.name
};
var hdrs = {};
if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken;
var form = new FormData();
form.append('file', file);
form.append('filename', file.name);
return fetch(BASE + '/api/agents/' + agentId + '/upload', {
method: 'POST',
headers: hdrs,
body: file
body: form
}).then(function(r) {
if (!r.ok) throw new Error('Upload failed');
return r.json();
+113 -5
View File
@@ -24,9 +24,43 @@ function escapeHtml(text) {
function renderMarkdown(text) {
if (!text) return '';
if (typeof marked !== 'undefined') {
var html = marked.parse(text);
// Protect LaTeX blocks from marked.js mangling (underscores, backslashes, etc.)
var latexBlocks = [];
var protected_ = text;
// Protect display math $$...$$ first (greedy across lines)
protected_ = protected_.replace(/\$\$([\s\S]+?)\$\$/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect inline math $...$ (single line, not empty, not starting/ending with space)
protected_ = protected_.replace(/\$([^\s$](?:[^$]*[^\s$])?)\$/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect \[...\] display math
protected_ = protected_.replace(/\\\[([\s\S]+?)\\\]/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect \(...\) inline math
protected_ = protected_.replace(/\\\(([\s\S]+?)\\\)/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
var html = marked.parse(protected_);
// Restore LaTeX blocks
for (var i = 0; i < latexBlocks.length; i++) {
html = html.replace('\x00LATEX' + i + '\x00', latexBlocks[i]);
}
// Add copy buttons to code blocks
html = html.replace(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
// Open external links in new tab
html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"(?![^>]*target=)([^>]*)>/gi, '<a href="$1" target="_blank" rel="noopener"$2>');
return html;
}
return escapeHtml(text);
@@ -100,10 +134,14 @@ document.addEventListener('alpine:init', function() {
lastError: '',
version: '0.1.0',
agentCount: 0,
pendingApprovalCount: 0,
lastPendingApprovalSignature: '',
pendingAgent: null,
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
showAuthPrompt: false,
authMode: 'apikey',
sessionUser: null,
toggleFocusMode() {
this.focusMode = !this.focusMode;
@@ -118,6 +156,23 @@ document.addEventListener('alpine:init', function() {
} catch(e) { /* silent */ }
},
async refreshApprovals() {
try {
var data = await OpenFangAPI.get('/api/approvals');
var approvals = Array.isArray(data) ? data : (data.approvals || []);
var pending = approvals.filter(function(a) { return a.status === 'pending'; });
var signature = pending
.map(function(a) { return a.id; })
.sort()
.join(',');
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
}
this.pendingApprovalCount = pending.length;
this.lastPendingApprovalSignature = signature;
} catch(e) { /* silent */ }
},
async checkStatus() {
try {
var s = await OpenFangAPI.get('/api/status');
@@ -155,10 +210,36 @@ document.addEventListener('alpine:init', function() {
async checkAuth() {
try {
await OpenFangAPI.get('/api/providers');
// First check if session-based auth is configured
var authInfo = await OpenFangAPI.get('/api/auth/check');
if (authInfo.mode === 'none') {
// No session auth — fall back to API key detection
this.authMode = 'apikey';
this.sessionUser = null;
} else if (authInfo.mode === 'session') {
this.authMode = 'session';
if (authInfo.authenticated) {
this.sessionUser = authInfo.username;
this.showAuthPrompt = false;
return;
}
// Session auth enabled but not authenticated — show login prompt
this.showAuthPrompt = true;
return;
}
} catch(e) { /* ignore — fall through to API key check */ }
// API key mode detection
try {
await OpenFangAPI.get('/api/tools');
this.showAuthPrompt = false;
} catch(e) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0)) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
var saved = localStorage.getItem('openfang-api-key');
if (saved) {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
this.showAuthPrompt = true;
}
}
@@ -172,6 +253,29 @@ document.addEventListener('alpine:init', function() {
this.refreshAgents();
},
async sessionLogin(username, password) {
try {
var result = await OpenFangAPI.post('/api/auth/login', { username: username, password: password });
if (result.status === 'ok') {
this.sessionUser = result.username;
this.showAuthPrompt = false;
this.refreshAgents();
} else {
OpenFangToast.error(result.error || 'Login failed');
}
} catch(e) {
OpenFangToast.error(e.message || 'Login failed');
}
},
async sessionLogout() {
try {
await OpenFangAPI.post('/api/auth/logout');
} catch(e) { /* ignore */ }
this.sessionUser = null;
this.showAuthPrompt = true;
},
clearApiKey() {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
@@ -209,7 +313,7 @@ function app() {
});
// Hash routing
var validPages = ['overview','agents','sessions','approvals','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','runtime','settings','wizard'];
var pageRedirects = {
'chat': 'agents',
'templates': 'agents',
@@ -265,9 +369,13 @@ function app() {
// Initial data load
this.pollStatus();
Alpine.store('app').refreshApprovals();
Alpine.store('app').checkOnboarding();
Alpine.store('app').checkAuth();
setInterval(function() { self.pollStatus(); }, 5000);
setInterval(function() {
self.pollStatus();
Alpine.store('app').refreshApprovals();
}, 5000);
},
navigate(p) {
+84
View File
@@ -0,0 +1,84 @@
// On-demand KaTeX loader and renderer for chat messages.
var KATEX_VERSION = '0.16.21';
var KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.css';
var KATEX_JS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.js';
var KATEX_AUTORENDER_URL =
'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/contrib/auto-render.min.js';
var katexLoadPromise = null;
function hasLatexDelimiters(text) {
if (!text) return false;
return /\$\$|\\\[|\\\(|\$(?=\S)[^$\n]+\$/.test(text);
}
function loadScript(url) {
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = url;
script.async = true;
script.onload = function () {
resolve();
};
script.onerror = function () {
reject(new Error('Failed to load script: ' + url));
};
document.head.appendChild(script);
});
}
function ensureKatexLoaded() {
if (typeof renderMathInElement === 'function') return Promise.resolve(true);
if (katexLoadPromise) return katexLoadPromise;
katexLoadPromise = new Promise(function (resolve) {
var cssId = 'openfang-katex-css';
if (!document.getElementById(cssId)) {
var link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = KATEX_CSS_URL;
document.head.appendChild(link);
}
loadScript(KATEX_JS_URL)
.then(function () {
return loadScript(KATEX_AUTORENDER_URL);
})
.then(function () {
resolve(typeof renderMathInElement === 'function');
})
.catch(function () {
katexLoadPromise = null;
resolve(false);
});
});
return katexLoadPromise;
}
// Render LaTeX math in the chat message container using KaTeX auto-render.
// Call this after new messages are inserted into the DOM.
function renderLatex(el) {
var target = el || document.getElementById('messages');
if (!target) return;
if (!hasLatexDelimiters(target.textContent || '')) return;
ensureKatexLoaded().then(function (ok) {
if (!ok || typeof renderMathInElement !== 'function') return;
try {
renderMathInElement(target, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
],
throwOnError: false,
trust: false,
});
} catch (e) {
/* KaTeX render error — ignore gracefully */
}
});
}
+285 -107
View File
@@ -1,6 +1,21 @@
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
* broken up so the TOML parser never sees an unintended closing delimiter.
*/
function tomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("...").
* Backslashes, double-quotes, and common control chars are escaped.
*/
function tomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function agentsPage() {
return {
tab: 'agents',
@@ -25,6 +40,8 @@ function agentsPage() {
},
// -- Multi-step wizard state --
spawnProviders: [], // populated from /api/providers on wizard open
spawnProvidersLoading: false,
spawnStep: 1,
spawnIdentity: { emoji: '', color: '#FF5C00', archetype: '' },
selectedPreset: '',
@@ -54,6 +71,20 @@ function agentsPage() {
filesLoading: false,
configForm: {},
configSaving: false,
// -- Tool filters --
toolFilters: { tool_allowlist: [], tool_blocklist: [] },
toolFiltersLoading: false,
newAllowTool: '',
newBlockTool: '',
// -- Model switch --
editingModel: false,
newModelValue: '',
editingProvider: false,
newProviderValue: '',
modelSaving: false,
// -- Fallback chain --
editingFallback: false,
newFallbackValue: '',
// -- Templates state --
tplTemplates: [],
@@ -63,98 +94,12 @@ function agentsPage() {
selectedCategory: 'All',
searchQuery: '',
builtinTemplates: [
{
name: 'General Assistant',
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.'
},
{
name: 'Code Helper',
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.'
},
{
name: 'Researcher',
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
category: 'Research',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'research',
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.'
},
{
name: 'Writer',
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
category: 'Writing',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.'
},
{
name: 'Data Analyst',
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.'
},
{
name: 'DevOps Engineer',
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'automation',
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.'
},
{
name: 'Customer Support',
description: 'A professional, empathetic agent for handling customer inquiries and resolving issues.',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'messaging',
system_prompt: 'You are a professional customer support representative. Be empathetic, patient, and solution-oriented. Acknowledge concerns before offering solutions. Escalate complex issues appropriately.'
},
{
name: 'Tutor',
description: 'A patient educational agent that explains concepts step-by-step and adapts to the learner\'s level.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a patient and encouraging tutor. Explain concepts step by step, starting from fundamentals. Use analogies and examples. Check understanding before moving on. Adapt to the learner\'s pace.'
},
{
name: 'API Designer',
description: 'An agent specialized in RESTful API design, OpenAPI specs, and integration architecture.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an API design expert. Help users design clean, consistent RESTful APIs following best practices. Cover endpoint naming, request/response schemas, error handling, and versioning.'
},
{
name: 'Meeting Notes',
description: 'Summarizes meeting transcripts into structured notes with action items and key decisions.',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'minimal',
system_prompt: 'You are a meeting summarizer. When given a meeting transcript or notes, produce a structured summary with: key decisions, action items (with owners), discussion highlights, and follow-up questions.'
}
],
builtinTemplates: [],
// Load templates from API
async init() {
await this.loadTemplates();
},
// ── Profile Descriptions ──
profileDescriptions: {
@@ -251,6 +196,7 @@ function agentsPage() {
this.loadError = '';
try {
await Alpine.store('app').refreshAgents();
await this.loadTemplates();
} catch(e) {
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
}
@@ -288,10 +234,73 @@ function agentsPage() {
OpenFangAPI.get('/api/templates'),
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
]);
this.tplTemplates = results[0].templates || [];
// Combine static and dynamic templates
this.builtinTemplates = [
{
name: 'General Assistant',
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.',
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
},
{
name: 'Code Helper',
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.',
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
},
{
name: 'Researcher',
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
category: 'Research',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'research',
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.',
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
},
{
name: 'Writer',
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
category: 'Writing',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.',
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
},
{
name: 'Data Analyst',
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.',
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
},
{
name: 'DevOps Engineer',
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'automation',
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.',
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
},
...results[0].templates || []
];
this.tplProviders = results[1].providers || [];
} catch(e) {
this.tplTemplates = [];
this.builtinTemplates = [];
this.tplLoadError = e.message || 'Could not load templates.';
}
this.tplLoading = false;
@@ -307,12 +316,15 @@ function agentsPage() {
OpenFangAPI.wsDisconnect();
},
showDetail(agent) {
async showDetail(agent) {
this.detailAgent = agent;
this.detailAgent._fallbacks = [];
this.detailTab = 'info';
this.agentFiles = [];
this.editingFile = null;
this.fileContent = '';
this.editingFallback = false;
this.newFallbackValue = '';
this.configForm = {
name: agent.name || '',
system_prompt: agent.system_prompt || '',
@@ -322,6 +334,11 @@ function agentsPage() {
vibe: (agent.identity && agent.identity.vibe) || ''
};
this.showDetailModal = true;
// Fetch full agent detail to get fallback_models
try {
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
this.detailAgent._fallbacks = full.fallback_models || [];
} catch(e) { /* ignore */ }
},
killAgent(agent) {
@@ -358,7 +375,7 @@ function agentsPage() {
},
// ── Multi-step wizard navigation ──
openSpawnWizard() {
async openSpawnWizard() {
this.showSpawnModal = true;
this.spawnStep = 1;
this.spawnMode = 'wizard';
@@ -366,8 +383,26 @@ function agentsPage() {
this.selectedPreset = '';
this.soulContent = '';
this.spawnForm.name = '';
this.spawnForm.provider = 'groq';
this.spawnForm.model = 'llama-3.3-70b-versatile';
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
this.spawnForm.profile = 'full';
// Fetch status defaults and dynamic provider list concurrently
this.spawnProvidersLoading = true;
try {
var results = await Promise.all([
OpenFangAPI.get('/api/status').catch(function() { return {}; }),
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
]);
var status = results[0];
var provData = results[1];
if (status.default_provider) this.spawnForm.provider = status.default_provider;
if (status.default_model) this.spawnForm.model = status.default_model;
this.spawnProviders = provData.providers || [];
} catch(e) {
this.spawnProviders = [];
}
this.spawnProvidersLoading = false;
},
nextStep() {
@@ -391,7 +426,7 @@ function agentsPage() {
var f = this.spawnForm;
var si = this.spawnIdentity;
var lines = [
'name = "' + f.name + '"',
'name = "' + tomlBasicEscape(f.name) + '"',
'module = "builtin:chat"'
];
if (f.profile && f.profile !== 'custom') {
@@ -400,7 +435,7 @@ function agentsPage() {
lines.push('', '[model]');
lines.push('provider = "' + f.provider + '"');
lines.push('model = "' + f.model + '"');
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
if (f.profile === 'custom') {
lines.push('', '[capabilities]');
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
@@ -543,15 +578,20 @@ function agentsPage() {
},
// -- Template methods --
async spawnFromTemplate(name) {
async spawnFromTemplate(template) {
try {
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(name));
if (data.manifest_toml) {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: data.manifest_toml });
var manifestToml = template.manifest_toml;
if (!manifestToml) {
// If template doesn't have manifest_toml, fetch it from the API
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(template.name));
manifestToml = data.manifest_toml;
}
if (manifestToml) {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: manifestToml });
if (res.agent_id) {
OpenFangToast.success('Agent "' + (res.name || name) + '" spawned from template');
OpenFangToast.success('Agent "' + (res.name || template.name) + '" spawned from template');
await Alpine.store('app').refreshAgents();
this.chatWithAgent({ id: res.agent_id, name: res.name || name, model_provider: '?', model_name: '?' });
this.chatWithAgent({ id: res.agent_id, name: res.name || template.name, model_provider: '?', model_name: '?' });
}
}
} catch(e) {
@@ -559,13 +599,151 @@ function agentsPage() {
}
},
// ── Clear agent history ──
async clearHistory(agent) {
var self = this;
OpenFangToast.confirm('Clear History', 'Clear all conversation history for "' + agent.name + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/agents/' + agent.id + '/history');
OpenFangToast.success('History cleared for "' + agent.name + '"');
} catch(e) {
OpenFangToast.error('Failed to clear history: ' + e.message);
}
});
},
// ── Model switch ──
async changeModel() {
if (!this.detailAgent || !this.newModelValue.trim()) return;
this.modelSaving = true;
try {
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
this.editingModel = false;
await Alpine.store('app').refreshAgents();
// Refresh detailAgent
var agents = Alpine.store('app').agents;
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
}
} catch(e) {
OpenFangToast.error('Failed to change model: ' + e.message);
}
this.modelSaving = false;
},
// ── Provider switch ──
async changeProvider() {
if (!this.detailAgent || !this.newProviderValue.trim()) return;
this.modelSaving = true;
try {
var combined = this.newProviderValue.trim() + '/' + this.detailAgent.model_name;
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: combined });
OpenFangToast.success('Provider changed to ' + (resp && resp.provider ? resp.provider : this.newProviderValue.trim()));
this.editingProvider = false;
await Alpine.store('app').refreshAgents();
var agents = Alpine.store('app').agents;
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
}
} catch(e) {
OpenFangToast.error('Failed to change provider: ' + e.message);
}
this.modelSaving = false;
},
// ── Fallback model chain ──
async addFallback() {
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
var parts = this.newFallbackValue.trim().split('/');
var provider = parts.length > 1 ? parts[0] : this.detailAgent.model_provider;
var model = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
if (!this.detailAgent._fallbacks) this.detailAgent._fallbacks = [];
this.detailAgent._fallbacks.push({ provider: provider, model: model });
try {
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
fallback_models: this.detailAgent._fallbacks
});
OpenFangToast.success('Fallback added: ' + provider + '/' + model);
} catch(e) {
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
this.detailAgent._fallbacks.pop();
}
this.editingFallback = false;
this.newFallbackValue = '';
},
async removeFallback(idx) {
if (!this.detailAgent || !this.detailAgent._fallbacks) return;
var removed = this.detailAgent._fallbacks.splice(idx, 1);
try {
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
fallback_models: this.detailAgent._fallbacks
});
OpenFangToast.success('Fallback removed');
} catch(e) {
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
this.detailAgent._fallbacks.splice(idx, 0, removed[0]);
}
},
// ── Tool filters ──
async loadToolFilters() {
if (!this.detailAgent) return;
this.toolFiltersLoading = true;
try {
this.toolFilters = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/tools');
} catch(e) {
this.toolFilters = { tool_allowlist: [], tool_blocklist: [] };
}
this.toolFiltersLoading = false;
},
addAllowTool() {
var t = this.newAllowTool.trim();
if (t && this.toolFilters.tool_allowlist.indexOf(t) === -1) {
this.toolFilters.tool_allowlist.push(t);
this.newAllowTool = '';
this.saveToolFilters();
}
},
removeAllowTool(tool) {
this.toolFilters.tool_allowlist = this.toolFilters.tool_allowlist.filter(function(t) { return t !== tool; });
this.saveToolFilters();
},
addBlockTool() {
var t = this.newBlockTool.trim();
if (t && this.toolFilters.tool_blocklist.indexOf(t) === -1) {
this.toolFilters.tool_blocklist.push(t);
this.newBlockTool = '';
this.saveToolFilters();
}
},
removeBlockTool(tool) {
this.toolFilters.tool_blocklist = this.toolFilters.tool_blocklist.filter(function(t) { return t !== tool; });
this.saveToolFilters();
},
async saveToolFilters() {
if (!this.detailAgent) return;
try {
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/tools', this.toolFilters);
} catch(e) {
OpenFangToast.error('Failed to update tool filters: ' + e.message);
}
},
async spawnBuiltin(t) {
var toml = 'name = "' + t.name + '"\n';
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
toml += 'module = "builtin:chat"\n';
toml += 'profile = "' + t.profile + '"\n\n';
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
try {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
@@ -7,6 +7,22 @@ function approvalsPage() {
filterStatus: 'all',
loading: true,
loadError: '',
refreshTimer: null,
init() {
var self = this;
this.loadData();
this.refreshTimer = setInterval(function() {
self.loadData();
}, 5000);
},
destroy() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
},
get filtered() {
var f = this.filterStatus;
@@ -141,7 +141,16 @@ function channelsPage() {
openSetup(ch) {
this.setupModal = ch;
this.formValues = {};
// Pre-populate form values from saved config (non-secret fields).
var vals = {};
if (ch.fields) {
ch.fields.forEach(function(f) {
if (f.value !== undefined && f.value !== null && f.type !== 'secret') {
vals[f.key] = String(f.value);
}
});
}
this.formValues = vals;
this.showAdvanced = false;
this.showBusinessApi = false;
this.setupStep = ch.configured ? 3 : 1;
+222 -25
View File
@@ -29,6 +29,19 @@ function chatPage() {
_audioChunks: [],
recordingTime: 0,
_recordingTimer: null,
// Model autocomplete state
showModelPicker: false,
modelPickerList: [],
modelPickerFilter: '',
modelPickerIdx: 0,
// Model switcher dropdown
showModelSwitcher: false,
modelSwitcherFilter: '',
modelSwitcherProviderFilter: '',
modelSwitcherIdx: 0,
modelSwitching: false,
_modelCache: null,
_modelCacheTime: 0,
slashCommands: [
{ cmd: '/help', desc: 'Show available commands' },
{ cmd: '/agents', desc: 'Switch to Agents page' },
@@ -80,6 +93,47 @@ function chatPage() {
}
},
get modelDisplayName() {
if (!this.currentAgent) return '';
var name = this.currentAgent.model_name || '';
var short = name.replace(/-\d{8}$/, '');
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
},
get switcherProviders() {
var seen = {};
(this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
return Object.keys(seen).sort();
},
get filteredSwitcherModels() {
var models = this._modelCache || [];
var provFilter = this.modelSwitcherProviderFilter;
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
if (!provFilter && !textFilter) return models;
return models.filter(function(m) {
if (provFilter && m.provider !== provFilter) return false;
if (textFilter) {
return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
(m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
m.provider.toLowerCase().indexOf(textFilter) !== -1;
}
return true;
});
},
get groupedSwitcherModels() {
var filtered = this.filteredSwitcherModels;
var groups = {}, order = [];
filtered.forEach(function(m) {
if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); }
groups[m.provider].push(m);
});
return order.map(function(p) {
return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] };
});
},
init() {
var self = this;
@@ -96,6 +150,11 @@ function chatPage() {
var input = document.getElementById('msg-input');
if (input) { input.focus(); self.inputText = '/'; }
}
// Ctrl+M for model switcher
if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) {
e.preventDefault();
self.toggleModelSwitcher();
}
// Ctrl+F for chat search
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) {
e.preventDefault();
@@ -126,18 +185,98 @@ function chatPage() {
}
});
// Watch for slash commands
// Watch for slash commands + model autocomplete
this.$watch('inputText', function(val) {
if (val.startsWith('/')) {
var modelMatch = val.match(/^\/model\s+(.*)$/i);
if (modelMatch) {
self.showSlashMenu = false;
self.modelPickerFilter = modelMatch[1].toLowerCase();
if (!self.modelPickerList.length) {
OpenFangAPI.get('/api/models').then(function(data) {
self.modelPickerList = (data.models || []).filter(function(m) { return m.available; });
self.showModelPicker = true;
self.modelPickerIdx = 0;
}).catch(function() {});
} else {
self.showModelPicker = true;
}
} else if (val.startsWith('/')) {
self.showModelPicker = false;
self.slashFilter = val.slice(1).toLowerCase();
self.showSlashMenu = true;
self.slashIdx = 0;
} else {
self.showSlashMenu = false;
self.showModelPicker = false;
}
});
},
get filteredModelPicker() {
if (!this.modelPickerFilter) return this.modelPickerList.slice(0, 15);
var f = this.modelPickerFilter;
return this.modelPickerList.filter(function(m) {
return m.id.toLowerCase().indexOf(f) !== -1 || (m.display_name || '').toLowerCase().indexOf(f) !== -1 || m.provider.toLowerCase().indexOf(f) !== -1;
}).slice(0, 15);
},
pickModel(modelId) {
this.showModelPicker = false;
this.inputText = '/model ' + modelId;
this.sendMessage();
},
toggleModelSwitcher() {
if (this.showModelSwitcher) { this.showModelSwitcher = false; return; }
var self = this;
var now = Date.now();
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
this.modelSwitcherFilter = '';
this.modelSwitcherProviderFilter = '';
this.modelSwitcherIdx = 0;
this.showModelSwitcher = true;
this.$nextTick(function() {
var el = document.getElementById('model-switcher-search');
if (el) el.focus();
});
return;
}
OpenFangAPI.get('/api/models').then(function(data) {
var models = (data.models || []).filter(function(m) { return m.available; });
self._modelCache = models;
self._modelCacheTime = Date.now();
self.modelPickerList = models;
self.modelSwitcherFilter = '';
self.modelSwitcherProviderFilter = '';
self.modelSwitcherIdx = 0;
self.showModelSwitcher = true;
self.$nextTick(function() {
var el = document.getElementById('model-switcher-search');
if (el) el.focus();
});
}).catch(function(e) {
OpenFangToast.error('Failed to load models: ' + e.message);
});
},
switchModel(model) {
if (!this.currentAgent) return;
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
var self = this;
this.modelSwitching = true;
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
self.currentAgent.model_name = (resp && resp.model) || model.id;
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
self.showModelSwitcher = false;
self.modelSwitching = false;
}).catch(function(e) {
OpenFangToast.error('Switch failed: ' + e.message);
self.modelSwitching = false;
});
},
// Fetch dynamic slash commands from server
fetchCommands: function() {
var self = this;
@@ -282,9 +421,13 @@ function chatPage() {
case '/model':
if (self.currentAgent) {
if (cmdArgs) {
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function() {
self.currentAgent.model_name = cmdArgs;
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`', meta: '', tools: [] });
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
// Use server-resolved model/provider (fixes #387/#466)
var resolvedModel = (resp && resp.model) || cmdArgs;
var resolvedProvider = (resp && resp.provider) || '';
self.currentAgent.model_name = resolvedModel;
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
self.scrollToBottom();
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
} else {
@@ -376,7 +519,14 @@ function chatPage() {
try {
var data = await OpenFangAPI.get('/api/agents/' + agentId + '/session');
if (data.messages && data.messages.length) {
self.messages = data.messages.map(function(m) {
// Defense-in-depth (#935): never render system-role messages in the
// conversation history view, even if the backend somehow returns
// one. The server already filters these out by default, but we
// guard here too so a regression cannot leak the system prompt.
var visible = data.messages.filter(function(m) {
return m && m.role !== 'System' && m.role !== 'system';
});
self.messages = visible.map(function(m) {
var role = m.role === 'User' ? 'user' : (m.role === 'System' ? 'system' : 'agent');
var text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
// Sanitize any raw function-call text from history
@@ -387,13 +537,16 @@ function chatPage() {
id: (t.name || 'tool') + '-hist-' + idx,
name: t.name || 'unknown',
running: false,
expanded: false,
expanded: true,
input: t.input || '',
result: t.result || '',
is_error: !!t.is_error
};
});
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
var images = (m.images || []).map(function(img) {
return { file_id: img.file_id, filename: img.filename || 'image' };
});
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
});
self.$nextTick(function() { self.scrollToBottom(); });
}
@@ -476,8 +629,12 @@ function chatPage() {
this.scrollToBottom();
this._resetTypingTimeout();
} else if (data.level) {
var lastThink = this.messages[this.messages.length - 1];
if (lastThink && lastThink.thinking) lastThink.text = 'Thinking (' + data.level + ')...';
var thinkIdx = this.messages.length - 1;
var lastThink = thinkIdx >= 0 ? this.messages[thinkIdx] : null;
if (lastThink && lastThink.thinking) {
lastThink.text = 'Thinking (' + data.level + ')...';
this.messages.splice(thinkIdx, 1, lastThink);
}
}
break;
@@ -490,9 +647,11 @@ function chatPage() {
}
this._resetTypingTimeout();
} else if (data.state === 'tool') {
var typingMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
var toolTypIdx = this.messages.length - 1;
var typingMsg = toolTypIdx >= 0 ? this.messages[toolTypIdx] : null;
if (typingMsg && (typingMsg.thinking || typingMsg.streaming)) {
typingMsg.text = 'Using ' + (data.tool || 'tool') + '...';
this.messages.splice(toolTypIdx, 1, typingMsg);
}
this._resetTypingTimeout();
} else if (data.state === 'stop') {
@@ -502,26 +661,45 @@ function chatPage() {
case 'phase':
// Show tool/phase progress so the user sees the agent is working
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
var phaseIdx = this.messages.length - 1;
var phaseMsg = phaseIdx >= 0 ? this.messages[phaseIdx] : null;
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
var detail = data.detail || data.phase || 'Working...';
// Context warning: show prominently
// Skip phases that have no user-meaningful display text — "streaming"
// and "done" are lifecycle signals, not status to show in the chat bubble.
if (data.phase === 'streaming' || data.phase === 'done') {
break;
}
// Context warning: show prominently as a separate system message
if (data.phase === 'context_warning') {
this.messages.push({ id: ++msgId, role: 'system', text: detail, meta: '', tools: [] });
var cwDetail = data.detail || 'Context limit reached.';
this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] });
} else if (data.phase === 'thinking' && this.thinkingMode === 'stream') {
// Stream reasoning tokens to a collapsible panel
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
phaseMsg._reasoning += (detail || '') + '\n';
phaseMsg._reasoning += (data.detail || '') + '\n';
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
} else {
phaseMsg.text = detail;
this.messages.splice(phaseIdx, 1, phaseMsg);
} else if (phaseMsg.thinking) {
// Only update text on messages still in thinking state (not yet
// receiving streamed content) to avoid overwriting accumulated text.
var phaseDetail;
if (data.phase === 'tool_use') {
phaseDetail = 'Using ' + (data.detail || 'tool') + '...';
} else if (data.phase === 'thinking') {
phaseDetail = 'Thinking...';
} else {
phaseDetail = data.detail || 'Working...';
}
phaseMsg.text = phaseDetail;
this.messages.splice(phaseIdx, 1, phaseMsg);
}
}
this.scrollToBottom();
break;
case 'text_delta':
var last = this.messages.length ? this.messages[this.messages.length - 1] : null;
var lastIdx = this.messages.length - 1;
var last = lastIdx >= 0 ? this.messages[lastIdx] : null;
if (last && last.streaming) {
if (last.thinking) { last.text = ''; last.thinking = false; }
// If we already detected a text-based tool call, skip further text
@@ -542,7 +720,7 @@ function chatPage() {
id: toolMatch[1] + '-txt-' + Date.now(),
name: toolMatch[1],
running: true,
expanded: false,
expanded: true,
input: inputMatch ? inputMatch[1].replace(/<\/function>?\s*$/, '').trim() : '',
result: '',
is_error: false
@@ -550,6 +728,10 @@ function chatPage() {
}
}
this.tokenCount = Math.round(last.text.length / 4);
// Force Alpine reactivity: splice-in-place so x-for re-renders
// this item. Direct property mutation on array elements may not
// trigger DOM updates from async WebSocket callbacks.
this.messages.splice(lastIdx, 1, last);
} else {
this.messages.push({ id: ++msgId, role: 'agent', text: data.content, meta: '', streaming: true, tools: [] });
}
@@ -557,17 +739,20 @@ function chatPage() {
break;
case 'tool_start':
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
var tsIdx = this.messages.length - 1;
var lastMsg = tsIdx >= 0 ? this.messages[tsIdx] : null;
if (lastMsg && lastMsg.streaming) {
if (!lastMsg.tools) lastMsg.tools = [];
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: false, input: '', result: '', is_error: false });
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false });
this.messages.splice(tsIdx, 1, lastMsg);
}
this.scrollToBottom();
break;
case 'tool_end':
// Tool call parsed by LLM — update tool card with input params
var lastMsg2 = this.messages.length ? this.messages[this.messages.length - 1] : null;
var teIdx = this.messages.length - 1;
var lastMsg2 = teIdx >= 0 ? this.messages[teIdx] : null;
if (lastMsg2 && lastMsg2.tools) {
for (var ti = lastMsg2.tools.length - 1; ti >= 0; ti--) {
if (lastMsg2.tools[ti].name === data.tool && lastMsg2.tools[ti].running) {
@@ -575,12 +760,14 @@ function chatPage() {
break;
}
}
this.messages.splice(teIdx, 1, lastMsg2);
}
break;
case 'tool_result':
// Tool execution completed — update tool card with result
var lastMsg3 = this.messages.length ? this.messages[this.messages.length - 1] : null;
var trIdx = this.messages.length - 1;
var lastMsg3 = trIdx >= 0 ? this.messages[trIdx] : null;
if (lastMsg3 && lastMsg3.tools) {
for (var ri = lastMsg3.tools.length - 1; ri >= 0; ri--) {
if (lastMsg3.tools[ri].name === data.tool && lastMsg3.tools[ri].running) {
@@ -609,6 +796,7 @@ function chatPage() {
break;
}
}
this.messages.splice(trIdx, 1, lastMsg3);
}
this.scrollToBottom();
break;
@@ -885,10 +1073,16 @@ function chatPage() {
});
},
_latexTimer: null,
scrollToBottom() {
var self = this;
var el = document.getElementById('messages');
if (el) self.$nextTick(function() { el.scrollTop = el.scrollHeight; });
if (el) self.$nextTick(function() {
el.scrollTop = el.scrollHeight;
// Debounce LaTeX rendering to avoid running on every streaming token
if (self._latexTimer) clearTimeout(self._latexTimer);
self._latexTimer = setTimeout(function() { renderLatex(el); }, 150);
});
},
addFiles(files) {
@@ -958,6 +1152,9 @@ function chatPage() {
formatToolJson: function(text) {
if (!text) return '';
if (typeof text === 'object') {
return JSON.stringify(text, null, 2);
}
try { return JSON.stringify(JSON.parse(text), null, 2); }
catch(e) { return text; }
},
@@ -0,0 +1,201 @@
// OpenFang Comms Page — Agent topology & inter-agent communication feed
'use strict';
function commsPage() {
return {
topology: { nodes: [], edges: [] },
events: [],
loading: true,
loadError: '',
sseSource: null,
showSendModal: false,
showTaskModal: false,
sendFrom: '',
sendTo: '',
sendMsg: '',
sendLoading: false,
taskTitle: '',
taskDesc: '',
taskAssign: '',
taskLoading: false,
async loadData() {
this.loading = true;
this.loadError = '';
try {
var results = await Promise.all([
OpenFangAPI.get('/api/comms/topology'),
OpenFangAPI.get('/api/comms/events?limit=200')
]);
this.topology = results[0] || { nodes: [], edges: [] };
this.events = results[1] || [];
this.startSSE();
} catch(e) {
this.loadError = e.message || 'Could not load comms data.';
}
this.loading = false;
},
startSSE() {
if (this.sseSource) this.sseSource.close();
var self = this;
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
this.sseSource = new EventSource(url);
this.sseSource.onmessage = function(ev) {
if (ev.data === 'ping') return;
try {
var event = JSON.parse(ev.data);
self.events.unshift(event);
if (self.events.length > 200) self.events.length = 200;
// Refresh topology on spawn/terminate events
if (event.kind === 'agent_spawned' || event.kind === 'agent_terminated') {
self.refreshTopology();
}
} catch(e) { /* ignore parse errors */ }
};
},
stopSSE() {
if (this.sseSource) {
this.sseSource.close();
this.sseSource = null;
}
},
async refreshTopology() {
try {
this.topology = await OpenFangAPI.get('/api/comms/topology');
} catch(e) { /* silent */ }
},
rootNodes() {
var childIds = {};
var self = this;
this.topology.edges.forEach(function(e) {
if (e.kind === 'parent_child') childIds[e.to] = true;
});
return this.topology.nodes.filter(function(n) { return !childIds[n.id]; });
},
childrenOf(id) {
var childIds = {};
this.topology.edges.forEach(function(e) {
if (e.kind === 'parent_child' && e.from === id) childIds[e.to] = true;
});
return this.topology.nodes.filter(function(n) { return childIds[n.id]; });
},
peersOf(id) {
var peerIds = {};
this.topology.edges.forEach(function(e) {
if (e.kind === 'peer') {
if (e.from === id) peerIds[e.to] = true;
if (e.to === id) peerIds[e.from] = true;
}
});
return this.topology.nodes.filter(function(n) { return peerIds[n.id]; });
},
stateBadgeClass(state) {
switch(state) {
case 'Running': return 'badge badge-success';
case 'Suspended': return 'badge badge-warning';
case 'Terminated': case 'Crashed': return 'badge badge-danger';
default: return 'badge badge-dim';
}
},
eventBadgeClass(kind) {
switch(kind) {
case 'agent_message': return 'badge badge-info';
case 'agent_spawned': return 'badge badge-success';
case 'agent_terminated': return 'badge badge-danger';
case 'task_posted': return 'badge badge-warning';
case 'task_claimed': return 'badge badge-info';
case 'task_completed': return 'badge badge-success';
default: return 'badge badge-dim';
}
},
eventIcon(kind) {
switch(kind) {
case 'agent_message': return '\u2709';
case 'agent_spawned': return '+';
case 'agent_terminated': return '\u2715';
case 'task_posted': return '\u2691';
case 'task_claimed': return '\u2690';
case 'task_completed': return '\u2713';
default: return '\u2022';
}
},
eventLabel(kind) {
switch(kind) {
case 'agent_message': return 'Message';
case 'agent_spawned': return 'Spawned';
case 'agent_terminated': return 'Terminated';
case 'task_posted': return 'Task Posted';
case 'task_claimed': return 'Task Claimed';
case 'task_completed': return 'Task Done';
default: return kind;
}
},
timeAgo(dateStr) {
if (!dateStr) return '';
var d = new Date(dateStr);
var secs = Math.floor((Date.now() - d.getTime()) / 1000);
if (secs < 60) return secs + 's ago';
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
return Math.floor(secs / 86400) + 'd ago';
},
openSendModal() {
this.sendFrom = '';
this.sendTo = '';
this.sendMsg = '';
this.showSendModal = true;
},
async submitSend() {
if (!this.sendFrom || !this.sendTo || !this.sendMsg.trim()) return;
this.sendLoading = true;
try {
await OpenFangAPI.post('/api/comms/send', {
from_agent_id: this.sendFrom,
to_agent_id: this.sendTo,
message: this.sendMsg
});
OpenFangToast.success('Message sent');
this.showSendModal = false;
} catch(e) {
OpenFangToast.error(e.message || 'Send failed');
}
this.sendLoading = false;
},
openTaskModal() {
this.taskTitle = '';
this.taskDesc = '';
this.taskAssign = '';
this.showTaskModal = true;
},
async submitTask() {
if (!this.taskTitle.trim()) return;
this.taskLoading = true;
try {
var body = { title: this.taskTitle, description: this.taskDesc };
if (this.taskAssign) body.assigned_to = this.taskAssign;
await OpenFangAPI.post('/api/comms/task', body);
OpenFangToast.success('Task posted');
this.showTaskModal = false;
} catch(e) {
OpenFangToast.error(e.message || 'Task failed');
}
this.taskLoading = false;
}
};
}
+463 -5
View File
@@ -18,6 +18,15 @@ function handsPage() {
browserViewerOpen: false,
_browserPollTimer: null,
// ── Trader Dashboard State ────────────────────────────────────────────
dashboardOpen: false,
dashboardLoading: false,
dashboardData: null,
_dashboardInst: null,
_chartEquity: null,
_chartPnl: null,
_chartRadar: null,
// ── Setup Wizard State ──────────────────────────────────────────────
setupWizard: null,
setupStep: 1,
@@ -27,6 +36,7 @@ function handsPage() {
_clipboardTimer: null,
detectedPlatform: 'linux',
installPlatforms: {},
apiKeyInputs: {},
async loadData() {
this.loading = true;
@@ -101,13 +111,19 @@ function handsPage() {
} else {
this._detectClientPlatform();
}
// Initialize per-requirement platform selections
// Initialize per-requirement platform selections and API key inputs
this.installPlatforms = {};
this.apiKeyInputs = {};
if (data.requirements) {
for (var j = 0; j < data.requirements.length; j++) {
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
if (data.requirements[j].type === 'ApiKey') {
this.apiKeyInputs[data.requirements[j].key] = '';
}
}
}
// Initialize optional instance name (for multi-instance hands).
data.instanceName = '';
this.setupWizard = data;
// Skip deps step if no requirements
var hasReqs = data.requirements && data.requirements.length > 0;
@@ -274,7 +290,10 @@ function handsPage() {
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
var count = 0;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
if (this.setupWizard.requirements[i].satisfied) count++;
var req = this.setupWizard.requirements[i];
if (req.satisfied) { count++; continue; }
// Count API key reqs as met if user entered a value
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
}
return count;
},
@@ -285,7 +304,34 @@ function handsPage() {
},
get setupAllReqsMet() {
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
if (!this.setupWizard || !this.setupWizard.requirements) return false;
if (this.setupReqsTotal === 0) return false;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.satisfied) continue;
// API key reqs are satisfied if the user entered a value in the input
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
return false;
}
return true;
},
getSettingKeyForReq(req) {
// Find the matching setting key for an API key requirement.
// Convention: setting key is the lowercase version of the requirement key.
if (!this.setupWizard || !this.setupWizard.settings) return null;
var lowerKey = req.key.toLowerCase();
for (var i = 0; i < this.setupWizard.settings.length; i++) {
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
}
// Fallback: try matching by check_value lowercased
if (req.check_value) {
var lowerCheck = req.check_value.toLowerCase();
for (var j = 0; j < this.setupWizard.settings.length; j++) {
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
}
}
return null;
},
get setupHasReqs() {
@@ -297,6 +343,10 @@ function handsPage() {
},
setupNextStep() {
// When leaving step 1, sync API key inputs into settings values
if (this.setupStep === 1) {
this._syncApiKeysToSettings();
}
if (this.setupStep === 1 && this.setupHasSettings) {
this.setupStep = 2;
} else if (this.setupStep === 1) {
@@ -306,6 +356,19 @@ function handsPage() {
}
},
_syncApiKeysToSettings() {
if (!this.setupWizard || !this.setupWizard.requirements) return;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
},
setupPrevStep() {
if (this.setupStep === 3 && this.setupHasSettings) {
this.setupStep = 2;
@@ -323,19 +386,38 @@ function handsPage() {
this.setupChecking = false;
this.clipboardMsg = null;
this.installPlatforms = {};
this.apiKeyInputs = {};
},
async launchHand() {
if (!this.setupWizard) return;
var handId = this.setupWizard.id;
// Sync API key inputs from step 1 into settings values
if (this.setupWizard.requirements) {
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
}
var config = {};
for (var key in this.settingsValues) {
config[key] = this.settingsValues[key];
}
this.activatingId = handId;
try {
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', { config: config });
this.showToast('Hand "' + handId + '" activated as ' + (data.agent_name || data.instance_id));
var payload = { config: config };
var name = (this.setupWizard.instanceName || '').trim();
if (name) {
payload.instance_name = name;
}
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', payload);
var label = data.instance_name || data.agent_name || data.instance_id;
this.showToast('Hand "' + handId + '" activated as ' + label);
this.closeSetupWizard();
await this.loadActive();
this.tab = 'active';
@@ -499,6 +581,382 @@ function handsPage() {
this.stopBrowserPolling();
this.browserViewerOpen = false;
this.browserViewer = null;
},
// ── Trader Dashboard ──────────────────────────────────────────────────
isTraderHand(inst) {
return inst.hand_id === 'trader';
},
async openDashboard(inst) {
this._dashboardInst = inst;
this.dashboardOpen = true;
this.dashboardLoading = true;
this.dashboardData = null;
await this._fetchDashboardData(inst);
this.dashboardLoading = false;
// Render charts after DOM update
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
async refreshDashboard() {
if (!this._dashboardInst) return;
this.dashboardLoading = true;
await this._fetchDashboardData(this._dashboardInst);
this.dashboardLoading = false;
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
closeDashboard() {
this.dashboardOpen = false;
this._destroyCharts();
this.dashboardData = null;
this._dashboardInst = null;
},
async _fetchDashboardData(inst) {
var data = {
agent_name: inst.agent_name || inst.hand_id,
portfolio_value: null,
total_pnl: null,
win_rate: null,
sharpe_ratio: null,
max_drawdown: null,
trades_count: null,
equity_curve: [],
daily_pnl: [],
watchlist_heatmap: [],
signal_radar: null,
recent_trades: []
};
// Fetch basic stats from the hand stats endpoint
try {
var stats = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
var m = stats.metrics || {};
if (m['Portfolio Value']) data.portfolio_value = this._metricVal(m['Portfolio Value']);
if (m['Total P&L']) data.total_pnl = this._metricVal(m['Total P&L']);
if (m['Win Rate']) data.win_rate = this._metricVal(m['Win Rate']);
if (m['Sharpe Ratio']) data.sharpe_ratio = this._metricVal(m['Sharpe Ratio']);
if (m['Max Drawdown']) data.max_drawdown = this._metricVal(m['Max Drawdown']);
if (m['Trades Executed']) data.trades_count = this._metricVal(m['Trades Executed']);
} catch(e) {
// Stats endpoint might fail — continue with KV data
}
// Fetch rich chart data from agent memory KV
var agentId = inst.agent_id || 'shared';
var kvKeys = [
'trader_hand_equity_curve',
'trader_hand_daily_pnl',
'trader_hand_watchlist_heatmap',
'trader_hand_signal_radar',
'trader_hand_recent_trades',
'trader_hand_portfolio_value',
'trader_hand_total_pnl',
'trader_hand_win_rate',
'trader_hand_sharpe_ratio',
'trader_hand_max_drawdown',
'trader_hand_trades_count'
];
for (var i = 0; i < kvKeys.length; i++) {
try {
var resp = await OpenFangAPI.get('/api/memory/agents/' + agentId + '/kv/' + kvKeys[i]);
if (resp && resp.value !== null && resp.value !== undefined) {
var val = resp.value;
this._applyKvToData(data, kvKeys[i], val);
}
} catch(e) {
// Key might not exist yet — that's fine
}
}
this.dashboardData = data;
},
_metricVal(metric) {
if (!metric) return null;
var v = metric.value;
if (v === null || v === undefined) return null;
// Values come as JSON values — could be string, number, etc.
if (typeof v === 'string') return v;
return String(v);
},
_applyKvToData(data, key, val) {
// Values from KV can be strings (JSON-encoded) or already parsed
var parsed = val;
if (typeof val === 'string') {
try { parsed = JSON.parse(val); } catch(e) { parsed = val; }
}
switch(key) {
case 'trader_hand_portfolio_value':
if (!data.portfolio_value) data.portfolio_value = String(parsed);
break;
case 'trader_hand_total_pnl':
if (!data.total_pnl) data.total_pnl = String(parsed);
break;
case 'trader_hand_win_rate':
if (!data.win_rate) data.win_rate = String(parsed);
break;
case 'trader_hand_sharpe_ratio':
if (!data.sharpe_ratio) data.sharpe_ratio = String(parsed);
break;
case 'trader_hand_max_drawdown':
if (!data.max_drawdown) data.max_drawdown = String(parsed);
break;
case 'trader_hand_trades_count':
if (!data.trades_count) data.trades_count = String(parsed);
break;
case 'trader_hand_equity_curve':
if (Array.isArray(parsed)) data.equity_curve = parsed;
break;
case 'trader_hand_daily_pnl':
if (Array.isArray(parsed)) data.daily_pnl = parsed;
break;
case 'trader_hand_watchlist_heatmap':
if (Array.isArray(parsed)) data.watchlist_heatmap = parsed;
break;
case 'trader_hand_signal_radar':
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data.signal_radar = parsed;
break;
case 'trader_hand_recent_trades':
if (Array.isArray(parsed)) data.recent_trades = parsed;
break;
}
},
_destroyCharts() {
if (this._chartEquity) { this._chartEquity.destroy(); this._chartEquity = null; }
if (this._chartPnl) { this._chartPnl.destroy(); this._chartPnl = null; }
if (this._chartRadar) { this._chartRadar.destroy(); this._chartRadar = null; }
},
_renderCharts() {
if (typeof Chart === 'undefined') return;
this._destroyCharts();
if (!this.dashboardData) return;
var d = this.dashboardData;
// Detect theme
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
var gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
var textColor = isDark ? '#8A8380' : '#6B6560';
var accentColor = '#FF5C00';
var successColor = isDark ? '#4ADE80' : '#22C55E';
var errorColor = '#EF4444';
// ── Equity Curve ──
if (d.equity_curve && d.equity_curve.length > 0) {
var eqCanvas = document.getElementById('traderEquityChart');
if (eqCanvas) {
var labels = [];
var values = [];
for (var i = 0; i < d.equity_curve.length; i++) {
labels.push(d.equity_curve[i].date || '');
values.push(parseFloat(d.equity_curve[i].value) || 0);
}
// Determine gradient
var eqCtx = eqCanvas.getContext('2d');
var gradient = eqCtx.createLinearGradient(0, 0, 0, eqCanvas.parentElement.clientHeight || 180);
gradient.addColorStop(0, isDark ? 'rgba(255, 92, 0, 0.25)' : 'rgba(255, 92, 0, 0.15)');
gradient.addColorStop(1, 'rgba(255, 92, 0, 0)');
this._chartEquity = new Chart(eqCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
data: values,
borderColor: accentColor,
backgroundColor: gradient,
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: d.equity_curve.length > 20 ? 0 : 3,
pointHoverRadius: 5,
pointBackgroundColor: accentColor
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
return '$' + ctx.parsed.y.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { color: gridColor },
ticks: { color: textColor, maxTicksLimit: 8, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) { return '$' + v.toLocaleString(); }
}
}
}
}
});
}
}
// ── Daily P&L Bar Chart ──
if (d.daily_pnl && d.daily_pnl.length > 0) {
var pnlCanvas = document.getElementById('traderPnlChart');
if (pnlCanvas) {
var pnlLabels = [];
var pnlValues = [];
var pnlColors = [];
for (var j = 0; j < d.daily_pnl.length; j++) {
pnlLabels.push(d.daily_pnl[j].date || '');
var pnlVal = parseFloat(d.daily_pnl[j].pnl) || 0;
pnlValues.push(pnlVal);
pnlColors.push(pnlVal >= 0 ? successColor : errorColor);
}
this._chartPnl = new Chart(pnlCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: pnlLabels,
datasets: [{
data: pnlValues,
backgroundColor: pnlColors,
borderRadius: 3,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
var v = ctx.parsed.y;
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: textColor, maxTicksLimit: 7, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) {
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString();
}
}
}
}
}
});
}
}
// ── Signal Radar Chart ──
if (d.signal_radar) {
var radarCanvas = document.getElementById('traderRadarChart');
if (radarCanvas) {
var radarLabels = [];
var radarValues = [];
var keys = ['technical', 'fundamental', 'sentiment', 'macro'];
var displayLabels = ['Technical', 'Fundamental', 'Sentiment', 'Macro'];
for (var k = 0; k < keys.length; k++) {
radarLabels.push(displayLabels[k]);
radarValues.push(parseFloat(d.signal_radar[keys[k]]) || 0);
}
this._chartRadar = new Chart(radarCanvas.getContext('2d'), {
type: 'radar',
data: {
labels: radarLabels,
datasets: [{
data: radarValues,
borderColor: accentColor,
backgroundColor: isDark ? 'rgba(255, 92, 0, 0.2)' : 'rgba(255, 92, 0, 0.12)',
borderWidth: 2,
pointBackgroundColor: accentColor,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) { return ctx.parsed.r + '/100'; }
}
}
},
scales: {
r: {
min: 0,
max: 100,
beginAtZero: true,
grid: { color: gridColor },
angleLines: { color: gridColor },
pointLabels: {
color: textColor,
font: { size: 11, weight: '600' }
},
ticks: {
color: textColor,
backdropColor: 'transparent',
stepSize: 25,
font: { size: 9 }
}
}
}
}
});
}
}
}
};
}
@@ -0,0 +1,59 @@
// Runtime page — system overview and provider status
document.addEventListener('alpine:init', function() {
Alpine.data('runtimePage', function() {
return {
loading: true,
uptime: '-',
agentCount: 0,
version: '-',
defaultModel: '-',
platform: '-',
arch: '-',
apiListen: '-',
homeDir: '-',
logLevel: '-',
networkEnabled: false,
providers: [],
async loadData() {
this.loading = true;
try {
var results = await Promise.all([
OpenFangAPI.get('/api/status'),
OpenFangAPI.get('/api/version'),
OpenFangAPI.get('/api/providers'),
OpenFangAPI.get('/api/agents')
]);
var status = results[0];
var ver = results[1];
var prov = results[2];
var agents = results[3];
this.version = ver.version || '-';
this.platform = ver.platform || '-';
this.arch = ver.arch || '-';
this.agentCount = Array.isArray(agents) ? agents.length : 0;
this.defaultModel = status.default_model || '-';
this.apiListen = status.api_listen || status.listen || '-';
this.homeDir = status.home_dir || '-';
this.logLevel = status.log_level || '-';
this.networkEnabled = !!status.network_enabled;
// Compute uptime from uptime_seconds
var diff = status.uptime_seconds || 0;
if (diff < 60) this.uptime = diff + 's';
else if (diff < 3600) this.uptime = Math.floor(diff / 60) + 'm ' + (diff % 60) + 's';
else if (diff < 86400) this.uptime = Math.floor(diff / 3600) + 'h ' + Math.floor((diff % 3600) / 60) + 'm';
else this.uptime = Math.floor(diff / 86400) + 'd ' + Math.floor((diff % 86400) / 3600) + 'h';
this.providers = (prov.providers || []).filter(function(p) {
return p.auth_status === 'Configured' || p.reachable || p.is_local;
});
} catch(e) {
console.error('Runtime load error:', e);
}
this.loading = false;
}
};
});
});
@@ -201,15 +201,17 @@ function schedulerPage() {
async runNow(job) {
this.runningJobId = job.id;
try {
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
if (result.status === 'completed') {
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
job.last_run = new Date().toISOString();
var result = await OpenFangAPI.post('/api/cron/jobs/' + job.id + '/run', {});
if (result.status === 'triggered' || result.status === 'completed') {
OpenFangToast.success('Job "' + (job.name || 'job') + '" triggered');
// Don't update job.last_run here — the job runs asynchronously in the
// background. The real last_run is set by the server on completion and
// will appear on the next data refresh.
} else {
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
OpenFangToast.error('Run failed: ' + (result.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Run Now is not yet available for cron jobs');
OpenFangToast.error('Run failed: ' + (e.message || e));
}
this.runningJobId = '';
},
+141 -7
View File
@@ -14,11 +14,23 @@ function settingsPage() {
modelSearch: '',
modelProviderFilter: '',
modelTierFilter: '',
showCustomModelForm: false,
customModelId: '',
customModelProvider: 'openrouter',
customModelContext: 128000,
customModelMaxOutput: 8192,
customModelStatus: '',
providerKeyInputs: {},
providerUrlInputs: {},
providerUrlSaving: {},
providerTesting: {},
providerTestResults: {},
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
customProviderName: '',
customProviderUrl: '',
customProviderKey: '',
customProviderStatus: '',
addingCustomProvider: false,
loading: true,
loadError: '',
@@ -212,8 +224,13 @@ function settingsPage() {
this.providers = data.providers || [];
for (var i = 0; i < this.providers.length; i++) {
var p = this.providers[i];
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
this.providerUrlInputs[p.id] = p.base_url;
if (p.is_local) {
if (!this.providerUrlInputs[p.id]) {
this.providerUrlInputs[p.id] = p.base_url || '';
}
if (this.providerUrlSaving[p.id] === undefined) {
this.providerUrlSaving[p.id] = false;
}
}
}
} catch(e) { this.providers = []; }
@@ -226,6 +243,37 @@ function settingsPage() {
} catch(e) { this.models = []; }
},
async addCustomModel() {
var id = this.customModelId.trim();
if (!id) return;
this.customModelStatus = 'Adding...';
try {
await OpenFangAPI.post('/api/models/custom', {
id: id,
provider: this.customModelProvider || 'openrouter',
context_window: this.customModelContext || 128000,
max_output_tokens: this.customModelMaxOutput || 8192,
});
this.customModelStatus = 'Added!';
this.customModelId = '';
this.showCustomModelForm = false;
await this.loadModels();
} catch(e) {
this.customModelStatus = 'Error: ' + (e.message || 'Failed');
}
},
async deleteCustomModel(modelId) {
if (!confirm('Delete custom model "' + modelId + '"?')) return;
try {
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
OpenFangToast.success('Model deleted');
await this.loadModels();
} catch(e) {
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
}
},
async loadConfigSchema() {
try {
var results = await Promise.all([
@@ -247,11 +295,14 @@ function settingsPage() {
async saveConfigField(section, field, value) {
var key = section + '.' + field;
// Root-level fields (api_key, api_listen, log_level) use just the field name
var sectionMeta = this.configSchema && this.configSchema[section];
var path = (sectionMeta && sectionMeta.root_level) ? field : key;
this.configSaving[key] = true;
try {
await OpenFangAPI.post('/api/config/set', { path: key, value: value });
await OpenFangAPI.post('/api/config/set', { path: path, value: value });
this.configDirty[key] = false;
OpenFangToast.success('Saved ' + key);
OpenFangToast.success('Saved ' + field);
} catch(e) {
OpenFangToast.error('Failed to save: ' + e.message);
}
@@ -301,7 +352,10 @@ function settingsPage() {
providerAuthText(p) {
if (p.auth_status === 'configured') return 'Configured';
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'Not Set';
if (p.auth_status === 'not_set' || p.auth_status === 'missing') {
if (p.id === 'claude-code') return 'Not Installed';
return 'Not Set';
}
return 'No Key Needed';
},
@@ -347,8 +401,12 @@ function settingsPage() {
var key = this.providerKeyInputs[provider.id];
if (!key || !key.trim()) { OpenFangToast.error('Please enter an API key'); return; }
try {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
OpenFangToast.success('API key saved for ' + provider.display_name);
var resp = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
if (resp && resp.switched_default) {
OpenFangToast.warning(resp.message || 'Default provider was switched to ' + provider.display_name);
} else {
OpenFangToast.success('API key saved for ' + provider.display_name);
}
this.providerKeyInputs[provider.id] = '';
await this.loadProviders();
await this.loadModels();
@@ -368,6 +426,54 @@ function settingsPage() {
}
},
async startCopilotOAuth() {
this.copilotOAuth.polling = true;
this.copilotOAuth.userCode = '';
try {
var resp = await OpenFangAPI.post('/api/providers/github-copilot/oauth/start', {});
this.copilotOAuth.userCode = resp.user_code;
this.copilotOAuth.verificationUri = resp.verification_uri;
this.copilotOAuth.pollId = resp.poll_id;
this.copilotOAuth.interval = resp.interval || 5;
window.open(resp.verification_uri, '_blank');
this.pollCopilotOAuth();
} catch(e) {
OpenFangToast.error('Failed to start Copilot login: ' + e.message);
this.copilotOAuth.polling = false;
}
},
pollCopilotOAuth() {
var self = this;
setTimeout(async function() {
if (!self.copilotOAuth.pollId) return;
try {
var resp = await OpenFangAPI.get('/api/providers/github-copilot/oauth/poll/' + self.copilotOAuth.pollId);
if (resp.status === 'complete') {
OpenFangToast.success('GitHub Copilot authenticated successfully!');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
await self.loadProviders();
await self.loadModels();
} else if (resp.status === 'pending') {
if (resp.interval) self.copilotOAuth.interval = resp.interval;
self.pollCopilotOAuth();
} else if (resp.status === 'expired') {
OpenFangToast.error('Device code expired. Please try again.');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
} else if (resp.status === 'denied') {
OpenFangToast.error('Access denied by user.');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
} else {
OpenFangToast.error('OAuth error: ' + (resp.error || resp.status));
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
}
} catch(e) {
OpenFangToast.error('Poll error: ' + e.message);
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
}
}, self.copilotOAuth.interval * 1000);
},
async testProvider(provider) {
this.providerTesting[provider.id] = true;
this.providerTestResults[provider.id] = null;
@@ -408,6 +514,34 @@ function settingsPage() {
this.providerUrlSaving[provider.id] = false;
},
async addCustomProvider() {
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
var url = this.customProviderUrl.trim();
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
OpenFangToast.error('URL must start with http:// or https://'); return;
}
this.addingCustomProvider = true;
this.customProviderStatus = '';
try {
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
if (this.customProviderKey.trim()) {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
}
this.customProviderName = '';
this.customProviderUrl = '';
this.customProviderKey = '';
this.customProviderStatus = '';
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
await this.loadProviders();
} catch(e) {
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
OpenFangToast.error('Failed to add provider: ' + e.message);
}
this.addingCustomProvider = false;
},
// -- Security methods --
async loadSecurity() {
this.secLoading = true;
+35 -1
View File
@@ -19,10 +19,16 @@ function skillsPage() {
installingSlug: null,
installResult: null,
_searchTimer: null,
_browseCache: {}, // { key: { ts, data } } client-side 60s cache
_searchCache: {},
// Skill detail modal
skillDetail: null,
detailLoading: false,
showSkillCode: false,
skillCode: '',
skillCodeFilename: '',
skillCodeLoading: false,
// MCP servers
mcpServers: [],
@@ -146,9 +152,16 @@ function skillsPage() {
if (this._searchTimer) clearTimeout(this._searchTimer);
},
// ClawHub browse by sort
// ClawHub browse by sort (with 60s client-side cache)
async browseClawHub(sort) {
this.clawhubSort = sort || 'trending';
var ckey = 'browse:' + this.clawhubSort;
var cached = this._browseCache[ckey];
if (cached && (Date.now() - cached.ts) < 60000) {
this.clawhubBrowseResults = cached.data.items || [];
this.clawhubNextCursor = cached.data.next_cursor || null;
return;
}
this.clawhubLoading = true;
this.clawhubError = '';
this.clawhubNextCursor = null;
@@ -157,6 +170,7 @@ function skillsPage() {
this.clawhubBrowseResults = data.items || [];
this.clawhubNextCursor = data.next_cursor || null;
if (data.error) this.clawhubError = data.error;
this._browseCache[ckey] = { ts: Date.now(), data: data };
} catch(e) {
this.clawhubBrowseResults = [];
this.clawhubError = e.message || 'Browse failed';
@@ -195,6 +209,26 @@ function skillsPage() {
closeDetail() {
this.skillDetail = null;
this.installResult = null;
this.showSkillCode = false;
this.skillCode = '';
this.skillCodeFilename = '';
},
async viewSkillCode(slug) {
if (this.showSkillCode) {
this.showSkillCode = false;
return;
}
this.skillCodeLoading = true;
try {
var data = await OpenFangAPI.get('/api/clawhub/skill/' + encodeURIComponent(slug) + '/code');
this.skillCode = data.code || '';
this.skillCodeFilename = data.filename || 'source';
this.showSkillCode = true;
} catch(e) {
OpenFangToast.error('Could not load skill source code');
}
this.skillCodeLoading = false;
},
// Install from ClawHub
+55 -19
View File
@@ -1,6 +1,16 @@
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
function wizardTomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
function wizardTomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function wizardPage() {
return {
step: 1,
@@ -244,6 +254,15 @@ function wizardPage() {
this.error = '';
try {
await this.loadProviders();
// Pre-select first unconfigured provider, or first one
var unconfigured = this.providers.filter(function(p) {
return p.auth_status !== 'configured' && p.api_key_env;
});
if (unconfigured.length > 0) {
this.selectedProvider = unconfigured[0].id;
} else if (this.providers.length > 0) {
this.selectedProvider = this.providers[0].id;
}
} catch(e) {
this.error = e.message || 'Could not load setup data.';
}
@@ -283,11 +302,13 @@ function wizardPage() {
},
get canGoNext() {
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider;
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider || this.claudeCodeDetected;
if (this.step === 3) return this.agentName.trim().length > 0;
return true;
},
claudeCodeDetected: false,
get hasConfiguredProvider() {
var self = this;
return this.providers.some(function(p) {
@@ -301,15 +322,6 @@ function wizardPage() {
try {
var data = await OpenFangAPI.get('/api/providers');
this.providers = data.providers || [];
// Pre-select first unconfigured provider, or first one
var unconfigured = this.providers.filter(function(p) {
return p.auth_status !== 'configured' && p.api_key_env;
});
if (unconfigured.length > 0) {
this.selectedProvider = unconfigured[0].id;
} else if (this.providers.length > 0) {
this.selectedProvider = this.providers[0].id;
}
} catch(e) { this.providers = []; }
},
@@ -320,7 +332,7 @@ function wizardPage() {
},
get popularProviders() {
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
return this.providers.filter(function(p) {
return popular.indexOf(p.id) >= 0;
}).sort(function(a, b) {
@@ -329,7 +341,7 @@ function wizardPage() {
},
get otherProviders() {
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
return this.providers.filter(function(p) {
return popular.indexOf(p.id) < 0;
});
@@ -355,7 +367,8 @@ function wizardPage() {
fireworks: { url: 'https://fireworks.ai/account/api-keys', text: 'Get your key from Fireworks AI' },
perplexity: { url: 'https://www.perplexity.ai/settings/api', text: 'Get your key from Perplexity Settings' },
cohere: { url: 'https://dashboard.cohere.com/api-keys', text: 'Get your key from the Cohere Dashboard' },
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' }
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' },
'claude-code': { url: 'https://docs.anthropic.com/en/docs/claude-code', text: 'Install: npm install -g @anthropic-ai/claude-code && claude auth (no API key needed)' }
};
return help[id] || null;
},
@@ -408,6 +421,28 @@ function wizardPage() {
this.testingProvider = false;
},
async detectClaudeCode() {
this.testingProvider = true;
this.testResult = null;
try {
var result = await OpenFangAPI.post('/api/providers/claude-code/test', {});
this.testResult = result;
if (result.status === 'ok') {
this.claudeCodeDetected = true;
this.keySaved = true;
this.setupSummary.provider = 'Claude Code';
OpenFangToast.success('Claude Code detected (' + (result.latency_ms || '?') + 'ms)');
} else {
this.testResult = { status: 'error', error: 'Claude Code CLI not detected' };
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
}
} catch(e) {
this.testResult = { status: 'error', error: e.message };
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
}
this.testingProvider = false;
},
// ── Step 3: Agent creation ──
selectTemplate(index) {
@@ -437,12 +472,12 @@ function wizardPage() {
}
var toml = '[agent]\n';
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'name = "' + model + '"\n\n';
toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
this.creatingAgent = true;
try {
@@ -468,13 +503,14 @@ function wizardPage() {
gemini: 'gemini-2.5-flash',
groq: 'llama-3.3-70b-versatile',
deepseek: 'deepseek-chat',
openrouter: 'openrouter/auto',
openrouter: 'openrouter/google/gemini-2.5-flash',
mistral: 'mistral-large-latest',
together: 'meta-llama/Llama-3-70b-chat-hf',
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
perplexity: 'llama-3.1-sonar-large-128k-online',
cohere: 'command-r-plus',
xai: 'grok-2'
xai: 'grok-2',
'claude-code': 'claude-code/sonnet'
};
return defaults[providerId] || '';
},
@@ -37,6 +37,13 @@ function workflowBuilder() {
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
],
_renderScheduled: false,
_lastClickNodeId: null,
_lastClickTime: 0,
_didDrag: false,
_didConnect: false,
_didPan: false,
async init() {
var self = this;
// Load agents for the agent step dropdown
@@ -50,6 +57,157 @@ function workflowBuilder() {
self.addNode('start', 60, 200);
},
// ── SVG Manual Rendering ────────────────────────────
// Alpine.js x-for inside <svg> breaks because document.importNode
// doesn't handle SVG namespace correctly. We render nodes/connections
// manually via createElementNS and schedule re-renders reactively.
scheduleRender: function() {
if (this._renderScheduled) return;
this._renderScheduled = true;
var self = this;
requestAnimationFrame(function() {
self._renderScheduled = false;
self.renderCanvas();
});
},
renderCanvas: function() {
var container = document.getElementById('wf-render-group');
if (!container) return;
var SVG_NS = 'http://www.w3.org/2000/svg';
var self = this;
// Clear previous rendered content
while (container.firstChild) container.removeChild(container.firstChild);
// ── Connections ──
for (var ci = 0; ci < this.connections.length; ci++) {
var conn = this.connections[ci];
var d = this.getConnectionPath(conn);
if (!d) continue;
var path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', (this.selectedConnection && this.selectedConnection.id === conn.id) ? 'var(--accent)' : 'var(--text-dim)');
path.setAttribute('stroke-width', (this.selectedConnection && this.selectedConnection.id === conn.id) ? '3' : '2');
path.style.cursor = 'pointer';
(function(c) {
path.addEventListener('click', function(e) { e.stopPropagation(); self.selectedConnection = c; self.scheduleRender(); });
})(conn);
container.appendChild(path);
}
// ── Connection preview ──
if (this.connecting && this.connectPreview) {
var pd = this.getPreviewPath();
if (pd) {
var preview = document.createElementNS(SVG_NS, 'path');
preview.setAttribute('d', pd);
preview.setAttribute('fill', 'none');
preview.setAttribute('stroke', 'var(--accent)');
preview.setAttribute('stroke-width', '2');
preview.setAttribute('stroke-dasharray', '6,3');
container.appendChild(preview);
}
}
// ── Nodes ──
for (var ni = 0; ni < this.nodes.length; ni++) {
var node = this.nodes[ni];
var g = document.createElementNS(SVG_NS, 'g');
g.classList.add('wf-node');
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
(function(n) {
g.addEventListener('mousedown', function(e) { self.onNodeMouseDown(n, e); });
g.addEventListener('dblclick', function() { self.editNode(n); });
})(node);
// Node body rect
var rect = document.createElementNS(SVG_NS, 'rect');
rect.setAttribute('x', '0'); rect.setAttribute('y', '0');
rect.setAttribute('width', node.width); rect.setAttribute('height', node.height);
rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8');
rect.setAttribute('fill', (self.selectedNode && self.selectedNode.id === node.id) ? 'var(--card-bg)' : 'var(--bg-secondary)');
rect.setAttribute('stroke', (self.selectedNode && self.selectedNode.id === node.id) ? node.color : 'var(--border)');
rect.setAttribute('stroke-width', '2');
rect.style.cursor = 'grab';
g.appendChild(rect);
// Color accent bar
var bar = document.createElementNS(SVG_NS, 'rect');
bar.setAttribute('x', '0'); bar.setAttribute('y', '0');
bar.setAttribute('width', '6'); bar.setAttribute('height', node.height);
bar.setAttribute('rx', '3'); bar.setAttribute('ry', '0');
bar.setAttribute('fill', node.color);
g.appendChild(bar);
// Icon circle + text
var circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', '28'); circle.setAttribute('cy', node.height / 2);
circle.setAttribute('r', '14'); circle.setAttribute('fill', node.color);
circle.setAttribute('opacity', '0.15');
g.appendChild(circle);
var iconText = document.createElementNS(SVG_NS, 'text');
iconText.setAttribute('x', '28'); iconText.setAttribute('y', node.height / 2 + 4);
iconText.setAttribute('text-anchor', 'middle'); iconText.setAttribute('fill', node.color);
iconText.setAttribute('style', 'font-size:12px;font-weight:700;pointer-events:none');
iconText.textContent = node.icon;
g.appendChild(iconText);
// Label
var label = document.createElementNS(SVG_NS, 'text');
label.setAttribute('x', '50'); label.setAttribute('y', node.height / 2 - 4);
label.setAttribute('fill', 'var(--text)');
label.setAttribute('style', 'font-size:12px;font-weight:600;pointer-events:none');
label.textContent = node.label;
g.appendChild(label);
// Sub-label
var subLabel = document.createElementNS(SVG_NS, 'text');
subLabel.setAttribute('x', '50'); subLabel.setAttribute('y', node.height / 2 + 12);
subLabel.setAttribute('fill', 'var(--text-dim)');
subLabel.setAttribute('style', 'font-size:10px;pointer-events:none');
if (node.type === 'agent') subLabel.textContent = node.config.agent_name || 'No agent';
else if (node.type === 'condition') subLabel.textContent = node.config.expression || 'No condition';
else if (node.type === 'loop') subLabel.textContent = 'max ' + (node.config.max_iterations || 5) + ' iters';
else if (node.type === 'parallel') subLabel.textContent = (node.config.fan_count || 3) + ' branches';
else if (node.type === 'collect') subLabel.textContent = node.config.strategy || 'all';
g.appendChild(subLabel);
// Input ports
for (var pi = 0; pi < node.ports.in; pi++) {
var inp = document.createElementNS(SVG_NS, 'circle');
inp.classList.add('wf-port', 'wf-port-in');
inp.setAttribute('cx', node.width / (node.ports.in + 1) * (pi + 1));
inp.setAttribute('cy', '0'); inp.setAttribute('r', '6');
inp.setAttribute('fill', 'var(--bg-secondary)');
inp.setAttribute('stroke', 'var(--text-dim)'); inp.setAttribute('stroke-width', '2');
(function(nid, idx) {
inp.addEventListener('mouseup', function(e) { e.stopPropagation(); self.endConnect(nid, idx, e); });
})(node.id, pi);
g.appendChild(inp);
}
// Output ports
for (var po = 0; po < node.ports.out; po++) {
var outp = document.createElementNS(SVG_NS, 'circle');
outp.classList.add('wf-port', 'wf-port-out');
outp.setAttribute('cx', node.width / (node.ports.out + 1) * (po + 1));
outp.setAttribute('cy', node.height); outp.setAttribute('r', '6');
outp.setAttribute('fill', 'var(--bg-secondary)');
outp.setAttribute('stroke', node.color); outp.setAttribute('stroke-width', '2');
(function(nid, idx) {
outp.addEventListener('mousedown', function(e) { e.stopPropagation(); self.startConnect(nid, idx, e); });
})(node.id, po);
g.appendChild(outp);
}
container.appendChild(g);
}
},
// ── Node Management ──────────────────────────────────
addNode: function(type, x, y) {
@@ -83,6 +241,7 @@ function workflowBuilder() {
node.config = { strategy: 'all' };
}
this.nodes.push(node);
this.scheduleRender();
return node;
},
@@ -95,6 +254,7 @@ function workflowBuilder() {
this.selectedNode = null;
this.showNodeEditor = false;
}
this.scheduleRender();
},
duplicateNode: function(node) {
@@ -166,19 +326,36 @@ function workflowBuilder() {
}
this.connecting = null;
this.connectPreview = null;
this.scheduleRender();
},
deleteConnection: function(connId) {
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
this.selectedConnection = null;
this.scheduleRender();
},
// ── Drag Handling ────────────────────────────────────
onNodeMouseDown: function(node, e) {
e.stopPropagation();
// Detect double-click manually — the native dblclick event never fires
// because scheduleRender() destroys and recreates all SVG elements between
// the first and second click, so the browser loses the DOM target for dblclick.
var now = Date.now();
if (this._lastClickNodeId === node.id && (now - this._lastClickTime) < 350) {
// Double-click detected — open editor instead of starting drag
this._lastClickNodeId = null;
this._lastClickTime = 0;
this.editNode(node);
return;
}
this._lastClickNodeId = node.id;
this._lastClickTime = now;
this.selectedNode = node;
this.selectedConnection = null;
this._didDrag = false;
this.dragging = node.id;
var rect = this._getCanvasRect();
this.dragOffset = {
@@ -193,6 +370,7 @@ function workflowBuilder() {
this.selectedConnection = null;
this.showNodeEditor = false;
// Start canvas pan
this._didPan = false;
this.canvasDragging = true;
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
},
@@ -200,17 +378,22 @@ function workflowBuilder() {
onCanvasMouseMove: function(e) {
var rect = this._getCanvasRect();
if (this.dragging) {
this._didDrag = true;
var node = this.getNode(this.dragging);
if (node) {
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
}
this.scheduleRender();
} else if (this.connecting) {
this._didConnect = true;
this.connectPreview = {
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
};
this.scheduleRender();
} else if (this.canvasDragging) {
this._didPan = true;
this.canvasOffset = {
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
y: (e.clientY - this.canvasDragStart.y) / this.zoom
@@ -219,10 +402,19 @@ function workflowBuilder() {
},
onCanvasMouseUp: function() {
// Only re-render if something actually moved. Rendering on every mouseup
// destroys SVG elements between clicks, which prevents dblclick detection.
var needsRender = this._didDrag || this._didConnect || this._didPan;
this.dragging = null;
this.connecting = null;
this.connectPreview = null;
this.canvasDragging = false;
this._didDrag = false;
this._didConnect = false;
this._didPan = false;
if (needsRender) {
this.scheduleRender();
}
},
onCanvasWheel: function(e) {
@@ -267,6 +459,12 @@ function workflowBuilder() {
editNode: function(node) {
this.selectedNode = node;
this.showNodeEditor = true;
this.scheduleRender();
},
// Called from editor panel inputs to reflect changes on the canvas SVG
applyNodeEdit: function() {
this.scheduleRender();
},
// ── TOML Generation ──────────────────────────────────
@@ -386,7 +584,7 @@ function workflowBuilder() {
var rect = this._getCanvasRect();
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
this.addNode(type, x - 90, y - 35);
this.addNode(type, x - 90, y - 35); // addNode already calls scheduleRender
},
onCanvasDragOver: function(e) {
@@ -405,6 +603,7 @@ function workflowBuilder() {
this.nodes[i].y = y;
y += 120;
}
this.scheduleRender();
},
// ── Clear ────────────────────────────────────────────
@@ -414,7 +613,7 @@ function workflowBuilder() {
this.connections = [];
this.selectedNode = null;
this.nextId = 1;
this.addNode('start', 60, 200);
this.addNode('start', 60, 200); // addNode already calls scheduleRender
},
// ── Zoom controls ────────────────────────────────────
@@ -13,6 +13,8 @@ function workflowsPage() {
loading: true,
loadError: '',
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
editModal: null,
editWf: { name: '', description: '', steps: [] },
// -- Workflows methods --
async loadWorkflows() {
@@ -74,6 +76,57 @@ function workflowsPage() {
} catch(e) {
OpenFangToast.error('Failed to load run history: ' + e.message);
}
},
async deleteWorkflow(wf) {
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
try {
await OpenFangAPI.delete('/api/workflows/' + wf.id);
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to delete workflow: ' + e.message);
}
},
async showEditModal(wf) {
try {
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
this.editWf = {
name: full.name || '',
description: full.description || '',
steps: (full.steps || []).map(function(s) {
return {
name: s.name || '',
agent_name: (s.agent && s.agent.name) || '',
mode: s.mode || 'sequential',
prompt: s.prompt_template || '{{input}}'
};
})
};
if (this.editWf.steps.length === 0) {
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
}
this.editModal = wf;
} catch(e) {
OpenFangToast.error('Failed to load workflow: ' + e.message);
}
},
async saveWorkflow() {
if (!this.editModal) return;
var steps = this.editWf.steps.map(function(s) {
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
});
try {
var wfName = this.editWf.name;
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
this.editModal = null;
OpenFangToast.success('Workflow "' + wfName + '" updated');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to update workflow: ' + e.message);
}
}
};
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "OpenFang Agent OS",
"short_name": "OpenFang",
"description": "Open-source Agent Operating System",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#6366f1",
"icons": [
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
]
}
+3
View File
@@ -0,0 +1,3 @@
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
File diff suppressed because one or more lines are too long
+145 -18
View File
@@ -76,6 +76,9 @@ async fn start_test_server_with_provider(
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()
@@ -221,10 +224,10 @@ async fn test_status_endpoint() {
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "running");
assert_eq!(body["agent_count"], 0);
assert_eq!(body["agent_count"], 1); // default assistant auto-spawned
assert!(body["uptime_seconds"].is_number());
assert_eq!(body["default_provider"], "ollama");
assert_eq!(body["agents"].as_array().unwrap().len(), 0);
assert_eq!(body["agents"].as_array().unwrap().len(), 1);
}
#[tokio::test]
@@ -246,7 +249,7 @@ async fn test_spawn_list_kill_agent() {
let agent_id = body["agent_id"].as_str().unwrap().to_string();
assert!(!agent_id.is_empty());
// --- List (1 agent) ---
// --- List (2 agents: default assistant + test-agent) ---
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
@@ -254,10 +257,10 @@ async fn test_spawn_list_kill_agent() {
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["name"], "test-agent");
assert_eq!(agents[0]["id"], agent_id);
assert_eq!(agents[0]["model_provider"], "ollama");
assert_eq!(agents.len(), 2);
let test_agent = agents.iter().find(|a| a["name"] == "test-agent").unwrap();
assert_eq!(test_agent["id"], agent_id);
assert_eq!(test_agent["model_provider"], "ollama");
// --- Kill ---
let resp = client
@@ -269,7 +272,7 @@ async fn test_spawn_list_kill_agent() {
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "killed");
// --- List (empty) ---
// --- List (only default assistant remains) ---
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
@@ -277,7 +280,8 @@ async fn test_spawn_list_kill_agent() {
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 0);
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["name"], "assistant");
}
#[tokio::test]
@@ -310,6 +314,115 @@ async fn test_agent_session_empty() {
assert_eq!(body["messages"].as_array().unwrap().len(), 0);
}
/// Regression test for #935: the GET /api/agents/:id/session endpoint
/// must NOT expose internal system-prompt messages to the Web UI.
///
/// We construct a session containing a System message + a User message + an
/// Assistant message, persist it via the kernel's memory store, then call the
/// HTTP endpoint and assert:
/// 1. The default response excludes the system message entirely.
/// 2. `message_count` reflects only the visible (user + assistant) messages.
/// 3. `raw_message_count` exposes the underlying total.
/// 4. With `?include_system=true`, the system message IS returned (debug
/// mode opt-in).
#[tokio::test]
async fn test_agent_session_filters_system_messages() {
use openfang_types::message::{Message, Role};
let server = start_test_server().await;
let client = reqwest::Client::new();
// Spawn agent
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let agent_id_str = body["agent_id"].as_str().unwrap().to_string();
// Look up the agent's session id and inject a forged history that
// contains a system-role message (simulating what an OpenAI-compat
// client could push, or what a future regression might persist).
let agent_id: openfang_types::agent::AgentId = agent_id_str.parse().unwrap();
let entry = server.state.kernel.registry.get(agent_id).unwrap();
let session_id = entry.session_id;
let mut session = server
.state
.kernel
.memory
.get_session(session_id)
.unwrap()
.expect("session should exist after spawn");
session.messages = vec![
Message {
role: Role::System,
content: openfang_types::message::MessageContent::Text(
"INTERNAL SYSTEM PROMPT — must not leak to UI".to_string(),
),
},
Message::user("hello"),
Message::assistant("hi there"),
];
server.state.kernel.memory.save_session(&session).unwrap();
// --- Default request: system message must be filtered out ---
let resp = client
.get(format!(
"{}/api/agents/{}/session",
server.base_url, agent_id_str
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 2, "should only see user + assistant");
assert_eq!(body["message_count"], 2);
assert_eq!(body["raw_message_count"], 3);
// No message in the response should carry the System role label, and
// the system prompt text MUST NOT appear anywhere in the payload.
for m in messages {
let role = m["role"].as_str().unwrap_or("");
assert_ne!(role, "System", "system role leaked into UI history");
assert_ne!(role, "system", "system role leaked into UI history");
}
let body_str = serde_json::to_string(&body).unwrap();
assert!(
!body_str.contains("INTERNAL SYSTEM PROMPT"),
"system prompt content leaked into session response: {body_str}"
);
// Verify the visible roles are exactly what we expect.
assert_eq!(messages[0]["role"], "User");
assert_eq!(messages[0]["content"], "hello");
assert_eq!(messages[1]["role"], "Assistant");
assert_eq!(messages[1]["content"], "hi there");
// --- Opt-in debug mode: ?include_system=true returns it ---
let resp = client
.get(format!(
"{}/api/agents/{}/session?include_system=true",
server.base_url, agent_id_str
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 3, "include_system=true should return all 3");
assert_eq!(messages[0]["role"], "System");
assert_eq!(messages[0]["content"], "INTERNAL SYSTEM PROMPT — must not leak to UI");
assert_eq!(body["message_count"], 3);
assert_eq!(body["raw_message_count"], 3);
}
#[tokio::test]
async fn test_send_message_with_llm() {
if std::env::var("GROQ_API_KEY").is_err() {
@@ -616,14 +729,14 @@ memory_write = ["self.*"]
ids.push(body["agent_id"].as_str().unwrap().to_string());
}
// List should show 3
// List should show 4 (3 spawned + default assistant)
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 3);
assert_eq!(agents.len(), 4);
// Status should agree
let resp = client
@@ -632,7 +745,7 @@ memory_write = ["self.*"]
.await
.unwrap();
let status: serde_json::Value = resp.json().await.unwrap();
assert_eq!(status["agent_count"], 3);
assert_eq!(status["agent_count"], 4);
// Kill one
let resp = client
@@ -642,14 +755,14 @@ memory_write = ["self.*"]
.unwrap();
assert_eq!(resp.status(), 200);
// List should show 2
// List should show 3 (2 spawned + default assistant)
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 2);
assert_eq!(agents.len(), 3);
// Kill the rest
for id in [&ids[0], &ids[2]] {
@@ -660,14 +773,14 @@ memory_write = ["self.*"]
.unwrap();
}
// List should be empty
// List should have only default assistant
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 0);
assert_eq!(agents.len(), 1);
}
// ---------------------------------------------------------------------------
@@ -702,9 +815,23 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
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 api_key_state = state.kernel.config.api_key.clone();
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let app = Router::new()
.route("/api/health", axum::routing::get(routes::health))
@@ -748,7 +875,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
)
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.layer(axum::middleware::from_fn_with_state(
api_key_state,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn(middleware::request_logging))
@@ -113,6 +113,9 @@ async fn test_full_daemon_lifecycle() {
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()
@@ -236,6 +239,9 @@ async fn test_server_immediate_responsiveness() {
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()
+4 -1
View File
@@ -57,6 +57,9 @@ async fn start_test_server() -> TestServer {
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()
@@ -540,7 +543,7 @@ async fn load_spawn_kill_cycle() {
.await
.unwrap();
let remaining = agents.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(remaining, 0, "All agents should be killed");
assert_eq!(remaining, 1, "Only default assistant should remain");
}
/// Test: Prometheus metrics endpoint under sustained load.
+8
View File
@@ -14,6 +14,7 @@ chrono = { workspace = true }
dashmap = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
prost = { workspace = true }
reqwest = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
@@ -24,13 +25,20 @@ zeroize = { workspace = true }
axum = { workspace = true }
hmac = { workspace = true }
sha2 = { workspace = true }
sha1 = { workspace = true }
aes = "0.8"
cbc = "0.1"
base64 = { workspace = true }
hex = { workspace = true }
html-escape = { workspace = true }
regex-lite = "0.1"
roxmltree = "0.21"
lettre = { workspace = true }
imap = { workspace = true }
native-tls = { workspace = true }
mailparse = { workspace = true }
rumqttc = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+7 -3
View File
@@ -215,7 +215,7 @@ impl BlueskyAdapter {
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in chunks {
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let mut record = serde_json::json!({
"$type": "app.bsky.feed.post",
@@ -435,7 +435,11 @@ impl ChannelAdapter for BlueskyAdapter {
service_url
);
if let Some(ref seen) = last_seen_at {
url.push_str(&format!("&seenAt={}", seen));
let encoded: String = url::form_urlencoded::Serializer::new(String::new())
.append_pair("seenAt", seen)
.finish();
url.push('&');
url.push_str(&encoded);
}
let resp = match client.get(&url).bearer_auth(&token).send().await {
@@ -492,7 +496,7 @@ impl ChannelAdapter for BlueskyAdapter {
if last_seen_at.is_some() {
let mark_url = format!("{}/xrpc/app.bsky.notification.updateSeen", service_url);
let mark_body = serde_json::json!({
"seenAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
"seenAt": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
});
let _ = client
.post(&mark_url)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,600 @@
//! DingTalk Stream channel adapter.
//!
//! Uses DingTalk Stream Mode (WebSocket long-connection) instead of the
//! legacy webhook approach. The webhook adapter in `dingtalk.rs` is preserved
//! for backwards compatibility.
//!
//! Protocol:
//! 1. POST /v1.0/oauth2/accessToken → get access token
//! 2. POST /v1.0/gateway/connections/open → get WebSocket URL
//! 3. Connect via WebSocket, handle ping/pong and EVENT messages
//! 4. Outbound: POST /v1.0/robot/oToMessages/batchSend
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use chrono::Utc;
use futures::{SinkExt, Stream, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{error, info, warn};
const API_BASE: &str = "https://api.dingtalk.com";
const MAX_MESSAGE_LEN: usize = 20000;
// ─── Adapter ─────────────────────────────────────────────────────────────────
pub struct DingTalkStreamAdapter {
app_key: String,
app_secret: String,
robot_code: String,
client: reqwest::Client,
token_cache: Arc<Mutex<TokenCache>>,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
}
impl DingTalkStreamAdapter {
pub fn new(app_key: String, app_secret: String, robot_code: String) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_key,
app_secret,
robot_code,
client: reqwest::Client::new(),
token_cache: Arc::new(Mutex::new(TokenCache::default())),
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
}
}
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
{
let c = self.token_cache.lock().unwrap();
if !c.token.is_empty() && c.expire_at > now + 300 {
return Ok(c.token.clone());
}
}
let resp: serde_json::Value = self
.client
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
.json(&serde_json::json!({
"appKey": self.app_key,
"appSecret": self.app_secret,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = resp["accessToken"]
.as_str()
.ok_or("missing accessToken")?
.to_string();
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
{
let mut c = self.token_cache.lock().unwrap();
c.token = token.clone();
c.expire_at = now + expire_in;
}
Ok(token)
}
async fn send_to_ids(
&self,
user_ids: &[&str],
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let token = self
.get_token()
.await
.map_err(|e| -> Box<dyn std::error::Error> { e })?;
let (msg_key, _msg_param) = match &content {
ChannelContent::Text(t) => (
"sampleText",
serde_json::json!({ "content": t }).to_string(),
),
_ => (
"sampleText",
serde_json::json!({ "content": "(unsupported content type)" }).to_string(),
),
};
let text = match &content {
ChannelContent::Text(t) => t.as_str(),
_ => "(unsupported)",
};
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in &chunks {
let param = serde_json::json!({ "content": chunk }).to_string();
let body = serde_json::json!({
"robotCode": self.robot_code,
"userIds": user_ids,
"msgKey": msg_key,
"msgParam": param,
});
let resp = self
.client
.post(format!("{API_BASE}/v1.0/robot/oToMessages/batchSend"))
.header("x-acs-dingtalk-access-token", &token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err_body = resp.text().await.unwrap_or_default();
return Err(format!("DingTalk batchSend error {status}: {err_body}").into());
}
if chunks.len() > 1 {
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
Ok(())
}
}
#[async_trait]
impl ChannelAdapter for DingTalkStreamAdapter {
fn name(&self) -> &str {
"dingtalk_stream"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Custom("dingtalk_stream".to_string())
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let app_key = self.app_key.clone();
let app_secret = self.app_secret.clone();
let client = self.client.clone();
let token_cache = Arc::clone(&self.token_cache);
let mut shutdown_rx = self.shutdown_rx.clone();
info!("DingTalk Stream adapter starting WebSocket connection");
tokio::spawn(async move {
let mut attempt: u32 = 0;
loop {
if *shutdown_rx.borrow() {
info!("DingTalk Stream: shutdown requested");
break;
}
// 1. Get access token
let token =
match get_access_token(&client, &app_key, &app_secret, &token_cache).await {
Ok(t) => t,
Err(e) => {
warn!("DingTalk Stream: token fetch failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
// 2. Get WebSocket endpoint
let ws_url = match get_ws_endpoint(&client, &app_key, &app_secret, &token).await {
Ok(u) => u,
Err(e) => {
warn!("DingTalk Stream: endpoint fetch failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
info!(
"DingTalk Stream: connecting to {}...",
&ws_url[..ws_url.len().min(60)]
);
// 3. Connect
let ws_stream = match connect_async(&ws_url).await {
Ok((ws, _)) => ws,
Err(e) => {
warn!("DingTalk Stream: WS connect failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
info!("DingTalk Stream: connected");
attempt = 0;
let (mut sink, mut source) = ws_stream.split();
// 4. Message loop
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("DingTalk Stream: graceful shutdown");
return;
}
}
msg = source.next() => {
match msg {
None => { warn!("DingTalk Stream: connection closed"); break; }
Some(Err(e)) => { warn!("DingTalk Stream: WS error: {e}"); break; }
Some(Ok(Message::Text(text))) => {
handle_frame(&text, &mut sink, &tx).await;
}
Some(Ok(Message::Ping(d))) => { let _ = sink.send(Message::Pong(d)).await; }
Some(Ok(Message::Close(_))) => { info!("DingTalk Stream: close frame"); break; }
_ => {}
}
}
}
}
// Reconnect
attempt += 1;
let delay = backoff(attempt);
info!("DingTalk Stream: reconnecting in {delay:?}");
tokio::time::sleep(delay).await;
}
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let uid = &user.platform_id;
if uid.is_empty() {
return Err("DingTalk Stream: no platform_id to reply to".into());
}
self.send_to_ids(&[uid.as_str()], content).await
}
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
}
}
// ─── Token helpers ───────────────────────────────────────────────────────────
#[derive(Default)]
struct TokenCache {
token: String,
expire_at: u64,
}
async fn get_access_token(
http: &reqwest::Client,
app_key: &str,
app_secret: &str,
cache: &Arc<Mutex<TokenCache>>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
{
let c = cache.lock().unwrap();
if !c.token.is_empty() && c.expire_at > now + 300 {
return Ok(c.token.clone());
}
}
let resp: serde_json::Value = http
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
.json(&serde_json::json!({ "appKey": app_key, "appSecret": app_secret }))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = resp["accessToken"]
.as_str()
.ok_or("missing accessToken")?
.to_string();
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
{
let mut c = cache.lock().unwrap();
c.token = token.clone();
c.expire_at = now + expire_in;
}
Ok(token)
}
// ─── Gateway / WebSocket helpers ─────────────────────────────────────────────
#[derive(Serialize)]
struct OpenConnectionRequest<'a> {
#[serde(rename = "clientId")]
client_id: &'a str,
#[serde(rename = "clientSecret")]
client_secret: &'a str,
subscriptions: Vec<SubItem>,
ua: &'a str,
#[serde(rename = "localIp")]
local_ip: &'a str,
}
#[derive(Serialize)]
struct SubItem {
#[serde(rename = "type")]
sub_type: String,
topic: String,
}
#[derive(Deserialize)]
struct OpenConnectionResponse {
endpoint: String,
ticket: String,
}
async fn get_ws_endpoint(
http: &reqwest::Client,
app_key: &str,
app_secret: &str,
token: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let body = OpenConnectionRequest {
client_id: app_key,
client_secret: app_secret,
subscriptions: vec![SubItem {
sub_type: "CALLBACK".to_string(),
topic: "/v1.0/im/bot/messages/get".to_string(),
}],
ua: "openfang/0.3",
local_ip: "",
};
let resp: OpenConnectionResponse = http
.post(format!("{API_BASE}/v1.0/gateway/connections/open"))
.header("x-acs-dingtalk-access-token", token)
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
let sep = if resp.endpoint.contains('?') {
"&"
} else {
"?"
};
Ok(format!("{}{}ticket={}", resp.endpoint, sep, resp.ticket))
}
// ─── Frame handling ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ProtoFrame {
#[serde(rename = "type")]
msg_type: String,
headers: ProtoHeaders,
#[serde(default)]
data: serde_json::Value,
}
#[derive(Deserialize)]
struct ProtoHeaders {
#[serde(rename = "messageId", default)]
message_id: String,
#[serde(default)]
topic: String,
}
#[derive(Serialize)]
struct AckReply {
code: u32,
headers: AckHeaders,
message: String,
data: String,
}
#[derive(Serialize)]
struct AckHeaders {
#[serde(rename = "contentType")]
content_type: String,
#[serde(rename = "messageId")]
message_id: String,
topic: String,
}
fn make_ack(message_id: &str, topic: &str) -> String {
serde_json::to_string(&AckReply {
code: 200,
headers: AckHeaders {
content_type: "application/json".to_string(),
message_id: message_id.to_string(),
topic: topic.to_string(),
},
message: "OK".to_string(),
data: String::new(),
})
.unwrap_or_default()
}
#[derive(Deserialize)]
struct CallbackPayload {
#[serde(rename = "msgtype", default)]
msg_type: String,
#[serde(default)]
text: Option<TextContent>,
#[serde(rename = "senderStaffId", default)]
sender_staff_id: String,
#[serde(rename = "senderId", default)]
sender_id: String,
#[serde(rename = "senderNick", default)]
sender_nick: String,
#[serde(rename = "conversationId", default)]
conversation_id: String,
#[serde(rename = "conversationType", default)]
conversation_type: String,
#[serde(rename = "messageId", default)]
message_id: String,
}
#[derive(Deserialize)]
struct TextContent {
content: String,
}
async fn handle_frame<S>(text: &str, sink: &mut S, tx: &mpsc::Sender<ChannelMessage>)
where
S: SinkExt<Message> + Unpin,
<S as futures::Sink<Message>>::Error: std::fmt::Display,
{
let frame: ProtoFrame = match serde_json::from_str(text) {
Ok(f) => f,
Err(e) => {
warn!("DingTalk Stream: bad frame: {e}");
return;
}
};
let mid = &frame.headers.message_id;
let topic = &frame.headers.topic;
match frame.msg_type.as_str() {
"SYSTEM" if topic == "ping" => {
let _ = sink.send(Message::Text(make_ack(mid, "pong"))).await;
}
"CALLBACK" | "EVENT" => {
let data_str = frame.data.to_string();
// Try direct parse, then try unwrapping double-encoded string
let cb: Option<CallbackPayload> = serde_json::from_str(&data_str).ok().or_else(|| {
serde_json::from_str::<String>(&data_str)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
});
if let Some(cb) = cb {
if cb.msg_type == "text" {
if let Some(ref tc) = cb.text {
let trimmed = tc.content.trim().to_string();
if !trimmed.is_empty() {
let content = if trimmed.starts_with('/') {
let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
let cmd = parts[0].trim_start_matches('/');
let args: Vec<String> = parts
.get(1)
.map(|a| a.split_whitespace().map(String::from).collect())
.unwrap_or_default();
ChannelContent::Command {
name: cmd.to_string(),
args,
}
} else {
ChannelContent::Text(trimmed)
};
let mut meta = HashMap::new();
meta.insert(
"conversation_id".to_string(),
serde_json::Value::String(cb.conversation_id),
);
let uid = if cb.sender_staff_id.is_empty() {
cb.sender_id
} else {
cb.sender_staff_id
};
let msg = ChannelMessage {
channel: ChannelType::Custom("dingtalk_stream".to_string()),
platform_message_id: cb.message_id,
sender: ChannelUser {
platform_id: uid,
display_name: cb.sender_nick,
openfang_user: None,
},
content,
target_agent: None,
timestamp: Utc::now(),
is_group: cb.conversation_type == "2",
thread_id: None,
metadata: meta,
};
if tx.send(msg).await.is_err() {
error!("DingTalk Stream: channel receiver dropped");
}
}
}
}
}
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
}
_ => {
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
}
}
}
fn backoff(attempt: u32) -> Duration {
let ms = (1000u64 * 2u64.saturating_pow(attempt.min(6))).min(60_000);
Duration::from_millis(ms)
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adapter_creation() {
let a = DingTalkStreamAdapter::new("k".into(), "s".into(), "r".into());
assert_eq!(a.name(), "dingtalk_stream");
assert_eq!(
a.channel_type(),
ChannelType::Custom("dingtalk_stream".to_string())
);
}
#[test]
fn backoff_doubles() {
assert_eq!(backoff(0), Duration::from_millis(1000));
assert_eq!(backoff(1), Duration::from_millis(2000));
assert_eq!(backoff(2), Duration::from_millis(4000));
}
#[test]
fn backoff_capped() {
assert_eq!(backoff(10), Duration::from_millis(60_000));
assert_eq!(backoff(20), Duration::from_millis(60_000));
}
#[test]
fn make_ack_valid_json() {
let ack = make_ack("msg1", "topic1");
let v: serde_json::Value = serde_json::from_str(&ack).unwrap();
assert_eq!(v["code"], 200);
assert_eq!(v["headers"]["messageId"], "msg1");
}
}
+366 -29
View File
@@ -10,9 +10,11 @@ use async_trait::async_trait;
use futures::{SinkExt, Stream, StreamExt};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch, RwLock};
use tokio::sync::{mpsc, watch, Mutex, RwLock};
use tokio::task::JoinHandle;
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
@@ -33,12 +35,26 @@ mod opcode {
pub const HEARTBEAT_ACK: u64 = 11;
}
/// Build a Discord gateway heartbeat (opcode 1) payload.
///
/// Per the Discord gateway spec, the payload `d` field is the last received
/// dispatch sequence number, or `null` if no dispatch has been received yet.
/// See: <https://discord.com/developers/docs/topics/gateway#sending-heartbeats>
fn build_heartbeat_payload(last_sequence: Option<u64>) -> serde_json::Value {
serde_json::json!({
"op": opcode::HEARTBEAT,
"d": last_sequence,
})
}
/// Discord Gateway adapter using WebSocket.
pub struct DiscordAdapter {
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
token: Zeroizing<String>,
client: reqwest::Client,
allowed_guilds: Vec<u64>,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
@@ -51,12 +67,20 @@ pub struct DiscordAdapter {
}
impl DiscordAdapter {
pub fn new(token: String, allowed_guilds: Vec<u64>, intents: u64) -> Self {
pub fn new(
token: String,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
token: Zeroizing::new(token),
client: reqwest::Client::new(),
allowed_guilds,
allowed_users,
ignore_bots,
intents,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
@@ -147,6 +171,8 @@ impl ChannelAdapter for DiscordAdapter {
let token = self.token.clone();
let intents = self.intents;
let allowed_guilds = self.allowed_guilds.clone();
let allowed_users = self.allowed_users.clone();
let ignore_bots = self.ignore_bots;
let bot_user_id = self.bot_user_id.clone();
let session_id_store = self.session_id.clone();
let resume_url_store = self.resume_gateway_url.clone();
@@ -179,8 +205,15 @@ impl ChannelAdapter for DiscordAdapter {
backoff = INITIAL_BACKOFF;
info!("Discord gateway connected");
let (mut ws_tx, mut ws_rx) = ws_stream.split();
let mut _heartbeat_interval: Option<u64> = None;
let (ws_tx_raw, mut ws_rx) = ws_stream.split();
// Wrap the sink so the periodic heartbeat task and the inner
// loop can both write to it.
let ws_tx = Arc::new(Mutex::new(ws_tx_raw));
let mut heartbeat_handle: Option<JoinHandle<()>> = None;
// Tracks whether the most recent heartbeat we sent has been
// ACKed (opcode 11). Initialized to `true` so the first
// heartbeat is always allowed to fire.
let heartbeat_acked = Arc::new(AtomicBool::new(true));
// Inner message loop — returns true if we should reconnect
let should_reconnect = 'inner: loop {
@@ -189,7 +222,10 @@ impl ChannelAdapter for DiscordAdapter {
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("Discord shutdown requested");
let _ = ws_tx.close().await;
if let Some(h) = heartbeat_handle.take() {
h.abort();
}
let _ = ws_tx.lock().await.close().await;
return;
}
continue;
@@ -227,7 +263,8 @@ impl ChannelAdapter for DiscordAdapter {
let op = payload["op"].as_u64().unwrap_or(999);
// Update sequence number
// Update sequence number from any payload that carries one
// (typically dispatch events, opcode 0).
if let Some(s) = payload["s"].as_u64() {
*sequence.write().await = Some(s);
}
@@ -236,9 +273,72 @@ impl ChannelAdapter for DiscordAdapter {
opcode::HELLO => {
let interval =
payload["d"]["heartbeat_interval"].as_u64().unwrap_or(45000);
_heartbeat_interval = Some(interval);
debug!("Discord HELLO: heartbeat_interval={interval}ms");
// Spawn the periodic heartbeat task BEFORE we send
// IDENTIFY/RESUME, per the Discord gateway flow.
// Abort any stale handle from a previous attempt
// first (defensive — should normally be None here).
if let Some(h) = heartbeat_handle.take() {
h.abort();
}
heartbeat_acked.store(true, Ordering::Relaxed);
let hb_sink = ws_tx.clone();
let hb_seq = sequence.clone();
let hb_acked = heartbeat_acked.clone();
let mut hb_shutdown = shutdown.clone();
heartbeat_handle = Some(tokio::spawn(async move {
let mut ticker =
tokio::time::interval(Duration::from_millis(interval));
// Skip the immediate first tick — we want to
// wait one full interval before the first beat.
ticker.tick().await;
loop {
tokio::select! {
_ = ticker.tick() => {}
_ = hb_shutdown.changed() => {
if *hb_shutdown.borrow() {
return;
}
continue;
}
}
// If the previous heartbeat was never
// ACKed, the connection is zombied — close
// the sink so the read loop sees EOF and
// triggers a reconnect (Discord spec).
if !hb_acked.swap(false, Ordering::Relaxed) {
warn!(
"Discord: previous heartbeat not ACKed, \
forcing reconnect"
);
let _ = hb_sink.lock().await.close().await;
return;
}
let seq = *hb_seq.read().await;
let payload = build_heartbeat_payload(seq);
let text = match serde_json::to_string(&payload) {
Ok(s) => s,
Err(e) => {
error!("Discord: failed to serialize heartbeat: {e}");
return;
}
};
let send_res = hb_sink
.lock()
.await
.send(tokio_tungstenite::tungstenite::Message::Text(text))
.await;
if let Err(e) = send_res {
warn!("Discord: failed to send heartbeat: {e}");
return;
}
debug!("Discord heartbeat sent (seq={:?})", seq);
}
}));
// Try RESUME if we have a session, otherwise IDENTIFY
let has_session = session_id_store.read().await.is_some();
let has_seq = sequence.read().await.is_some();
@@ -272,6 +372,8 @@ impl ChannelAdapter for DiscordAdapter {
};
if let Err(e) = ws_tx
.lock()
.await
.send(tokio_tungstenite::tungstenite::Message::Text(
serde_json::to_string(&gateway_msg).unwrap(),
))
@@ -306,9 +408,14 @@ impl ChannelAdapter for DiscordAdapter {
}
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
if let Some(msg) =
parse_discord_message(d, &bot_user_id, &allowed_guilds)
.await
if let Some(msg) = parse_discord_message(
d,
&bot_user_id,
&allowed_guilds,
&allowed_users,
ignore_bots,
)
.await
{
debug!(
"Discord {event_name} from {}: {:?}",
@@ -333,16 +440,23 @@ impl ChannelAdapter for DiscordAdapter {
opcode::HEARTBEAT => {
// Server requests immediate heartbeat
let seq = *sequence.read().await;
let hb = serde_json::json!({ "op": opcode::HEARTBEAT, "d": seq });
let hb = build_heartbeat_payload(seq);
let _ = ws_tx
.lock()
.await
.send(tokio_tungstenite::tungstenite::Message::Text(
serde_json::to_string(&hb).unwrap(),
))
.await;
// The server-requested heartbeat counts as a fresh
// beat — reset the ACK gate so the periodic task
// doesn't see a stale "unacked" flag.
heartbeat_acked.store(false, Ordering::Relaxed);
}
opcode::HEARTBEAT_ACK => {
debug!("Discord heartbeat ACK received");
heartbeat_acked.store(true, Ordering::Relaxed);
}
opcode::RECONNECT => {
@@ -368,6 +482,12 @@ impl ChannelAdapter for DiscordAdapter {
}
};
// Tear down the heartbeat task before we either exit or
// reconnect, so it doesn't outlive its WebSocket sink.
if let Some(h) = heartbeat_handle.take() {
h.abort();
}
if !should_reconnect || *shutdown.borrow() {
break;
}
@@ -422,7 +542,9 @@ impl ChannelAdapter for DiscordAdapter {
async fn parse_discord_message(
d: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_guilds: &[u64],
allowed_guilds: &[String],
allowed_users: &[String],
ignore_bots: bool,
) -> Option<ChannelMessage> {
let author = d.get("author")?;
let author_id = author["id"].as_str()?;
@@ -434,16 +556,21 @@ async fn parse_discord_message(
}
}
// Filter out other bots
if author["bot"].as_bool() == Some(true) {
// Filter out other bots (configurable via ignore_bots)
if ignore_bots && author["bot"].as_bool() == Some(true) {
return None;
}
// Filter by allowed users
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == author_id) {
debug!("Discord: ignoring message from unlisted user {author_id}");
return None;
}
// Filter by allowed guilds
if !allowed_guilds.is_empty() {
if let Some(guild_id) = d["guild_id"].as_str() {
let gid: u64 = guild_id.parse().unwrap_or(0);
if !allowed_guilds.contains(&gid) {
if !allowed_guilds.iter().any(|g| g == guild_id) {
return None;
}
}
@@ -487,6 +614,29 @@ async fn parse_discord_message(
ChannelContent::Text(content_text.to_string())
};
// Determine if this is a group message (guild_id present = server channel)
let is_group = d["guild_id"].as_str().is_some();
// Check if bot was @mentioned (for MentionOnly policy enforcement)
let was_mentioned = if let Some(ref bid) = *bot_user_id.read().await {
// Check Discord mentions array
let mentioned_in_array = d["mentions"]
.as_array()
.map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str())))
.unwrap_or(false);
// Also check content for <@bot_id> or <@!bot_id> patterns
let mentioned_in_content = content_text.contains(&format!("<@{bid}>"))
|| content_text.contains(&format!("<@!{bid}>"));
mentioned_in_array || mentioned_in_content
} else {
false
};
let mut metadata = HashMap::new();
if was_mentioned {
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
Some(ChannelMessage {
channel: ChannelType::Discord,
platform_message_id: message_id.to_string(),
@@ -498,9 +648,9 @@ async fn parse_discord_message(
content,
target_agent: None,
timestamp,
is_group: true,
is_group,
thread_id: None,
metadata: HashMap::new(),
metadata,
})
}
@@ -524,7 +674,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert_eq!(msg.sender.display_name, "alice");
assert_eq!(msg.sender.platform_id, "ch1");
@@ -546,7 +698,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
assert!(msg.is_none());
}
@@ -566,7 +718,52 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
assert!(msg.is_none());
}
#[tokio::test]
async fn test_parse_discord_ignore_bots_false_allows_other_bots() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"content": "Bot message",
"author": {
"id": "other_bot",
"username": "somebot",
"discriminator": "0",
"bot": true
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
// With ignore_bots=false, other bots' messages should be allowed
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
assert!(msg.is_some());
let msg = msg.unwrap();
assert_eq!(msg.sender.display_name, "somebot");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Bot message"));
}
#[tokio::test]
async fn test_parse_discord_ignore_bots_false_still_filters_self() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"content": "My own message",
"author": {
"id": "bot123",
"username": "openfang",
"discriminator": "0",
"bot": true
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
// Even with ignore_bots=false, the bot's own messages must still be filtered
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
assert!(msg.is_none());
}
@@ -587,11 +784,12 @@ mod tests {
});
// Not in allowed guilds
let msg = parse_discord_message(&d, &bot_id, &[111, 222]).await;
let msg =
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
assert!(msg.is_none());
// In allowed guilds
let msg = parse_discord_message(&d, &bot_id, &[999]).await;
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
assert!(msg.is_some());
}
@@ -610,7 +808,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -635,7 +835,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
assert!(msg.is_none());
}
@@ -654,7 +854,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
}
@@ -676,16 +878,151 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert!(
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
);
}
#[tokio::test]
async fn test_parse_discord_allowed_users_filter() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"content": "Hello",
"author": {
"id": "user999",
"username": "bob",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
// Not in allowed users
let msg = parse_discord_message(
&d,
&bot_id,
&[],
&["user111".into(), "user222".into()],
true,
)
.await;
assert!(msg.is_none());
// In allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
assert!(msg.is_some());
// Empty allowed_users = allow all
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
assert!(msg.is_some());
}
#[tokio::test]
async fn test_parse_discord_mention_detection() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
// Message with bot mentioned in mentions array
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"guild_id": "guild1",
"content": "Hey <@bot123> help me",
"mentions": [{"id": "bot123", "username": "openfang"}],
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(msg.is_group);
assert_eq!(
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
Some(true)
);
// Message without mention in group
let d2 = serde_json::json!({
"id": "msg2",
"channel_id": "ch1",
"guild_id": "guild1",
"content": "Just chatting",
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(msg2.is_group);
assert!(!msg2.metadata.contains_key("was_mentioned"));
}
#[tokio::test]
async fn test_parse_discord_dm_not_group() {
let bot_id = Arc::new(RwLock::new(None));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "dm-ch1",
"content": "Hello",
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(!msg.is_group);
}
#[test]
fn test_build_heartbeat_payload_with_sequence() {
let payload = build_heartbeat_payload(Some(42));
assert_eq!(payload["op"], 1);
assert_eq!(payload["d"], 42);
// Round-trip through serde_json::to_string and re-parse to assert
// valid JSON matching {"op":1,"d":42} regardless of key ordering.
let s = serde_json::to_string(&payload).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed, serde_json::json!({"op": 1, "d": 42}));
}
#[test]
fn test_build_heartbeat_payload_without_sequence() {
let payload = build_heartbeat_payload(None);
assert_eq!(payload["op"], 1);
assert!(payload["d"].is_null());
let s = serde_json::to_string(&payload).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed, serde_json::json!({"op": 1, "d": serde_json::Value::Null}));
}
#[test]
fn test_discord_adapter_creation() {
let adapter = DiscordAdapter::new("test-token".to_string(), vec![123, 456], 33280);
let adapter = DiscordAdapter::new(
"test-token".to_string(),
vec!["123".to_string(), "456".to_string()],
vec![],
true,
37376,
);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
}
+35 -9
View File
@@ -20,6 +20,21 @@ use tokio::sync::{mpsc, watch};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
/// SASL PLAIN authenticator for IMAP servers that reject LOGIN
/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN).
struct PlainAuthenticator {
username: String,
password: String,
}
impl imap::Authenticator for PlainAuthenticator {
type Response = String;
fn process(&self, _data: &[u8]) -> Self::Response {
// SASL PLAIN: \0<username>\0<password>
format!("\x00{}\x00{}", self.username, self.password)
}
}
/// Reply context for email threading (In-Reply-To / Subject continuity).
#[derive(Debug, Clone)]
struct ReplyCtx {
@@ -124,8 +139,7 @@ impl EmailAdapter {
async fn build_smtp_transport(
&self,
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
let creds =
Credentials::new(self.username.clone(), self.password.as_str().to_string());
let creds = Credentials::new(self.username.clone(), self.password.as_str().to_string());
let transport = if self.smtp_port == 465 {
// Implicit TLS (port 465)
@@ -200,12 +214,25 @@ fn fetch_unseen_emails(
.build()
.map_err(|e| format!("TLS connector error: {e}"))?;
let client = imap::connect((host, port), host, &tls)
.map_err(|e| format!("IMAP connect failed: {e}"))?;
let client =
imap::connect((host, port), host, &tls).map_err(|e| format!("IMAP connect failed: {e}"))?;
let mut session = client
.login(username, password)
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
// that reject LOGIN and only support AUTH=PLAIN (SASL).
let mut session = match client.login(username, password) {
Ok(s) => s,
Err((login_err, client)) => {
let authenticator = PlainAuthenticator {
username: username.to_string(),
password: password.to_string(),
};
client
.authenticate("PLAIN", &authenticator)
.map_err(|(e, _)| {
format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}")
})?
}
};
let mut results = Vec::new();
@@ -362,8 +389,7 @@ impl ChannelAdapter for EmailAdapter {
}
// Extract target agent from subject brackets (stored in metadata for router)
let _target_agent =
EmailAdapter::extract_agent_from_subject(&subject);
let _target_agent = EmailAdapter::extract_agent_from_subject(&subject);
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
// Build the message body: prepend subject context
File diff suppressed because it is too large Load Diff
+466 -43
View File
@@ -17,16 +17,177 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String {
}
}
/// Format a message for WeCom, using a stronger plain-text conversion to avoid
/// leaking Markdown syntax into enterprise chat replies.
pub fn format_for_wecom(text: &str, format: OutputFormat) -> String {
match format {
OutputFormat::PlainText => markdown_to_wecom_plain(text),
_ => format_for_channel(text, format),
}
}
/// Convert Markdown to Telegram HTML subset.
///
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`.
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`, `<blockquote>`.
fn markdown_to_telegram_html(text: &str) -> String {
let mut result = text.to_string();
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let mut blocks = Vec::new();
let lines: Vec<&str> = normalized.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if trimmed.is_empty() {
i += 1;
continue;
}
// Fenced code block
if let Some(fence) = fence_delimiter(trimmed) {
i += 1;
let mut code_lines = Vec::new();
while i < lines.len() {
let candidate = lines[i].trim();
if candidate.starts_with(fence) {
i += 1;
break;
}
code_lines.push(lines[i]);
i += 1;
}
let code = escape_html(&code_lines.join("\n"));
blocks.push(format!("<pre><code>{}</code></pre>", code));
continue;
}
// ATX heading (#, ##, ...)
if let Some(content) = heading_text(trimmed) {
blocks.push(format!("<b>{}</b>", render_inline_markdown(content.trim())));
i += 1;
continue;
}
// Blockquote
if trimmed.starts_with('>') {
let mut quote_lines = Vec::new();
while i < lines.len() {
let current = lines[i].trim();
if current.is_empty() || !current.starts_with('>') {
break;
}
let content = current.strip_prefix('>').unwrap_or(current).trim_start();
quote_lines.push(render_inline_markdown(content));
i += 1;
}
blocks.push(format!(
"<blockquote>{}</blockquote>",
quote_lines.join("\n")
));
continue;
}
// Unordered list
if let Some(item) = unordered_list_item(trimmed) {
let mut items = vec![format!("{}", render_inline_markdown(item.trim()))];
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if let Some(next_item) = unordered_list_item(current) {
items.push(format!("{}", render_inline_markdown(next_item.trim())));
i += 1;
} else if current.is_empty() {
i += 1;
break;
} else {
break;
}
}
blocks.push(items.join("\n"));
continue;
}
// Ordered list
if let Some(item) = ordered_list_item(trimmed) {
let mut items = vec![format!("1. {}", render_inline_markdown(item.trim()))];
let mut counter = 2;
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if let Some(next_item) = ordered_list_item(current) {
items.push(format!(
"{}. {}",
counter,
render_inline_markdown(next_item.trim())
));
counter += 1;
i += 1;
} else if current.is_empty() {
i += 1;
break;
} else {
break;
}
}
blocks.push(items.join("\n"));
continue;
}
// Paragraph
let mut paragraph_lines = vec![trimmed];
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if current.is_empty()
|| fence_delimiter(current).is_some()
|| heading_text(current).is_some()
|| current.starts_with('>')
|| unordered_list_item(current).is_some()
|| ordered_list_item(current).is_some()
{
break;
}
paragraph_lines.push(current);
i += 1;
}
let joined = paragraph_lines.join("\n");
blocks.push(render_inline_markdown(&joined));
}
blocks.join("\n\n")
}
fn render_inline_markdown(text: &str) -> String {
let mut result = escape_html(text);
// Links: [text](url) → <a href="url">text</a>
while let Some(bracket_start) = result.find('[') {
if let Some(bracket_end_rel) = result[bracket_start..].find("](") {
let bracket_end = bracket_start + bracket_end_rel;
if let Some(paren_end_rel) = result[bracket_end + 2..].find(')') {
let paren_end = bracket_end + 2 + paren_end_rel;
let link_text = result[bracket_start + 1..bracket_end].to_string();
let url = result[bracket_end + 2..paren_end].to_string();
result = format!(
"{}<a href=\"{}\">{}</a>{}",
&result[..bracket_start],
url,
link_text,
&result[paren_end + 1..]
);
} else {
break;
}
} else {
break;
}
}
// Bold: **text** → <b>text</b>
while let Some(start) = result.find("**") {
if let Some(end) = result[start + 2..].find("**") {
let end = start + 2 + end;
if let Some(end_rel) = result[start + 2..].find("**") {
let end = start + 2 + end_rel;
let inner = result[start + 2..end].to_string();
result = format!("{}<b>{}</b>{}", &result[..start], inner, &result[end + 2..]);
} else {
@@ -34,8 +195,23 @@ fn markdown_to_telegram_html(text: &str) -> String {
}
}
// Italic: *text* → <i>text</i> (but not inside bold tags)
// Simple heuristic: match single * not preceded/followed by *
// Inline code: `text` → <code>text</code>
while let Some(start) = result.find('`') {
if let Some(end_rel) = result[start + 1..].find('`') {
let end = start + 1 + end_rel;
let inner = result[start + 1..end].to_string();
result = format!(
"{}<code>{}</code>{}",
&result[..start],
inner,
&result[end + 1..]
);
} else {
break;
}
}
// Italic: *text* → <i>text</i> (single star only)
let mut out = String::with_capacity(result.len());
let chars: Vec<char> = result.chars().collect();
let mut i = 0;
@@ -56,48 +232,57 @@ fn markdown_to_telegram_html(text: &str) -> String {
}
i += 1;
}
result = out;
// Inline code: `text` → <code>text</code>
while let Some(start) = result.find('`') {
if let Some(end) = result[start + 1..].find('`') {
let end = start + 1 + end;
let inner = result[start + 1..end].to_string();
result = format!(
"{}<code>{}</code>{}",
&result[..start],
inner,
&result[end + 1..]
);
} else {
break;
out
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn fence_delimiter(line: &str) -> Option<&'static str> {
if line.starts_with("```") {
Some("```")
} else if line.starts_with("~~~") {
Some("~~~")
} else {
None
}
}
fn heading_text(line: &str) -> Option<&str> {
let hashes = line.chars().take_while(|c| *c == '#').count();
if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
Some(&line[hashes + 1..])
} else {
None
}
}
fn unordered_list_item(line: &str) -> Option<&str> {
for prefix in ["- ", "* ", "+ "] {
if let Some(rest) = line.strip_prefix(prefix) {
return Some(rest);
}
}
None
}
// Links: [text](url) → <a href="url">text</a>
while let Some(bracket_start) = result.find('[') {
if let Some(bracket_end) = result[bracket_start..].find("](") {
let bracket_end = bracket_start + bracket_end;
if let Some(paren_end) = result[bracket_end + 2..].find(')') {
let paren_end = bracket_end + 2 + paren_end;
let link_text = &result[bracket_start + 1..bracket_end];
let url = &result[bracket_end + 2..paren_end];
result = format!(
"{}<a href=\"{}\">{}</a>{}",
&result[..bracket_start],
url,
link_text,
&result[paren_end + 1..]
);
} else {
break;
}
} else {
break;
}
fn ordered_list_item(line: &str) -> Option<&str> {
let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count();
if digit_count == 0 {
return None;
}
let rest = &line[digit_count..];
if let Some(item) = rest.strip_prefix(". ") {
Some(item)
} else if let Some(item) = rest.strip_prefix(") ") {
Some(item)
} else {
None
}
result
}
/// Convert Markdown to Slack mrkdwn format.
@@ -141,6 +326,192 @@ fn markdown_to_slack_mrkdwn(text: &str) -> String {
result
}
fn strip_atx_heading(line: &str) -> String {
let trimmed = line.trim_start();
let heading_level = trimmed.chars().take_while(|c| *c == '#').count();
if !(1..=6).contains(&heading_level) {
return line.to_string();
}
if trimmed.chars().nth(heading_level) != Some(' ') {
return line.to_string();
}
trimmed[heading_level..]
.trim()
.trim_end_matches('#')
.trim_end()
.to_string()
}
fn strip_blockquote_prefix(line: &str) -> String {
let mut trimmed = line.trim_start();
while let Some(rest) = trimmed.strip_prefix('>') {
trimmed = rest.trim_start();
}
trimmed.to_string()
}
fn strip_task_list_prefix(line: &str) -> String {
let trimmed = line.trim_start();
for prefix in [
"- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] ",
] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
return rest.to_string();
}
}
line.to_string()
}
fn is_fenced_code_marker(line: &str) -> bool {
let trimmed = line.trim();
let mut chars = trimmed.chars();
let Some(marker) = chars.next() else {
return false;
};
if marker != '`' && marker != '~' {
return false;
}
chars.all(|c| c == marker || c.is_ascii_alphanumeric())
}
fn is_setext_heading_underline(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.len() < 3 {
return false;
}
trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.contains(['=', '-'])
}
fn is_table_divider(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '|' | ':' | '-' | ' '))
}
fn strip_inline_markdown(mut text: String) -> String {
while let Some(start) = text.find("![") {
if let Some(mid) = text[start..].find("](") {
let mid = start + mid;
if let Some(end) = text[mid + 2..].find(')') {
let end = mid + 2 + end;
let alt = &text[start + 2..mid];
let url = &text[mid + 2..end];
let replacement = if alt.is_empty() {
url.to_string()
} else {
format!("{alt} ({url})")
};
text = format!("{}{}{}", &text[..start], replacement, &text[end + 1..]);
continue;
}
}
break;
}
while let Some(start) = text.find('[') {
if let Some(mid) = text[start..].find("](") {
let mid = start + mid;
if let Some(end) = text[mid + 2..].find(')') {
let end = mid + 2 + end;
let label = &text[start + 1..mid];
let url = &text[mid + 2..end];
text = format!("{}{} ({}){}", &text[..start], label, url, &text[end + 1..]);
continue;
}
}
break;
}
while let Some(start) = text.find('<') {
if let Some(end) = text[start + 1..].find('>') {
let end = start + 1 + end;
let inner = &text[start + 1..end];
if inner.starts_with("http://")
|| inner.starts_with("https://")
|| inner.starts_with("mailto:")
{
text = format!("{}{}{}", &text[..start], inner, &text[end + 1..]);
continue;
}
}
break;
}
text = text.replace("**", "");
text = text.replace("__", "");
text = text.replace("~~", "");
text = text.replace('`', "");
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if ch == '*'
&& (i == 0 || chars[i - 1] != '*')
&& (i + 1 >= chars.len() || chars[i + 1] != '*')
{
continue;
}
out.push(ch);
}
out
}
/// Strip common Markdown blocks for WeCom plain-text replies.
fn markdown_to_wecom_plain(text: &str) -> String {
let mut result_lines = Vec::new();
let mut in_fenced_code = false;
for raw_line in text.replace("\r\n", "\n").lines() {
let trimmed = raw_line.trim();
if is_fenced_code_marker(trimmed) {
in_fenced_code = !in_fenced_code;
continue;
}
if in_fenced_code {
result_lines.push(raw_line.trim_end().to_string());
continue;
}
if is_setext_heading_underline(trimmed) || is_table_divider(trimmed) {
continue;
}
let mut line = strip_atx_heading(raw_line);
line = strip_blockquote_prefix(&line);
line = strip_task_list_prefix(&line);
let trimmed_line = line.trim();
if trimmed_line.starts_with('|') && trimmed_line.ends_with('|') && trimmed_line.len() > 2 {
line = trimmed_line
.trim_matches('|')
.split('|')
.map(|cell| cell.trim())
.collect::<Vec<_>>()
.join(" ");
}
line = strip_inline_markdown(line);
result_lines.push(line.trim().to_string());
}
let mut collapsed = Vec::new();
for line in result_lines {
if line.is_empty()
&& collapsed
.last()
.is_some_and(|prev: &String| prev.is_empty())
{
continue;
}
collapsed.push(line);
}
collapsed.join("\n").trim().to_string()
}
/// Strip all Markdown formatting, producing plain text.
fn markdown_to_plain(text: &str) -> String {
let mut result = text.to_string();
@@ -226,6 +597,36 @@ mod tests {
assert_eq!(result, "<a href=\"https://example.com\">click here</a>");
}
#[test]
fn test_telegram_html_heading() {
let result = markdown_to_telegram_html("## Result");
assert_eq!(result, "<b>Result</b>");
}
#[test]
fn test_telegram_html_unordered_list() {
let result = markdown_to_telegram_html("- alpha\n- beta");
assert_eq!(result, "• alpha\n• beta");
}
#[test]
fn test_telegram_html_ordered_list() {
let result = markdown_to_telegram_html("1. alpha\n2. beta");
assert_eq!(result, "1. alpha\n2. beta");
}
#[test]
fn test_telegram_html_fenced_code_block() {
let result = markdown_to_telegram_html("```rust\nfn main() {}\n```");
assert_eq!(result, "<pre><code>fn main() {}</code></pre>");
}
#[test]
fn test_telegram_html_blockquote() {
let result = markdown_to_telegram_html("> note\n> second line");
assert_eq!(result, "<blockquote>note\nsecond line</blockquote>");
}
#[test]
fn test_slack_mrkdwn_bold() {
let result = markdown_to_slack_mrkdwn("Hello **world**!");
@@ -249,4 +650,26 @@ mod tests {
let result = markdown_to_plain("[click](https://example.com)");
assert_eq!(result, "click (https://example.com)");
}
#[test]
fn test_wecom_plain_text_strips_common_markdown_blocks() {
let result = markdown_to_wecom_plain(
"# Title\n\
\n\
> quoted text\n\
\n\
- [x] done item\n\
- [ ] todo item\n\
\n\
```rust\n\
let value = 1;\n\
```\n\
\n\
[docs](https://example.com)\n",
);
assert_eq!(
result,
"Title\n\nquoted text\n\ndone item\ntodo item\n\nlet value = 1;\n\ndocs (https://example.com)"
);
}
}
+3
View File
@@ -43,10 +43,13 @@ pub mod twist;
pub mod webex;
// Wave 5 — Niche & differentiating channels
pub mod dingtalk;
pub mod dingtalk_stream;
pub mod discourse;
pub mod gitter;
pub mod gotify;
pub mod linkedin;
pub mod mqtt;
pub mod mumble;
pub mod ntfy;
pub mod webhook;
pub mod wecom;
+106 -11
View File
@@ -15,7 +15,7 @@ use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::{mpsc, watch};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
/// LINE push message API endpoint.
@@ -62,8 +62,8 @@ impl LineAdapter {
pub fn new(channel_secret: String, access_token: String, webhook_port: u16) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
channel_secret: Zeroizing::new(channel_secret),
access_token: Zeroizing::new(access_token),
channel_secret: Zeroizing::new(channel_secret.trim().to_string()),
access_token: Zeroizing::new(access_token.trim().to_string()),
webhook_port,
client: reqwest::Client::new(),
shutdown_tx: Arc::new(shutdown_tx),
@@ -96,12 +96,35 @@ impl LineAdapter {
// Constant-time comparison to prevent timing attacks
if result.len() != expected.len() {
debug!(
"LINE: signature length mismatch: computed={} received={}",
result.len(),
expected.len()
);
return false;
}
let mut diff = 0u8;
for (a, b) in result.iter().zip(expected.iter()) {
diff |= a ^ b;
}
if diff != 0 {
let computed = base64::engine::general_purpose::STANDARD.encode(result);
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
let comp_redacted = format!(
"{}...{}",
&computed[..4.min(computed.len())],
&computed[computed.len().saturating_sub(4)..]
);
let recv_redacted = format!(
"{}...{}",
&signature[..4.min(signature.len())],
&signature[signature.len().saturating_sub(4)..]
);
debug!(
"LINE: signature mismatch: computed={comp_redacted} received={recv_redacted} body_len={}",
body.len()
);
}
diff == 0
}
@@ -358,19 +381,18 @@ impl ChannelAdapter for LineAdapter {
axum::routing::post({
let secret = Arc::clone(&channel_secret);
let tx = Arc::clone(&tx);
move |headers: axum::http::HeaderMap,
body: axum::extract::Json<serde_json::Value>| {
move |headers: axum::http::HeaderMap, body: axum::body::Bytes| {
let secret = Arc::clone(&secret);
let tx = Arc::clone(&tx);
async move {
// Verify X-Line-Signature
// Verify X-Line-Signature using the raw request
// body bytes — NOT re-serialized JSON — because the
// HMAC must be computed over the exact bytes LINE sent.
let signature = headers
.get("x-line-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let body_bytes = serde_json::to_vec(&body.0).unwrap_or_default();
// Create a temporary adapter-like verifier
let adapter = LineAdapter {
channel_secret: secret.as_ref().clone(),
@@ -381,15 +403,23 @@ impl ChannelAdapter for LineAdapter {
shutdown_rx: watch::channel(false).1,
};
if !signature.is_empty()
&& !adapter.verify_signature(&body_bytes, signature)
if !signature.is_empty() && !adapter.verify_signature(&body, signature)
{
warn!("LINE: invalid webhook signature");
return axum::http::StatusCode::UNAUTHORIZED;
}
// Parse the raw bytes into JSON after signature verification
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
warn!("LINE: failed to parse webhook body as JSON: {e}");
return axum::http::StatusCode::BAD_REQUEST;
}
};
// Parse events array
if let Some(events) = body.0["events"].as_array() {
if let Some(events) = parsed["events"].as_array() {
for event in events {
if let Some(msg) = parse_line_event(event) {
let _ = tx.send(msg).await;
@@ -626,6 +656,71 @@ mod tests {
assert!(parse_line_event(&event).is_none());
}
#[test]
fn test_verify_signature_with_raw_body() {
// Verify that HMAC-SHA256 signature validation works with raw body bytes
let secret = "test-channel-secret";
let adapter = LineAdapter::new(secret.to_string(), "token".to_string(), 9000);
// Compute the expected signature manually
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let body = br#"{"events":[{"type":"message"}]}"#;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let expected_sig =
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
assert!(adapter.verify_signature(body, &expected_sig));
// Re-serialized JSON should NOT match (this was the bug)
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap();
let reserialized = serde_json::to_vec(&parsed).unwrap();
// The re-serialized form may differ in whitespace/key order
// If it happens to be identical for this input, the test still validates
// the core mechanism works with raw bytes
if reserialized != body.to_vec() {
assert!(!adapter.verify_signature(&reserialized, &expected_sig));
}
}
#[test]
fn test_channel_secret_trimmed() {
// Environment variables often have trailing newlines or spaces
let adapter = LineAdapter::new(
" my-secret\n".to_string(),
" my-token\r\n".to_string(),
9000,
);
assert_eq!(adapter.channel_secret.as_str(), "my-secret");
assert_eq!(adapter.access_token.as_str(), "my-token");
}
#[test]
fn test_verify_signature_bad_base64() {
let adapter = LineAdapter::new("secret".to_string(), "token".to_string(), 9000);
assert!(!adapter.verify_signature(b"body", "not-valid-base64!!!"));
}
#[test]
fn test_verify_signature_wrong_secret() {
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let body = b"test body";
let mut mac = HmacSha256::new_from_slice(b"wrong-secret").unwrap();
mac.update(body);
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
let adapter = LineAdapter::new("correct-secret".to_string(), "token".to_string(), 9000);
assert!(!adapter.verify_signature(body, &sig));
}
#[test]
fn test_parse_line_event_room_source() {
let event = serde_json::json!({

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