Compare commits

...
90 Commits
Author SHA1 Message Date
jaberjaber23 a4c6c038a0 bump v0.5.8 2026-04-10 19:57:59 +03:00
Jaber Jaber 864e957261 Merge pull request #1015 from AlexZander85/feature/russian-i18n-translation
feat(i18n): add Russian localization and i18n framework
2026-04-10 19:37:20 +03:00
Jaber Jaber f9921780e2 Merge pull request #1017 from dmbutko/fix/copilot-oauth-device-flow
fix: rewrite Copilot driver with OAuth device flow authentication
2026-04-10 19:37:10 +03:00
Jaber Jaber 1c049d06c1 Merge pull request #922 from Myshkouski/build/armv7
Add armv7-unknown-linux-gnueabihf target
2026-04-10 19:36:36 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c47e15c1ce chore(deps): bump prost 0.13 -> 0.14
Bumps [prost](https://github.com/tokio-rs/prost) from 0.13.5 to 0.14.3.
- [Release notes](https://github.com/tokio-rs/prost/releases)
- [Changelog](https://github.com/tokio-rs/prost/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/prost/compare/v0.13.5...v0.14.3)

---
updated-dependencies:
- dependency-name: prost
  dependency-version: 0.14.3
  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-04-10 19:16:03 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 185ebc429f chore(deps): bump lettre 0.11.19 -> 0.11.20
Bumps [lettre](https://github.com/lettre/lettre) from 0.11.19 to 0.11.20.
- [Release notes](https://github.com/lettre/lettre/releases)
- [Changelog](https://github.com/lettre/lettre/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lettre/lettre/compare/v0.11.19...v0.11.20)

---
updated-dependencies:
- dependency-name: lettre
  dependency-version: 0.11.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 19:15:56 +03:00
Jaber Jaber 87626ae1c9 Merge pull request #966 from t4min0/fix/nix-perl-buildinput
fix(nix): add perl to buildInputs for openssl-sys vendored build
2026-04-10 19:14:20 +03:00
Jaber Jaber 4a8af88fe8 Merge pull request #1002 from lc-soft/fix/first-chat-bug
fix(chat): prevent duplicate message keys on first chat welcome message
2026-04-10 19:14:11 +03:00
Jaber Jaber 2ef5704132 Merge pull request #1006 from lc-soft/fix/comms-api-base-url
fix(comms): OpenFangAPI.baseUrl is undefined
2026-04-10 19:14:02 +03:00
Jaber Jaber 747314b19b Merge pull request #1022 from lc-soft/fix/bar-width-style
fix(analytics): correct bar style attribute binding
2026-04-10 19:13:53 +03:00
Jaber Jaber 3787c13fb1 Merge pull request #1024 from Hypn0sis/fix/crypto-discord-free-response
fix: crypto provider, silent failure debug, Discord free_response_channels
2026-04-10 19:13:47 +03:00
Jaber Jaber 8136281e68 Merge pull request #1027 from lc-soft/fix/comms-page-modals-style
fix(comms): align task and send modals to center
2026-04-10 19:13:45 +03:00
Jaber Jaber 779389e68d Merge pull request #968 from chrisyoung2005/fix/new-agent-default-model
fix: new agents default to configured model instead of hardcoded groq
2026-04-10 19:13:37 +03:00
Jaber Jaber 4e3569778e Merge pull request #1029 from zhujg007/main
Fix: Safe char boundary handling for UTF-8 string slicing
2026-04-10 19:13:33 +03:00
Jaber Jaber 6b19bac049 Merge pull request #989 from letzdoo-js/fix/accumulated-text-response
fix: capture text from intermediate tool_use iterations
2026-04-10 19:13:25 +03:00
Jaber Jaber 2671791742 Merge pull request #1004 from lc-soft/fix/analytics-svg-rendering-errors
fix: avoid Alpine SVG template rendering errors in cost charts
2026-04-10 19:13:16 +03:00
Jaber Jaber 1ca86c4284 Merge pull request #1010 from nldhuyen0047/fix/gemini-array-items-missing
fix: inject default items schema for array parameters in Gemini normalization
2026-04-10 19:13:01 +03:00
Jaber Jaber a603dc9d96 Merge pull request #1011 from nldhuyen0047/fix/gemini-function-call-turn-ordering
fix: ensure message history starts with a user turn for Gemini compatibility
2026-04-10 19:12:47 +03:00
Jaber Jaber 8a2197126c Merge pull request #1019 from letzdoo-js/upstream-pr/cron-loss
fix(kernel): preserve cron jobs across hand reactivation
2026-04-10 19:12:35 +03:00
Jaber Jaber c743a72481 Merge pull request #1020 from rozsival/upstream
feat(channels): use plain text as default output formatter for Signal
2026-04-10 19:12:22 +03:00
jaberjaber23 605ce747ec fix: 6 bugs (#962, #939, #983, #987, #970, #882)
- #962: WebSocket auth now URL-decodes token before comparison. API keys with +/=/
  characters (base64-derived) now work correctly for WS streaming.
- #939: Clippy bool_comparison lint fixed in web_fetch.rs test.
- #983: Dockerfile adds perl and make for openssl-sys compilation on slim-bookworm.
- #987: Nextcloud chat poll endpoint corrected from api/v4/room/{token}/chat to
  api/v1/chat/{token}/ matching the send endpoint.
- #970: Moonshot Kimi K2/K2.5 models now redirect to api.moonshot.cn/v1 instead of
  api.moonshot.ai/v1. The .ai domain only serves legacy moonshot-v1-* models.
- #882: Closed as resolved by v0.5.7 custom hand persistence fix (#984).
- #926: Verified already fixed (rmcp builder API from previous session).

All tests passing. 8 files changed, 75 insertions.
2026-04-10 16:35:31 +03:00
zhujg007 34a27de85e Fix: Safe char boundary handling for UTF-8 string slicing 2026-04-10 20:51:36 +08:00
Liu 3e7d57b095 fix(comms): align task and send modals to center 2026-04-10 15:42:10 +08:00
Matteo De Agazio e988572c82 fix: clippy bool_comparison in web_fetch test 2026-04-09 17:31:28 +02:00
Matteo De Agazio 2d94963627 chore: gitignore personal deploy script 2026-04-09 17:16:39 +02:00
Matteo De Agazio d94508e72e feat: add free_response_channels support for Discord
Allow certain Discord channel IDs to respond without requiring @mention,
similar to Hermes gateway's free_response_channels.

- Add free_response_channels field to DiscordConfig
- Add free_response_channels method to ChannelBridgeHandle trait
- Implement free_response_channels in KernelBridgeAdapter
- Modify dispatch_message to bypass mention_only policy for free channels
- Add test for free_response_channels deserialization
2026-04-09 17:10:21 +02:00
Brandon Freeman af51f6c971 chore: don't fail silently, leave a debug message. 2026-04-09 17:10:21 +02:00
Brandon Freeman e1790b5380 fix: add explicit crypto provider 2026-04-09 17:10:21 +02:00
Dmitry ButkoandCopilot be1c4dba47 fix: revert Claude-specific strip, keep empty-only + empty tool name fix
The model-aware assistant strip caused infinite agent loops for Claude.
Reverted to empty-only strip which is safe for all models. The
Telegram prefill issue needs to be fixed in the agent loop, not the
driver.

Remaining openai.rs changes:
- strip_trailing_empty_assistant: strips truly empty trailing messages
- Skip tool calls with empty ID or name from streaming responses

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 21:23:55 +10:00
Liu 51a5f7983c fix(analytics): correct style attribute binding to ensure it takes effect 2026-04-09 19:17:30 +08:00
Vít Rozsíval 760f35f9de test(channels): add assert for Signal default output formatter 2026-04-09 13:16:06 +02:00
Dmitry ButkoandCopilot 64b1ec5a7e fix: model-aware assistant message stripping for Claude via Copilot
The Copilot proxy for Claude enforces Anthropic's rule that conversations
must end with a user message. For Claude models, strip any trailing
assistant message without tool_calls (including non-empty ones). For
other models, only strip truly empty assistant messages.

This fixes the 'assistant message prefill not supported' error seen
in Telegram and other channel adapters when using Claude via Copilot,
without causing infinite agent loops for other models.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 21:07:14 +10:00
Dmitry ButkoandCopilot dc6119d2cc fix: revert aggressive assistant strip, keep empty-only version
The aggressive strip (all trailing assistant messages) caused infinite
agent loops by removing non-empty responses the agent loop needs.
Reverted to only strip truly empty assistant messages (no content,
no tool_calls). The Telegram prefill issue needs a different fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 21:00:16 +10:00
Dmitry ButkoandCopilot a8b4d3b48d fix: strip all trailing assistant messages without tool_calls
Strengthens the strip to remove any trailing assistant message (not
just empty ones) when it has no tool_calls. The Copilot proxy for
Claude rejects conversations ending with any assistant message as
unsupported prefill. This fixes the Telegram bot channel where the
agent loop appends an assistant message with content.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 20:37:56 +10:00
Vít Rozsíval 36ba675f02 chore(clippy): fix warning 2026-04-09 12:34:40 +02:00
Vít Rozsíval 546816f692 chore(cargo): fix formatting 2026-04-09 12:27:18 +02:00
Vít Rozsíval 80af18a174 feat(channels): use plain text as default output formatter for Signal 2026-04-09 12:26:54 +02:00
Dmitry ButkoandCopilot abeaaf5446 fix: skip tool calls with empty ID or name from streaming response
The Copilot API proxy can sometimes deliver streaming tool call chunks
without a function name, resulting in empty-name tool calls stored in
conversation history. When replayed to the API, these cause
'tool call must have a tool call ID and function name' errors.

Skip malformed tool calls (empty ID or name) during streaming response
finalization and log a warning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 19:13:43 +10:00
Dmitry ButkoandCopilot 3854e3eb89 chore: remove debug logging, keep defensive strip_trailing_empty_assistant
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 19:01:23 +10:00
Dmitry ButkoandCopilot 73d50c0284 debug: add OAI message logging for tool call investigation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 18:55:43 +10:00
Dmitry ButkoandCopilot 6403871aa1 fix: strip trailing empty assistant messages for Claude/Gemini via Copilot proxy
The Copilot API proxy rejects conversations ending with an empty
assistant message as unsupported 'assistant message prefill' when
proxying Claude and Gemini models. GPT models are unaffected.

Strips trailing empty assistant messages (no content, no tool calls)
before sending the request. Applied in both complete() and stream()
paths.

Also reverts unused fixup_request method from copilot.rs since the
fix belongs in the OpenAI driver layer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 17:38:28 +10:00
defaultandClaude Opus 4.6 df22a3db64 fix(kernel): preserve cron jobs across hand reactivation
Bug: in activate_hand(), kill_agent() is called on the existing agent
BEFORE the new agent is spawned. kill_agent() invokes
cron_scheduler.remove_agent_jobs() which deletes all cron jobs from memory
AND persists [] to cron_jobs.json. The reassign_agent_jobs() call further
down was meant to migrate jobs from old to new (per #461), but it always
runs as a no-op because the jobs are already gone — the order of
operations defeats the fix.

Symptom: every daemon restart silently destroys cron jobs for hand-style
agents. cron_jobs.json is rewritten as []. /api/cron/jobs returns empty.
No error message.

Fix: snapshot the cron jobs into a local Vec BEFORE kill_agent (same
pattern as saved_triggers above), then re-add them under the new agent_id
AFTER spawn_agent_with_parent. Runtime state (next_run, last_run) is
reset so jobs get a fresh start. The existing reassign_agent_jobs()
block is kept as a defensive safety net but is now redundant in the
common path.

Verified with cargo check -p openfang-kernel --lib (clean compile, no
warnings).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:18:44 +00:00
Dmitry ButkoandCopilot c1a14e884e fix: rewrite Copilot driver with OAuth device flow authentication
The Copilot LLM driver was broken - it expected users to provide a
GITHUB_TOKEN env var, but no standard token type (PAT, gh CLI token)
works with the Copilot token exchange endpoint.

Changes:
- Full rewrite of copilot.rs with OAuth device flow using Copilot's
  client ID (Iv1.b507a08c87ecfe98)
- Three-layer token chain: ghu_ (8h) -> Copilot API token (30min),
  with automatic caching and refresh
- Dynamic model fetching from Copilot API on daemon startup and on
  model_not_supported error
- Init wizard: TUI auth screen with device code display, live model
  picker after authentication
- set-key command: interactive device flow for github-copilot provider
- Doctor: detects Copilot auth via persisted token file
- Removed static Copilot model entries (now fetched dynamically)
- Simplified driver instantiation (no env vars needed)

Tested end-to-end with Copilot Enterprise: auth, token exchange,
43 models fetched, completions working with claude-opus-4.6-1m.

Closes #1014

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 13:01:07 +10:00
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
AlexZander85 62b6aa4eb8 feat(i18n): add Russian localization and i18n framework
- Add complete i18n framework with en.json and ru.json locale files
- Add i18n.js with language loading, detection, and fallback logic
- Localize chat.js: slash commands, Toast messages, welcome message
- Localize agents.js: personality presets, profile descriptions
- Localize wizard.js: step labels, channel options, suggestions, profiles
- Localize skills.js: 18 skill categories, uninstall confirm
- Localize sessions.js: delete session/key confirm dialogs

Provides full Russian translation for OpenFang UI as first non-English locale.
2026-04-08 18:30:59 +05:00
Liu 09ec6f5549 fix(comms): OpenFangAPI.baseUrl is undefined 2026-04-07 17:07:17 +08:00
Liu 47c743f17c fix: avoid Alpine SVG template rendering errors in cost charts 2026-04-07 16:59:22 +08:00
Liu 489fc1312c fix(chat): prevent duplicate message keys on first chat welcome message 2026-04-07 15:20:55 +08:00
default 0408d65d8f fix: capture text from intermediate tool_use iterations
When an LLM produces text alongside tool_use blocks (e.g., a chat
message followed by memory_store calls), the text was lost if the
final EndTurn iteration returned empty text. The empty-response guard
would activate and return "[Task completed — the agent executed tools
but did not produce a text summary.]" even though the agent DID
produce text in an earlier iteration.

This is a common pattern when agents are instructed to respond to
users AND persist state via memory_store in the same turn.

Fix: accumulate text content from all ToolUse iterations. When the
final EndTurn has empty text, use accumulated text as fallback before
triggering the empty-response guard.

Applied to both sync and streaming agent loop paths.
2026-04-05 11:17:10 +00:00
chris-young 8d14d0c225 fix: new agents default to configured model instead of hardcoded groq
Built-in templates and the spawn wizard both hardcoded
provider = "groq" / model = "llama-3.3-70b-versatile" in the
manifest TOML sent to the API. The kernel's default_model overlay
only activates when provider/model are empty or "default", so
hardcoded values bypassed the user's configured default entirely.

Fixes #967
2026-04-02 18:04:54 -07:00
jaberjaber23 07963779be fix: install script skips empty releases, finds latest with binary assets 2026-04-03 03:52:46 +03:00
t4min0 28d01acf91 fix(nix): add perl to buildInputs for openssl-sys vendored build
Since v0.5.4, native-tls uses features = ["vendored"], which compiles OpenSSL from source via openssl-sys. This requires perl for the OpenSSL Configure script, but perl was missing from the flake's buildInputs.

Mirrors the Dockerfile fix in #952. Fixes #894.
2026-04-03 00:10:54 +02:00
nldhuyen0047 3b21494867 fix: inject default items schema for array parameters in Gemini normalization 2026-04-02 17:08:46 +07: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
Alexei e421594185 Merge branch 'RightNow-AI:main' into build/armv7 2026-03-30 23:26:29 +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
Alexei Myshkouski 8b925d8a04 ci: add armv7 target to CLI build matrix 2026-03-30 19:05:21 +03:00
Alexei Myshkouski 449a29418d build(cross): add armv7 target pre-build configuration 2026-03-30 19:02:20 +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
nldhuyen0047 3c221dc3ca fix: ensure message history starts with a user turn for Gemini compatibility 2026-03-30 17:00:09 +07: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
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
73 changed files with 5999 additions and 1048 deletions
+8 -5
View File
@@ -134,7 +134,7 @@ jobs:
projectPath: crates/openfang-desktop
args: ${{ matrix.platform.args }}
# ── CLI Binary (5 platforms) ──────────────────────────────────────────────
# ── CLI Binary (7 platforms) ──────────────────────────────────────────────
cli:
name: CLI / ${{ matrix.target }}
runs-on: ${{ matrix.os }}
@@ -148,6 +148,9 @@ jobs:
- target: aarch64-unknown-linux-gnu
os: ubuntu-22.04
archive: tar.gz
- target: armv7-unknown-linux-gnueabihf
os: ubuntu-22.04
archive: tar.gz
- target: x86_64-apple-darwin
os: macos-latest
archive: tar.gz
@@ -169,17 +172,17 @@ jobs:
- name: Install build deps (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev
- name: Install cross (Linux aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
- name: Install cross (Linux aarch64/armv7)
if: matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'armv7-unknown-linux-gnueabihf'
run: cargo install cross --locked
- uses: Swatinem/rust-cache@v2
with:
key: cli-${{ matrix.target }}
- name: Build CLI (cross)
if: matrix.target == 'aarch64-unknown-linux-gnu'
if: matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'armv7-unknown-linux-gnueabihf'
run: cross build --release --target ${{ matrix.target }} --bin openfang
- name: Build CLI
if: matrix.target != 'aarch64-unknown-linux-gnu'
if: matrix.target != 'aarch64-unknown-linux-gnu' && matrix.target != 'armv7-unknown-linux-gnueabihf'
run: cargo build --release --target ${{ matrix.target }} --bin openfang
- name: Ad-hoc codesign CLI binary (macOS)
if: runner.os == 'macOS'
+3
View File
@@ -45,3 +45,6 @@ Thumbs.db
*.swo
*~
.serena/
# Personal deploy scripts
scripts/deploy-remote.sh
+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
Generated
+151 -150
View File
@@ -139,7 +139,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -159,15 +159,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ar_archive_writer"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
dependencies = [
"object",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -730,9 +721,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.57"
version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -813,16 +804,6 @@ dependencies = [
"phf 0.12.1",
]
[[package]]
name = "chumsky"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
dependencies = [
"hashbrown 0.14.5",
"stacker",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -884,9 +865,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.57"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
@@ -912,7 +893,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1579,7 +1560,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1641,17 +1622,17 @@ dependencies = [
[[package]]
name = "dom_query"
version = "0.25.1"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e"
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser 0.36.0",
"foldhash 0.2.0",
"html5ever 0.36.1",
"html5ever 0.38.0",
"precomputed-hash",
"selectors 0.35.0",
"tendril",
"selectors 0.36.1",
"tendril 0.5.0",
]
[[package]]
@@ -1739,9 +1720,9 @@ checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "embed-resource"
version = "3.0.7"
version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f"
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
dependencies = [
"cc",
"memchr",
@@ -1829,7 +1810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2549,7 +2530,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
@@ -2651,12 +2631,12 @@ dependencies = [
[[package]]
name = "html5ever"
version = "0.36.1"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e"
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
dependencies = [
"log",
"markup5ever 0.36.1",
"markup5ever 0.38.0",
]
[[package]]
@@ -2760,7 +2740,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -3019,9 +2999,9 @@ dependencies = [
[[package]]
name = "instability"
version = "0.3.11"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
@@ -3038,9 +3018,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.10"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
dependencies = [
"memchr",
"serde",
@@ -3091,9 +3071,9 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "ittapi"
@@ -3147,7 +3127,7 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
@@ -3156,9 +3136,31 @@ dependencies = [
[[package]]
name = "jni-sys"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
@@ -3250,13 +3252,12 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lettre"
version = "0.11.19"
version = "0.11.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
checksum = "471816f3e24b85e820dee02cde962379ea1a669e5242f19c61bcbcffedf4c4fb"
dependencies = [
"async-trait",
"base64 0.22.1",
"chumsky",
"email-encoding",
"email_address",
"fastrand",
@@ -3338,9 +3339,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.14"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"bitflags 2.11.0",
"libc",
@@ -3456,17 +3457,17 @@ dependencies = [
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
"tendril",
"tendril 0.4.3",
]
[[package]]
name = "markup5ever"
version = "0.36.1"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c"
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
dependencies = [
"log",
"tendril",
"tendril 0.5.0",
"web_atoms",
]
@@ -3560,9 +3561,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
@@ -3642,7 +3643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.11.0",
"jni-sys",
"jni-sys 0.3.1",
"log",
"ndk-sys",
"num_enum",
@@ -3662,7 +3663,7 @@ version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys",
"jni-sys 0.3.1",
]
[[package]]
@@ -3735,14 +3736,14 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
name = "num-conv"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-traits"
@@ -3955,8 +3956,9 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"argon2",
"async-trait",
"axum",
"base64 0.22.1",
@@ -3976,6 +3978,7 @@ dependencies = [
"openfang-skills",
"openfang-types",
"openfang-wire",
"rand 0.8.5",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -3995,7 +3998,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"aes",
"async-trait",
@@ -4034,7 +4037,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"clap",
"clap_complete",
@@ -4062,7 +4065,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"axum",
"open",
@@ -4088,7 +4091,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"aes-gcm",
"argon2",
@@ -4116,10 +4119,11 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"chrono",
"dashmap",
"dirs 6.0.0",
"openfang-types",
"serde",
"serde_json",
@@ -4133,7 +4137,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"async-trait",
"chrono",
@@ -4155,6 +4159,7 @@ dependencies = [
"openfang-wire",
"rand 0.8.5",
"reqwest 0.12.28",
"rustls 0.23.37",
"serde",
"serde_json",
"subtle",
@@ -4171,7 +4176,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"async-trait",
"chrono",
@@ -4191,7 +4196,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4210,7 +4215,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"anyhow",
"async-trait",
@@ -4246,7 +4251,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"chrono",
"hex",
@@ -4269,7 +4274,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"async-trait",
"chrono",
@@ -4288,7 +4293,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.5.1"
version = "0.5.8"
dependencies = [
"async-trait",
"chrono",
@@ -4391,7 +4396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -4943,7 +4948,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.4+spec-1.1.0",
"toml_edit 0.25.8+spec-1.1.0",
]
[[package]]
@@ -5001,9 +5006,9 @@ dependencies = [
[[package]]
name = "prost"
version = "0.13.5"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive",
@@ -5011,9 +5016,9 @@ dependencies = [
[[package]]
name = "prost-derive"
version = "0.13.5"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -5022,16 +5027,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "psm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "pulley-interpreter"
version = "41.0.4"
@@ -5107,7 +5102,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.37",
"socket2 0.6.3",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5145,7 +5140,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
]
@@ -5161,9 +5156,9 @@ dependencies = [
[[package]]
name = "quoted_printable"
version = "0.5.1"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
@@ -5584,9 +5579,9 @@ dependencies = [
[[package]]
name = "rmcp"
version = "1.2.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f"
checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419"
dependencies = [
"async-trait",
"chrono",
@@ -5710,7 +5705,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5805,7 +5800,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5980,9 +5975,9 @@ dependencies = [
[[package]]
name = "selectors"
version = "0.35.0"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.11.0",
"cssparser 0.36.0",
@@ -6106,9 +6101,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.0.4"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
dependencies = [
"serde_core",
]
@@ -6317,9 +6312,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
version = "0.3.8"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
@@ -6375,7 +6370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6473,19 +6468,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.59.0",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -6643,9 +6625,9 @@ dependencies = [
[[package]]
name = "tao"
version = "0.34.6"
version = "0.34.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
dependencies = [
"bitflags 2.11.0",
"block2",
@@ -7124,7 +7106,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7138,6 +7120,16 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tendril"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
dependencies = [
"new_debug_unreachable",
"utf-8",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -7384,7 +7376,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned 1.0.4",
"serde_spanned 1.1.0",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
@@ -7411,9 +7403,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.0.0+spec-1.1.0"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
dependencies = [
"serde_core",
]
@@ -7444,30 +7436,30 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.4+spec-1.1.0"
version = "0.25.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
dependencies = [
"indexmap 2.13.0",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_datetime 1.1.0+spec-1.1.0",
"toml_parser",
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
[[package]]
name = "tower"
@@ -7687,7 +7679,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7745,9 +7737,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
@@ -7851,9 +7843,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.22.0"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -8584,7 +8576,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9094,6 +9086,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
@@ -9227,9 +9228,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "wry"
version = "0.54.3"
version = "0.54.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a24eda84b5d488f99344e54b807138896cee8df0b2d16c793f1f6b80e6d8df1f"
checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc"
dependencies = [
"base64 0.22.1",
"block2",
@@ -9325,7 +9326,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.5.1"
version = "0.5.8"
[[package]]
name = "yoke"
@@ -9413,18 +9414,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.42"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.42"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
+3 -2
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.5.1"
version = "0.5.8"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -63,6 +63,7 @@ clap_complete = "4"
# HTTP client (for LLM drivers)
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
# Async trait
async-trait = "0.1"
@@ -75,7 +76,7 @@ bytes = "1"
# Futures
futures = "0.3"
prost = "0.13"
prost = "0.14"
# WebSocket client (for Discord/Slack gateway)
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
+6
View File
@@ -3,3 +3,9 @@ pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]
[target.armv7-unknown-linux-gnueabihf]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]
+1 -1
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
FROM rust:1-slim-bookworm AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y pkg-config libssl-dev perl make && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
COPY xtask ./xtask
+2
View File
@@ -38,6 +38,8 @@ hmac = { workspace = true }
hex = { workspace = true }
socket2 = { workspace = true }
reqwest = { workspace = true }
argon2 = { workspace = true }
rand = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+14 -1
View File
@@ -49,8 +49,8 @@ use openfang_channels::discourse::DiscourseAdapter;
use openfang_channels::gitter::GitterAdapter;
use openfang_channels::gotify::GotifyAdapter;
use openfang_channels::linkedin::LinkedInAdapter;
use openfang_channels::mumble::MumbleAdapter;
use openfang_channels::mqtt::MqttAdapter;
use openfang_channels::mumble::MumbleAdapter;
use openfang_channels::ntfy::NtfyAdapter;
use openfang_channels::webhook::WebhookAdapter;
use openfang_channels::wecom::WeComAdapter;
@@ -816,6 +816,19 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
}
async fn free_response_channels(&self, channel_type: &str) -> Vec<String> {
let channels = &self.kernel.config.channels;
match channel_type {
"discord" => channels
.discord
.as_ref()
.map(|c| c.free_response_channels.clone())
.unwrap_or_default(),
// Add other channel types here as needed (e.g., "telegram" => ...)
_ => Vec::new(),
}
}
async fn authorize_channel_user(
&self,
channel_type: &str,
+32
View File
@@ -3,6 +3,38 @@
//! Exposes agent management, status, and chat via JSON REST endpoints.
//! The kernel runs in-process; the CLI connects over HTTP.
/// Decode percent-encoded strings (e.g. `%2B` → `+`).
/// Used to normalise `?token=` values that browsers encode with `encodeURIComponent`.
pub(crate) fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (
hex_val(bytes[i + 1]),
hex_val(bytes[i + 2]),
) {
out.push(hi << 4 | lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| input.to_string())
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
pub mod channel_bridge;
pub mod middleware;
pub mod openai_compat;
+4 -3
View File
@@ -167,13 +167,14 @@ pub async fn auth(
// Also check ?token= query parameter (for EventSource/SSE clients that
// cannot set custom headers, same approach as WebSocket auth).
let query_token = request
let query_token_decoded = request
.uri()
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")));
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(crate::percent_decode);
// SECURITY: Use constant-time comparison to prevent timing attacks.
let query_auth = query_token.map(|token| {
let query_auth = query_token_decoded.as_deref().map(|token| {
use subtle::ConstantTimeEq;
if token.len() != api_key.len() {
return false;
+54 -9
View File
@@ -436,9 +436,16 @@ pub async fn send_message(
}
/// GET /api/agents/:id/session — Get agent session (conversation history).
///
/// Query parameters:
/// - `include_system` — when `true`, system-role messages are included in the
/// response (intended for debugging only). Defaults to `false` so the
/// internal system prompt is never leaked into the Web UI conversation
/// history (issue #935).
pub async fn get_agent_session(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let agent_id: AgentId = match id.parse() {
Ok(id) => id,
@@ -450,6 +457,14 @@ pub async fn get_agent_session(
}
};
// SECURITY (#935): Default to filtering out system-role messages so the
// internal system prompt is never exposed in the Web UI conversation
// history. Callers can opt-in via `?include_system=true` for debugging.
let include_system = params
.get("include_system")
.map(|v| matches!(v.as_str(), "1" | "true" | "yes" | "TRUE" | "True"))
.unwrap_or(false);
let entry = match state.kernel.registry.get(agent_id) {
Some(e) => e,
None => {
@@ -462,6 +477,16 @@ pub async fn get_agent_session(
match state.kernel.memory.get_session(entry.session_id) {
Ok(Some(session)) => {
// Filter out system-role messages BEFORE any rendering / truncation
// logic so the system prompt cannot leak into the response. The
// raw message count is preserved separately for the API consumer.
let raw_message_count = session.messages.len();
let filtered_messages: Vec<&openfang_types::message::Message> = session
.messages
.iter()
.filter(|m| include_system || m.role != openfang_types::message::Role::System)
.collect();
// Two-pass approach: ToolUse blocks live in Assistant messages while
// ToolResult blocks arrive in subsequent User messages. Pass 1
// collects all tool_use entries keyed by id; pass 2 attaches results.
@@ -472,7 +497,8 @@ pub async fn get_agent_session(
let mut tool_use_index: std::collections::HashMap<String, (usize, usize)> =
std::collections::HashMap::new();
for m in &session.messages {
for m in &filtered_messages {
let m = *m;
let mut tools: Vec<serde_json::Value> = Vec::new();
let mut msg_images: Vec<serde_json::Value> = Vec::new();
let content = match &m.content {
@@ -562,8 +588,8 @@ pub async fn get_agent_session(
built_messages.push(msg);
}
// Pass 2: walk messages again and attach ToolResult to the correct tool
for m in &session.messages {
// Pass 2: walk filtered messages again and attach ToolResult to the correct tool
for m in &filtered_messages {
if let openfang_types::message::MessageContent::Blocks(blocks) = &m.content {
for b in blocks {
if let openfang_types::message::ContentBlock::ToolResult {
@@ -579,7 +605,8 @@ pub async fn get_agent_session(
msg.get_mut("tools").and_then(|v| v.as_array_mut())
{
if let Some(tool_obj) = tools_arr.get_mut(tool_idx) {
tool_obj["result"] = serde_json::Value::String(result.clone());
tool_obj["result"] =
serde_json::Value::String(result.clone());
tool_obj["is_error"] =
serde_json::Value::Bool(*is_error);
}
@@ -592,12 +619,16 @@ pub async fn get_agent_session(
}
let messages = built_messages;
// `message_count` reflects what the API actually returns (system
// messages excluded by default). `raw_message_count` is exposed
// for callers that need to know the underlying total.
(
StatusCode::OK,
Json(serde_json::json!({
"session_id": session.id.0.to_string(),
"agent_id": session.agent_id.0.to_string(),
"message_count": session.messages.len(),
"message_count": messages.len(),
"raw_message_count": raw_message_count,
"context_window_tokens": session.context_window_tokens,
"label": session.label,
"messages": messages,
@@ -4037,12 +4068,18 @@ pub async fn list_active_hands(State(state): State<Arc<AppState>>) -> impl IntoR
let items: Vec<serde_json::Value> = instances
.iter()
.map(|i| {
// Effective agent name: custom instance_name takes priority, otherwise HAND.toml default.
let effective_agent_name = i
.instance_name
.clone()
.unwrap_or_else(|| i.agent_name.clone());
serde_json::json!({
"instance_id": i.instance_id,
"hand_id": i.hand_id,
"instance_name": i.instance_name,
"status": format!("{}", i.status),
"agent_id": i.agent_id.map(|a| a.to_string()),
"agent_name": i.agent_name,
"agent_name": effective_agent_name,
"activated_at": i.activated_at.to_rfc3339(),
"updated_at": i.updated_at.to_rfc3339(),
})
@@ -4506,9 +4543,12 @@ pub async fn activate_hand(
Path(hand_id): Path<String>,
body: Option<Json<openfang_hands::ActivateHandRequest>>,
) -> impl IntoResponse {
let config = body.map(|b| b.0.config).unwrap_or_default();
let (config, instance_name) = match body.map(|b| b.0) {
Some(r) => (r.config, r.instance_name),
None => (std::collections::HashMap::new(), None),
};
match state.kernel.activate_hand(&hand_id, config) {
match state.kernel.activate_hand(&hand_id, config, instance_name) {
Ok(instance) => {
// If the hand agent has a non-reactive schedule (autonomous hands),
// start its background loop so it begins running immediately.
@@ -4532,14 +4572,19 @@ pub async fn activate_hand(
}
}
}
let effective_agent_name = instance
.instance_name
.clone()
.unwrap_or_else(|| instance.agent_name.clone());
(
StatusCode::OK,
Json(serde_json::json!({
"instance_id": instance.instance_id,
"hand_id": instance.hand_id,
"instance_name": instance.instance_name,
"status": format!("{}", instance.status),
"agent_id": instance.agent_id.map(|a| a.to_string()),
"agent_name": instance.agent_name,
"agent_name": effective_agent_name,
"activated_at": instance.activated_at.to_rfc3339(),
})),
)
+9
View File
@@ -104,6 +104,15 @@ pub async fn build_router(
.allow_headers(tower_http::cors::Any)
};
// Warn if dashboard auth is enabled but the password hash is not Argon2id.
let ph = &state.kernel.config.auth.password_hash;
if state.kernel.config.auth.enabled && !ph.is_empty() && !ph.starts_with("$argon2") {
tracing::warn!(
"Dashboard auth password_hash is not in Argon2id format. \
Login will fail. Regenerate with: openfang auth hash-password"
);
}
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = crate::middleware::AuthState {
+45 -10
View File
@@ -55,20 +55,27 @@ pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
}
}
/// Hash a password with SHA256 for config storage.
/// Hash a password with Argon2id for config storage.
///
/// Returns a PHC-format string (e.g. `$argon2id$v=19$m=19456,t=2,p=1$...`).
pub fn hash_password(password: &str) -> String {
use sha2::Digest;
hex::encode(Sha256::digest(password.as_bytes()))
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
let salt = SaltString::generate(&mut rand::thread_rng());
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.expect("Argon2 hashing should not fail with valid inputs")
.to_string()
}
/// Verify a password against a stored SHA256 hash (constant-time).
/// Verify a password against a stored Argon2id hash (PHC string format).
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
let computed = hash_password(password);
use subtle::ConstantTimeEq;
if computed.len() != stored_hash.len() {
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
let Ok(parsed) = PasswordHash::new(stored_hash) else {
return false;
}
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
#[cfg(test)]
@@ -78,10 +85,31 @@ mod tests {
#[test]
fn test_hash_and_verify_password() {
let hash = hash_password("secret123");
assert!(
hash.starts_with("$argon2id$"),
"should produce Argon2id PHC string"
);
assert!(verify_password("secret123", &hash));
assert!(!verify_password("wrong", &hash));
}
#[test]
fn test_hash_produces_unique_salts() {
let h1 = hash_password("same");
let h2 = hash_password("same");
assert_ne!(h1, h2, "each hash should use a unique salt");
assert!(verify_password("same", &h1));
assert!(verify_password("same", &h2));
}
#[test]
fn test_rejects_non_argon2_hash() {
// A plain SHA256 hex string should no longer be accepted.
use sha2::Digest;
let sha256_hash = hex::encode(sha2::Sha256::digest(b"password"));
assert!(!verify_password("password", &sha256_hash));
}
#[test]
fn test_create_and_verify_token() {
let token = create_session_token("admin", "my-secret", 1);
@@ -103,7 +131,14 @@ mod tests {
}
#[test]
fn test_password_hash_length_mismatch() {
fn test_rejects_garbage_input() {
assert!(!verify_password("x", "short"));
assert!(!verify_password("x", ""));
}
#[test]
fn test_verify_malformed_argon2_hash() {
// Starts with $argon2 but is not a valid PHC string.
assert!(!verify_password("x", "$argon2id$garbage"));
}
}
+2 -1
View File
@@ -169,7 +169,8 @@ pub async fn agent_ws(
let query_auth = uri
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(|token| ct_eq(token, api_key))
.map(|raw| crate::percent_decode(raw))
.map(|token| ct_eq(&token, api_key))
.unwrap_or(false);
if !header_auth && !query_auth {
+608
View File
@@ -0,0 +1,608 @@
{
"app.name": "OpenFang",
"app.version": "v",
"nav.chat": "Chat",
"nav.monitor": "Monitor",
"nav.overview": "Overview",
"nav.analytics": "Analytics",
"nav.logs": "Logs",
"nav.agents": "Agents",
"nav.sessions": "Sessions",
"nav.approvals": "Approvals",
"nav.comms": "Comms",
"nav.automation": "Automation",
"nav.workflows": "Workflows",
"nav.scheduler": "Scheduler",
"nav.extensions": "Extensions",
"nav.channels": "Channels",
"nav.skills": "Skills",
"nav.hands": "Hands",
"nav.system": "System",
"nav.runtime": "Runtime",
"nav.settings": "Settings",
"auth.sign_in": "Sign In",
"auth.enter_credentials": "Enter your dashboard credentials.",
"auth.username": "Username",
"auth.password": "Password",
"auth.api_key_required": "API Key Required",
"auth.api_key_desc": "This instance requires an API key. Enter the key from your config.toml.",
"auth.api_key_hint": "Add api_key = \"your-key\" at the top of ~/.openfang/config.toml (not under any [section]).",
"auth.enter_api_key": "Enter API key...",
"auth.unlock_dashboard": "Unlock Dashboard",
"auth.login_failed": "Login failed",
"status.agents_running": "agent(s) running",
"status.connecting": "Connecting...",
"status.reconnecting": "Reconnecting...",
"status.disconnected": "disconnected",
"status.ws": "WS",
"status.http": "HTTP",
"status.ready": "Ready",
"status.loading": "Loading...",
"status.loading_workflows": "Loading workflows...",
"status.loading_channels": "Loading channels...",
"status.loading_skills": "Loading skills...",
"status.loading_jobs": "Loading scheduled jobs...",
"status.loading_triggers": "Loading triggers...",
"status.loading_history": "Loading run history...",
"status.loading_hands": "Loading hands...",
"status.loading_active_hands": "Loading active hands...",
"status.loading_mcp": "Loading MCP servers...",
"status.loading_files": "Loading files...",
"status.loading_skills_details": "Loading skills details...",
"status.no_channels_match": "No channels match your search",
"actions.logout": "Logout",
"actions.new_agent": "New Agent",
"actions.browse_skills": "Browse Skills",
"actions.add_channel": "Add Channel",
"actions.create_workflow": "Create Workflow",
"actions.settings": "Settings",
"actions.create_agent": "Create Agent",
"actions.configure_provider": "Configure Provider",
"actions.cancel": "Cancel",
"actions.confirm": "Confirm",
"actions.save": "Save",
"actions.delete": "Delete",
"actions.edit": "Edit",
"actions.clone": "Clone",
"actions.stop": "Stop",
"actions.run": "Run",
"actions.enable": "Enable",
"actions.disable": "Disable",
"actions.view_all": "View All",
"actions.retry": "Retry",
"actions.refresh": "Refresh",
"actions.approve": "Approve",
"actions.reject": "Reject",
"actions.update": "Update",
"actions.test_connection": "Test Connection",
"actions.remove": "Remove",
"actions.save_test": "Save & Test",
"actions.export_toml": "Export TOML",
"actions.save_workflow": "Save Workflow",
"actions.auto_layout": "Auto Layout",
"actions.clear": "Clear",
"actions.zoom_out": "Zoom out",
"actions.zoom_in": "Zoom in",
"actions.fit": "Fit",
"actions.duplicate": "Duplicate",
"actions.copy_clipboard": "Copy to Clipboard",
"actions.copied": "Copied!",
"actions.copy": "Copy",
"actions.hide_code": "Hide Code",
"actions.view_code": "View Code",
"actions.install": "Install",
"actions.installing": "Installing...",
"actions.installed": "Installed",
"actions.load_more": "Load More",
"actions.back_to_browse": "Back to browse",
"actions.activate": "Activate",
"actions.create_schedule": "Create Schedule",
"actions.submit": "Submit",
"actions.close": "Close",
"actions.next": "Next",
"actions.back": "Back",
"actions.spawn_agent": "Spawn Agent",
"actions.spawning": "Spawning...",
"actions.create_job": "Create Job",
"actions.spawn_wizard": "Wizard",
"actions.raw_toml": "Raw TOML",
"actions.setup_wizard": "Setup Wizard",
"actions.configure_manually": "Configure Manually",
"actions.dismiss": "Dismiss",
"actions.create_workflow_btn": "Create Workflow",
"actions.execute": "Execute",
"actions.executing": "Executing...",
"actions.generated_toml": "Generated TOML",
"actions.running": "Running...",
"footer.shortcuts": "Ctrl+K agents | Ctrl+N new",
"theme.light": "Light",
"theme.system": "System",
"theme.dark": "Dark",
"errors.connection_error": "Connection Error",
"errors.daemon_unreachable": "Cannot reach daemon — is openfang running?",
"errors.not_authorized": "Not authorized — check your API key",
"errors.permission_denied": "Permission denied",
"errors.resource_not_found": "Resource not found",
"errors.rate_limited": "Rate limited — slow down and try again",
"errors.request_too_large": "Request too large",
"errors.server_error": "Server error — check daemon logs",
"errors.daemon_unavailable": "Daemon unavailable — is it running?",
"errors.unexpected": "Unexpected error",
"errors.reconnected": "Reconnected",
"errors.connection_lost": "Connection lost, reconnecting...",
"errors.switched_http": "Connection lost — switched to HTTP mode",
"errors.connection_lost": "Connection lost, reconnecting...",
"errors.switched_http": "Connection lost — switched to HTTP mode",
"errors.reconnected": "Reconnected",
"toasts.approval_waiting": "An agent is waiting for approval. Open Approvals to review.",
"toasts.agent_created": "Agent Created",
"toasts.agent_stopped": "Agent Stopped",
"toasts.tool_used": "Tool Used",
"toasts.tool_completed": "Tool Completed",
"toasts.message_in": "Message In",
"toasts.response_sent": "Response Sent",
"toasts.session_reset": "Session Reset",
"toasts.compacted": "Compacted",
"toasts.model_changed": "Model Changed",
"toasts.login_attempt": "Login Attempt",
"toasts.login_ok": "Login OK",
"toasts.login_failed": "Login Failed",
"toasts.denied": "Denied",
"toasts.rate_limited": "Rate Limited",
"toasts.workflow_run": "Workflow Run",
"toasts.trigger_fired": "Trigger Fired",
"toasts.skill_installed": "Skill Installed",
"toasts.mcp_connected": "MCP Connected",
"toasts.session_deleted": "Session deleted",
"overview.welcome": "Welcome to OpenFang",
"overview.getting_started": "Getting Started",
"overview.setup_wizard": "Setup Wizard",
"overview.steps_completed": "of 5 steps completed",
"overview.agents_running": "Agents Running",
"overview.tokens_used": "Tokens Used",
"overview.total_cost": "Total Cost",
"overview.uptime": "Uptime",
"overview.channels": "Channels",
"overview.skills": "Skills",
"overview.mcp_servers": "MCP Servers",
"overview.tool_calls": "Tool Calls",
"overview.providers": "Providers",
"overview.recent_activity": "Recent Activity",
"overview.no_recent_activity": "No Recent Activity",
"overview.chat_with_agent": "Chat with an Agent",
"overview.system_health": "System Health",
"overview.healthy": "Healthy",
"overview.unreachable": "Unreachable",
"overview.security_systems": "Security Systems",
"overview.llm_providers": "LLM Providers",
"overview.defense_active": "9 defense-in-depth systems active",
"overview.quick_actions": "Quick Actions",
"setup.configure_provider": "Configure an LLM provider",
"setup.create_first_agent": "Create your first agent",
"setup.send_first_message": "Send your first message",
"setup.connect_channel": "Connect a messaging channel",
"setup.browse_install_skill": "Browse or install a skill",
"tooltips.cooling_down": "cooling down (rate limited)",
"tooltips.circuit_open": "circuit breaker open",
"tooltips.ready": "ready",
"tooltips.not_configured": "not configured",
"chat.placeholder": "Message OpenFang... (/ for commands)",
"chat.ready": "Ready",
"chat.generating": "Generating...",
"chat.queued": "queued",
"chat.sessions": "Sessions",
"chat.new_session": "+ New",
"chat.no_sessions": "No sessions",
"chat.search_messages": "Search messages...",
"chat.select_agent": "Select an agent to start chatting",
"chat.recording": "Recording... release to send",
"chat.drop_files": "Drop files here",
"chat.attach_file": "Attach file",
"chat.stop_generating": "Stop generating",
"chat.switch_model": "Switch model",
"chat.search_models": "Search models...",
"chat.no_models_found": "No models found",
"chat.available_models": "Available models — pick one or keep typing",
"chat.switching": "Switching...",
"chat.model_switched": "Switched to",
"chat.model_switch_failed": "Model switch failed",
"chat.using_http_mode": "Using HTTP mode (no streaming)",
"chat.session_name_prompt": "Session name (optional):",
"chat.session_created": "Session created",
"chat.session_create_failed": "Failed to create session",
"chat.stop_agent_title": "Stop Agent",
"chat.stop_agent_confirm": "Stop agent",
"chat.agent_stopped": "Agent stopped",
"chat.stop_agent_failed": "Failed to stop agent",
"chat.welcome_message": "**Welcome to OpenFang Chat!**\n\n- Type `/` to see available commands\n- `/help` shows all commands\n- `/think on` enables extended reasoning\n- `/context` shows context window usage\n- `/verbose off` hides tool details\n- `Ctrl+Shift+F` toggles focus mode\n- Drag & drop files to attach them\n- `Ctrl+/` opens the command palette",
"chat.slash.help": "Show available commands",
"chat.slash.agents": "Switch to Agents page",
"chat.slash.new": "New session (clear history)",
"chat.slash.compact": "Compact session context",
"chat.slash.model": "Show or switch model (/model [name])",
"chat.slash.stop": "Cancel current agent run",
"chat.slash.usage": "Show token usage",
"chat.slash.think": "Toggle reasoning (/think [on|off|stream])",
"chat.slash.context": "Show context window usage",
"chat.slash.verbose": "Toggle tool details (/verbose [off|on|full])",
"chat.slash.queue": "Check if agent is processing",
"chat.slash.status": "Show system status",
"chat.slash.clear": "Clear chat",
"chat.slash.exit": "Disconnect from agent",
"chat.slash.budget": "Show budget limits and costs",
"chat.slash.peers": "Show OFP network status",
"chat.slash.a2a": "List A2A agents",
"commands.help": "Show available commands",
"commands.agents": "Switch to Agents page",
"commands.new": "Reset session",
"commands.switch": "Switch agent",
"commands.clear": "Clear conversation",
"commands.model": "Switch model",
"commands.think": "Toggle reasoning mode",
"commands.focus": "Toggle focus mode",
"commands.theme": "Cycle theme",
"tips.commands": "Type / for commands",
"tips.think": "/think on for reasoning",
"tips.focus": "Ctrl+Shift+F for focus mode",
"agents.info": "Info",
"agents.files": "Files",
"agents.config": "Config",
"agents.chat": "Chat",
"agents.clone": "Clone",
"agents.clear_history": "Clear History",
"agents.change": "Change",
"agents.none_fallback": "None — add a fallback chain",
"agents.add": "+ Add",
"agents.loading_files": "Loading files...",
"agents.no_workspace_files": "No workspace files found",
"agents.save_config": "Save Config",
"agents.tool_filters": "Tool Filters",
"agents.allowlist": "Allowlist",
"agents.blocklist": "Blocklist",
"agents.agent_name": "Agent Name",
"agents.emoji": "Emoji",
"agents.color": "Color",
"agents.archetype": "Archetype",
"agents.provider": "Provider",
"agents.model": "Model",
"agents.system_prompt": "System Prompt",
"agents.soul_persona": "Soul / Persona",
"agents.tool_profile": "Tool Profile",
"agents.minimal_profile": "Minimal — Read-only file access",
"agents.coding_profile": "Coding — Files + shell + web fetch",
"agents.fullstack_profile": "Full-Stack — Files + shell + web fetch + search",
"agents.research_profile": "Research — Web + search + analysis",
"agents.admin_profile": "Admin — Full system access (dangerous)",
"agents.agent_created": "Agent Created",
"agents.agent_stopped": "Agent Stopped",
"agents.agent_deleted": "Agent Deleted",
"presets.professional": "Professional",
"presets.professional_desc": "Precise, business-oriented assistant focused on efficiency and clarity. Prioritizes actionable insights and structured communication.",
"presets.professional_soul": "Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.",
"presets.friendly": "Friendly",
"presets.friendly_desc": "Warm and approachable assistant that builds rapport and uses conversational language. Great for brainstorming and exploration.",
"presets.friendly_soul": "Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.",
"presets.technical": "Technical",
"presets.technical_desc": "Expert developer companion optimized for code, architecture, and technical problem-solving. Precise terminology, deep dives, benchmarks.",
"presets.technical_soul": "Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.",
"presets.creative": "Creative",
"presets.creative_desc": "Imaginative collaborator for content creation, design thinking, and unconventional solutions. Embraces ambiguity and explores possibilities.",
"presets.creative_soul": "Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.",
"presets.concise": "Concise",
"presets.concise_desc": "Minimal and direct assistant that respects your time. Cuts through noise to deliver focused, actionable responses.",
"presets.concise_soul": "Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.",
"presets.mentor": "Mentor",
"presets.mentor_desc": "Patient educator that explains concepts thoroughly, provides context, and guides learning. Socratic method when appropriate.",
"presets.mentor_soul": "Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.",
"agents.profile.minimal": "Minimal",
"agents.profile.minimal_desc": "Read-only file access",
"agents.profile.coding": "Coding",
"agents.profile.coding_desc": "Files + shell + web fetch",
"agents.profile.research": "Research",
"agents.profile.research_desc": "Web search + file read/write",
"agents.profile.messaging": "Messaging",
"agents.profile.messaging_desc": "Agents + memory access",
"agents.profile.automation": "Automation",
"agents.profile.automation_desc": "All tools except custom",
"agents.profile.balanced": "Balanced",
"agents.profile.balanced_desc": "General-purpose tool set",
"agents.profile.precise": "Precise",
"agents.profile.precise_desc": "Focused tool set for accuracy",
"agents.profile.creative": "Creative",
"agents.profile.creative_desc": "Full tools with creative emphasis",
"agents.profile.full": "Full",
"agents.profile.full_desc": "All 35+ tools",
"wizard.general_assistant": "General Assistant",
"wizard.general_assistant_desc": "You are a versatile AI assistant that helps users with a wide range of tasks. You are knowledgeable, helpful, and able to adapt to the user's needs.",
"wizard.code_helper": "Code Helper",
"wizard.code_helper_desc": "You are an expert programming assistant specialized in software development. You help write, debug, and refactor code across multiple languages.",
"wizard.researcher": "Research Assistant",
"wizard.researcher_desc": "You are a research assistant that helps users find, analyze, and synthesize information from various sources.",
"wizard.writer": "Writer",
"wizard.writer_desc": "You are a skilled writer that helps with content creation, editing, and creative writing projects.",
"wizard.data_analyst": "Data Analyst",
"wizard.data_analyst_desc": "You are a data analyst that helps explore, analyze, and visualize data to extract insights.",
"wizard.devops": "DevOps Engineer",
"wizard.devops_desc": "You are a DevOps engineer that helps with infrastructure, deployment, CI/CD, and system administration.",
"wizard.support": "Customer Support",
"wizard.support_desc": "You are a customer support representative that helps resolve inquiries with patience and professionalism.",
"wizard.tutor": "Tutor",
"wizard.tutor_desc": "You are an educational tutor that explains concepts clearly and adapts teaching to the student's level.",
"wizard.api_designer": "API Designer",
"wizard.api_designer_desc": "You are an API designer that helps create well-structured, intuitive APIs following best practices.",
"wizard.meeting_notes": "Meeting Notes",
"wizard.meeting_notes_desc": "You are a meeting notes specialist that summarizes discussions, extracts action items, and tracks decisions.",
"wizard.step_welcome": "Welcome",
"wizard.step_provider": "Provider",
"wizard.step_agent": "Agent",
"wizard.step_try_it": "Try It",
"wizard.step_channel": "Channel",
"wizard.step_done": "Done",
"wizard.cat_general": "General",
"wizard.cat_development": "Development",
"wizard.cat_research": "Research",
"wizard.cat_writing": "Writing",
"wizard.cat_business": "Business",
"wizard.channel_telegram": "Telegram",
"wizard.channel_telegram_desc": "Connect your agent to a Telegram bot for messaging.",
"wizard.channel_telegram_token": "Bot Token",
"wizard.channel_telegram_help": "Create a bot via @BotFather on Telegram to get your token.",
"wizard.channel_discord": "Discord",
"wizard.channel_discord_desc": "Connect your agent to a Discord server via bot token.",
"wizard.channel_discord_token": "Bot Token",
"wizard.channel_discord_help": "Create a Discord application at discord.com/developers and add a bot.",
"wizard.channel_slack": "Slack",
"wizard.channel_slack_desc": "Connect your agent to a Slack workspace.",
"wizard.channel_slack_token": "Bot Token",
"wizard.channel_slack_help": "Create a Slack app at api.slack.com/apps and install it to your workspace.",
"wizard.profile_minimal": "Minimal",
"wizard.profile_minimal_desc": "Read-only file access",
"wizard.profile_coding": "Coding",
"wizard.profile_coding_desc": "Files + shell + web fetch",
"wizard.profile_research": "Research",
"wizard.profile_research_desc": "Web search + file read/write",
"wizard.profile_balanced": "Balanced",
"wizard.profile_balanced_desc": "General-purpose tool set",
"wizard.profile_precise": "Precise",
"wizard.profile_precise_desc": "Focused tool set for accuracy",
"wizard.profile_creative": "Creative",
"wizard.profile_creative_desc": "Full tools with creative emphasis",
"wizard.profile_full": "Full",
"wizard.profile_full_desc": "All 35+ tools",
"wizard.enter_api_key": "Please enter an API key",
"wizard.api_key_saved": "API key saved for",
"wizard.failed_save_key": "Failed to save key:",
"wizard.connected": "connected",
"wizard.connection_failed": "Connection failed",
"wizard.test_failed": "Test failed:",
"wizard.enter_agent_name": "Please enter a name for your agent",
"wizard.agent_created": "Agent created",
"wizard.failed_create_agent": "Failed to create agent:",
"wizard.enter_token": "Please enter the",
"wizard.channel_configured": "configured and activated.",
"wizard.failed_configure": "Failed:",
"wizard.suggestions.general.1": "What can you help me with?",
"wizard.suggestions.general.2": "Tell me a fun fact",
"wizard.suggestions.general.3": "Summarize the latest AI news",
"wizard.suggestions.development.1": "Write a Python hello world",
"wizard.suggestions.development.2": "Explain async/await",
"wizard.suggestions.development.3": "Review this code snippet",
"wizard.suggestions.research.1": "Explain quantum computing simply",
"wizard.suggestions.research.2": "Compare React vs Vue",
"wizard.suggestions.research.3": "What are the latest trends in AI?",
"wizard.suggestions.writing.1": "Help me write a professional email",
"wizard.suggestions.writing.2": "Improve this paragraph",
"wizard.suggestions.writing.3": "Write a blog intro about AI",
"wizard.suggestions.business.1": "Draft a meeting agenda",
"wizard.suggestions.business.2": "How do I handle a complaint?",
"wizard.suggestions.business.3": "Create a project status update",
"approvals.title": "Execution Approvals",
"approvals.pending": "pending",
"approvals.all": "All",
"approvals.pending_tab": "Pending",
"approvals.approved": "Approved",
"approvals.rejected": "Rejected",
"approvals.expired": "Expired",
"approvals.no_approvals": "No approvals",
"approvals.approve": "Approve",
"approvals.reject": "Reject",
"workflows.title": "Workflows",
"workflows.visual_builder": "Visual Builder",
"workflows.what_are": "What are Workflows?",
"workflows.no_workflows": "No workflows yet",
"workflows.sequential": "Sequential",
"workflows.fan_out": "Fan Out",
"workflows.conditional": "Conditional",
"workflows.loop": "Loop",
"workflows.add_step": "+ Add Step",
"workflows.execute": "Execute",
"workflows.result": "Result",
"workflows.node_palette": "Node Palette",
"workflows.drag_nodes": "Drag nodes onto the canvas",
"workflows.steps_connections": "steps, connections",
"workflows.agent": "Agent",
"workflows.prompt_template": "Prompt Template",
"workflows.expression": "Expression",
"workflows.top_port_true": "Top port = true, bottom port = false",
"workflows.max_iterations": "Max Iterations",
"workflows.until_stop": "Until (stop condition)",
"workflows.fan_out_count": "Fan-out Count",
"workflows.wait_all": "Wait for all",
"workflows.first_finish": "First to finish",
"workflows.majority_vote": "Majority vote",
"workflows.connection_selected": "Connection selected",
"workflows.delete_connection": "Delete Connection",
"scheduler.title": "Scheduler",
"scheduler.scheduled_jobs": "Scheduled Jobs",
"scheduler.event_triggers": "Event Triggers",
"scheduler.run_history": "Run History",
"scheduler.new_job": "+ New Job",
"scheduler.job_name": "Job Name",
"scheduler.cron_expression": "Cron Expression",
"scheduler.quick_presets": "Quick Presets",
"scheduler.target_agent": "Target Agent",
"scheduler.any_agent": "Any available agent",
"scheduler.message_send": "Message to Send",
"scheduler.enabled": "Enabled (will start running immediately)",
"scheduler.disabled": "Disabled (create paused)",
"scheduler.active": "Active",
"scheduler.paused": "Paused",
"scheduler.cron_job": "Cron Job",
"scheduler.trigger": "Trigger",
"scheduler.no_jobs": "No scheduled jobs",
"scheduler.no_triggers": "No event triggers",
"scheduler.no_history": "No run history yet",
"channels.title": "Channels",
"channels.configured": "configured",
"channels.search": "Search channels...",
"channels.setup": "Set up",
"channels.edit": "Edit",
"channels.configure": "Configure",
"channels.verify": "Verify",
"channels.ready": "Ready",
"channels.is_ready": "is ready!",
"channels.get_credentials": "How to get credentials",
"channels.show_advanced": "Show advanced",
"channels.hide_advanced": "Hide advanced",
"channels.connecting": "Connecting to WhatsApp Web gateway...",
"channels.linked_success": "WhatsApp linked successfully!",
"channels.business_api": "Business API",
"skills.title": "Skills & Ecosystem",
"skills.installed": "Installed",
"skills.clawhub": "ClawHub",
"skills.mcp_servers": "MCP Servers",
"skills.quick_start": "Quick Start",
"skills.no_installed": "No skills installed",
"skills.browse_clawhub": "Browse ClawHub",
"skills.search_clawhub": "Search ClawHub skills...",
"skills.trending": "Trending",
"skills.most_downloaded": "Most Downloaded",
"skills.most_starred": "Most Starred",
"skills.recently_updated": "Recently Updated",
"skills.categories": "CATEGORIES",
"skills.already_installed": "Already Installed",
"skills.no_skills_found": "No skills found",
"skills.security_warnings": "Security Warnings",
"skills.security_scan": "Skills are security-scanned before installation",
"skills.create": "Create Skill",
"skills.created": "Created",
"skills.cat_coding": "Coding & IDEs",
"skills.cat_git": "Git & GitHub",
"skills.cat_frontend": "Web & Frontend",
"skills.cat_devops": "DevOps & Cloud",
"skills.cat_database": "Database",
"skills.cat_security": "Security",
"skills.cat_ai": "AI & ML",
"skills.cat_data": "Data & Analytics",
"skills.cat_mobile": "Mobile",
"skills.cat_desktop": "Desktop Apps",
"skills.cat_api": "API & Integrations",
"skills.cat_testing": "Testing",
"skills.cat_docs": "Documentation",
"skills.cat_productivity": "Productivity",
"skills.cat_other": "Other",
"skills.cat_browser": "Browser & Automation",
"skills.cat_search": "Search & Research",
"skills.cat_communication": "Communication",
"skills.cat_media": "Media & Streaming",
"skills.cat_notes": "Notes & PKM",
"skills.cat_cli": "CLI Utilities",
"skills.cat_marketing": "Marketing & Sales",
"skills.cat_finance": "Finance",
"skills.cat_smarthome": "Smart Home & IoT",
"skills.uninstall_skill": "Uninstall Skill",
"skills.uninstall_confirm": "Uninstall skill",
"skills.source_clawhub": "ClawHub",
"skills.source_openclaw": "OpenClaw",
"skills.source_builtin": "Built-in",
"skills.source_local": "Local",
"hands.title": "Hands — Curated Autonomous Capability Packages",
"hands.available": "Available",
"hands.active": "Active",
"hands.ready": "Ready",
"hands.setup_needed": "Setup needed",
"hands.requirements": "REQUIREMENTS",
"hands.details": "Details",
"hands.no_hands": "No hands available",
"sessions.title": "Sessions",
"sessions.memory": "Memory",
"sessions.delete_session": "Delete Session",
"sessions.delete_confirm": "This will permanently remove the session and its messages.",
"sessions.delete_key": "Delete Key",
"sessions.delete_key_confirm": "Delete key",
"logs.title": "Logs",
"logs.live": "Live",
"logs.audit_trail": "Audit Trail",
"settings.title": "Settings",
"settings.providers": "Providers",
"settings.models": "Models",
"settings.config": "Config",
"settings.tools": "Tools",
"settings.migration": "Migration",
"settings.security": "Security",
"settings.network": "Network",
"settings.migration": "Migration",
"settings.language": "Language",
"settings.sec_path_traversal": "Path Traversal Prevention",
"settings.sec_path_traversal_desc": "Blocks attempts to access files outside the workspace directory using .. or absolute paths.",
"settings.sec_ssrf": "SSRF Protection",
"settings.sec_ssrf_desc": "Prevents agents from making requests to internal IP ranges (localhost, cloud metadata, private networks).",
"settings.sec_capability": "Capability-Based Access Control",
"settings.sec_capability_desc": "Agents can only access explicitly granted capabilities. No implicit access to tools or data.",
"settings.sec_taint": "Taint Tracking",
"settings.sec_taint_desc": "Tracks untrusted data (user input, file content) through agent reasoning to prevent prompt injection.",
"settings.sec_sandbox": "WASM Sandbox",
"settings.sec_sandbox_desc": "Executes untrusted code in isolated WebAssembly sandboxes with memory and syscall restrictions.",
"settings.sec_audit": "Merkle Audit",
"settings.sec_audit_desc": "Maintains a verifiable audit log of all agent actions using Merkle tree cryptography.",
"settings.sec_workspace": "Workspace Isolation",
"settings.sec_workspace_desc": "Each agent has an isolated workspace directory. No cross-agent file access unless explicitly granted.",
"settings.sec_rate_limit": "Rate Limiting",
"settings.sec_rate_limit_desc": "Enforces per-agent and global rate limits to prevent resource exhaustion and cost overruns.",
"settings.sec_approval": "Execution Approvals",
"settings.sec_approval_desc": "Requires human approval for high-risk actions (shell commands, file writes, external requests).",
"settings.sec_enabled": "Enabled",
"settings.sec_disabled": "Disabled",
"settings.sec_inherited": "Inherited",
"settings.sec_global": "Global"
}
+230
View File
@@ -0,0 +1,230 @@
/**
* OpenFang i18n (Internationalization) Module
*
* Provides runtime language switching for the OpenFang dashboard UI.
* Supports English (default) and Russian.
*
* Usage:
* - HTML: <span data-i18n="nav.overview">Overview</span>
* - JS: window.t('nav.overview')
* - Auto-applies translations on load based on stored/preferred language
*/
(function() {
'use strict';
// Language store
let currentLang = 'en';
let translations = {};
let isInitialized = false;
/**
* Load translations from a JSON file
* @param {string} lang - Language code (en, ru)
* @returns {Promise<Object>} Translation object
*/
async function loadTranslations(lang) {
try {
// Use cached translations if available
if (window.__i18nCache && window.__i18nCache[lang]) {
return window.__i18nCache[lang];
}
const response = await fetch(`/i18n/${lang}.json`);
if (!response.ok) {
console.warn(`[i18n] Failed to load ${lang}.json, falling back to en`);
if (lang !== 'en') {
return loadTranslations('en');
}
return {};
}
const data = await response.json();
// Cache for future use
if (!window.__i18nCache) window.__i18nCache = {};
window.__i18nCache[lang] = data;
return data;
} catch (error) {
console.error(`[i18n] Error loading translations for ${lang}:`, error);
if (lang !== 'en') {
return loadTranslations('en');
}
return {};
}
}
/**
* Get a translated string by key
* @param {string} key - Translation key (e.g., 'nav.overview')
* @param {Object} params - Optional interpolation parameters
* @returns {string} Translated string or key if not found
*/
function t(key, params) {
if (!isInitialized) {
console.warn('[i18n] Not initialized, returning key');
return key;
}
let text = translations[key] || key;
// Handle interpolation (e.g., 'Hello, {{name}}')
if (params && typeof params === 'object') {
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`{{${param}}}`, 'g'), params[param]);
});
}
return text;
}
/**
* Apply translations to all elements with data-i18n attribute
* Also updates the <html> lang attribute
*/
function applyTranslations() {
// Update document language
document.documentElement.lang = currentLang;
// Find and translate all elements with data-i18n attribute
const elements = document.querySelectorAll('[data-i18n]');
elements.forEach(el => {
const key = el.getAttribute('data-i18n');
const translation = t(key);
// Check if element is a form input/textarea
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
// For form elements, only update if it's a placeholder or aria-label
if (el.hasAttribute('placeholder')) {
el.placeholder = translation;
}
if (el.hasAttribute('aria-label')) {
el.setAttribute('aria-label', translation);
}
if (el.hasAttribute('title')) {
el.setAttribute('title', translation);
}
} else {
// For regular elements, update text content
el.textContent = translation;
}
});
// Update elements with data-i18n-* attributes for attributes
const attrElements = document.querySelectorAll('[data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]');
attrElements.forEach(el => {
if (el.hasAttribute('data-i18n-placeholder')) {
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
}
if (el.hasAttribute('data-i18n-title')) {
el.title = t(el.getAttribute('data-i18n-title'));
}
if (el.hasAttribute('data-i18n-aria-label')) {
el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label')));
}
});
// Update meta tags
const metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc) {
const desc = t('app.description', { name: 'OpenFang' });
if (desc !== 'app.description') {
metaDesc.content = desc;
}
}
console.log(`[i18n] Applied translations for language: ${currentLang}`);
}
/**
* Set the current language and apply translations
* @param {string} lang - Language code (en, ru)
* @param {boolean} persist - Whether to save to localStorage
*/
async function setLanguage(lang, persist = true) {
if (!['en', 'ru'].includes(lang)) {
console.warn(`[i18n] Unknown language: ${lang}, defaulting to en`);
lang = 'en';
}
currentLang = lang;
translations = await loadTranslations(lang);
isInitialized = true;
// Save preference
if (persist) {
localStorage.setItem('openfang_language', lang);
}
// Apply to DOM
applyTranslations();
// Dispatch event for Alpine.js components to react
window.dispatchEvent(new CustomEvent('i18n:language-changed', {
detail: { language: lang }
}));
}
/**
* Get the current language
* @returns {string} Current language code
*/
function getLanguage() {
return currentLang;
}
/**
* Initialize i18n system
* Loads language preference and applies translations
*/
async function init() {
// Determine language priority:
// 1. localStorage (user preference)
// 2. Browser language
// 3. Default to English
let lang = localStorage.getItem('openfang_language');
if (!lang) {
// Try to detect browser language
const browserLang = navigator.language || navigator.userLanguage || '';
if (browserLang.startsWith('ru')) {
lang = 'ru';
} else {
lang = 'en';
}
}
await setLanguage(lang, false);
}
/**
* Get available languages
* @returns {Array<{code: string, name: string}>}
*/
function getAvailableLanguages() {
return [
{ code: 'en', name: 'English' },
{ code: 'ru', name: 'Русский' }
];
}
// Expose to global scope
window.i18n = {
t,
setLanguage,
getLanguage,
getAvailableLanguages,
init,
isInitialized: () => isInitialized
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+607
View File
@@ -0,0 +1,607 @@
{
"app.name": "OpenFang",
"app.version": "v",
"nav.chat": "Чат",
"nav.monitor": "Мониторинг",
"nav.overview": "Обзор",
"nav.analytics": "Аналитика",
"nav.logs": "Логи",
"nav.agents": "Агенты",
"nav.sessions": "Сессии",
"nav.approvals": "Одобрения",
"nav.comms": "Коммуникации",
"nav.automation": "Автоматизация",
"nav.workflows": "Рабочие процессы",
"nav.scheduler": "Планировщик",
"nav.extensions": "Расширения",
"nav.channels": "Каналы",
"nav.skills": "Навыки",
"nav.hands": "Руки",
"nav.system": "Система",
"nav.runtime": "Среда выполнения",
"nav.settings": "Настройки",
"auth.sign_in": "Войти",
"auth.enter_credentials": "Введите учётные данные панели управления.",
"auth.username": "Имя пользователя",
"auth.password": "Пароль",
"auth.api_key_required": "Требуется API-ключ",
"auth.api_key_desc": "Этот экземпляр требует API-ключ. Введите ключ из вашего config.toml.",
"auth.api_key_hint": "Добавьте api_key = \"your-key\" в начало ~/.openfang/config.toml (не внутри секции).",
"auth.enter_api_key": "Введите API-ключ...",
"auth.unlock_dashboard": "Разблокировать панель",
"auth.login_failed": "Ошибка входа",
"status.agents_running": "агент(ов) запущено",
"status.connecting": "Подключение...",
"status.reconnecting": "Переподключение...",
"status.disconnected": "отключено",
"status.ws": "ВС",
"status.http": "HTTP",
"status.ready": "Готово",
"status.loading": "Загрузка...",
"status.loading_workflows": "Загрузка рабочих процессов...",
"status.loading_channels": "Загрузка каналов...",
"status.loading_skills": "Загрузка навыков...",
"status.loading_jobs": "Загрузка заданий...",
"status.loading_triggers": "Загрузка триггеров...",
"status.loading_history": "Загрузка истории...",
"status.loading_hands": "Загрузка модулей...",
"status.loading_active_hands": "Загрузка активных модулей...",
"status.loading_mcp": "Загрузка MCP-серверов...",
"status.loading_files": "Загрузка файлов...",
"status.loading_skills_details": "Загрузка деталей навыков...",
"status.no_channels_match": "Нет каналов по запросу",
"actions.logout": "Выйти",
"actions.new_agent": "Новый агент",
"actions.browse_skills": "Навыки",
"actions.add_channel": "Добавить канал",
"actions.create_workflow": "Создать процесс",
"actions.settings": "Настройки",
"actions.create_agent": "Создать агента",
"actions.configure_provider": "Настроить провайдера",
"actions.cancel": "Отмена",
"actions.confirm": "Подтвердить",
"actions.save": "Сохранить",
"actions.delete": "Удалить",
"actions.edit": "Редактировать",
"actions.clone": "Клонировать",
"actions.stop": "Остановить",
"actions.run": "Запустить",
"actions.enable": "Включить",
"actions.disable": "Отключить",
"actions.view_all": "Показать все",
"actions.retry": "Повторить",
"actions.refresh": "Обновить",
"actions.approve": "Одобрить",
"actions.reject": "Отклонить",
"actions.update": "Обновить",
"actions.test_connection": "Проверить подключение",
"actions.remove": "Удалить",
"actions.save_test": "Сохранить и проверить",
"actions.export_toml": "Экспорт TOML",
"actions.save_workflow": "Сохранить процесс",
"actions.auto_layout": "Автораскладка",
"actions.clear": "Очистить",
"actions.zoom_out": "Уменьшить",
"actions.zoom_in": "Увеличить",
"actions.fit": "По размеру",
"actions.duplicate": "Дублировать",
"actions.copy_clipboard": "Копировать в буфер",
"actions.copied": "Скопировано!",
"actions.copy": "Копировать",
"actions.hide_code": "Скрыть код",
"actions.view_code": "Показать код",
"actions.install": "Установить",
"actions.installing": "Установка...",
"actions.installed": "Установлено",
"actions.load_more": "Загрузить ещё",
"actions.back_to_browse": "Назад",
"actions.activate": "Активировать",
"actions.create_schedule": "Создать расписание",
"actions.submit": "Отправить",
"actions.close": "Закрыть",
"actions.next": "Далее",
"actions.back": "Назад",
"actions.spawn_agent": "Создать агента",
"actions.spawning": "Создание...",
"actions.spawn_wizard": "Мастер",
"actions.raw_toml": "TOML",
"actions.setup_wizard": "Мастер настройки",
"actions.configure_manually": "Настроить вручную",
"actions.dismiss": "Закрыть",
"actions.create_workflow_btn": "Создать процесс",
"actions.execute": "Выполнить",
"actions.executing": "Выполнение...",
"actions.generated_toml": "Сгенерированный TOML",
"actions.running": "Выполняется...",
"footer.shortcuts": "Ctrl+K агенты | Ctrl+N новый",
"theme.light": "Светлая",
"theme.system": "Системная",
"theme.dark": "Тёмная",
"errors.connection_error": "Ошибка подключения",
"errors.daemon_unreachable": "Не удаётся связаться с демоном — запущен ли openfang?",
"errors.not_authorized": "Не авторизован — проверьте API-ключ",
"errors.permission_denied": "Доступ запрещён",
"errors.resource_not_found": "Ресурс не найден",
"errors.rate_limited": "Превышен лимит — подождите и попробуйте снова",
"errors.request_too_large": "Запрос слишком большой",
"errors.server_error": "Ошибка сервера — проверьте логи демона",
"errors.daemon_unavailable": "Демон недоступен — запущен ли он?",
"errors.unexpected": "Неожиданная ошибка",
"errors.reconnected": "Переподключено",
"errors.connection_lost": "Соединение потеряно, переподключение...",
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
"errors.connection_lost": "Соединение потеряно, переподключение...",
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
"errors.reconnected": "Переподключено",
"toasts.approval_waiting": "Агент ожидает одобрения. Откройте раздел Одобрения.",
"toasts.agent_created": "Агент создан",
"toasts.agent_stopped": "Агент остановлен",
"toasts.tool_used": "Инструмент использован",
"toasts.tool_completed": "Инструмент завершён",
"toasts.message_in": "Входящее сообщение",
"toasts.response_sent": "Ответ отправлен",
"toasts.session_reset": "Сессия сброшена",
"toasts.compacted": "Сжато",
"toasts.model_changed": "Модель изменена",
"toasts.login_attempt": "Попытка входа",
"toasts.login_ok": "Вход успешен",
"toasts.login_failed": "Ошибка входа",
"toasts.denied": "Отклонено",
"toasts.rate_limited": "Лимит запросов",
"toasts.workflow_run": "Запуск процесса",
"toasts.trigger_fired": "Триггер сработал",
"toasts.skill_installed": "Навык установлен",
"toasts.mcp_connected": "MCP подключён",
"toasts.session_deleted": "Сессия удалена",
"overview.welcome": "Добро пожаловать в OpenFang",
"overview.getting_started": "Начало работы",
"overview.setup_wizard": "Мастер настройки",
"overview.steps_completed": "из 5 шагов выполнено",
"overview.agents_running": "Агентов запущено",
"overview.tokens_used": "Использовано токенов",
"overview.total_cost": "Общая стоимость",
"overview.uptime": "Время работы",
"overview.channels": "Каналы",
"overview.skills": "Навыки",
"overview.mcp_servers": "MCP-серверы",
"overview.tool_calls": "Вызовов инструментов",
"overview.providers": "Провайдеры",
"overview.recent_activity": "Недавняя активность",
"overview.no_recent_activity": "Нет недавней активности",
"overview.chat_with_agent": "Написать агенту",
"overview.system_health": "Состояние системы",
"overview.healthy": "Исправно",
"overview.unreachable": "Недоступно",
"overview.security_systems": "Системы безопасности",
"overview.llm_providers": "LLM-провайдеры",
"overview.defense_active": "9 уровней защиты активно",
"overview.quick_actions": "Быстрые действия",
"setup.configure_provider": "Настройте LLM-провайдера",
"setup.create_first_agent": "Создайте первого агента",
"setup.send_first_message": "Отправьте первое сообщение",
"setup.connect_channel": "Подключите канал связи",
"setup.browse_install_skill": "Найдите или установите навык",
"tooltips.cooling_down": "остывает (лимит запросов)",
"tooltips.circuit_open": "автомат сработал",
"tooltips.ready": "готово",
"tooltips.not_configured": "не настроено",
"chat.placeholder": "Напишите OpenFang... (/ для команд)",
"chat.ready": "Готово",
"chat.generating": "Генерация...",
"chat.queued": "в очереди",
"chat.sessions": "Сессии",
"chat.new_session": "+ Новая",
"chat.no_sessions": "Нет сессий",
"chat.search_messages": "Поиск сообщений...",
"chat.select_agent": "Выберите агента для начала общения",
"chat.recording": "Запись... отпустите для отправки",
"chat.drop_files": "Перетащите файлы сюда",
"chat.attach_file": "Прикрепить файл",
"chat.stop_generating": "Остановить генерацию",
"chat.switch_model": "Сменить модель",
"chat.search_models": "Поиск моделей...",
"chat.no_models_found": "Модели не найдены",
"chat.available_models": "Доступные модели — выберите или продолжите ввод",
"chat.switching": "Переключение...",
"chat.model_switched": "Модель изменена на",
"chat.model_switch_failed": "Не удалось сменить модель",
"chat.using_http_mode": "Используется HTTP режим (без потоковой передачи)",
"chat.session_name_prompt": "Название сессии (необязательно):",
"chat.session_created": "Сессия создана",
"chat.session_create_failed": "Не удалось создать сессию",
"chat.stop_agent_title": "Остановить агента",
"chat.stop_agent_confirm": "Остановить агента",
"chat.agent_stopped": "Агент остановлен",
"chat.stop_agent_failed": "Не удалось остановить агента",
"chat.welcome_message": "**Добро пожаловать в OpenFang Чат!**\n\n- Введите `/` для просмотра команд\n- `/help` покажет все команды\n- `/think on` включает расширенные размышления\n- `/context` покажет использование контекста\n- `/verbose off` скроет детали инструментов\n- `Ctrl+Shift+F` переключает режим фокуса\n- Перетащите файлы для прикрепления\n- `Ctrl+/` открывает палитру команд",
"chat.slash.help": "Показать доступные команды",
"chat.slash.agents": "Перейти на страницу агентов",
"chat.slash.new": "Новая сессия (очистить историю)",
"chat.slash.compact": "Сжать контекст сессии",
"chat.slash.model": "Показать или сменить модель (/model [имя])",
"chat.slash.stop": "Отменить текущий запуск агента",
"chat.slash.usage": "Показать использование токенов",
"chat.slash.think": "Переключить размышления (/think [on|off|stream])",
"chat.slash.context": "Показать использование контекста",
"chat.slash.verbose": "Переключить детали инструментов (/verbose [off|on|full])",
"chat.slash.queue": "Проверить очередь обработки",
"chat.slash.status": "Показать статус системы",
"chat.slash.clear": "Очистить чат",
"chat.slash.exit": "Отключиться от агента",
"chat.slash.budget": "Показать лимиты и расходы",
"chat.slash.peers": "Показать статус сети OFP",
"chat.slash.a2a": "Список A2A агентов",
"commands.help": "Показать доступные команды",
"commands.agents": "Перейти на страницу агентов",
"commands.new": "Новая сессия",
"commands.switch": "Сменить агента",
"commands.clear": "Очистить диалог",
"commands.model": "Сменить модель",
"commands.think": "Переключить режим размышлений",
"commands.focus": "Переключить режим фокуса",
"commands.theme": "Сменить тему",
"tips.commands": "Введите / для команд",
"tips.think": "/think on для размышлений",
"tips.focus": "Ctrl+Shift+F для режима фокуса",
"agents.info": "Информация",
"agents.files": "Файлы",
"agents.config": "Настройки",
"agents.chat": "Чат",
"agents.clone": "Клонировать",
"agents.clear_history": "Очистить историю",
"agents.change": "Изменить",
"agents.none_fallback": "Нет — добавить цепочку резервов",
"agents.add": "+ Добавить",
"agents.loading_files": "Загрузка файлов...",
"agents.no_workspace_files": "Файлы рабочей области не найдены",
"agents.save_config": "Сохранить настройки",
"agents.tool_filters": "Фильтры инструментов",
"agents.allowlist": "Белый список",
"agents.blocklist": "Чёрный список",
"agents.agent_name": "Имя агента",
"agents.emoji": "Эмодзи",
"agents.color": "Цвет",
"agents.archetype": "Архетип",
"agents.provider": "Провайдер",
"agents.model": "Модель",
"agents.system_prompt": "Системный промпт",
"agents.soul_persona": "Душa / Персона",
"agents.tool_profile": "Профиль инструментов",
"agents.minimal_profile": "Минимальный — только чтение файлов",
"agents.coding_profile": "Кодинг — файлы + оболочка + веб-запросы",
"agents.fullstack_profile": "Full-Stack — файлы + оболочка + веб + поиск",
"agents.research_profile": "Исследование — веб + поиск + анализ",
"agents.admin_profile": "Админ — полный доступ к системе (опасно)",
"agents.agent_created": "Агент создан",
"agents.agent_stopped": "Агент остановлен",
"agents.agent_deleted": "Агент удалён",
"presets.professional": "Деловой",
"presets.professional_desc": "Точный, бизнес-ориентированный ассистент, сосредоточенный на эффективности и ясности. Приоритет — практические выводы и структурированная коммуникация.",
"presets.professional_soul": "Общайтесь чётко и профессионально. Будьте прямым и структурированным. Используйте формальный язык и выводы на основе данных. ставьте точность выше личности.",
"presets.friendly": "Дружелюбный",
"presets.friendly_desc": "Тёплый и открытый ассистент, который выстраивает rapport и использует разговорный язык. Отлично подходит для мозгового штурма и исследования.",
"presets.friendly_soul": "Будьте тёплым, доступным и разговорчивым. Используйте неформальный язык и проявляйте искренний интерес к пользователю. Добавляйте личность к вашим ответам, оставаясь полезным.",
"presets.technical": "Технический",
"presets.technical_desc": "Эксперт-помощник по разработке, оптимизированный для кода, архитектуры и технических задач. Точная терминология, глубокие погружения, бенчмарки.",
"presets.technical_soul": "Сосредоточьтесь на технической точности и глубине. Используйте точную терминологию. Покажите вашу работу и рассуждения. Предпочитайте примеры кода и структурированные объяснения.",
"presets.creative": "Креативный",
"presets.creative_desc": "Творческий партнёр для создания контента, дизайн-мышления и нестандартных решений. Приветствует неоднозначность и исследует возможности.",
"presets.creative_soul": "Будьте изобретательным и выразительным. Используйте яркий язык, аналогии и неожиданные связи. Поощряйте творческое мышление и исследуйте различные перспективы.",
"presets.concise": "Краткий",
"presets.concise_desc": "Минималистичный и прямой ассистент, который ценит ваше время. Убирает лишнее и даёт сфокусированные, практичные ответы.",
"presets.concise_soul": "Будьте предельно кратким и точным. Без воды и формальностей. Отвечайте наименьшим количеством слов, оставаясь точным и полным.",
"presets.mentor": "Наставник",
"presets.mentor_desc": "Терпеливый педагог, который подробно объясняет концепции, даёт контекст и направляет обучение. Метод Сократа при необходимости.",
"presets.mentor_soul": "Будьте терпеливым и ободряющим как хороший учитель. Разбивайте сложные темы по шагам. Задавайте направляющие вопросы. Празднуйте прогресс и укрепляйте уверенность.",
"agents.profile.minimal": "Минимальный",
"agents.profile.minimal_desc": "Только чтение файлов",
"agents.profile.coding": "Кодинг",
"agents.profile.coding_desc": "Файлы + оболочка + веб-запросы",
"agents.profile.research": "Исследование",
"agents.profile.research_desc": "Веб-поиск + чтение/запись файлов",
"agents.profile.messaging": "Коммуникации",
"agents.profile.messaging_desc": "Агенты + доступ к памяти",
"agents.profile.automation": "Автоматизация",
"agents.profile.automation_desc": "Все инструменты кроме пользовательских",
"agents.profile.balanced": "Сбалансированный",
"agents.profile.balanced_desc": "Набор инструментов общего назначения",
"agents.profile.precise": "Точный",
"agents.profile.precise_desc": "Фокусированный набор инструментов для точности",
"agents.profile.creative": "Креативный",
"agents.profile.creative_desc": "Полный набор инструментов с творческим уклоном",
"agents.profile.full": "Полный",
"agents.profile.full_desc": "Все 35+ инструментов",
"wizard.general_assistant": "Универсальный ассистент",
"wizard.general_assistant_desc": "Вы универсальный AI-ассистент, который помогает пользователям с широким кругом задач. Вы знающий, полезный и способны адаптироваться к потребностям пользователя.",
"wizard.code_helper": "Помощник по коду",
"wizard.code_helper_desc": "Вы опытный программный ассистент, специализирующийся на разработке ПО. Вы помогаете писать, отлаживать и рефакторить код на разных языках.",
"wizard.researcher": "Исследовательский ассистент",
"wizard.researcher_desc": "Вы исследовательский ассистент, который помогает находить, анализировать и синтезировать информацию из различных источников.",
"wizard.writer": "Писатель",
"wizard.writer_desc": "Вы квалифицированный писатель, который помогает с созданием контента, редактированием и творческими проектами.",
"wizard.data_analyst": "Аналитик данных",
"wizard.data_analyst_desc": "Вы аналитик данных, который помогает исследовать, анализировать и визуализировать данные для извлечения инсайтов.",
"wizard.devops": "DevOps-инженер",
"wizard.devops_desc": "Вы DevOps-инженер, который помогает с инфраструктурой, деплоем, CI/CD и системным администрированием.",
"wizard.support": "Поддержка клиентов",
"wizard.support_desc": "Вы представитель поддержки клиентов, который помогает решать вопросы с терпением и профессионализмом.",
"wizard.tutor": "Репетитор",
"wizard.tutor_desc": "Вы образовательный репетитор, который ясно объясняет концепции и адаптирует обучение к уровню ученика.",
"wizard.api_designer": "API-дизайнер",
"wizard.api_designer_desc": "Вы дизайнер API, который помогает создавать хорошо структурированные, интуитивные API по лучшим практикам.",
"wizard.meeting_notes": "Заметки к встрече",
"wizard.meeting_notes_desc": "Вы специалист по заметкам встреч, который суммирует обсуждения, извлекает задачи и отслеживает решения.",
"wizard.step_welcome": "Приветствие",
"wizard.step_provider": "Провайдер",
"wizard.step_agent": "Агент",
"wizard.step_try_it": "Попробовать",
"wizard.step_channel": "Канал",
"wizard.step_done": "Готово",
"wizard.cat_general": "Общее",
"wizard.cat_development": "Разработка",
"wizard.cat_research": "Исследования",
"wizard.cat_writing": "Написание",
"wizard.cat_business": "Бизнес",
"wizard.channel_telegram": "Telegram",
"wizard.channel_telegram_desc": "Подключите агента к Telegram-боту для обмена сообщениями.",
"wizard.channel_telegram_token": "Токен бота",
"wizard.channel_telegram_help": "Создайте бота через @BotFather в Telegram, чтобы получить токен.",
"wizard.channel_discord": "Discord",
"wizard.channel_discord_desc": "Подключите агента к Discord-серверу через токен бота.",
"wizard.channel_discord_token": "Токен бота",
"wizard.channel_discord_help": "Создайте приложение Discord на discord.com/developers и добавьте бота.",
"wizard.channel_slack": "Slack",
"wizard.channel_slack_desc": "Подключите агента к рабочему пространству Slack.",
"wizard.channel_slack_token": "Токен бота",
"wizard.channel_slack_help": "Создайте приложение Slack на api.slack.com/apps и установите его в рабочее пространство.",
"wizard.profile_minimal": "Минимальный",
"wizard.profile_minimal_desc": "Только чтение файлов",
"wizard.profile_coding": "Кодинг",
"wizard.profile_coding_desc": "Файлы + оболочка + веб-запросы",
"wizard.profile_research": "Исследование",
"wizard.profile_research_desc": "Веб-поиск + чтение/запись файлов",
"wizard.profile_balanced": "Сбалансированный",
"wizard.profile_balanced_desc": "Набор инструментов общего назначения",
"wizard.profile_precise": "Точный",
"wizard.profile_precise_desc": "Фокусированный набор инструментов для точности",
"wizard.profile_creative": "Креативный",
"wizard.profile_creative_desc": "Полный набор инструментов с творческим уклоном",
"wizard.profile_full": "Полный",
"wizard.profile_full_desc": "Все 35+ инструментов",
"wizard.enter_api_key": "Пожалуйста, введите API-ключ",
"wizard.api_key_saved": "API-ключ сохранён для",
"wizard.failed_save_key": "Не удалось сохранить ключ:",
"wizard.connected": "подключён",
"wizard.connection_failed": "Ошибка подключения",
"wizard.test_failed": "Тест не прошёл:",
"wizard.enter_agent_name": "Пожалуйста, введите имя агента",
"wizard.agent_created": "Агент создан",
"wizard.failed_create_agent": "Не удалось создать агента:",
"wizard.enter_token": "Пожалуйста, введите",
"wizard.channel_configured": "настроен и активирован.",
"wizard.failed_configure": "Ошибка:",
"wizard.suggestions.general.1": "Чем вы можете помочь?",
"wizard.suggestions.general.2": "Расскажите интересный факт",
"wizard.suggestions.general.3": "Резюмируйте последние новости AI",
"wizard.suggestions.development.1": "Напишите Python hello world",
"wizard.suggestions.development.2": "Объясните async/await",
"wizard.suggestions.development.3": "Проверьте этот фрагмент кода",
"wizard.suggestions.research.1": "Объясните квантовые вычисления просто",
"wizard.suggestions.research.2": "Сравните React и Vue",
"wizard.suggestions.research.3": "Какие последние тренды в AI?",
"wizard.suggestions.writing.1": "Помогите написать профессиональное письмо",
"wizard.suggestions.writing.2": "Улучшите этот абзац",
"wizard.suggestions.writing.3": "Напишите введение в блог об AI",
"wizard.suggestions.business.1": "Составьте повестку встречи",
"wizard.suggestions.business.2": "Как обработать жалобу?",
"wizard.suggestions.business.3": "Создайте статус-отчёт проекта",
"approvals.title": "Одобрения выполнения",
"approvals.pending": "ожидает",
"approvals.all": "Все",
"approvals.pending_tab": "Ожидающие",
"approvals.approved": "Одобрено",
"approvals.rejected": "Отклонено",
"approvals.expired": "Истекло",
"approvals.no_approvals": "Нет одобрений",
"approvals.approve": "Одобрить",
"approvals.reject": "Отклонить",
"workflows.title": "Рабочие процессы",
"workflows.visual_builder": "Визуальный конструктор",
"workflows.what_are": "Что такое рабочие процессы?",
"workflows.no_workflows": "Нет рабочих процессов",
"workflows.sequential": "Последовательный",
"workflows.fan_out": "Распределение",
"workflows.conditional": "Условный",
"workflows.loop": "Цикл",
"workflows.add_step": "+ Добавить шаг",
"workflows.execute": "Выполнить",
"workflows.result": "Результат",
"workflows.node_palette": "Палитра узлов",
"workflows.drag_nodes": "Перетащите узлы на холст",
"workflows.steps_connections": "шагов, связей",
"workflows.agent": "Агент",
"workflows.prompt_template": "Шаблон промпта",
"workflows.expression": "Выражение",
"workflows.top_port_true": "Верхний порт = истина, нижний = ложь",
"workflows.max_iterations": "Макс. итераций",
"workflows.until_stop": "До (условие остановки)",
"workflows.fan_out_count": "Количество ветвей",
"workflows.wait_all": "Ждать все",
"workflows.first_finish": "Первый завершился",
"workflows.majority_vote": "Большинство",
"workflows.connection_selected": "Связь выбрана",
"workflows.delete_connection": "Удалить связь",
"scheduler.title": "Планировщик",
"scheduler.scheduled_jobs": "Запланированные задания",
"scheduler.event_triggers": "Триггеры событий",
"scheduler.run_history": "История запусков",
"scheduler.new_job": "+ Новое задание",
"scheduler.job_name": "Название задания",
"scheduler.cron_expression": "Cron-выражение",
"scheduler.quick_presets": "Быстрые шаблоны",
"scheduler.target_agent": "Целевой агент",
"scheduler.any_agent": "Любой доступный агент",
"scheduler.message_send": "Сообщение для отправки",
"scheduler.enabled": "Включено (запустится сразу)",
"scheduler.disabled": "Отключено (создать приостановленным)",
"scheduler.active": "Активно",
"scheduler.paused": "Приостановлено",
"scheduler.cron_job": "Cron-задание",
"scheduler.trigger": "Триггер",
"scheduler.no_jobs": "Нет запланированных заданий",
"scheduler.no_triggers": "Нет триггеров событий",
"scheduler.no_history": "Нет истории запусков",
"channels.title": "Каналы",
"channels.configured": "настроено",
"channels.search": "Поиск каналов...",
"channels.setup": "Настроить",
"channels.edit": "Изменить",
"channels.configure": "Настройка",
"channels.verify": "Проверить",
"channels.ready": "Готово",
"channels.is_ready": "готово!",
"channels.get_credentials": "Как получить учётные данные",
"channels.show_advanced": "Показать расширенные",
"channels.hide_advanced": "Скрыть расширенные",
"channels.connecting": "Подключение к шлюзу WhatsApp Web...",
"channels.linked_success": "WhatsApp успешно связан!",
"channels.business_api": "Business API",
"skills.title": "Навыки и экосистема",
"skills.installed": "Установленные",
"skills.clawhub": "ClawHub",
"skills.mcp_servers": "MCP-серверы",
"skills.quick_start": "Быстрый старт",
"skills.no_installed": "Нет установленных навыков",
"skills.browse_clawhub": "Обзор ClawHub",
"skills.search_clawhub": "Поиск навыков ClawHub...",
"skills.trending": "Популярные",
"skills.most_downloaded": "Самые скачиваемые",
"skills.most_starred": "Самые оценённые",
"skills.recently_updated": "Недавно обновлённые",
"skills.categories": "КАТЕГОРИИ",
"skills.already_installed": "Уже установлено",
"skills.no_skills_found": "Навыки не найдены",
"skills.security_warnings": "Предупреждения безопасности",
"skills.security_scan": "Навыки проверяются на безопасность перед установкой",
"skills.create": "Создать навык",
"skills.created": "Создан",
"skills.cat_coding": "Кодинг и IDE",
"skills.cat_git": "Git и GitHub",
"skills.cat_frontend": "Веб и фронтенд",
"skills.cat_devops": "DevOps и облака",
"skills.cat_database": "Базы данных",
"skills.cat_security": "Безопасность",
"skills.cat_ai": "AI и ML",
"skills.cat_data": "Данные и аналитика",
"skills.cat_mobile": "Мобильная разработка",
"skills.cat_desktop": "Десктопные приложения",
"skills.cat_api": "API и интеграции",
"skills.cat_testing": "Тестирование",
"skills.cat_docs": "Документация",
"skills.cat_productivity": "Продуктивность",
"skills.cat_other": "Другое",
"skills.cat_browser": "Браузер и автоматизация",
"skills.cat_search": "Поиск и исследования",
"skills.cat_communication": "Коммуникации",
"skills.cat_media": "Медиа и стриминг",
"skills.cat_notes": "Заметки и PKM",
"skills.cat_cli": "CLI утилиты",
"skills.cat_marketing": "Маркетинг и продажи",
"skills.cat_finance": "Финансы",
"skills.cat_smarthome": "Умный дом и IoT",
"skills.uninstall_skill": "Удалить навык",
"skills.uninstall_confirm": "Удалить навык",
"skills.source_clawhub": "ClawHub",
"skills.source_openclaw": "OpenClaw",
"skills.source_builtin": "Встроенный",
"skills.source_local": "Локальный",
"hands.title": "Руки — Наборы автономных возможностей",
"hands.available": "Доступные",
"hands.active": "Активные",
"hands.ready": "Готово",
"hands.setup_needed": "Требуется настройка",
"hands.requirements": "ТРЕБОВАНИЯ",
"hands.details": "Подробности",
"hands.no_hands": "Нет доступных модулей",
"sessions.title": "Сессии",
"sessions.memory": "Память",
"sessions.delete_session": "Удалить сессию",
"sessions.delete_confirm": "Это навсегда удалит сессию и все её сообщения.",
"sessions.delete_key": "Удалить ключ",
"sessions.delete_key_confirm": "Удалить ключ",
"logs.title": "Логи",
"logs.live": "Онлайн",
"logs.audit_trail": "Аудит",
"settings.title": "Настройки",
"settings.providers": "Провайдеры",
"settings.models": "Модели",
"settings.config": "Конфигурация",
"settings.tools": "Инструменты",
"settings.migration": "Миграция",
"settings.security": "Безопасность",
"settings.network": "Сеть",
"settings.migration": "Миграция",
"settings.language": "Язык",
"settings.sec_path_traversal": "Защита от обхода пути",
"settings.sec_path_traversal_desc": "Блокирует попытки доступа к файлам за пределами рабочей директории через .. или абсолютные пути.",
"settings.sec_ssrf": "Защита от SSRF",
"settings.sec_ssrf_desc": "Предотвращает запросы агентов к внутренним IP-диапазонам (localhost, облачный метаданные, частные сети).",
"settings.sec_capability": "Управление доступом по возможностям",
"settings.sec_capability_desc": "Агенты могут получать доступ только к явно предоставленным возможностям. Нет неявного доступа к инструментам или данным.",
"settings.sec_taint": "Отслеживание заражения",
"settings.sec_taint_desc": "Отслеживает ненадёжные данные (ввод пользователя, содержимое файлов) через рассуждения агента для предотвращения инъекции промпта.",
"settings.sec_sandbox": "WASM-песочница",
"settings.sec_sandbox_desc": "Выполняет ненадёжный код в изолированных WebAssembly-песочницах с ограничениями памяти и системных вызовов.",
"settings.sec_audit": "Меркл-проверка",
"settings.sec_audit_desc": "Ведёт верифицируемый журнал аудита всех действий агентов с использованием криптографии деревьев Меркла.",
"settings.sec_workspace": "Изоляция рабочих областей",
"settings.sec_workspace_desc": "Каждый агент имеет изолированную рабочую директорию. Нет межагентного доступа к файлам без явного разрешения.",
"settings.sec_rate_limit": "Ограничение частоты",
"settings.sec_rate_limit_desc": "Устанавливает лимиты на запросы для каждого агента и глобально для предотвращения истощения ресурсов и перерасхода.",
"settings.sec_approval": "Одобрения выполнения",
"settings.sec_approval_desc": "Требует одобрения человека для рискованных действий (команды оболочки, запись файлов, внешние запросы).",
"settings.sec_enabled": "Включено",
"settings.sec_disabled": "Отключено",
"settings.sec_inherited": "Унаследовано",
"settings.sec_global": "Глобально"
}
+125 -158
View File
@@ -2892,6 +2892,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- ═══ Step 2: Configure ═══ -->
<div class="hand-wizard-body" x-show="setupStep === 2">
<!-- Optional instance name (for running multiple instances of the same hand) -->
<div class="mb-4">
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px;text-transform:uppercase">
Instance name <span style="text-transform:none;letter-spacing:0;color:#888">(optional)</span>
</div>
<div class="text-xs text-dim mb-2">Leave empty for a single instance of this hand. Set a name to run multiple instances in parallel.</div>
<input type="text" class="form-input" x-model="setupWizard.instanceName" placeholder="e.g. clip-youtube, lead-q4-outreach" style="width:100%">
</div>
<template x-if="!setupHasSettings">
<div class="text-sm text-dim" style="text-align:center;padding:20px 0">No configuration needed for this hand. Click Next to continue.</div>
</template>
@@ -3753,90 +3761,92 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<button class="btn btn-sm" @click="editMode ? saveBudget() : startEdit()" :disabled="saving" x-text="editMode ? (saving ? 'Saving...' : 'Save') : 'Edit Limits'" style="white-space:nowrap"></button>
</div>
<div x-show="budgetLoading" class="loading-state"><div class="spinner"></div><span>Loading budget...</span></div>
<div x-show="!budgetLoading && budgetData">
<!-- Global budget meters -->
<div class="stats-row" style="margin-bottom:16px">
<div class="stat-card" style="flex:1">
<div class="stat-label">Hourly</div>
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.hourly_spend || 0).toFixed(4)"></div>
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.hourly_limit)"></div>
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.hourly_limit > 0">
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.hourly_pct*100,100)+'%', background: pctColor(budgetData.hourly_pct)}"></div>
<template x-if="!budgetLoading && budgetData">
<div>
<!-- Global budget meters -->
<div class="stats-row" style="margin-bottom:16px">
<div class="stat-card" style="flex:1">
<div class="stat-label">Hourly</div>
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.hourly_spend || 0).toFixed(4)"></div>
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.hourly_limit)"></div>
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.hourly_limit > 0">
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.hourly_pct*100,100)+'%', background: pctColor(budgetData.hourly_pct)}"></div>
</div>
</div>
<div class="stat-card" style="flex:1">
<div class="stat-label">Daily</div>
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.daily_spend || 0).toFixed(4)"></div>
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.daily_limit)"></div>
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.daily_limit > 0">
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.daily_pct*100,100)+'%', background: pctColor(budgetData.daily_pct)}"></div>
</div>
</div>
<div class="stat-card" style="flex:1">
<div class="stat-label">Monthly</div>
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.monthly_spend || 0).toFixed(4)"></div>
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.monthly_limit)"></div>
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.monthly_limit > 0">
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.monthly_pct*100,100)+'%', background: pctColor(budgetData.monthly_pct)}"></div>
</div>
</div>
</div>
<div class="stat-card" style="flex:1">
<div class="stat-label">Daily</div>
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.daily_spend || 0).toFixed(4)"></div>
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.daily_limit)"></div>
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.daily_limit > 0">
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.daily_pct*100,100)+'%', background: pctColor(budgetData.daily_pct)}"></div>
</div>
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
</div>
<div class="stat-card" style="flex:1">
<div class="stat-label">Monthly</div>
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.monthly_spend || 0).toFixed(4)"></div>
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.monthly_limit)"></div>
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.monthly_limit > 0">
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.monthly_pct*100,100)+'%', background: pctColor(budgetData.monthly_pct)}"></div>
</div>
<div class="text-xs text-dim mb-3" x-show="!editMode">
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
</div>
</div>
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
</div>
<div class="text-xs text-dim mb-3" x-show="!editMode">
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
</div>
<!-- Edit limits form -->
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
<div class="stats-row" style="margin-bottom:8px;gap:8px">
<div style="flex:1">
<label class="text-xs text-dim">Hourly Limit ($)</label>
<input type="number" step="0.1" min="0" x-model="editHourly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
<!-- Edit limits form -->
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
<div class="stats-row" style="margin-bottom:8px;gap:8px">
<div style="flex:1">
<label class="text-xs text-dim">Hourly Limit ($)</label>
<input type="number" step="0.1" min="0" x-model="editHourly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
</div>
<div style="flex:1">
<label class="text-xs text-dim">Daily Limit ($)</label>
<input type="number" step="1" min="0" x-model="editDaily" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
</div>
<div style="flex:1">
<label class="text-xs text-dim">Monthly Limit ($)</label>
<input type="number" step="1" min="0" x-model="editMonthly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
</div>
<div style="flex:0.6">
<label class="text-xs text-dim">Alert (%)</label>
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
</div>
</div>
<div style="flex:1">
<label class="text-xs text-dim">Daily Limit ($)</label>
<input type="number" step="1" min="0" x-model="editDaily" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
</div>
<div style="flex:1">
<label class="text-xs text-dim">Monthly Limit ($)</label>
<input type="number" step="1" min="0" x-model="editMonthly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
</div>
<div style="flex:0.6">
<label class="text-xs text-dim">Alert (%)</label>
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
<div style="margin-bottom:8px">
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
</div>
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
</div>
<div style="margin-bottom:8px">
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
</div>
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
</div>
<!-- Per-agent cost ranking -->
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
<div class="table-wrap" x-show="agentRanking.length">
<table>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
<tbody>
<template x-for="a in agentRanking" :key="a.agent_id">
<tr>
<td class="font-bold" x-text="a.name"></td>
<td x-text="'$' + (a.daily_cost_usd || 0).toFixed(4)"></td>
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
</tr>
</template>
</tbody>
</table>
<!-- Per-agent cost ranking -->
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
<div class="table-wrap" x-show="agentRanking.length">
<table>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
<tbody>
<template x-for="a in agentRanking" :key="a.agent_id">
<tr>
<td class="font-bold" x-text="a.name"></td>
<td x-text="'$' + (a.daily_cost_usd || 0).toFixed(4)"></td>
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="text-xs text-dim" x-show="!agentRanking.length">No spending recorded today.</div>
</div>
<div class="text-xs text-dim" x-show="!agentRanking.length">No spending recorded today.</div>
</div>
</template>
</div>
<!-- Migration tab -->
@@ -3880,42 +3890,46 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</div>
<div x-show="migStep === 'preview' && scanResult">
<div class="card mb-4" style="border-left:3px solid var(--success, #22c55e)">
<div class="flex justify-between items-center mb-2">
<div class="font-bold" style="font-size:14px">OpenClaw Workspace Found</div>
<span class="badge badge-connected">Ready to Migrate</span>
<template x-if="migStep === 'preview' && scanResult">
<div>
<div class="card mb-4" style="border-left:3px solid var(--success, #22c55e)">
<div class="flex justify-between items-center mb-2">
<div class="font-bold" style="font-size:14px">OpenClaw Workspace Found</div>
<span class="badge badge-connected">Ready to Migrate</span>
</div>
<div class="text-sm text-dim" style="font-family:monospace" x-text="scanResult.path"></div>
</div>
<div class="stats-row mb-4">
<div class="stat-card"><div class="stat-value" x-text="scanResult.agents ? scanResult.agents.length : 0"></div><div class="stat-label">Agents</div></div>
<div class="stat-card"><div class="stat-value" x-text="scanResult.channels ? scanResult.channels.length : 0"></div><div class="stat-label">Channels</div></div>
<div class="stat-card"><div class="stat-value" x-text="scanResult.skills ? scanResult.skills.length : 0"></div><div class="stat-label">Skills</div></div>
</div>
<div class="flex gap-2 mb-4">
<button class="btn btn-primary" @click="runMigration(false)" :disabled="migrating">
<span x-show="!migrating">Migrate Now</span>
<span x-show="migrating">Migrating...</span>
</button>
<button class="btn btn-ghost" @click="runMigration(true)" :disabled="migrating">Dry Run</button>
<button class="btn btn-ghost" @click="migStep = 'intro'; scanResult = null">Start Over</button>
</div>
<div class="text-sm text-dim" style="font-family:monospace" x-text="scanResult.path"></div>
</div>
<div class="stats-row mb-4">
<div class="stat-card"><div class="stat-value" x-text="scanResult.agents ? scanResult.agents.length : 0"></div><div class="stat-label">Agents</div></div>
<div class="stat-card"><div class="stat-value" x-text="scanResult.channels ? scanResult.channels.length : 0"></div><div class="stat-label">Channels</div></div>
<div class="stat-card"><div class="stat-value" x-text="scanResult.skills ? scanResult.skills.length : 0"></div><div class="stat-label">Skills</div></div>
</div>
<div class="flex gap-2 mb-4">
<button class="btn btn-primary" @click="runMigration(false)" :disabled="migrating">
<span x-show="!migrating">Migrate Now</span>
<span x-show="migrating">Migrating...</span>
</button>
<button class="btn btn-ghost" @click="runMigration(true)" :disabled="migrating">Dry Run</button>
<button class="btn btn-ghost" @click="migStep = 'intro'; scanResult = null">Start Over</button>
</div>
</div>
</template>
<div x-show="migStep === 'result' && migResult">
<div class="card mb-4" :style="'border-left:3px solid ' + (migResult.status === 'completed' ? 'var(--success, #22c55e)' : 'var(--error)')">
<div class="flex justify-between items-center mb-2">
<div class="font-bold" style="font-size:14px" x-text="migResult.dry_run ? 'Dry Run Complete' : 'Migration Complete!'"></div>
<span class="badge" :class="migResult.status === 'completed' ? 'badge-connected' : 'badge-crashed'" x-text="migResult.status === 'completed' ? 'SUCCESS' : 'FAILED'"></span>
<template x-if="migStep === 'result' && migResult">
<div>
<div class="card mb-4" :style="'border-left:3px solid ' + (migResult.status === 'completed' ? 'var(--success, #22c55e)' : 'var(--error)')">
<div class="flex justify-between items-center mb-2">
<div class="font-bold" style="font-size:14px" x-text="migResult.dry_run ? 'Dry Run Complete' : 'Migration Complete!'"></div>
<span class="badge" :class="migResult.status === 'completed' ? 'badge-connected' : 'badge-crashed'" x-text="migResult.status === 'completed' ? 'SUCCESS' : 'FAILED'"></span>
</div>
<div class="text-sm text-dim" x-show="migResult.error" style="color:var(--error)" x-text="migResult.error"></div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" x-show="migResult.dry_run" @click="runMigration(false)" :disabled="migrating">Run Migration for Real</button>
<button class="btn btn-ghost" @click="migStep = 'intro'; migResult = null; scanResult = null">Start New Migration</button>
</div>
<div class="text-sm text-dim" x-show="migResult.error" style="color:var(--error)" x-text="migResult.error"></div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" x-show="migResult.dry_run" @click="runMigration(false)" :disabled="migrating">Run Migration for Real</button>
<button class="btn btn-ghost" @click="migStep = 'intro'; migResult = null; scanResult = null">Start New Migration</button>
</div>
</div>
</template>
<div class="card" x-show="migStep === 'not_found'" style="border-left:3px solid var(--warning, #f59e0b)">
<div class="font-bold mb-2" style="font-size:14px">OpenClaw Not Found</div>
@@ -3980,7 +3994,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<td x-text="formatTokens(m.total_input_tokens)"></td>
<td x-text="formatTokens(m.total_output_tokens)"></td>
<td x-text="formatCost(m.total_cost_usd)"></td>
<td><div style="background:var(--surface2);border-radius:4px;height:16px;overflow:hidden"><div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="'width:' + barWidth(m)"></div></div></td>
<td><div style="background:var(--surface2);border-radius:4px;height:16px;overflow:hidden"><div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="{ width: barWidth(m) }"></div></div></td>
</tr>
</template>
</tbody>
@@ -4039,20 +4053,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div x-show="costByProvider().length > 0" class="donut-chart-wrap">
<div class="donut-chart">
<svg viewBox="0 0 160 160" width="160" height="160">
<template x-for="(seg, idx) in donutSegments()" :key="seg.provider">
<circle
cx="80" cy="80" r="60"
fill="none"
:stroke="seg.color"
stroke-width="24"
:stroke-dasharray="seg.dasharray"
:stroke-dashoffset="seg.dashoffset"
transform="rotate(-90 80 80)"
class="donut-segment"
>
<title x-text="seg.provider + ': ' + seg.percent + '% (' + formatCost(seg.cost) + ')'"></title>
</circle>
</template>
<g x-html="donutSegmentsSvg()"></g>
<!-- Center text -->
<text x="80" y="76" text-anchor="middle" fill="var(--text)" style="font-size:14px;font-weight:700;font-family:var(--font-mono)" x-text="formatCost(summary.total_cost_usd)"></text>
<text x="80" y="92" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">TOTAL</text>
@@ -4079,41 +4080,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<svg :viewBox="'0 0 ' + (barChartData().length * 50 + 20) + ' 180'" :width="barChartData().length * 50 + 20" height="180">
<!-- Baseline -->
<line x1="10" :x2="barChartData().length * 50 + 10" y1="150" y2="150" stroke="var(--border)" stroke-width="1"/>
<template x-for="(bar, idx) in barChartData()" :key="bar.date">
<g>
<!-- Bar rect -->
<rect
:x="idx * 50 + 18"
:y="150 - bar.barHeight"
width="24"
:height="bar.barHeight"
rx="3"
fill="var(--accent)"
class="cost-bar"
style="opacity:0.85"
>
<title x-text="bar.date + ': ' + formatCost(bar.cost) + ' (' + bar.calls + ' calls)'"></title>
</rect>
<!-- Day label -->
<text
:x="idx * 50 + 30"
y="166"
text-anchor="middle"
fill="var(--text-muted)"
style="font-size:9px;font-family:var(--font-mono)"
x-text="bar.dayName"
></text>
<!-- Cost label on top -->
<text
:x="idx * 50 + 30"
:y="150 - bar.barHeight - 4"
text-anchor="middle"
fill="var(--text-dim)"
style="font-size:8px;font-family:var(--font-mono)"
x-text="formatCost(bar.cost)"
></text>
</g>
</template>
<g x-html="barChartSvg()"></g>
</svg>
</div>
</div>
@@ -4152,7 +4119,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<td class="font-bold" x-text="formatCost(m.total_cost_usd)"></td>
<td>
<div style="background:var(--surface2);border-radius:4px;height:16px;overflow:hidden">
<div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="'width:' + costBarWidth(m)"></div>
<div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="{ width: costBarWidth(m) }"></div>
</div>
</td>
</tr>
@@ -4524,7 +4491,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
<!-- Send Message Modal -->
<div x-show="showSendModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" x-transition>
<div style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" :style="{ display: showSendModal ? 'flex' : 'none' }" x-transition>
<div class="card" style="width:420px;max-width:90vw" @click.stop>
<div class="card-header">Send Agent Message</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
@@ -4562,7 +4529,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
<!-- Post Task Modal -->
<div x-show="showTaskModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showTaskModal=false" x-transition>
<div style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" :style="{ display: showTaskModal ? 'flex' : 'none' }" @click.self="showTaskModal=false" x-transition>
<div class="card" style="width:420px;max-width:90vw" @click.stop>
<div class="card-header">Post Task</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
+60 -39
View File
@@ -53,14 +53,23 @@ function agentsPage() {
'\u{2764}\uFE0F', '\u{1F31F}', '\u{1F527}', '\u{1F4DD}', '\u{1F4A1}', '\u{1F3A8}'
],
archetypeOptions: ['Assistant', 'Researcher', 'Coder', 'Writer', 'DevOps', 'Support', 'Analyst', 'Custom'],
personalityPresets: [
{ id: 'professional', label: 'Professional', soul: 'Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.' },
{ id: 'friendly', label: 'Friendly', soul: 'Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.' },
{ id: 'technical', label: 'Technical', soul: 'Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.' },
{ id: 'creative', label: 'Creative', soul: 'Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.' },
{ id: 'concise', label: 'Concise', soul: 'Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.' },
{ id: 'mentor', label: 'Mentor', soul: 'Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.' }
],
_personalityPresetsLoaded: false,
personalityPresets: [], // Loaded dynamically with i18n
// Load personality presets with i18n
loadPersonalityPresets: function() {
if (this._personalityPresetsLoaded) return;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
this.personalityPresets = [
{ id: 'professional', label: t('presets.professional'), soul: t('presets.professional_soul') },
{ id: 'friendly', label: t('presets.friendly'), soul: t('presets.friendly_soul') },
{ id: 'technical', label: t('presets.technical'), soul: t('presets.technical_soul') },
{ id: 'creative', label: t('presets.creative'), soul: t('presets.creative_soul') },
{ id: 'concise', label: t('presets.concise'), soul: t('presets.concise_soul') },
{ id: 'mentor', label: t('presets.mentor'), soul: t('presets.mentor_soul') }
];
this._personalityPresetsLoaded = true;
},
// -- Detail modal tabs --
detailTab: 'info',
@@ -99,21 +108,31 @@ function agentsPage() {
// Load templates from API
async init() {
await this.loadTemplates();
// Load personality presets with i18n
this.loadPersonalityPresets();
},
// ── Profile Descriptions ──
profileDescriptions: {
minimal: { label: 'Minimal', desc: 'Read-only file access' },
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
research: { label: 'Research', desc: 'Web search + file read/write' },
messaging: { label: 'Messaging', desc: 'Agents + memory access' },
automation: { label: 'Automation', desc: 'All tools except custom' },
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
full: { label: 'Full', desc: 'All 35+ tools' }
// ── Profile Descriptions (loaded dynamically with i18n) ──
_profileDescriptionsLoaded: false,
profileDescriptions: {},
loadProfileDescriptions: function() {
if (this._profileDescriptionsLoaded) return;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
this.profileDescriptions = {
minimal: { label: t('agents.profile.minimal'), desc: t('agents.profile.minimal_desc') },
coding: { label: t('agents.profile.coding'), desc: t('agents.profile.coding_desc') },
research: { label: t('agents.profile.research'), desc: t('agents.profile.research_desc') },
messaging: { label: t('agents.profile.messaging'), desc: t('agents.profile.messaging_desc') },
automation: { label: t('agents.profile.automation'), desc: t('agents.profile.automation_desc') },
balanced: { label: t('agents.profile.balanced'), desc: t('agents.profile.balanced_desc') },
precise: { label: t('agents.profile.precise'), desc: t('agents.profile.precise_desc') },
creative: { label: t('agents.profile.creative'), desc: t('agents.profile.creative_desc') },
full: { label: t('agents.profile.full'), desc: t('agents.profile.full_desc') }
};
this._profileDescriptionsLoaded = true;
},
profileInfo: function(name) {
this.loadProfileDescriptions();
return this.profileDescriptions[name] || { label: name, desc: '' };
},
@@ -197,6 +216,8 @@ function agentsPage() {
try {
await Alpine.store('app').refreshAgents();
await this.loadTemplates();
this.loadPersonalityPresets();
this.loadProfileDescriptions();
} catch(e) {
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
}
@@ -240,61 +261,61 @@ function agentsPage() {
name: 'General Assistant',
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
provider: 'default',
model: 'default',
profile: 'full',
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.',
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
},
{
name: 'Code Helper',
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
provider: 'default',
model: 'default',
profile: 'coding',
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.',
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
},
{
name: 'Researcher',
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
category: 'Research',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
provider: 'default',
model: 'default',
profile: 'research',
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.',
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
},
{
name: 'Writer',
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
category: 'Writing',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
provider: 'default',
model: 'default',
profile: 'full',
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.',
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
},
{
name: 'Data Analyst',
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
provider: 'default',
model: 'default',
profile: 'coding',
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.',
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
},
{
name: 'DevOps Engineer',
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
provider: 'default',
model: 'default',
profile: 'automation',
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.',
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
},
...results[0].templates || []
];
@@ -383,8 +404,8 @@ function agentsPage() {
this.selectedPreset = '';
this.soulContent = '';
this.spawnForm.name = '';
this.spawnForm.provider = 'groq';
this.spawnForm.model = 'llama-3.3-70b-versatile';
this.spawnForm.provider = 'default';
this.spawnForm.model = 'default';
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
this.spawnForm.profile = 'full';
// Fetch status defaults and dynamic provider list concurrently
+71 -42
View File
@@ -42,34 +42,31 @@ function chatPage() {
modelSwitching: false,
_modelCache: null,
_modelCacheTime: 0,
slashCommands: [
{ cmd: '/help', desc: 'Show available commands' },
{ cmd: '/agents', desc: 'Switch to Agents page' },
{ cmd: '/new', desc: 'Reset session (clear history)' },
{ cmd: '/compact', desc: 'Trigger LLM session compaction' },
{ cmd: '/model', desc: 'Show or switch model (/model [name])' },
{ cmd: '/stop', desc: 'Cancel current agent run' },
{ cmd: '/usage', desc: 'Show session token usage & cost' },
{ cmd: '/think', desc: 'Toggle extended thinking (/think [on|off|stream])' },
{ cmd: '/context', desc: 'Show context window usage & pressure' },
{ cmd: '/verbose', desc: 'Cycle tool detail level (/verbose [off|on|full])' },
{ cmd: '/queue', desc: 'Check if agent is processing' },
{ cmd: '/status', desc: 'Show system status' },
{ cmd: '/clear', desc: 'Clear chat display' },
{ cmd: '/exit', desc: 'Disconnect from agent' },
{ cmd: '/budget', desc: 'Show spending limits and current costs' },
{ cmd: '/peers', desc: 'Show OFP peer network status' },
{ cmd: '/a2a', desc: 'List discovered external A2A agents' }
],
slashCommands: [], // Loaded dynamically with i18n in init()
_slashCommandsLoaded: false,
tokenCount: 0,
// ── Tip Bar ──
tipIndex: 0,
tips: ['Type / for commands', '/think on for reasoning', 'Ctrl+Shift+F for focus mode', 'Drag files to attach', '/model to switch models', '/context to check usage', '/verbose off to hide tool details'],
tips: [],
_tipsInitialized: false,
tipTimer: null,
get currentTip() {
if (localStorage.getItem('of-tips-off') === 'true') return '';
return this.tips[this.tipIndex % this.tips.length];
if (!this._tipsInitialized) {
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
this.tips = [
t('tips.commands'),
t('tips.think'),
t('tips.focus'),
'Drag files to attach',
'/model to switch models',
'/context to check usage',
'/verbose off to hide tool details'
];
this._tipsInitialized = true;
}
return this.tips[this.tipIndex % this.tips.length] || '';
},
dismissTips: function() { localStorage.setItem('of-tips-off', 'true'); },
startTipCycle: function() {
@@ -137,6 +134,9 @@ function chatPage() {
init() {
var self = this;
// Initialize slash commands with i18n
this.initSlashCommands();
// Start tip cycle
this.startTipCycle();
@@ -264,19 +264,46 @@ function chatPage() {
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
var self = this;
this.modelSwitching = true;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
self.currentAgent.model_name = (resp && resp.model) || model.id;
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
OpenFangToast.success(t('chat.model_switched') + ' ' + (model.display_name || model.id));
self.showModelSwitcher = false;
self.modelSwitching = false;
}).catch(function(e) {
OpenFangToast.error('Switch failed: ' + e.message);
OpenFangToast.error(t('chat.model_switch_failed') + ': ' + e.message);
self.modelSwitching = false;
});
},
// Initialize slash commands with i18n translations
initSlashCommands: function() {
if (this._slashCommandsLoaded) return;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
this.slashCommands = [
{ cmd: '/help', desc: t('chat.slash.help') },
{ cmd: '/agents', desc: t('chat.slash.agents') },
{ cmd: '/new', desc: t('chat.slash.new') },
{ cmd: '/compact', desc: t('chat.slash.compact') },
{ cmd: '/model', desc: t('chat.slash.model') },
{ cmd: '/stop', desc: t('chat.slash.stop') },
{ cmd: '/usage', desc: t('chat.slash.usage') },
{ cmd: '/think', desc: t('chat.slash.think') },
{ cmd: '/context', desc: t('chat.slash.context') },
{ cmd: '/verbose', desc: t('chat.slash.verbose') },
{ cmd: '/queue', desc: t('chat.slash.queue') },
{ cmd: '/status', desc: t('chat.slash.status') },
{ cmd: '/clear', desc: t('chat.slash.clear') },
{ cmd: '/exit', desc: t('chat.slash.exit') },
{ cmd: '/budget', desc: t('chat.slash.budget') },
{ cmd: '/peers', desc: t('chat.slash.peers') },
{ cmd: '/a2a', desc: t('chat.slash.a2a') }
];
this._slashCommandsLoaded = true;
},
// Fetch dynamic slash commands from server
fetchCommands: function() {
var self = this;
@@ -486,21 +513,13 @@ function chatPage() {
this.currentAgent = agent;
this.messages = [];
this.connectWs(agent.id);
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
// Show welcome tips on first use
if (!localStorage.getItem('of-chat-tips-seen')) {
var localMsgId = 0;
this.messages.push({
id: ++localMsgId,
id: ++msgId,
role: 'system',
text: '**Welcome to OpenFang Chat!**\n\n' +
'- Type `/` to see available commands\n' +
'- `/help` shows all commands\n' +
'- `/think on` enables extended reasoning\n' +
'- `/context` shows context window usage\n' +
'- `/verbose off` hides tool details\n' +
'- `Ctrl+Shift+F` toggles focus mode\n' +
'- Drag & drop files to attach them\n' +
'- `Ctrl+/` opens the command palette',
text: t('chat.welcome_message'),
meta: '',
tools: []
});
@@ -519,7 +538,14 @@ function chatPage() {
try {
var data = await OpenFangAPI.get('/api/agents/' + agentId + '/session');
if (data.messages && data.messages.length) {
self.messages = data.messages.map(function(m) {
// Defense-in-depth (#935): never render system-role messages in the
// conversation history view, even if the backend somehow returns
// one. The server already filters these out by default, but we
// guard here too so a regression cannot leak the system prompt.
var visible = data.messages.filter(function(m) {
return m && m.role !== 'System' && m.role !== 'system';
});
self.messages = visible.map(function(m) {
var role = m.role === 'User' ? 'user' : (m.role === 'System' ? 'system' : 'agent');
var text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
// Sanitize any raw function-call text from history
@@ -557,7 +583,8 @@ function chatPage() {
// Multi-session: create a new session
async createSession() {
if (!this.currentAgent) return;
var label = prompt('Session name (optional):');
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
var label = prompt(t('chat.session_name_prompt'));
if (label === null) return; // cancelled
try {
await OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/sessions', {
@@ -567,9 +594,9 @@ function chatPage() {
await this.loadSession(this.currentAgent.id);
this.messages = [];
this.scrollToBottom();
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success('New session created');
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success(t('chat.session_created'));
} catch(e) {
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error('Failed to create session');
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error(t('chat.session_create_failed'));
}
},
@@ -1006,8 +1033,9 @@ function chatPage() {
}
// HTTP fallback
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
if (!OpenFangAPI.isWsConnected()) {
OpenFangToast.info('Using HTTP mode (no streaming)');
OpenFangToast.info(t('chat.using_http_mode'));
}
this.messages.push({ id: ++msgId, role: 'agent', text: '', meta: '', thinking: true, tools: [], ts: Date.now() });
this.scrollToBottom();
@@ -1050,18 +1078,19 @@ function chatPage() {
killAgent() {
if (!this.currentAgent) return;
var self = this;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
var name = this.currentAgent.name;
OpenFangToast.confirm('Stop Agent', 'Stop agent "' + name + '"? The agent will be shut down.', async function() {
OpenFangToast.confirm(t('chat.stop_agent_title'), t('chat.stop_agent_confirm') + ' "' + name + '"?', async function() {
try {
await OpenFangAPI.del('/api/agents/' + self.currentAgent.id);
OpenFangAPI.wsDisconnect();
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
OpenFangToast.success('Agent "' + name + '" stopped');
OpenFangToast.success(t('chat.agent_stopped') + ' "' + name + '"');
Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to stop agent: ' + e.message);
OpenFangToast.error(t('chat.stop_agent_failed') + ': ' + e.message);
}
});
},
+1 -1
View File
@@ -39,7 +39,7 @@ function commsPage() {
startSSE() {
if (this.sseSource) this.sseSource.close();
var self = this;
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
var url = '/api/comms/events/stream';
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
this.sseSource = new EventSource(url);
this.sseSource.onmessage = function(ev) {
+10 -2
View File
@@ -122,6 +122,8 @@ function handsPage() {
}
}
}
// Initialize optional instance name (for multi-instance hands).
data.instanceName = '';
this.setupWizard = data;
// Skip deps step if no requirements
var hasReqs = data.requirements && data.requirements.length > 0;
@@ -408,8 +410,14 @@ function handsPage() {
}
this.activatingId = handId;
try {
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', { config: config });
this.showToast('Hand "' + handId + '" activated as ' + (data.agent_name || data.instance_id));
var payload = { config: config };
var name = (this.setupWizard.instanceName || '').trim();
if (name) {
payload.instance_name = name;
}
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', payload);
var label = data.instance_name || data.agent_name || data.instance_id;
this.showToast('Hand "' + handId + '" activated as ' + label);
this.closeSetupWizard();
await this.loadActive();
this.tab = 'active';
+26 -16
View File
@@ -64,15 +64,20 @@ function sessionsPage() {
deleteSession(sessionId) {
var self = this;
OpenFangToast.confirm('Delete Session', 'This will permanently remove the session and its messages.', async function() {
try {
await OpenFangAPI.del('/api/sessions/' + sessionId);
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
OpenFangToast.success('Session deleted');
} catch(e) {
OpenFangToast.error('Failed to delete session: ' + e.message);
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
OpenFangToast.confirm(
t('sessions.delete_session') || 'Delete Session',
t('sessions.delete_confirm') || 'This will permanently remove the session and its messages.',
async function() {
try {
await OpenFangAPI.del('/api/sessions/' + sessionId);
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
OpenFangToast.success('Session deleted');
} catch(e) {
OpenFangToast.error('Failed to delete session: ' + e.message);
}
}
});
);
},
// -- Memory methods --
@@ -108,15 +113,20 @@ function sessionsPage() {
deleteKey(key) {
var self = this;
OpenFangToast.confirm('Delete Key', 'Delete key "' + key + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
OpenFangToast.success('Key "' + key + '" deleted');
await self.loadKv();
} catch(e) {
OpenFangToast.error('Failed to delete key: ' + e.message);
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
OpenFangToast.confirm(
t('sessions.delete_key') || 'Delete Key',
(t('sessions.delete_key_confirm') || 'Delete key') + ' "' + key + '"? This cannot be undone.',
async function() {
try {
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
OpenFangToast.success('Key "' + key + '" deleted');
await self.loadKv();
} catch(e) {
OpenFangToast.error('Failed to delete key: ' + e.message);
}
}
});
);
},
startEdit(kv) {
+37 -29
View File
@@ -34,27 +34,30 @@ function skillsPage() {
mcpServers: [],
mcpLoading: false,
// Category definitions from the OpenClaw ecosystem
categories: [
{ id: 'coding', name: 'Coding & IDEs' },
{ id: 'git', name: 'Git & GitHub' },
{ id: 'web', name: 'Web & Frontend' },
{ id: 'devops', name: 'DevOps & Cloud' },
{ id: 'browser', name: 'Browser & Automation' },
{ id: 'search', name: 'Search & Research' },
{ id: 'ai', name: 'AI & LLMs' },
{ id: 'data', name: 'Data & Analytics' },
{ id: 'productivity', name: 'Productivity' },
{ id: 'communication', name: 'Communication' },
{ id: 'media', name: 'Media & Streaming' },
{ id: 'notes', name: 'Notes & PKM' },
{ id: 'security', name: 'Security' },
{ id: 'cli', name: 'CLI Utilities' },
{ id: 'marketing', name: 'Marketing & Sales' },
{ id: 'finance', name: 'Finance' },
{ id: 'smart-home', name: 'Smart Home & IoT' },
{ id: 'docs', name: 'PDF & Documents' },
],
// Category definitions from the OpenClaw ecosystem (loaded from i18n)
get categories() {
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
return [
{ id: 'coding', name: t('skills.cat_coding') || 'Coding & IDEs' },
{ id: 'git', name: t('skills.cat_git') || 'Git & GitHub' },
{ id: 'web', name: t('skills.cat_frontend') || 'Web & Frontend' },
{ id: 'devops', name: t('skills.cat_devops') || 'DevOps & Cloud' },
{ id: 'browser', name: t('skills.cat_browser') || 'Browser & Automation' },
{ id: 'search', name: t('skills.cat_search') || 'Search & Research' },
{ id: 'ai', name: t('skills.cat_ai') || 'AI & ML' },
{ id: 'data', name: t('skills.cat_data') || 'Data & Analytics' },
{ id: 'productivity', name: t('skills.cat_productivity') || 'Productivity' },
{ id: 'communication', name: t('skills.cat_communication') || 'Communication' },
{ id: 'media', name: t('skills.cat_media') || 'Media & Streaming' },
{ id: 'notes', name: t('skills.cat_notes') || 'Notes & PKM' },
{ id: 'security', name: t('skills.cat_security') || 'Security' },
{ id: 'cli', name: t('skills.cat_cli') || 'CLI Utilities' },
{ id: 'marketing', name: t('skills.cat_marketing') || 'Marketing & Sales' },
{ id: 'finance', name: t('skills.cat_finance') || 'Finance' },
{ id: 'smart-home', name: t('skills.cat_smarthome') || 'Smart Home & IoT' },
{ id: 'docs', name: t('skills.cat_docs') || 'Documentation' },
];
},
runtimeBadge: function(rt) {
var r = (rt || '').toLowerCase();
@@ -264,15 +267,20 @@ function skillsPage() {
// Uninstall
uninstallSkill: function(name) {
var self = this;
OpenFangToast.confirm('Uninstall Skill', 'Uninstall skill "' + name + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
OpenFangToast.success('Skill "' + name + '" uninstalled');
await self.loadSkills();
} catch(e) {
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
OpenFangToast.confirm(
t('skills.uninstall_skill') || 'Uninstall Skill',
t('skills.uninstall_confirm') + ' "' + name + '"? This cannot be undone.',
async function() {
try {
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
OpenFangToast.success('Skill "' + name + '" uninstalled');
await self.loadSkills();
} catch(e) {
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
}
}
});
);
},
// Create prompt-only skill
@@ -191,6 +191,33 @@ function analyticsPage() {
return segments;
},
donutSegmentsSvg() {
var segments = this.donutSegments();
if (!segments.length) return '';
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
var out = [];
for (var i = 0; i < segments.length; i++) {
var seg = segments[i];
var title = seg.provider + ': ' + seg.percent + '% (' + this.formatCost(seg.cost) + ')';
out.push(
'<circle cx="80" cy="80" r="60" fill="none" stroke="' + escapeXml(seg.color) + '" stroke-width="24" stroke-dasharray="' + escapeXml(seg.dasharray) + '" stroke-dashoffset="' + escapeXml(seg.dashoffset) + '" transform="rotate(-90 80 80)" class="donut-segment">' +
'<title>' + escapeXml(title) + '</title>' +
'</circle>'
);
}
return out.join('');
},
// ── Bar chart (last 7 days) ──
barChartData() {
@@ -218,6 +245,42 @@ function analyticsPage() {
return result;
},
barChartSvg() {
var bars = this.barChartData();
if (!bars.length) return '';
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
var out = [];
for (var i = 0; i < bars.length; i++) {
var bar = bars[i];
var x = i * 50 + 18;
var labelX = i * 50 + 30;
var y = 150 - bar.barHeight;
var costLabelY = y - 4;
var title = bar.date + ': ' + this.formatCost(bar.cost) + ' (' + bar.calls + ' calls)';
out.push(
'<g>' +
'<rect x="' + x + '" y="' + y + '" width="24" height="' + bar.barHeight + '" rx="3" fill="var(--accent)" class="cost-bar" style="opacity:0.85">' +
'<title>' + escapeXml(title) + '</title>' +
'</rect>' +
'<text x="' + labelX + '" y="166" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">' + escapeXml(bar.dayName) + '</text>' +
'<text x="' + labelX + '" y="' + costLabelY + '" text-anchor="middle" fill="var(--text-dim)" style="font-size:8px;font-family:var(--font-mono)">' + escapeXml(this.formatCost(bar.cost)) + '</text>' +
'</g>'
);
}
return out.join('');
},
// ── Cost by model table (sorted by cost descending) ──
costByModelSorted() {
+97 -61
View File
@@ -158,15 +158,17 @@ function wizardPage() {
return this.templates.filter(function(t) { return t.category === cat; });
},
// Step 3: Profile/tool descriptions
profileDescriptions: {
minimal: { label: 'Minimal', desc: 'Read-only file access' },
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
research: { label: 'Research', desc: 'Web search + file read/write' },
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
full: { label: 'Full', desc: 'All 35+ tools' }
// Step 3: Profile/tool descriptions (loaded from i18n)
get profileDescriptions() {
return {
minimal: { label: window.i18n ? window.i18n.t('wizard.profile_minimal') : 'Minimal', desc: window.i18n ? window.i18n.t('wizard.profile_minimal_desc') : 'Read-only file access' },
coding: { label: window.i18n ? window.i18n.t('wizard.profile_coding') : 'Coding', desc: window.i18n ? window.i18n.t('wizard.profile_coding_desc') : 'Files + shell + web fetch' },
research: { label: window.i18n ? window.i18n.t('wizard.profile_research') : 'Research', desc: window.i18n ? window.i18n.t('wizard.profile_research_desc') : 'Web search + file read/write' },
balanced: { label: window.i18n ? window.i18n.t('wizard.profile_balanced') : 'Balanced', desc: window.i18n ? window.i18n.t('wizard.profile_balanced_desc') : 'General-purpose tool set' },
precise: { label: window.i18n ? window.i18n.t('wizard.profile_precise') : 'Precise', desc: window.i18n ? window.i18n.t('wizard.profile_precise_desc') : 'Focused tool set for accuracy' },
creative: { label: window.i18n ? window.i18n.t('wizard.profile_creative') : 'Creative', desc: window.i18n ? window.i18n.t('wizard.profile_creative_desc') : 'Full tools with creative emphasis' },
full: { label: window.i18n ? window.i18n.t('wizard.profile_full') : 'Full', desc: window.i18n ? window.i18n.t('wizard.profile_full_desc') : 'All 35+ tools' }
};
},
profileInfo: function(name) { return this.profileDescriptions[name] || { label: name, desc: '' }; },
@@ -174,12 +176,35 @@ function wizardPage() {
tryItMessages: [],
tryItInput: '',
tryItSending: false,
suggestedMessages: {
'General': ['What can you help me with?', 'Tell me a fun fact', 'Summarize the latest AI news'],
'Development': ['Write a Python hello world', 'Explain async/await', 'Review this code snippet'],
'Research': ['Explain quantum computing simply', 'Compare React vs Vue', 'What are the latest trends in AI?'],
'Writing': ['Help me write a professional email', 'Improve this paragraph', 'Write a blog intro about AI'],
'Business': ['Draft a meeting agenda', 'How do I handle a complaint?', 'Create a project status update']
get suggestedMessages() {
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
return {
'General': [
t('wizard.suggestions.general.1') || 'What can you help me with?',
t('wizard.suggestions.general.2') || 'Tell me a fun fact',
t('wizard.suggestions.general.3') || 'Summarize the latest AI news'
],
'Development': [
t('wizard.suggestions.development.1') || 'Write a Python hello world',
t('wizard.suggestions.development.2') || 'Explain async/await',
t('wizard.suggestions.development.3') || 'Review this code snippet'
],
'Research': [
t('wizard.suggestions.research.1') || 'Explain quantum computing simply',
t('wizard.suggestions.research.2') || 'Compare React vs Vue',
t('wizard.suggestions.research.3') || 'What are the latest trends in AI?'
],
'Writing': [
t('wizard.suggestions.writing.1') || 'Help me write a professional email',
t('wizard.suggestions.writing.2') || 'Improve this paragraph',
t('wizard.suggestions.writing.3') || 'Write a blog intro about AI'
],
'Business': [
t('wizard.suggestions.business.1') || 'Draft a meeting agenda',
t('wizard.suggestions.business.2') || 'How do I handle a complaint?',
t('wizard.suggestions.business.3') || 'Create a project status update'
]
};
},
get currentSuggestions() {
var tpl = this.templates[this.selectedTemplate];
@@ -204,38 +229,41 @@ function wizardPage() {
// Step 5: Channel setup (optional)
channelType: '',
channelOptions: [
{
name: 'telegram',
display_name: 'Telegram',
icon: 'TG',
description: 'Connect your agent to a Telegram bot for messaging.',
token_label: 'Bot Token',
token_placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
token_env: 'TELEGRAM_BOT_TOKEN',
help: 'Create a bot via @BotFather on Telegram to get your token.'
},
{
name: 'discord',
display_name: 'Discord',
icon: 'DC',
description: 'Connect your agent to a Discord server via bot token.',
token_label: 'Bot Token',
token_placeholder: 'MTIz...abc',
token_env: 'DISCORD_BOT_TOKEN',
help: 'Create a Discord application at discord.com/developers and add a bot.'
},
{
name: 'slack',
display_name: 'Slack',
icon: 'SL',
description: 'Connect your agent to a Slack workspace.',
token_label: 'Bot Token',
token_placeholder: 'xoxb-...',
token_env: 'SLACK_BOT_TOKEN',
help: 'Create a Slack app at api.slack.com/apps and install it to your workspace.'
}
],
get channelOptions() {
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
return [
{
name: 'telegram',
display_name: t('wizard.channel_telegram') || 'Telegram',
icon: 'TG',
description: t('wizard.channel_telegram_desc') || 'Connect your agent to a Telegram bot for messaging.',
token_label: t('wizard.channel_telegram_token') || 'Bot Token',
token_placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
token_env: 'TELEGRAM_BOT_TOKEN',
help: t('wizard.channel_telegram_help') || 'Create a bot via @BotFather on Telegram to get your token.'
},
{
name: 'discord',
display_name: t('wizard.channel_discord') || 'Discord',
icon: 'DC',
description: t('wizard.channel_discord_desc') || 'Connect your agent to a Discord server via bot token.',
token_label: t('wizard.channel_discord_token') || 'Bot Token',
token_placeholder: 'MTIz...abc',
token_env: 'DISCORD_BOT_TOKEN',
help: t('wizard.channel_discord_help') || 'Create a Discord application at discord.com/developers and add a bot.'
},
{
name: 'slack',
display_name: t('wizard.channel_slack') || 'Slack',
icon: 'SL',
description: t('wizard.channel_slack_desc') || 'Connect your agent to a Slack workspace.',
token_label: t('wizard.channel_slack_token') || 'Bot Token',
token_placeholder: 'xoxb-...',
token_env: 'SLACK_BOT_TOKEN',
help: t('wizard.channel_slack_help') || 'Create a Slack app at api.slack.com/apps and install it to your workspace.'
}
];
},
channelToken: '',
configuringChannel: false,
channelConfigured: false,
@@ -297,7 +325,15 @@ function wizardPage() {
},
stepLabel(n) {
var labels = ['Welcome', 'Provider', 'Agent', 'Try It', 'Channel', 'Done'];
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
var labels = [
t('wizard.step_welcome') || 'Welcome',
t('wizard.step_provider') || 'Provider',
t('wizard.step_agent') || 'Agent',
t('wizard.step_try_it') || 'Try It',
t('wizard.step_channel') || 'Channel',
t('wizard.step_done') || 'Done'
];
return labels[n - 1] || '';
},
@@ -382,7 +418,7 @@ function wizardPage() {
if (!provider) return;
var key = this.apiKeyInput.trim();
if (!key) {
OpenFangToast.error('Please enter an API key');
OpenFangToast.error(window.i18n ? window.i18n.t('wizard.enter_api_key') : 'Please enter an API key');
return;
}
this.savingKey = true;
@@ -391,12 +427,12 @@ function wizardPage() {
this.apiKeyInput = '';
this.keySaved = true;
this.setupSummary.provider = provider.display_name;
OpenFangToast.success('API key saved for ' + provider.display_name);
OpenFangToast.success((window.i18n ? window.i18n.t('wizard.api_key_saved') : 'API key saved for') + ' ' + provider.display_name);
await this.loadProviders();
// Auto-test after saving
await this.testKey();
} catch(e) {
OpenFangToast.error('Failed to save key: ' + e.message);
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_save_key') : 'Failed to save key:') + ' ' + e.message);
}
this.savingKey = false;
},
@@ -410,13 +446,13 @@ function wizardPage() {
var result = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/test', {});
this.testResult = result;
if (result.status === 'ok') {
OpenFangToast.success(provider.display_name + ' connected (' + (result.latency_ms || '?') + 'ms)');
OpenFangToast.success(provider.display_name + ' ' + (window.i18n ? window.i18n.t('wizard.connected') : 'connected') + ' (' + (result.latency_ms || '?') + 'ms)');
} else {
OpenFangToast.error(provider.display_name + ': ' + (result.error || 'Connection failed'));
OpenFangToast.error(provider.display_name + ': ' + (result.error || (window.i18n ? window.i18n.t('wizard.connection_failed') : 'Connection failed')));
}
} catch(e) {
this.testResult = { status: 'error', error: e.message };
OpenFangToast.error('Test failed: ' + e.message);
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.test_failed') : 'Test failed:') + ' ' + e.message);
}
this.testingProvider = false;
},
@@ -458,7 +494,7 @@ function wizardPage() {
if (!tpl) return;
var name = this.agentName.trim();
if (!name) {
OpenFangToast.error('Please enter a name for your agent');
OpenFangToast.error(window.i18n ? window.i18n.t('wizard.enter_agent_name') : 'Please enter a name for your agent');
return;
}
@@ -485,13 +521,13 @@ function wizardPage() {
if (res.agent_id) {
this.createdAgent = { id: res.agent_id, name: res.name || name };
this.setupSummary.agent = res.name || name;
OpenFangToast.success('Agent "' + (res.name || name) + '" created');
OpenFangToast.success((window.i18n ? window.i18n.t('wizard.agent_created') : 'Agent') + ' "' + (res.name || name) + '" ' + (window.i18n ? window.i18n.t('wizard.agent_created_suffix') || 'created' : 'created'));
await Alpine.store('app').refreshAgents();
} else {
OpenFangToast.error('Failed: ' + (res.error || 'Unknown error'));
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_create_agent') : 'Failed:') + ' ' + (res.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Failed to create agent: ' + e.message);
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_create_agent') : 'Failed to create agent:') + ' ' + e.message);
}
this.creatingAgent = false;
},
@@ -538,7 +574,7 @@ function wizardPage() {
if (!ch) return;
var token = this.channelToken.trim();
if (!token) {
OpenFangToast.error('Please enter the ' + ch.token_label);
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.enter_token') : 'Please enter the') + ' ' + ch.token_label);
return;
}
this.configuringChannel = true;
@@ -549,9 +585,9 @@ function wizardPage() {
await OpenFangAPI.post('/api/channels/' + ch.name + '/configure', { fields: fields });
this.channelConfigured = true;
this.setupSummary.channel = ch.display_name;
OpenFangToast.success(ch.display_name + ' configured and activated.');
OpenFangToast.success(ch.display_name + ' ' + (window.i18n ? window.i18n.t('wizard.channel_configured') : 'configured and activated.'));
} catch(e) {
OpenFangToast.error('Failed: ' + (e.message || 'Unknown error'));
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_configure') : 'Failed:') + ' ' + (e.message || 'Unknown error'));
}
this.configuringChannel = false;
},
@@ -314,6 +314,118 @@ 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() {
+29 -10
View File
@@ -220,6 +220,13 @@ pub trait ChannelBridgeHandle: Send + Sync {
None
}
/// Get channel IDs that respond without requiring @mention (free response mode).
///
/// Returns an empty vector if the channel type is not configured or has no free response channels.
async fn free_response_channels(&self, _channel_type: &str) -> Vec<String> {
Vec::new()
}
/// Record a delivery result for tracking (optional — default no-op).
///
/// `thread_id` preserves Telegram forum-topic context so cron/workflow
@@ -517,6 +524,7 @@ fn default_output_format_for_channel(channel_type: &str) -> OutputFormat {
"telegram" => OutputFormat::TelegramHtml,
"slack" => OutputFormat::SlackMrkdwn,
"wecom" => OutputFormat::PlainText,
"signal" => OutputFormat::PlainText,
_ => OutputFormat::Markdown,
}
}
@@ -659,16 +667,23 @@ async fn dispatch_message(
}
}
GroupPolicy::MentionOnly => {
// Only allow messages where the bot was @mentioned or commands.
let was_mentioned = message
.metadata
.get("was_mentioned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_command = matches!(&message.content, ChannelContent::Command { .. });
if !was_mentioned && !is_command {
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
return;
// Check if this channel is in the free_response list - if so, allow all messages
let free_channels = handle.free_response_channels(ct_str).await;
let channel_id = &message.sender.platform_id;
let is_free_channel = free_channels.iter().any(|id| id == channel_id);
if !is_free_channel {
// Only allow messages where the bot was @mentioned or commands.
let was_mentioned = message
.metadata
.get("was_mentioned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_command = matches!(&message.content, ChannelContent::Command { .. });
if !was_mentioned && !is_command {
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
return;
}
}
}
GroupPolicy::All => {}
@@ -1932,6 +1947,10 @@ mod tests {
default_output_format_for_channel("discord"),
OutputFormat::Markdown
);
assert_eq!(
default_output_format_for_channel("signal"),
OutputFormat::PlainText
)
}
#[tokio::test]
+135 -7
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,6 +35,18 @@ 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.
@@ -191,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 {
@@ -201,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;
@@ -239,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);
}
@@ -248,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();
@@ -284,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(),
))
@@ -350,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 => {
@@ -385,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;
}
@@ -889,6 +992,31 @@ mod tests {
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(
+1 -1
View File
@@ -48,8 +48,8 @@ pub mod discourse;
pub mod gitter;
pub mod gotify;
pub mod linkedin;
pub mod mumble;
pub mod mqtt;
pub mod mumble;
pub mod ntfy;
pub mod webhook;
pub mod wecom;
+3 -5
View File
@@ -108,7 +108,7 @@ impl LineAdapter {
diff |= a ^ b;
}
if diff != 0 {
let computed = base64::engine::general_purpose::STANDARD.encode(&result);
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!(
"{}...{}",
@@ -381,8 +381,7 @@ 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::body::Bytes| {
move |headers: axum::http::HeaderMap, body: axum::body::Bytes| {
let secret = Arc::clone(&secret);
let tx = Arc::clone(&tx);
async move {
@@ -404,8 +403,7 @@ impl ChannelAdapter for LineAdapter {
shutdown_rx: watch::channel(false).1,
};
if !signature.is_empty()
&& !adapter.verify_signature(&body, signature)
if !signature.is_empty() && !adapter.verify_signature(&body, signature)
{
warn!("LINE: invalid webhook signature");
return axum::http::StatusCode::UNAUTHORIZED;
+6 -2
View File
@@ -152,7 +152,10 @@ impl MqttAdapter {
}
/// Parse host:port string.
fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16), Box<dyn std::error::Error>> {
fn parse_host_port(
s: &str,
default_port: u16,
) -> Result<(String, u16), Box<dyn std::error::Error>> {
let s = s.trim();
if let Some(colon_pos) = s.rfind(':') {
let host = s[..colon_pos].to_string();
@@ -239,7 +242,8 @@ impl ChannelAdapter for MqttAdapter {
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>> {
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let options = self.build_mqtt_options()?;
let (client, mut eventloop) = AsyncClient::new(options, 10);
+1 -1
View File
@@ -260,7 +260,7 @@ impl ChannelAdapter for NextcloudAdapter {
// Use lookIntoFuture=1 and lastKnownMessageId for incremental polling
let url = format!(
"{}/ocs/v2.php/apps/spreed/api/v4/room/{}/chat?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
"{}/ocs/v2.php/apps/spreed/api/v1/chat/{}?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
server_url, room_token, last_id
);
+4
View File
@@ -25,6 +25,8 @@ const MAX_BACKOFF: Duration = Duration::from_secs(60);
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
/// Telegram long-polling timeout (seconds) — sent as the `timeout` parameter to getUpdates.
const LONG_POLL_TIMEOUT: u64 = 30;
/// Bound startup control-plane calls so a flaky Local Bot API cannot block daemon boot forever.
const STARTUP_API_TIMEOUT: Duration = Duration::from_secs(10);
/// Default Telegram Bot API base URL.
const DEFAULT_API_URL: &str = "https://api.telegram.org";
@@ -120,6 +122,7 @@ impl TelegramAdapter {
let resp: serde_json::Value = self
.client
.post(&url)
.timeout(STARTUP_API_TIMEOUT)
.json(&serde_json::json!({ "commands": commands }))
.send()
.await?
@@ -468,6 +471,7 @@ impl ChannelAdapter for TelegramAdapter {
.client
.post(&delete_url)
.json(&serde_json::json!({"drop_pending_updates": true}))
.timeout(STARTUP_API_TIMEOUT)
.send()
.await
{
+93 -9
View File
@@ -237,6 +237,9 @@ enum Commands {
#[arg(long)]
json: bool,
},
/// Dashboard authentication [*].
#[command(subcommand)]
Auth(AuthCommands),
/// Security tools and audit trail [*].
#[command(subcommand)]
Security(SecurityCommands),
@@ -403,6 +406,9 @@ enum HandCommands {
Activate {
/// Hand ID (e.g. "clip", "lead", "researcher").
id: String,
/// Optional instance name. Required to run multiple instances of the same hand.
#[arg(long, short = 'n')]
name: Option<String>,
},
/// Deactivate an active hand instance.
Deactivate {
@@ -679,6 +685,12 @@ enum CronCommands {
},
}
#[derive(Subcommand)]
enum AuthCommands {
/// Generate an Argon2id password hash for dashboard authentication.
HashPassword,
}
#[derive(Subcommand)]
enum SecurityCommands {
/// Show security status summary.
@@ -991,7 +1003,7 @@ fn main() {
HandCommands::List => cmd_hand_list(),
HandCommands::Active => cmd_hand_active(),
HandCommands::Install { path } => cmd_hand_install(&path),
HandCommands::Activate { id } => cmd_hand_activate(&id),
HandCommands::Activate { id, name } => cmd_hand_activate(&id, name),
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
HandCommands::Info { id } => cmd_hand_info(&id),
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
@@ -1057,6 +1069,9 @@ fn main() {
Some(Commands::Sessions { agent, json }) => cmd_sessions(agent.as_deref(), json),
Some(Commands::Logs { lines, follow }) => cmd_logs(lines, follow),
Some(Commands::Health { json }) => cmd_health(json),
Some(Commands::Auth(sub)) => match sub {
AuthCommands::HashPassword => cmd_auth_hash_password(),
},
Some(Commands::Security(sub)) => match sub {
SecurityCommands::Status { json } => cmd_security_status(json),
SecurityCommands::Audit { limit, json } => cmd_security_audit(limit, json),
@@ -2442,6 +2457,18 @@ decay_rate = 0.05
}
}
// Check GitHub Copilot auth (separate from env var checks)
{
let openfang_dir = cli_openfang_home();
if openfang_runtime::drivers::copilot::copilot_auth_available(&openfang_dir) {
any_key_set = true;
if !json {
ui::check_ok("GitHub Copilot (authenticated via device flow)");
}
checks.push(serde_json::json!({"check": "provider", "name": "GitHub Copilot", "status": "ok"}));
}
}
if !any_key_set {
if !json {
println!();
@@ -4356,23 +4383,37 @@ fn cmd_hand_active() {
}
}
fn cmd_hand_activate(id: &str) {
fn cmd_hand_activate(id: &str, name: Option<String>) {
let base = require_daemon("hand activate");
let client = daemon_client();
let request_body = match &name {
Some(n) => serde_json::json!({ "instance_name": n }).to_string(),
None => "{}".to_string(),
};
let body = daemon_json(
client
.post(format!("{base}/api/hands/{id}/activate"))
.header("content-type", "application/json")
.body("{}")
.body(request_body)
.send(),
);
if body.get("instance_id").is_some() {
println!(
"Hand '{}' activated (instance: {}, agent: {})",
id,
body["instance_id"].as_str().unwrap_or("?"),
body["agent_name"].as_str().unwrap_or("?"),
);
if let Some(n) = &name {
println!(
"Hand '{}' activated (instance: {}, name: {}, agent: {})",
id,
body["instance_id"].as_str().unwrap_or("?"),
n,
body["agent_name"].as_str().unwrap_or("?"),
);
} else {
println!(
"Hand '{}' activated (instance: {}, agent: {})",
id,
body["instance_id"].as_str().unwrap_or("?"),
body["agent_name"].as_str().unwrap_or("?"),
);
}
} else {
eprintln!(
"Failed to activate hand '{}': {}",
@@ -4940,6 +4981,27 @@ fn cmd_config_unset(key: &str) {
}
fn cmd_config_set_key(provider: &str) {
// GitHub Copilot uses OAuth device flow, not a simple API key paste.
if provider == "github-copilot" || provider == "copilot" {
let openfang_dir = cli_openfang_home();
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
ui::error(&format!("Failed to create async runtime: {e}"));
std::process::exit(1);
});
match rt.block_on(openfang_runtime::drivers::copilot::run_interactive_setup(&openfang_dir)) {
Ok(_) => {
ui::success("GitHub Copilot configured successfully");
ui::hint("Restart the daemon: openfang stop && openfang start");
}
Err(e) => {
ui::error(&format!("Copilot setup failed: {e}"));
ui::hint("Check your Client ID/Secret and try again");
std::process::exit(1);
}
}
return;
}
let env_var = provider_to_env_var(provider);
let key = prompt_input(&format!(" Paste your {provider} API key: "));
@@ -5985,6 +6047,28 @@ fn cmd_health(json: bool) {
}
}
fn cmd_auth_hash_password() {
let password = prompt_input("Enter password: ");
if password.is_empty() {
ui::error("Empty password.");
std::process::exit(1);
}
let confirm = prompt_input("Confirm password: ");
if password != confirm {
ui::error("Passwords do not match.");
std::process::exit(1);
}
let hash = openfang_api::session_auth::hash_password(&password);
println!();
ui::success("Argon2id hash generated. Add this to your config.toml:");
println!();
println!(" [auth]");
println!(" enabled = true");
println!(" password_hash = \"{}\"", hash);
println!();
ui::hint("Restart the daemon after updating config.toml");
}
fn cmd_security_status(json: bool) {
let base = require_daemon("security status");
let client = daemon_client();
+12 -3
View File
@@ -2226,13 +2226,22 @@ pub fn spawn_fetch_active_hands(backend: BackendRef, tx: mpsc::Sender<AppEvent>)
}
/// Activate a hand.
pub fn spawn_activate_hand(backend: BackendRef, hand_id: String, tx: mpsc::Sender<AppEvent>) {
pub fn spawn_activate_hand(
backend: BackendRef,
hand_id: String,
instance_name: Option<String>,
tx: mpsc::Sender<AppEvent>,
) {
std::thread::spawn(move || match backend {
BackendRef::Daemon(base_url) => {
let client = daemon_client();
let payload = match &instance_name {
Some(n) => serde_json::json!({ "instance_name": n }),
None => serde_json::json!({}),
};
match client
.post(format!("{base_url}/api/hands/{hand_id}/activate"))
.json(&serde_json::json!({}))
.json(&payload)
.send()
{
Ok(resp) if resp.status().is_success() => {
@@ -2252,7 +2261,7 @@ pub fn spawn_activate_hand(backend: BackendRef, hand_id: String, tx: mpsc::Sende
}
}
BackendRef::InProcess(kernel) => {
match kernel.activate_hand(&hand_id, std::collections::HashMap::new()) {
match kernel.activate_hand(&hand_id, std::collections::HashMap::new(), instance_name) {
Ok(_) => {
let _ = tx.send(AppEvent::HandActivated(hand_id));
}
+7 -2
View File
@@ -1598,9 +1598,14 @@ impl App {
event::spawn_fetch_active_hands(backend, self.event_tx.clone());
}
}
hands::HandsAction::ActivateHand(hand_id) => {
hands::HandsAction::ActivateHand(hand_id, instance_name) => {
if let Some(backend) = self.backend.to_ref() {
event::spawn_activate_hand(backend, hand_id, self.event_tx.clone());
event::spawn_activate_hand(
backend,
hand_id,
instance_name,
self.event_tx.clone(),
);
}
}
hands::HandsAction::DeactivateHand(instance_id) => {
+5 -2
View File
@@ -55,7 +55,9 @@ pub enum HandsAction {
Continue,
RefreshDefinitions,
RefreshActive,
ActivateHand(String),
/// Activate a hand. Second field is the optional instance name.
/// TODO: add text-input modal for custom instance names (#878 follow-up).
ActivateHand(String, Option<String>),
DeactivateHand(String),
PauseHand(String),
ResumeHand(String),
@@ -124,7 +126,8 @@ impl HandsState {
KeyCode::Enter | KeyCode::Char('a') => {
if let Some(sel) = self.marketplace_list.selected() {
if sel < self.definitions.len() {
return HandsAction::ActivateHand(self.definitions[sel].id.clone());
// TODO: add text-input modal for custom instance names (#878 follow-up)
return HandsAction::ActivateHand(self.definitions[sel].id.clone(), None);
}
}
}
@@ -159,10 +159,10 @@ const PROVIDERS: &[ProviderInfo] = &[
ProviderInfo {
name: "github-copilot",
display: "GitHub Copilot",
env_var: "GITHUB_TOKEN",
default_model: "gpt-4o",
needs_key: true,
hint: "via PAT",
env_var: "",
default_model: "claude-sonnet-4.6",
needs_key: false, // Auth handled via OAuth device flow after init
hint: "free with subscription",
},
ProviderInfo {
name: "replicate",
@@ -257,6 +257,7 @@ enum Step {
Welcome,
Migration,
Provider,
CopilotAuth,
ApiKey,
Model,
Routing,
@@ -288,6 +289,26 @@ enum KeyTestState {
Warn,
}
#[derive(Clone, PartialEq, Eq)]
enum CopilotAuthStatus {
/// Requesting device code from GitHub.
Starting,
/// Waiting for user to authorize in browser.
WaitingForUser,
/// Authorized, fetching models.
FetchingModels,
/// Done — models loaded.
Done,
/// Error.
Failed(String),
}
enum CopilotAuthEvent {
DeviceCode { user_code: String, verification_uri: String },
Authenticated,
Models(Vec<String>),
}
/// A model entry for list display.
struct ModelEntry {
id: String,
@@ -348,6 +369,11 @@ struct State {
daemon_url: String,
daemon_error: String,
saving_done: bool,
// Copilot auth
copilot_user_code: String,
copilot_verification_uri: String,
copilot_auth_status: CopilotAuthStatus,
save_error: String,
}
@@ -386,6 +412,9 @@ impl State {
daemon_error: String::new(),
saving_done: false,
save_error: String::new(),
copilot_user_code: String::new(),
copilot_verification_uri: String::new(),
copilot_auth_status: CopilotAuthStatus::Starting,
};
s.build_provider_order();
s.provider_list.select(Some(0));
@@ -431,6 +460,7 @@ impl State {
Step::Welcome => "1 of 7",
Step::Migration => "2 of 7",
Step::Provider => "3 of 7",
Step::CopilotAuth => "4 of 7",
Step::ApiKey => "4 of 7",
Step::Model => "5 of 7",
Step::Routing => "6 of 7",
@@ -610,12 +640,50 @@ pub fn run() -> InitResult {
let (test_tx, test_rx) = std::sync::mpsc::channel::<bool>();
let (migrate_tx, migrate_rx) =
std::sync::mpsc::channel::<Result<openfang_migrate::report::MigrationReport, String>>();
let (copilot_tx, copilot_rx) =
std::sync::mpsc::channel::<Result<CopilotAuthEvent, String>>();
let result = loop {
terminal
.draw(|f| draw(f, f.area(), &mut state))
.expect("draw failed");
// Check for Copilot auth events
if state.step == Step::CopilotAuth {
while let Ok(event) = copilot_rx.try_recv() {
match event {
Ok(CopilotAuthEvent::DeviceCode { user_code, verification_uri }) => {
state.copilot_user_code = user_code;
state.copilot_verification_uri = verification_uri;
state.copilot_auth_status = CopilotAuthStatus::WaitingForUser;
}
Ok(CopilotAuthEvent::Authenticated) => {
state.copilot_auth_status = CopilotAuthStatus::FetchingModels;
}
Ok(CopilotAuthEvent::Models(models)) => {
state.copilot_auth_status = CopilotAuthStatus::Done;
state.model_entries.clear();
for model_id in &models {
state.model_entries.push(ModelEntry {
id: model_id.clone(),
display_name: model_id.clone(),
tier: "copilot",
cost: "free".to_string(),
});
}
if !state.model_entries.is_empty() {
state.model_list.select(Some(0));
}
// Auto-advance to model picker
state.step = Step::Model;
}
Err(e) => {
state.copilot_auth_status = CopilotAuthStatus::Failed(e);
}
}
}
}
// Check for background key-test result
if state.key_test == KeyTestState::Testing {
if let Ok(ok) = test_rx.try_recv() {
@@ -750,7 +818,79 @@ pub fn run() -> InitResult {
state.selected_provider = Some(prov_idx);
let p = &PROVIDERS[prov_idx];
if !p.needs_key {
if p.name == "github-copilot" {
// Start Copilot device flow in background
state.copilot_auth_status = CopilotAuthStatus::Starting;
state.api_key_from_env = false;
state.step = Step::CopilotAuth;
// Kick off background auth
let copilot_tx = copilot_tx.clone();
std::thread::spawn(move || {
let openfang_dir = crate::cli_openfang_home();
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
let _ = copilot_tx.send(Err(format!("Runtime error: {e}")));
return;
}
};
rt.block_on(async {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("HTTP error: {e}"));
let http = match http {
Ok(h) => h,
Err(e) => { let _ = copilot_tx.send(Err(e)); return; }
};
// Step 1: request device code
use openfang_runtime::drivers::copilot;
let device = match copilot::request_device_code(&http).await {
Ok(d) => d,
Err(e) => { let _ = copilot_tx.send(Err(e)); return; }
};
// Send device code to TUI for display
let _ = copilot_tx.send(Ok(CopilotAuthEvent::DeviceCode {
user_code: device.user_code.clone(),
verification_uri: device.verification_uri.clone(),
}));
// Browser will be opened by user pressing Enter in TUI
// Step 2: poll for token
let tokens = match copilot::poll_for_token(
&http,
&device.device_code,
device.interval,
).await {
Ok(t) => t,
Err(e) => { let _ = copilot_tx.send(Err(e)); return; }
};
// Save tokens
if let Err(e) = tokens.save(&openfang_dir) {
let _ = copilot_tx.send(Err(e));
return;
}
let _ = copilot_tx.send(Ok(CopilotAuthEvent::Authenticated));
// Step 3: fetch models
let ct = match copilot::exchange_copilot_token(&http, &tokens.access_token).await {
Ok(ct) => ct,
Err(e) => { let _ = copilot_tx.send(Err(format!("Token exchange: {e}"))); return; }
};
match copilot::fetch_models(&http, &ct.base_url, &ct.token).await {
Ok(models) => { let _ = copilot_tx.send(Ok(CopilotAuthEvent::Models(models))); }
Err(e) => { let _ = copilot_tx.send(Err(format!("Model fetch: {e}"))); }
}
});
});
} else if !p.needs_key {
state.api_key_from_env = false;
state.load_models_for_provider();
state.step = Step::Model;
@@ -769,6 +909,24 @@ pub fn run() -> InitResult {
_ => {}
},
Step::CopilotAuth => match key.code {
KeyCode::Esc => {
if matches!(state.copilot_auth_status, CopilotAuthStatus::Failed(_)) {
state.step = Step::Provider;
}
}
KeyCode::Enter => {
if matches!(state.copilot_auth_status, CopilotAuthStatus::WaitingForUser) {
if !state.copilot_verification_uri.is_empty() {
let _ = openfang_runtime::drivers::copilot::open_verification_url(
&state.copilot_verification_uri,
);
}
}
}
_ => {}
},
Step::ApiKey => {
if matches!(state.key_test, KeyTestState::Ok | KeyTestState::Warn) {
continue;
@@ -1242,6 +1400,7 @@ fn draw(f: &mut Frame, area: Rect, state: &mut State) {
Step::Welcome => draw_welcome(f, chunks[3]),
Step::Migration => draw_migration(f, chunks[3], state),
Step::Provider => draw_provider(f, chunks[3], state),
Step::CopilotAuth => draw_copilot_auth(f, chunks[3], state),
Step::ApiKey => draw_api_key(f, chunks[3], state),
Step::Model => draw_model(f, chunks[3], state),
Step::Routing => draw_routing(f, chunks[3], state),
@@ -1743,6 +1902,12 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
} else {
"no API key needed".to_string()
}
} else if p.name == "github-copilot" {
if detected {
format!("{} detected", p.env_var)
} else {
"run set-key after init".to_string()
}
} else if detected {
format!("{} detected", p.env_var)
} else if !p.needs_key {
@@ -1772,6 +1937,109 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
f.render_widget(hints, chunks[2]);
}
fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
let chunks = Layout::vertical([
Constraint::Length(2), // title
Constraint::Length(1), // blank
Constraint::Length(1), // status line 1
Constraint::Length(1), // status line 2
Constraint::Length(1), // blank
Constraint::Length(1), // code label
Constraint::Length(1), // code value
Constraint::Length(1), // blank
Constraint::Length(1), // url
Constraint::Min(0), // spacer
Constraint::Length(1), // hint
])
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled(" GitHub Copilot Authentication", Style::default().fg(theme::ACCENT)),
]));
f.render_widget(title, chunks[0]);
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
match &state.copilot_auth_status {
CopilotAuthStatus::Starting => {
let line = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
Span::raw(" Requesting device code..."),
]));
f.render_widget(line, chunks[2]);
}
CopilotAuthStatus::WaitingForUser => {
let line1 = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
Span::raw(" Waiting for authorization..."),
]));
f.render_widget(line1, chunks[2]);
let code_label = Paragraph::new(Line::from(vec![
Span::raw(" Enter this code:"),
]));
f.render_widget(code_label, chunks[5]);
let code_value = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(
&state.copilot_user_code,
Style::default()
.fg(theme::GREEN)
.add_modifier(Modifier::BOLD),
),
]));
f.render_widget(code_value, chunks[6]);
let url = Paragraph::new(Line::from(vec![
Span::raw(" at "),
Span::styled(&state.copilot_verification_uri, theme::dim_style()),
]));
f.render_widget(url, chunks[8]);
let hint = Paragraph::new(Line::from(vec![
Span::styled(" [Enter] Open browser", theme::dim_style()),
]));
f.render_widget(hint, chunks[10]);
}
CopilotAuthStatus::FetchingModels => {
let line = Paragraph::new(Line::from(vec![
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
Span::raw("Authenticated"),
]));
f.render_widget(line, chunks[2]);
let line2 = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
Span::raw(" Fetching available models..."),
]));
f.render_widget(line2, chunks[3]);
}
CopilotAuthStatus::Done => {
let line = Paragraph::new(Line::from(vec![
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
Span::raw("Models loaded"),
]));
f.render_widget(line, chunks[2]);
}
CopilotAuthStatus::Failed(err) => {
let line = Paragraph::new(Line::from(vec![
Span::styled(" \u{2718} ", Style::default().fg(theme::RED)),
Span::raw(err.as_str()),
]));
f.render_widget(line, chunks[2]);
let hint = Paragraph::new(Line::from(vec![
Span::styled(" Esc to go back", theme::dim_style()),
]));
f.render_widget(hint, chunks[10]);
}
}
}
fn draw_api_key(f: &mut Frame, area: Rect, state: &mut State) {
let p = match state.provider() {
Some(p) => p,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenFang",
"version": "0.1.0",
"version": "0.5.8",
"identifier": "ai.openfang.desktop",
"build": {},
"app": {
+1
View File
@@ -15,6 +15,7 @@ tracing = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
dashmap = { workspace = true }
dirs = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+39 -1
View File
@@ -330,6 +330,31 @@ pub fn parse_hand_toml(content: &str) -> Result<HandDefinition, toml::de::Error>
Ok(wrapper.hand)
}
/// Recursively copy a directory and all its contents.
///
/// Used by `HandRegistry::install_from_path` to persist a custom hand's
/// source directory into `~/.openfang/hands/<hand_id>/` so installed hands
/// survive daemon restarts (issue #984).
pub(crate) fn copy_dir_all(
src: impl AsRef<std::path::Path>,
dst: impl AsRef<std::path::Path>,
) -> std::io::Result<()> {
let src = src.as_ref();
let dst = dst.as_ref();
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_all(entry.path(), &dst_path)?;
} else {
std::fs::copy(entry.path(), &dst_path)?;
}
}
Ok(())
}
/// Complete Hand definition — parsed from HAND.toml.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandDefinition {
@@ -396,6 +421,12 @@ pub struct HandInstance {
pub instance_id: Uuid,
/// Which hand definition this is an instance of.
pub hand_id: String,
/// Optional user-supplied instance label. When set, multiple instances of
/// the same hand can coexist as long as each (hand_id, instance_name) pair
/// is unique. When `None`, the legacy single-instance-per-hand rule
/// applies.
#[serde(default)]
pub instance_name: Option<String>,
/// Current status.
pub status: HandStatus,
/// The agent that was spawned for this hand.
@@ -416,11 +447,13 @@ impl HandInstance {
hand_id: &str,
agent_name: &str,
config: HashMap<String, serde_json::Value>,
instance_name: Option<String>,
) -> Self {
let now = Utc::now();
Self {
instance_id: Uuid::new_v4(),
hand_id: hand_id.to_string(),
instance_name,
status: HandStatus::Active,
agent_id: None,
agent_name: agent_name.to_string(),
@@ -437,6 +470,10 @@ pub struct ActivateHandRequest {
/// Optional configuration overrides.
#[serde(default)]
pub config: HashMap<String, serde_json::Value>,
/// Optional unique instance label. Allows multiple instances of the same
/// hand to coexist as long as each name is distinct.
#[serde(default)]
pub instance_name: Option<String>,
}
#[cfg(test)]
@@ -462,11 +499,12 @@ mod tests {
#[test]
fn hand_instance_new() {
let instance = HandInstance::new("clip", "clip-hand", HashMap::new());
let instance = HandInstance::new("clip", "clip-hand", HashMap::new(), None);
assert_eq!(instance.hand_id, "clip");
assert_eq!(instance.agent_name, "clip-hand");
assert_eq!(instance.status, HandStatus::Active);
assert!(instance.agent_id.is_none());
assert!(instance.instance_name.is_none());
}
#[test]
+261 -14
View File
@@ -124,6 +124,67 @@ impl HandRegistry {
count
}
/// Scan a directory for custom hand definitions and load them into the
/// registry. Mirrors `SkillRegistry::load_workspace_skills` — each
/// subdirectory containing a `HAND.toml` is treated as a hand, with an
/// optional sibling `SKILL.md` attached as the skill content.
///
/// Parse failures on individual hands are logged and skipped so a single
/// bad manifest cannot take down the whole registry.
///
/// Returns the number of hands successfully loaded. A non-existent
/// `hands_dir` returns `Ok(0)` — this is the normal case on a fresh
/// install where the user has not run `openfang hand install` yet.
///
/// Added for issue #984 — custom hands installed via `openfang hand
/// install <path>` were only held in memory and lost on daemon restart.
pub fn load_workspace_hands(&self, hands_dir: &std::path::Path) -> HandResult<usize> {
if !hands_dir.exists() {
return Ok(0);
}
let mut count = 0;
let entries = std::fs::read_dir(hands_dir)
.map_err(|e| HandError::Config(format!("read_dir {}: {e}", hands_dir.display())))?;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
warn!(error = %e, "Failed to read hands dir entry, skipping");
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let toml_path = path.join("HAND.toml");
if !toml_path.exists() {
continue;
}
let contents = match std::fs::read_to_string(&toml_path) {
Ok(s) => s,
Err(e) => {
warn!(path = %toml_path.display(), error = %e, "Failed to read HAND.toml, skipping");
continue;
}
};
let skill_path = path.join("SKILL.md");
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
match bundled::parse_bundled("custom", &contents, &skill_content) {
Ok(def) => {
let hand_id = def.id.clone();
info!(hand = %hand_id, path = %path.display(), "Loaded workspace hand");
self.definitions.insert(hand_id, def);
count += 1;
}
Err(e) => {
warn!(path = %toml_path.display(), error = %e, "Invalid HAND.toml, skipping");
}
}
}
Ok(count)
}
/// Install a hand from a directory containing HAND.toml (and optional SKILL.md).
pub fn install_from_path(&self, path: &std::path::Path) -> HandResult<HandDefinition> {
let toml_path = path.join("HAND.toml");
@@ -145,6 +206,33 @@ impl HandRegistry {
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
self.definitions.insert(def.id.clone(), def.clone());
// Persist the hand to the user's data dir so it survives daemon
// restart (issue #984). Best-effort: failures are logged but do not
// abort the install, because the hand is already registered in
// memory and the user gets a working install for the current
// session. On next restart, `load_workspace_hands` will pick it up
// from disk.
if let Some(home) = dirs::home_dir() {
let dest_dir = home.join(".openfang").join("hands").join(&def.id);
// Canonicalize both paths before comparing so we don't re-copy a
// hand that is already being installed from its persistent
// location (e.g. `openfang hand install ~/.openfang/hands/foo`).
let same_path = match (path.canonicalize(), dest_dir.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => path == dest_dir,
};
if !same_path {
if let Err(e) = std::fs::create_dir_all(&dest_dir) {
warn!(error = %e, dest = %dest_dir.display(), "Failed to create hands persistence dir");
} else if let Err(e) = crate::copy_dir_all(path, &dest_dir) {
warn!(error = %e, dest = %dest_dir.display(), "Failed to persist hand");
} else {
info!(hand = %def.id, dest = %dest_dir.display(), "Persisted hand to workspace");
}
}
}
Ok(def)
}
@@ -200,27 +288,42 @@ impl HandRegistry {
}
/// Activate a hand — creates an instance (agent spawning is done by kernel).
///
/// `instance_name` is an optional user-supplied label. When set, multiple
/// instances of the same hand can coexist as long as each
/// (hand_id, instance_name) pair is unique. When `None`, the legacy
/// single-instance-per-hand rule applies — a second unnamed activation of
/// the same hand is rejected.
pub fn activate(
&self,
hand_id: &str,
config: HashMap<String, serde_json::Value>,
instance_name: Option<String>,
) -> HandResult<HandInstance> {
let def = self
.definitions
.get(hand_id)
.ok_or_else(|| HandError::NotFound(hand_id.to_string()))?;
// Check if already active
// Reject only when the exact same (hand_id, instance_name) is already active.
// This lets multiple uniquely-named instances of the same hand coexist.
for entry in self.instances.iter() {
if entry.hand_id == hand_id && entry.status == HandStatus::Active {
return Err(HandError::AlreadyActive(hand_id.to_string()));
if entry.hand_id == hand_id
&& entry.instance_name == instance_name
&& entry.status == HandStatus::Active
{
let label = match &instance_name {
Some(name) => format!("{hand_id} (instance: {name})"),
None => hand_id.to_string(),
};
return Err(HandError::AlreadyActive(label));
}
}
let instance = HandInstance::new(hand_id, &def.agent.name, config);
let instance = HandInstance::new(hand_id, &def.agent.name, config, instance_name.clone());
let id = instance.instance_id;
self.instances.insert(id, instance.clone());
info!(hand = %hand_id, instance = %id, "Hand activated");
info!(hand = %hand_id, instance = %id, instance_name = ?instance_name, "Hand activated");
Ok(instance)
}
@@ -673,7 +776,7 @@ mod tests {
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
assert_eq!(instance.hand_id, "clip");
assert_eq!(instance.status, HandStatus::Active);
@@ -681,7 +784,7 @@ mod tests {
assert_eq!(instances.len(), 1);
// Can't activate again while active
let err = reg.activate("clip", HashMap::new());
let err = reg.activate("clip", HashMap::new(), None);
assert!(err.is_err());
// Deactivate
@@ -695,7 +798,7 @@ mod tests {
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
let id = instance.instance_id;
reg.pause(id).unwrap();
@@ -714,7 +817,7 @@ mod tests {
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
let id = instance.instance_id;
let agent_id = AgentId::new();
@@ -745,7 +848,7 @@ mod tests {
fn not_found_errors() {
let reg = HandRegistry::new();
assert!(reg.get_definition("nonexistent").is_none());
assert!(reg.activate("nonexistent", HashMap::new()).is_err());
assert!(reg.activate("nonexistent", HashMap::new(), None).is_err());
assert!(reg.check_requirements("nonexistent").is_err());
assert!(reg.deactivate(Uuid::new_v4()).is_err());
assert!(reg.pause(Uuid::new_v4()).is_err());
@@ -757,7 +860,7 @@ mod tests {
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
let id = instance.instance_id;
reg.set_error(id, "something broke".to_string()).unwrap();
@@ -830,7 +933,7 @@ mod tests {
reg.load_bundled();
// Lead hand has no requirements — activate it
let instance = reg.activate("lead", HashMap::new()).unwrap();
let instance = reg.activate("lead", HashMap::new(), None).unwrap();
let r = reg.readiness("lead").unwrap();
assert!(r.requirements_met);
assert!(r.active);
@@ -847,7 +950,7 @@ mod tests {
// Browser hand requires python3 (non-optional) + chromium (optional).
// requirements_met only reflects non-optional requirements.
// degraded = active + any requirement (including optional) unsatisfied.
let instance = reg.activate("browser", HashMap::new()).unwrap();
let instance = reg.activate("browser", HashMap::new(), None).unwrap();
let r = reg.readiness("browser").unwrap();
assert!(r.active);
@@ -874,7 +977,7 @@ mod tests {
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("lead", HashMap::new()).unwrap();
let instance = reg.activate("lead", HashMap::new(), None).unwrap();
reg.pause(instance.instance_id).unwrap();
let r = reg.readiness("lead").unwrap();
@@ -897,4 +1000,148 @@ mod tests {
};
assert!(!req.optional);
}
#[test]
fn test_load_workspace_hands_from_directory() {
let tmp = tempfile::tempdir().unwrap();
let hands_dir = tmp.path();
let hand_dir = hands_dir.join("test-custom-hand");
std::fs::create_dir_all(&hand_dir).unwrap();
let hand_toml = r#"
id = "test-custom-hand"
name = "Test Custom Hand"
description = "A custom hand loaded from the workspace directory"
category = "other"
version = "0.1.0"
author = "tester"
[agent]
name = "test-agent"
description = "A test agent"
module = "builtin:chat"
provider = "anthropic"
model = "claude-sonnet-4-20250514"
system_prompt = "You are a test agent."
"#;
std::fs::write(hand_dir.join("HAND.toml"), hand_toml).unwrap();
let registry = HandRegistry::new();
let count = registry.load_workspace_hands(hands_dir).unwrap();
assert_eq!(count, 1);
assert!(registry.get_definition("test-custom-hand").is_some());
let def = registry.get_definition("test-custom-hand").unwrap();
assert_eq!(def.name, "Test Custom Hand");
}
#[test]
fn test_load_workspace_hands_missing_dir_returns_zero() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist");
let registry = HandRegistry::new();
let count = registry.load_workspace_hands(&missing).unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_load_workspace_hands_skips_invalid_toml() {
let tmp = tempfile::tempdir().unwrap();
let hands_dir = tmp.path();
// Valid hand
let good_dir = hands_dir.join("good-hand");
std::fs::create_dir_all(&good_dir).unwrap();
let good_toml = r#"
id = "good-hand"
name = "Good Hand"
description = "..."
category = "other"
[agent]
name = "good-agent"
description = "..."
system_prompt = "You are good."
"#;
std::fs::write(good_dir.join("HAND.toml"), good_toml).unwrap();
// Invalid hand (missing required agent section)
let bad_dir = hands_dir.join("bad-hand");
std::fs::create_dir_all(&bad_dir).unwrap();
std::fs::write(bad_dir.join("HAND.toml"), "not valid toml {[[[").unwrap();
// Directory without HAND.toml — should be silently skipped
let empty_dir = hands_dir.join("empty-dir");
std::fs::create_dir_all(&empty_dir).unwrap();
let registry = HandRegistry::new();
let count = registry.load_workspace_hands(hands_dir).unwrap();
assert_eq!(count, 1, "only the valid hand should load");
assert!(registry.get_definition("good-hand").is_some());
assert!(registry.get_definition("bad-hand").is_none());
}
/// Build a `HandRegistry` pre-populated with a single dummy hand
/// definition that has no requirements — used by the multi-instance
/// activation tests below.
fn test_registry_with_dummy_hand(hand_id: &str) -> HandRegistry {
let toml_str = format!(
r#"
id = "{hand_id}"
name = "Dummy Hand"
description = "A dummy hand for tests"
category = "other"
tools = []
[agent]
name = "dummy-agent"
description = "dummy"
system_prompt = "you are a dummy."
[dashboard]
metrics = []
"#
);
let def = crate::bundled::parse_bundled("dummy", &toml_str, "").unwrap();
let reg = HandRegistry::new();
reg.definitions.insert(def.id.clone(), def);
reg
}
#[test]
fn test_activate_same_hand_twice_with_different_instance_names_succeeds() {
let reg = test_registry_with_dummy_hand("test-hand");
let a = reg
.activate("test-hand", HashMap::new(), Some("instance-a".into()))
.unwrap();
let b = reg
.activate("test-hand", HashMap::new(), Some("instance-b".into()))
.unwrap();
assert_ne!(a.instance_id, b.instance_id);
assert_eq!(a.instance_name, Some("instance-a".into()));
assert_eq!(b.instance_name, Some("instance-b".into()));
let active: Vec<_> = reg
.list_instances()
.into_iter()
.filter(|i| i.status == HandStatus::Active)
.collect();
assert_eq!(active.len(), 2);
}
#[test]
fn test_activate_same_hand_same_instance_name_rejects() {
let reg = test_registry_with_dummy_hand("test-hand");
reg.activate("test-hand", HashMap::new(), Some("same".into()))
.unwrap();
let err = reg
.activate("test-hand", HashMap::new(), Some("same".into()))
.unwrap_err();
assert!(matches!(err, HandError::AlreadyActive(_)));
}
#[test]
fn test_activate_same_hand_unnamed_twice_still_rejects() {
let reg = test_registry_with_dummy_hand("test-hand");
reg.activate("test-hand", HashMap::new(), None).unwrap();
let err = reg.activate("test-hand", HashMap::new(), None).unwrap_err();
assert!(matches!(err, HandError::AlreadyActive(_)));
}
}
+1
View File
@@ -33,6 +33,7 @@ subtle = { workspace = true }
rand = { workspace = true }
hex = { workspace = true }
reqwest = { workspace = true }
rustls = { workspace = true }
cron = "0.15"
zeroize = { workspace = true }
+315 -105
View File
@@ -509,8 +509,48 @@ impl OpenFangKernel {
Self::boot_with_config(config)
}
/// Fetch live Copilot models by exchanging the persisted token and querying the API.
/// Works both inside and outside a tokio runtime.
fn fetch_copilot_models(openfang_dir: &Path) -> Result<Vec<String>, String> {
use openfang_runtime::drivers::copilot;
let tokens = copilot::PersistedTokens::load(&openfang_dir.to_path_buf())
.ok_or("No persisted Copilot tokens found")?;
let fetch = async {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
let ct = copilot::exchange_copilot_token(&http, &tokens.access_token).await?;
copilot::fetch_models(&http, &ct.base_url, &ct.token).await
};
// If we're already inside a tokio runtime (daemon start), use the existing one.
// Otherwise (CLI commands), create a new one.
if let Ok(handle) = tokio::runtime::Handle::try_current() {
std::thread::scope(|s| {
s.spawn(|| {
handle.block_on(fetch)
}).join().unwrap_or(Err("Thread panicked".to_string()))
})
} else {
let rt = tokio::runtime::Runtime::new()
.map_err(|e| format!("Failed to create runtime: {e}"))?;
rt.block_on(fetch)
}
}
/// Boot the kernel with an explicit configuration.
pub fn boot_with_config(mut config: KernelConfig) -> KernelResult<Self> {
if rustls::crypto::ring::default_provider()
.install_default()
.is_err()
{
debug!("rustls crypto provider already installed, skipping");
}
use openfang_types::config::KernelMode;
// Env var overrides — useful for Docker where config.toml is baked in.
@@ -746,6 +786,21 @@ impl OpenFangKernel {
// Load user's custom models from ~/.openfang/custom_models.json
let custom_models_path = config.home_dir.join("custom_models.json");
model_catalog.load_custom_models(&custom_models_path);
// Fetch live Copilot models if authenticated
if openfang_runtime::drivers::copilot::copilot_auth_available(&config.home_dir) {
let copilot_dir = config.home_dir.clone();
match Self::fetch_copilot_models(&copilot_dir) {
Ok(models) => {
info!(count = models.len(), "Fetched live Copilot model catalog");
model_catalog.merge_discovered_models("github-copilot", &models);
}
Err(e) => {
warn!("Failed to fetch Copilot models (will use static catalog): {e}");
}
}
}
let available_count = model_catalog.available_models().len();
let total_count = model_catalog.list_models().len();
let local_count = model_catalog
@@ -790,6 +845,23 @@ impl OpenFangKernel {
info!("Loaded {hand_count} bundled hand(s)");
}
// Load custom hands from the user's workspace (issue #984).
// Hands installed via `openfang hand install <path>` are persisted to
// `<home>/hands/<hand_id>/` so they survive daemon restarts.
let workspace_hands_dir = config.home_dir.join("hands");
match hand_registry.load_workspace_hands(&workspace_hands_dir) {
Ok(n) if n > 0 => {
info!(
"Loaded {n} workspace hand(s) from {}",
workspace_hands_dir.display()
);
}
Ok(_) => {}
Err(e) => {
warn!("Failed to load workspace hands: {e}");
}
}
// Initialize extension/integration registry
let mut extension_registry =
openfang_extensions::registry::IntegrationRegistry::new(&config.home_dir);
@@ -877,40 +949,77 @@ impl OpenFangKernel {
None
}
}
} else if std::env::var("OPENAI_API_KEY").is_ok() {
let model = if configured_model == "all-MiniLM-L6-v2" {
default_embedding_model_for_provider("openai")
} else {
configured_model.as_str()
};
let openai_url = config.provider_urls.get("openai").map(|s| s.as_str());
match create_embedding_driver("openai", model, "OPENAI_API_KEY", openai_url) {
Ok(d) => {
info!(model = %model, "Embedding driver auto-detected: OpenAI");
Some(Arc::from(d))
}
Err(e) => {
warn!(error = %e, "OpenAI embedding auto-detect failed");
None
}
}
} else {
// Try Ollama (local, no key needed)
let model = if configured_model == "all-MiniLM-L6-v2" {
default_embedding_model_for_provider("ollama")
// Auto-detect embedding provider by checking API key env vars in
// priority order. First match wins.
const API_KEY_PROVIDERS: &[(&str, &str)] = &[
("OPENAI_API_KEY", "openai"),
("GROQ_API_KEY", "groq"),
("MISTRAL_API_KEY", "mistral"),
("TOGETHER_API_KEY", "together"),
("FIREWORKS_API_KEY", "fireworks"),
("COHERE_API_KEY", "cohere"),
];
let detected_from_key = API_KEY_PROVIDERS
.iter()
.find(|(env_var, _)| std::env::var(env_var).is_ok())
.and_then(|(env_var, provider)| {
let model = if configured_model == "all-MiniLM-L6-v2" {
default_embedding_model_for_provider(provider)
} else {
configured_model.as_str()
};
let custom_url = config.provider_urls.get(*provider).map(|s| s.as_str());
match create_embedding_driver(provider, model, env_var, custom_url) {
Ok(d) => {
info!(provider = %provider, model = %model, "Embedding driver auto-detected via {}", env_var);
Some(Arc::from(d))
}
Err(e) => {
warn!(provider = %provider, error = %e, "Embedding auto-detect failed for {}", provider);
None
}
}
});
if detected_from_key.is_some() {
detected_from_key
} else {
configured_model.as_str()
};
let ollama_url = config.provider_urls.get("ollama").map(|s| s.as_str());
match create_embedding_driver("ollama", model, "", ollama_url) {
Ok(d) => {
info!(model = %model, "Embedding driver auto-detected: Ollama (local)");
Some(Arc::from(d))
// No API key found — try local providers in order:
// Ollama, vLLM, LM Studio (no key needed).
const LOCAL_PROVIDERS: &[&str] = &["ollama", "vllm", "lmstudio"];
let mut local_result = None;
for provider in LOCAL_PROVIDERS {
let model = if configured_model == "all-MiniLM-L6-v2" {
default_embedding_model_for_provider(provider)
} else {
configured_model.as_str()
};
let custom_url = config.provider_urls.get(*provider).map(|s| s.as_str());
match create_embedding_driver(provider, model, "", custom_url) {
Ok(d) => {
info!(provider = %provider, model = %model, "Embedding driver auto-detected: {} (local)", provider);
local_result = Some(Arc::from(d));
break;
}
Err(e) => {
debug!(provider = %provider, error = %e, "Local embedding provider {} not available", provider);
}
}
}
Err(e) => {
debug!("No embedding driver available (Ollama probe failed: {e}) — using text search fallback");
None
if local_result.is_none() {
warn!(
"No embedding provider available. Memory recall will use text search only. \
Configure [memory] embedding_provider in config.toml or set an API key \
(OPENAI_API_KEY, GROQ_API_KEY, MISTRAL_API_KEY, TOGETHER_API_KEY, \
FIREWORKS_API_KEY, COHERE_API_KEY)."
);
}
local_result
}
}
};
@@ -1089,7 +1198,10 @@ impl OpenFangKernel {
|| disk_manifest.tool_allowlist
!= entry.manifest.tool_allowlist
|| disk_manifest.tool_blocklist
!= entry.manifest.tool_blocklist;
!= entry.manifest.tool_blocklist
|| disk_manifest.skills != entry.manifest.skills
|| disk_manifest.mcp_servers
!= entry.manifest.mcp_servers;
if changed {
info!(
agent = %name,
@@ -2889,20 +3001,16 @@ impl OpenFangKernel {
model: &str,
explicit_provider: Option<&str>,
) -> KernelResult<()> {
let catalog_entry = self
.model_catalog
.read()
.ok()
.and_then(|catalog| {
// When the caller specifies a provider, use provider-aware lookup
// so we resolve the model on the correct provider — not a builtin
// from a different provider that happens to share the same name (#833).
if let Some(ep) = explicit_provider {
catalog.find_model_for_provider(model, ep).cloned()
} else {
catalog.find_model(model).cloned()
}
});
let catalog_entry = self.model_catalog.read().ok().and_then(|catalog| {
// When the caller specifies a provider, use provider-aware lookup
// so we resolve the model on the correct provider — not a builtin
// from a different provider that happens to share the same name (#833).
if let Some(ep) = explicit_provider {
catalog.find_model_for_provider(model, ep).cloned()
} else {
catalog.find_model(model).cloned()
}
});
let provider = if let Some(ep) = explicit_provider {
// User explicitly set the provider — use it as-is
Some(ep.to_string())
@@ -3280,6 +3388,7 @@ impl OpenFangKernel {
&self,
hand_id: &str,
config: std::collections::HashMap<String, serde_json::Value>,
instance_name: Option<String>,
) -> KernelResult<openfang_hands::HandInstance> {
use openfang_hands::HandError;
@@ -3296,7 +3405,7 @@ impl OpenFangKernel {
// Create the instance in the registry
let instance = self
.hand_registry
.activate(hand_id, config)
.activate(hand_id, config, instance_name.clone())
.map_err(|e| match e {
HandError::AlreadyActive(id) => KernelError::OpenFang(OpenFangError::Internal(
format!("Hand already active: {id}"),
@@ -3317,8 +3426,15 @@ impl OpenFangKernel {
def.agent.model.clone()
};
// When a custom instance_name is provided, use it as the agent name so multiple
// instances of the same hand type can coexist. Falls back to the HAND.toml name
// for backward compatibility (single-instance mode).
let agent_name = instance_name
.clone()
.unwrap_or_else(|| def.agent.name.clone());
let mut manifest = AgentManifest {
name: def.agent.name.clone(),
name: agent_name.clone(),
description: def.agent.description.clone(),
module: def.agent.module.clone(),
model: ModelConfig {
@@ -3423,11 +3539,20 @@ impl OpenFangKernel {
.registry
.list()
.into_iter()
.find(|e| e.name == def.agent.name);
.find(|e| e.name == agent_name);
let old_agent_id = existing.as_ref().map(|e| e.id);
let saved_triggers = old_agent_id
.map(|id| self.triggers.take_agent_triggers(id))
.unwrap_or_default();
// Snapshot cron jobs before kill_agent destroys them. kill_agent calls
// remove_agent_jobs() which deletes the jobs from memory and persists
// an empty cron_jobs.json to disk. The reassign_agent_jobs() call below
// would always be a no-op without this snapshot — same pattern as
// saved_triggers above. Fixes the silent loss of cron jobs across
// every daemon restart for hand-style agents.
let saved_crons: Vec<openfang_types::scheduler::CronJob> = old_agent_id
.map(|id| self.cron_scheduler.list_jobs(id))
.unwrap_or_default();
if let Some(old) = existing {
info!(agent = %old.name, id = %old.id, "Removing existing hand agent for reactivation");
let _ = self.kill_agent(old.id);
@@ -3435,7 +3560,14 @@ impl OpenFangKernel {
// Spawn the agent with a fixed ID based on hand_id for stable identity across restarts.
// This ensures triggers and cron jobs continue to work after daemon restart.
let fixed_agent_id = AgentId::from_string(hand_id);
// Named instances derive the UUID from instance_id so each coexists with a
// unique stable agent id. Unnamed instances keep the legacy "derive from
// hand_id" behavior for backward compatibility.
let fixed_agent_id = if instance_name.is_some() {
AgentId::from_string(&format!("hand_instance_{}", instance.instance_id))
} else {
AgentId::from_string(hand_id)
};
let agent_id = self.spawn_agent_with_parent(manifest, None, Some(fixed_agent_id))?;
// Restore triggers from the old agent under the new agent ID (#519).
@@ -3451,9 +3583,38 @@ impl OpenFangKernel {
}
}
// Migrate cron jobs from old agent to new agent so they survive restarts.
// Without this, persisted cron jobs would reference the stale old UUID
// and fail silently (issue #461).
// Restore cron jobs that were snapshotted before kill_agent. They're
// re-added under the new agent_id (which equals old.id when fixed_id is
// derived from hand_id, but be explicit). Runtime state is reset so
// jobs get a fresh start.
if !saved_crons.is_empty() {
let mut restored = 0usize;
for mut job in saved_crons {
job.agent_id = agent_id;
job.next_run = None;
job.last_run = None;
if self.cron_scheduler.add_job(job, false).is_ok() {
restored += 1;
}
}
if restored > 0 {
info!(
agent = %agent_id,
restored,
"Restored cron jobs after hand reactivation"
);
if let Err(e) = self.cron_scheduler.persist() {
warn!("Failed to persist cron jobs after restoration: {e}");
}
}
}
// Belt-and-braces: also reassign any jobs that somehow still reference
// the old UUID (shouldn't happen after the snapshot/restore above, but
// kept as a safety net for edge cases like out-of-band cron creation
// between kill and respawn). Removed reassign as primary path because
// kill_agent's remove_agent_jobs always wipes saved_crons before this
// could fire — see issue with #461's original fix.
if let Some(old_id) = old_agent_id {
let migrated = self.cron_scheduler.reassign_agent_jobs(old_id, agent_id);
if migrated > 0 {
@@ -3847,7 +4008,7 @@ impl OpenFangKernel {
if !saved_hands.is_empty() {
info!("Restoring {} persisted hand(s)", saved_hands.len());
for (hand_id, config, old_agent_id) in saved_hands {
match self.activate_hand(&hand_id, config) {
match self.activate_hand(&hand_id, config, None) {
Ok(inst) => {
info!(hand = %hand_id, instance = %inst.instance_id, "Hand restored");
// Reassign cron jobs and triggers from the pre-restart
@@ -4641,62 +4802,108 @@ impl OpenFangKernel {
}
};
// If fallback models are configured, wrap in FallbackDriver
if !manifest.fallback_models.is_empty() {
// Primary driver uses the agent's own model name (already set in request)
let mut chain: Vec<(
std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>,
String,
)> = vec![(primary.clone(), String::new())];
for fb in &manifest.fallback_models {
// Resolve "default" provider/model to the kernel's configured defaults,
// mirroring the overlay logic for the primary model.
let dm = &self.config.default_model;
let fb_provider = if fb.provider.is_empty() || fb.provider == "default" {
dm.provider.clone()
} else {
fb.provider.clone()
};
let fb_model_name = if fb.model.is_empty() || fb.model == "default" {
dm.model.clone()
} else {
fb.model.clone()
};
let _ = &fb_model_name; // used below in strip_provider_prefix
// Build the complete fallback chain:
// 1. Primary driver (from the agent manifest)
// 2. Per-agent `manifest.fallback_models` (#845)
// 3. Global `config.fallback_providers` (#1003) — applied to *every* agent
//
// Wrap in FallbackDriver whenever the chain has more than one entry. This
// ensures that when a local provider (e.g. LM Studio) goes offline at
// runtime, the agent loop transparently fails over to the next provider
// instead of retrying the unreachable primary forever.
//
// Primary driver uses an empty model name so the request's `model` field
// (which is the agent's own model) is used as-is.
let mut chain: Vec<(
std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>,
String,
)> = vec![(primary.clone(), String::new())];
let fb_api_key = if let Some(env) = &fb.api_key_env {
std::env::var(env).ok()
} else if fb_provider == dm.provider && !dm.api_key_env.is_empty() {
std::env::var(&dm.api_key_env).ok()
} else {
// Resolve using provider_api_keys / convention for custom providers
let env_var = self.config.resolve_api_key_env(&fb_provider);
std::env::var(&env_var).ok()
};
let config = DriverConfig {
provider: fb_provider.clone(),
api_key: fb_api_key,
base_url: fb
.base_url
.clone()
.or_else(|| dm.base_url.clone())
.or_else(|| self.lookup_provider_url(&fb_provider)),
skip_permissions: true,
};
match drivers::create_driver(&config) {
Ok(d) => chain.push((d, strip_provider_prefix(&fb_model_name, &fb_provider))),
Err(e) => {
warn!("Fallback driver '{}' failed to init: {e}", fb_provider);
}
// 2. Per-agent fallback models from the manifest.
for fb in &manifest.fallback_models {
// Resolve "default" provider/model to the kernel's configured defaults,
// mirroring the overlay logic for the primary model.
let dm = &self.config.default_model;
let fb_provider = if fb.provider.is_empty() || fb.provider == "default" {
dm.provider.clone()
} else {
fb.provider.clone()
};
let fb_model_name = if fb.model.is_empty() || fb.model == "default" {
dm.model.clone()
} else {
fb.model.clone()
};
let fb_api_key = if let Some(env) = &fb.api_key_env {
self.resolve_credential(env)
} else if fb_provider == dm.provider && !dm.api_key_env.is_empty() {
self.resolve_credential(&dm.api_key_env)
} else {
// Resolve using provider_api_keys / convention for custom providers
let env_var = self.config.resolve_api_key_env(&fb_provider);
self.resolve_credential(&env_var)
};
let config = DriverConfig {
provider: fb_provider.clone(),
api_key: fb_api_key,
base_url: fb
.base_url
.clone()
.or_else(|| dm.base_url.clone())
.or_else(|| self.lookup_provider_url(&fb_provider)),
skip_permissions: true,
};
match drivers::create_driver(&config) {
Ok(d) => chain.push((d, strip_provider_prefix(&fb_model_name, &fb_provider))),
Err(e) => {
warn!("Fallback driver '{}' failed to init: {e}", fb_provider);
}
}
if chain.len() > 1 {
return Ok(Arc::new(
openfang_runtime::drivers::fallback::FallbackDriver::with_models(chain),
));
}
// 3. Global fallback providers from config.toml — `[[fallback_providers]]`.
// These apply to every agent so that when the primary provider becomes
// unreachable at runtime (network failure, daemon shutdown, etc.) the
// agent loop fails over to the next provider in the chain. (#1003)
for fb in &self.config.fallback_providers {
let fb_api_key = {
let env_var = if !fb.api_key_env.is_empty() {
fb.api_key_env.clone()
} else {
self.config.resolve_api_key_env(&fb.provider)
};
self.resolve_credential(&env_var)
};
let fb_config = DriverConfig {
provider: fb.provider.clone(),
api_key: fb_api_key,
base_url: fb
.base_url
.clone()
.or_else(|| self.lookup_provider_url(&fb.provider)),
skip_permissions: true,
};
match drivers::create_driver(&fb_config) {
Ok(d) => {
chain.push((d, strip_provider_prefix(&fb.model, &fb.provider)));
}
Err(e) => {
warn!(
provider = %fb.provider,
error = %e,
"Global fallback provider init failed — skipped"
);
}
}
}
if chain.len() > 1 {
return Ok(Arc::new(
openfang_runtime::drivers::fallback::FallbackDriver::with_models(chain),
));
}
Ok(primary)
}
@@ -5663,7 +5870,10 @@ fn apply_budget_defaults(
fn default_embedding_model_for_provider(provider: &str) -> &'static str {
match provider {
"openai" => "text-embedding-3-small",
"groq" => "nomic-embed-text",
"mistral" => "mistral-embed",
"together" => "togethercomputer/m2-bert-80M-8k-retrieval",
"fireworks" => "nomic-ai/nomic-embed-text-v1.5",
"cohere" => "embed-english-v3.0",
// Local providers use nomic-embed-text as a good default
"ollama" | "vllm" | "lmstudio" => "nomic-embed-text",
@@ -6194,7 +6404,7 @@ impl KernelHandle for OpenFangKernel {
config: std::collections::HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value, String> {
let instance = self
.activate_hand(hand_id, config)
.activate_hand(hand_id, config, None)
.map_err(|e| format!("{e}"))?;
Ok(serde_json::json!({
@@ -6825,7 +7035,7 @@ mod tests {
let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot");
let instance = kernel
.activate_hand("browser", HashMap::new())
.activate_hand("browser", HashMap::new(), None)
.expect("browser hand should activate");
let agent_id = instance.agent_id.expect("browser hand agent id");
let entry = kernel
@@ -161,3 +161,39 @@ memory_write = ["self.*"]
kernel.kill_agent(id2).unwrap();
kernel.shutdown();
}
#[test]
fn test_agent_manifest_skills_parsing() {
let toml_str = r#"
name = "skills-test-agent"
version = "0.1.0"
description = "Test agent with skills"
author = "test"
module = "builtin:chat"
skills = ["Productivity", "web-search"]
mcp_servers = ["github"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
[capabilities]
tools = ["file_read"]
[resources]
max_llm_tokens_per_hour = 100000
"#;
let manifest: AgentManifest = toml::from_str(toml_str).unwrap();
assert_eq!(
manifest.skills,
vec!["Productivity", "web-search"],
"Skills should be parsed correctly (must be at top level, not after [capabilities])"
);
assert_eq!(
manifest.mcp_servers,
vec!["github"],
"MCP servers should be parsed correctly (must be at top level)"
);
}
+334 -33
View File
@@ -86,6 +86,13 @@ fn phantom_action_detected(text: &str) -> bool {
has_action && has_channel
}
/// Returns true when the agent response text indicates an intentional silent completion.
/// Matches `NO_REPLY` (exact) and `[SILENT]` (case-insensitive).
fn is_silent_token(text: &str) -> bool {
let trimmed = text.trim();
trimmed == "NO_REPLY" || trimmed.eq_ignore_ascii_case("[silent]")
}
/// Extra guidance injected after failed tool calls to prevent fabricated follow-up actions.
const TOOL_ERROR_GUIDANCE: &str =
"[System: One or more tool calls failed. Failed tools did not produce usable data. Do NOT invent missing results, cite nonexistent search results, or pretend failed tools succeeded. If your next steps depend on a failed tool, either retry with a materially different approach or explain the failure to the user and stop. Do not write files, store memory, or take downstream actions based on failed tool outputs.]";
@@ -325,6 +332,10 @@ pub async fn run_agent_loop(
let mut total_usage = TokenUsage::default();
let final_response;
// Accumulate text from intermediate iterations (tool_use turns may include text
// alongside tool calls — this text would otherwise be lost when the final
// EndTurn iteration has empty text).
let mut accumulated_text = String::new();
// Safety valve: trim excessively long message histories to prevent context overflow.
// The full compaction system handles sophisticated summarization, but this prevents
@@ -342,6 +353,10 @@ pub async fn run_agent_loop(
// pair across the cut boundary, leaving orphaned blocks that cause the LLM
// to return empty responses (input_tokens=0).
messages = crate::session_repair::validate_and_repair(&messages);
// Ensure history starts with a user turn: trimming may have left an
// assistant turn at position 0, which strict providers (e.g. Gemini)
// reject with INVALID_ARGUMENT on function-call turns.
messages = crate::session_repair::ensure_starts_with_user(messages);
}
// Use autonomous config max_iterations if set, else default
@@ -381,6 +396,8 @@ pub async fn run_agent_loop(
// which may have broken assistant→tool ordering invariants.
if recovery != RecoveryStage::None {
messages = crate::session_repair::validate_and_repair(&messages);
// Ensure history starts with a user turn after overflow recovery.
messages = crate::session_repair::ensure_starts_with_user(messages);
}
// Context guard: compact oversized tool results before LLM call
@@ -463,8 +480,9 @@ pub async fn run_agent_loop(
crate::reply_directives::parse_directives(&text);
let text = cleaned_text;
// NO_REPLY: agent intentionally chose not to reply
if text.trim() == "NO_REPLY" || parsed_directives.silent {
// NO_REPLY / [SILENT]: agent intentionally chose not to reply.
// [SILENT] must not be stored literally — it reinforces silence in future turns.
if is_silent_token(&text) || parsed_directives.silent {
debug!(agent = %manifest.name, "Agent chose NO_REPLY/silent — silent completion");
session
.messages
@@ -516,20 +534,30 @@ pub async fn run_agent_loop(
}
}
// Guard against empty response — covers both iteration 0 and post-tool cycles
// Guard against empty response — covers both iteration 0 and post-tool cycles.
// Use accumulated_text from intermediate tool_use iterations as fallback.
let text = if text.trim().is_empty() {
warn!(
agent = %manifest.name,
iteration,
input_tokens = total_usage.input_tokens,
output_tokens = total_usage.output_tokens,
messages_count = messages.len(),
"Empty response from LLM — guard activated"
);
if any_tools_executed {
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
if !accumulated_text.is_empty() {
debug!(
agent = %manifest.name,
accumulated_len = accumulated_text.len(),
"Using accumulated text from intermediate tool_use iterations"
);
accumulated_text.clone()
} else {
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
warn!(
agent = %manifest.name,
iteration,
input_tokens = total_usage.input_tokens,
output_tokens = total_usage.output_tokens,
messages_count = messages.len(),
"Empty response from LLM — guard activated"
);
if any_tools_executed {
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
} else {
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
}
}
} else {
text
@@ -650,6 +678,18 @@ pub async fn run_agent_loop(
consecutive_max_tokens = 0;
any_tools_executed = true;
// Capture any text content from this tool_use turn — the LLM may
// produce text alongside tool calls (e.g., a message to the user
// before calling memory_store). Without this, the text is lost if
// the next iteration returns EndTurn with empty text.
let intermediate_text = response.text();
if !intermediate_text.trim().is_empty() {
if !accumulated_text.is_empty() {
accumulated_text.push_str("\n\n");
}
accumulated_text.push_str(intermediate_text.trim());
}
// Execute tool calls
let assistant_blocks = response.content.clone();
@@ -1489,6 +1529,7 @@ pub async fn run_agent_loop_streaming(
let mut total_usage = TokenUsage::default();
let final_response;
let mut accumulated_text = String::new();
// Safety valve: trim excessively long message histories to prevent context overflow.
if messages.len() > MAX_HISTORY_MESSAGES {
@@ -1504,6 +1545,10 @@ pub async fn run_agent_loop_streaming(
// pair across the cut boundary, leaving orphaned blocks that cause the LLM
// to return empty responses (input_tokens=0).
messages = crate::session_repair::validate_and_repair(&messages);
// Ensure history starts with a user turn: trimming may have left an
// assistant turn at position 0, which strict providers (e.g. Gemini)
// reject with INVALID_ARGUMENT on function-call turns.
messages = crate::session_repair::ensure_starts_with_user(messages);
}
// Use autonomous config max_iterations if set, else default
@@ -1561,6 +1606,8 @@ pub async fn run_agent_loop_streaming(
// be followed by tool messages" errors after context overflow recovery.)
if recovery != RecoveryStage::None {
messages = crate::session_repair::validate_and_repair(&messages);
// Ensure history starts with a user turn after overflow recovery.
messages = crate::session_repair::ensure_starts_with_user(messages);
}
// Context guard: compact oversized tool results before LLM call
@@ -1641,8 +1688,9 @@ pub async fn run_agent_loop_streaming(
crate::reply_directives::parse_directives(&text);
let text = cleaned_text_s;
// NO_REPLY: agent intentionally chose not to reply
if text.trim() == "NO_REPLY" || parsed_directives_s.silent {
// NO_REPLY / [SILENT]: agent intentionally chose not to reply.
// [SILENT] must not be stored literally — it reinforces silence in future turns.
if is_silent_token(&text) || parsed_directives_s.silent {
debug!(agent = %manifest.name, "Agent chose NO_REPLY/silent (streaming) — silent completion");
session
.messages
@@ -1694,20 +1742,29 @@ pub async fn run_agent_loop_streaming(
}
}
// Guard against empty response — covers both iteration 0 and post-tool cycles
// Guard against empty response — use accumulated text as fallback (streaming).
let text = if text.trim().is_empty() {
warn!(
agent = %manifest.name,
iteration,
input_tokens = total_usage.input_tokens,
output_tokens = total_usage.output_tokens,
messages_count = messages.len(),
"Empty response from LLM (streaming) — guard activated"
);
if any_tools_executed {
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
if !accumulated_text.is_empty() {
debug!(
agent = %manifest.name,
accumulated_len = accumulated_text.len(),
"Using accumulated text from intermediate tool_use iterations (streaming)"
);
accumulated_text.clone()
} else {
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
warn!(
agent = %manifest.name,
iteration,
input_tokens = total_usage.input_tokens,
output_tokens = total_usage.output_tokens,
messages_count = messages.len(),
"Empty response from LLM (streaming) — guard activated"
);
if any_tools_executed {
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
} else {
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
}
}
} else {
text
@@ -1807,6 +1864,15 @@ pub async fn run_agent_loop_streaming(
consecutive_max_tokens = 0;
any_tools_executed = true;
// Capture text from intermediate tool_use turns (streaming path).
let intermediate_text = response.text();
if !intermediate_text.trim().is_empty() {
if !accumulated_text.is_empty() {
accumulated_text.push_str("\n\n");
}
accumulated_text.push_str(intermediate_text.trim());
}
let assistant_blocks = response.content.clone();
session.messages.push(Message {
@@ -2140,6 +2206,7 @@ pub async fn run_agent_loop_streaming(
/// 11. `Action: tool\nAction Input: {"key":"value"}` — ReAct-style (LM Studio, GPT-OSS)
/// 12. `tool_name\n{"key":"value"}` — bare name + JSON on next line (Llama 4 Scout)
/// 13. `<tool_use>{"name":"tool","arguments":{...}}</tool_use>` — Llama 3.1+ variant
/// 14. `<function=tool><parameter=name>value</parameter></function>` — nested XML parameter style
///
/// Validates tool names against available tools and returns synthetic `ToolCall` entries.
fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Vec<ToolCall> {
@@ -2177,13 +2244,16 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
continue;
}
// Parse JSON input
// Parse JSON input, or fall back to nested XML parameter blocks.
let input: serde_json::Value = match serde_json::from_str(json_body) {
Ok(v) => v,
Err(e) => {
warn!(tool = tool_name, error = %e, "Failed to parse text-based tool call JSON — skipping");
continue;
}
Err(json_err) => match parse_xml_parameter_blocks(json_body) {
Some(v) => v,
None => {
warn!(tool = tool_name, error = %json_err, "Failed to parse text-based tool call payload — skipping");
continue;
}
},
};
info!(
@@ -2751,6 +2821,42 @@ fn parse_json_tool_call_object(
Some((name.to_string(), args))
}
fn unescape_xml_entities(text: &str) -> String {
text.replace("&quot;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&apos;", "'")
}
fn parse_xml_parameter_blocks(text: &str) -> Option<serde_json::Value> {
use regex_lite::Regex;
let re = Regex::new(r#"(?s)<parameter=([A-Za-z0-9_.:-]+)>\s*(.*?)\s*</parameter>"#).unwrap();
let mut params = serde_json::Map::new();
for caps in re.captures_iter(text) {
let Some(name) = caps.get(1).map(|m| m.as_str().trim()) else {
continue;
};
if name.is_empty() {
continue;
}
let raw_value = caps.get(2).map(|m| m.as_str()).unwrap_or_default();
let value_text = unescape_xml_entities(raw_value).trim().to_string();
let value =
serde_json::from_str(&value_text).unwrap_or(serde_json::Value::String(value_text));
params.insert(name.to_string(), value);
}
if params.is_empty() {
None
} else {
Some(serde_json::Value::Object(params))
}
}
/// Parse the custom arrow syntax used by some Ollama models:
/// `{tool => "name", args => {--key "value"}}` or `{tool => "name", args => {"key":"value"}}`
fn parse_arrow_syntax_tool_call(
@@ -3639,6 +3745,44 @@ mod tests {
assert!(calls[0].id.starts_with("recovered_"));
}
#[test]
fn test_recover_text_tool_calls_xml_parameters() {
let tools = vec![ToolDefinition {
name: "shell_exec".into(),
description: "Execute".into(),
input_schema: serde_json::json!({}),
}];
let text = r#"<function=shell_exec><parameter=command>python3 "/tmp/run.py" --flag value</parameter></function>"#;
let calls = recover_text_tool_calls(text, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "shell_exec");
assert_eq!(
calls[0].input["command"],
r#"python3 "/tmp/run.py" --flag value"#
);
}
#[test]
fn test_recover_text_tool_calls_xml_parameters_with_wrapper() {
let tools = vec![ToolDefinition {
name: "shell_exec".into(),
description: "Execute".into(),
input_schema: serde_json::json!({}),
}];
let text = r#"<tool_call>
<function=shell_exec>
<parameter=command>python3 "/tmp/poll.py" --job-id "abc123"</parameter>
</function>
</tool_call>"#;
let calls = recover_text_tool_calls(text, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "shell_exec");
assert_eq!(
calls[0].input["command"],
r#"python3 "/tmp/poll.py" --job-id "abc123""#
);
}
#[test]
fn test_recover_text_tool_calls_unknown_tool() {
let tools = vec![ToolDefinition {
@@ -4405,6 +4549,56 @@ mod tests {
}
}
/// Mock driver that emits nested XML parameter-style tool calls as plain text.
struct NestedXmlTextToolCallDriver {
call_count: AtomicU32,
}
impl NestedXmlTextToolCallDriver {
fn new() -> Self {
Self {
call_count: AtomicU32::new(0),
}
}
}
#[async_trait]
impl LlmDriver for NestedXmlTextToolCallDriver {
async fn complete(
&self,
_request: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
let call = self.call_count.fetch_add(1, Ordering::Relaxed);
if call == 0 {
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: "<tool_call><function=web_search><parameter=query>rust async</parameter></function></tool_call>".to_string(),
provider_metadata: None,
}],
stop_reason: StopReason::EndTurn,
tool_calls: vec![],
usage: TokenUsage {
input_tokens: 18,
output_tokens: 10,
},
})
} else {
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: "Recovered nested XML tool call successfully.".to_string(),
provider_metadata: None,
}],
stop_reason: StopReason::EndTurn,
tool_calls: vec![],
usage: TokenUsage {
input_tokens: 24,
output_tokens: 8,
},
})
}
}
}
#[async_trait]
impl LlmDriver for TextToolCallDriver {
async fn complete(
@@ -4518,6 +4712,81 @@ mod tests {
);
}
#[tokio::test]
async fn test_nested_xml_text_tool_call_recovery_e2e() {
let memory = openfang_memory::MemorySubstrate::open_in_memory(0.01).unwrap();
let agent_id = openfang_types::agent::AgentId::new();
let mut session = openfang_memory::session::Session {
id: openfang_types::agent::SessionId::new(),
agent_id,
messages: Vec::new(),
context_window_tokens: 0,
label: None,
};
let manifest = test_manifest();
let driver: Arc<dyn LlmDriver> = Arc::new(NestedXmlTextToolCallDriver::new());
let tools = vec![ToolDefinition {
name: "web_search".into(),
description: "Search the web".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": {"type": "string"}
}
}),
}];
let result = run_agent_loop(
&manifest,
"Search for rust async programming",
&mut session,
&memory,
driver,
&tools,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await
.expect("Agent loop should recover nested XML tool calls");
assert!(
!result.response.contains("<tool_call>"),
"Response should not contain raw tool_call tags, got: {:?}",
result.response
);
assert!(
!result.response.contains("<function="),
"Response should not contain raw function tags, got: {:?}",
result.response
);
assert!(
result
.response
.contains("Recovered nested XML tool call successfully."),
"Expected final response text, got: {:?}",
result.response
);
assert!(
result.iterations >= 2,
"Should have at least 2 iterations (tool call + final response), got: {}",
result.iterations
);
}
/// Mock driver that returns NO text-based tool calls — just normal text.
/// Verifies recovery does NOT interfere with normal flow.
#[tokio::test]
@@ -4648,4 +4917,36 @@ mod tests {
}
assert!(!events.is_empty(), "Should have received stream events");
}
#[test]
fn test_silent_detection_uppercase() {
assert!(is_silent_token("[SILENT]"));
}
#[test]
fn test_silent_detection_lowercase() {
assert!(is_silent_token("[silent]"));
}
#[test]
fn test_silent_detection_mixed_case() {
assert!(is_silent_token("[Silent]"));
}
#[test]
fn test_silent_detection_with_whitespace() {
assert!(is_silent_token(" [SILENT] "));
}
#[test]
fn test_silent_detection_no_reply() {
assert!(is_silent_token("NO_REPLY"));
}
#[test]
fn test_silent_detection_rejects_normal_text() {
assert!(!is_silent_token("Hello, how can I help?"));
assert!(!is_silent_token("SILENT"));
assert!(!is_silent_token(""));
}
}
+1 -1
View File
@@ -877,7 +877,7 @@ pub async fn tool_browser_navigate(
agent_id: &str,
) -> Result<String, String> {
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
crate::web_fetch::check_ssrf(url)?;
crate::web_fetch::check_ssrf(url, &[])?;
let resp = mgr
.send_command(
+12 -7
View File
@@ -435,11 +435,10 @@ async fn summarize_messages(
let safe_start = if conversation_text.is_char_boundary(start) {
start
} else {
conversation_text[start..]
.char_indices()
.next()
.map(|(i, _)| start + i)
.unwrap_or(conversation_text.len())
// Find the nearest valid character boundary moving upward
(start..conversation_text.len())
.find(|&i| conversation_text.is_char_boundary(i))
.unwrap_or(conversation_text.len())
};
conversation_text = conversation_text[safe_start..].to_string();
}
@@ -1478,7 +1477,10 @@ mod tests {
Message::assistant("Done reading."),
];
let adjusted = adjust_split_for_tool_pairs(&messages, 2);
assert_eq!(adjusted, 1, "Should pull back split to keep ToolUse + ToolResult together");
assert_eq!(
adjusted, 1,
"Should pull back split to keep ToolUse + ToolResult together"
);
}
#[test]
@@ -1489,7 +1491,10 @@ mod tests {
Message::user("c"),
];
let adjusted = adjust_split_for_tool_pairs(&messages, 1);
assert_eq!(adjusted, 1, "Should not change split for plain text messages");
assert_eq!(
adjusted, 1,
"Should not change split for plain text messages"
);
}
#[test]
@@ -32,10 +32,14 @@ fn safe_drain_boundary(messages: &[Message], mut boundary: usize) -> usize {
// is in the last drained message (boundary - 1). Pull boundary back by 1.
if messages[boundary].role == Role::User {
if let MessageContent::Blocks(blocks) = &messages[boundary].content {
let has_tool_result = blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }));
let has_tool_result = blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolResult { .. }));
if has_tool_result && boundary > 0 && messages[boundary - 1].role == Role::Assistant {
if let MessageContent::Blocks(asst_blocks) = &messages[boundary - 1].content {
let has_tool_use = asst_blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. }));
let has_tool_use = asst_blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
if has_tool_use {
boundary -= 1;
debug!(
@@ -135,7 +139,8 @@ pub fn recover_from_overflow(
debug!(
estimated_tokens = estimated,
removing = remove,
"Stage 1: moderate trim to last {} messages", messages.len() - remove
"Stage 1: moderate trim to last {} messages",
messages.len() - remove
);
messages.drain(..remove);
// Re-check after trim
@@ -156,7 +161,8 @@ pub fn recover_from_overflow(
warn!(
estimated_tokens = estimate_tokens(messages, system_prompt, tools),
removing = remove,
"Stage 2: aggressive overflow compaction to last {} messages", messages.len() - remove
"Stage 2: aggressive overflow compaction to last {} messages",
messages.len() - remove
);
let summary = Message::user(format!(
"[System: {} earlier messages were removed due to context overflow. \
@@ -373,7 +379,10 @@ mod tests {
];
// Boundary 2 would cut between the assistant(ToolUse) at [1] and user(ToolResult) at [2].
let adjusted = safe_drain_boundary(&msgs, 2);
assert_eq!(adjusted, 1, "Should pull boundary back to keep the ToolUse/ToolResult pair together");
assert_eq!(
adjusted, 1,
"Should pull boundary back to keep the ToolUse/ToolResult pair together"
);
}
#[test]
@@ -385,7 +394,10 @@ mod tests {
Message::assistant("d"),
];
let adjusted = safe_drain_boundary(&msgs, 2);
assert_eq!(adjusted, 2, "Should not change boundary for plain text messages");
assert_eq!(
adjusted, 2,
"Should not change boundary for plain text messages"
);
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -245,4 +245,75 @@ mod tests {
// All drivers rate-limited — error should bubble up
assert!(matches!(result, Err(LlmError::RateLimited { .. })));
}
/// Regression test for #1003: when the primary driver returns a network /
/// connection error (e.g. LM Studio shut down → reqwest connection refused),
/// the FallbackDriver MUST escalate to the next driver in the chain instead
/// of bubbling the error up to the agent loop (which would then retry the
/// dead primary forever).
#[tokio::test]
async fn test_network_error_falls_through_to_secondary() {
struct NetworkFailDriver;
#[async_trait]
impl LlmDriver for NetworkFailDriver {
async fn complete(
&self,
_req: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
// Simulates `reqwest::Error` from a connection refused — exactly
// what an offline LM Studio looks like in production.
Err(LlmError::Http(
"error sending request: connection refused (os error 10061)".to_string(),
))
}
}
let driver = FallbackDriver::new(vec![
Arc::new(NetworkFailDriver) as Arc<dyn LlmDriver>,
Arc::new(OkDriver) as Arc<dyn LlmDriver>,
]);
let result = driver.complete(test_request()).await;
assert!(
result.is_ok(),
"FallbackDriver should escalate network errors to the next driver"
);
assert_eq!(result.unwrap().text(), "OK");
}
/// Same as above but for streaming. The streaming path is what the agent
/// loop hits in practice for LM Studio etc., so it must also fall through.
#[tokio::test]
async fn test_network_error_falls_through_streaming() {
struct NetworkFailDriver;
#[async_trait]
impl LlmDriver for NetworkFailDriver {
async fn complete(
&self,
_req: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
Err(LlmError::Http("connection refused".to_string()))
}
async fn stream(
&self,
_req: CompletionRequest,
_tx: tokio::sync::mpsc::Sender<StreamEvent>,
) -> Result<CompletionResponse, LlmError> {
Err(LlmError::Http("connection refused".to_string()))
}
}
let driver = FallbackDriver::new(vec![
Arc::new(NetworkFailDriver) as Arc<dyn LlmDriver>,
Arc::new(OkDriver) as Arc<dyn LlmDriver>,
]);
let (tx, _rx) = tokio::sync::mpsc::channel(16);
let result = driver.stream(test_request(), tx).await;
assert!(
result.is_ok(),
"FallbackDriver::stream should also escalate network errors"
);
}
}
@@ -370,7 +370,11 @@ fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
}
// Step 2: Drop orphaned functionCall parts from model turns.
// A model turn with functionCall must be followed by a user turn with functionResponse.
// A model turn with functionCall must be:
// (a) followed by a user turn with functionResponse, AND
// (b) preceded by a user turn (i.e. not at position 0).
// Gemini rejects with INVALID_ARGUMENT if a functionCall turn is at
// position 0 with no preceding user turn, even when (a) is satisfied.
let len = merged.len();
for i in 0..len {
let is_model = merged[i].role.as_deref() == Some("model");
@@ -394,7 +398,9 @@ fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
.iter()
.any(|p| matches!(p, GeminiPart::FunctionResponse { .. }));
if !next_has_response {
// After Step 1 merge, i > 0 guarantees a user turn precedes this model
// turn (alternating roles). i == 0 means no preceding user turn.
if i == 0 || !next_has_response {
// Drop the functionCall parts from this model turn (keep text parts)
merged[i]
.parts
+18 -22
View File
@@ -139,8 +139,8 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
}),
"github-copilot" | "copilot" => Some(ProviderDefaults {
base_url: copilot::GITHUB_COPILOT_BASE_URL,
api_key_env: "GITHUB_TOKEN",
key_required: true,
api_key_env: "COPILOT_CLIENT_ID",
key_required: false, // Auth handled via OAuth device flow, not simple API key
}),
"codex" | "openai-codex" => Some(ProviderDefaults {
base_url: OPENAI_BASE_URL,
@@ -334,27 +334,23 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
)));
}
// GitHub Copilot — wraps OpenAI-compatible driver with automatic token exchange.
// The CopilotDriver exchanges the GitHub PAT for a Copilot API token on demand,
// caches it, and refreshes when expired.
// GitHub Copilot — OAuth device flow + OpenAI-compatible completions.
// Authentication is handled automatically via persisted tokens from the device flow.
// Run `openfang config set-key github-copilot` to authenticate.
if provider == "github-copilot" || provider == "copilot" {
let github_token = config
.api_key
.clone()
.or_else(|| std::env::var("GITHUB_TOKEN").ok())
.ok_or_else(|| {
LlmError::MissingApiKey(
"Set GITHUB_TOKEN environment variable for GitHub Copilot".to_string(),
)
})?;
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| copilot::GITHUB_COPILOT_BASE_URL.to_string());
return Ok(Arc::new(copilot::CopilotDriver::new(
github_token,
base_url,
)));
let openfang_dir = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(|h| std::path::PathBuf::from(h).join(".openfang"))
.unwrap_or_else(|_| std::path::PathBuf::from(".openfang"));
if !copilot::copilot_auth_available(&openfang_dir) {
return Err(LlmError::MissingApiKey(
"Copilot not authenticated. Run `openfang config set-key github-copilot` to sign in."
.to_string(),
));
}
return Ok(Arc::new(copilot::CopilotDriver::new(openfang_dir)));
}
// Azure OpenAI — deployment-based URL with `api-key` header
+63 -1
View File
@@ -7,6 +7,7 @@ use crate::think_filter::{FilterAction, StreamingThinkFilter};
use async_trait::async_trait;
use futures::StreamExt;
use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage};
use openfang_types::model_catalog::MOONSHOT_KIMI_BASE_URL;
use openfang_types::tool::ToolCall;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
@@ -84,7 +85,17 @@ impl OpenAIDriver {
AZURE_API_VERSION,
)
} else {
format!("{}/chat/completions", self.base_url)
// Kimi K2/K2.5 models live on api.moonshot.cn, not api.moonshot.ai.
// When the moonshot provider is configured with the default .ai URL
// but the model is a kimi-k2* model, redirect to the .cn endpoint.
let effective_url = if self.base_url.contains("api.moonshot.ai")
&& model.to_lowercase().starts_with("kimi-k2")
{
MOONSHOT_KIMI_BASE_URL
} else {
&self.base_url
};
format!("{}/chat/completions", effective_url)
}
}
@@ -262,6 +273,22 @@ struct OaiUsage {
completion_tokens: u64,
}
/// Strip trailing empty assistant messages without tool calls.
/// Some API proxies reject empty assistant messages as "prefill".
fn strip_trailing_empty_assistant(messages: &mut Vec<OaiMessage>) {
while messages.last().map_or(false, |m| {
m.role == "assistant"
&& m.tool_calls.is_none()
&& match &m.content {
None => true,
Some(OaiMessageContent::Text(t)) => t.trim().is_empty(),
_ => false,
}
}) {
messages.pop();
}
}
#[async_trait]
impl LlmDriver for OpenAIDriver {
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
@@ -417,6 +444,8 @@ impl LlmDriver for OpenAIDriver {
}
}
strip_trailing_empty_assistant(&mut oai_messages);
let oai_tools: Vec<OaiTool> = request
.tools
.iter()
@@ -444,6 +473,7 @@ impl LlmDriver for OpenAIDriver {
} else {
(Some(request.max_tokens), None)
};
let mut oai_request = OaiRequest {
model: request.model.clone(),
messages: oai_messages,
@@ -873,6 +903,8 @@ impl LlmDriver for OpenAIDriver {
}
}
strip_trailing_empty_assistant(&mut oai_messages);
let oai_tools: Vec<OaiTool> = request
.tools
.iter()
@@ -1317,6 +1349,16 @@ impl LlmDriver for OpenAIDriver {
}
for (id, name, arguments) in &tool_accum {
// Skip malformed tool calls (empty ID or name can happen if
// streaming chunks arrive out of order or are dropped by proxy).
if id.is_empty() || name.is_empty() {
warn!(
tool_id = %id,
tool_name = %name,
"Skipping tool call with empty ID or name from streaming response"
);
continue;
}
let input: serde_json::Value =
serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
content.push(ContentBlock::ToolUse {
@@ -1836,4 +1878,24 @@ mod tests {
let url = driver.chat_url("gpt-4o");
assert_eq!(url, "https://api.openai.com/v1/chat/completions");
}
/// Regression test for #970: kimi-k2.5 on moonshot.ai should redirect to moonshot.cn
#[test]
fn test_kimi_k2_redirects_to_moonshot_cn() {
let driver = OpenAIDriver::new(
"test-key".to_string(),
"https://api.moonshot.ai/v1".to_string(),
);
// kimi-k2.5 must go to the .cn endpoint
let url = driver.chat_url("kimi-k2.5");
assert_eq!(url, "https://api.moonshot.cn/v1/chat/completions");
// kimi-k2 must also redirect
let url = driver.chat_url("kimi-k2");
assert_eq!(url, "https://api.moonshot.cn/v1/chat/completions");
// moonshot-v1-128k should NOT redirect (stays on .ai)
let url = driver.chat_url("moonshot-v1-128k");
assert_eq!(url, "https://api.moonshot.ai/v1/chat/completions");
}
}
+4 -6
View File
@@ -14,7 +14,6 @@ use rmcp::service::RunningService;
use rmcp::{RoleClient, ServiceExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info};
// ---------------------------------------------------------------------------
@@ -307,11 +306,10 @@ impl McpConnection {
}
}
let config = StreamableHttpClientTransportConfig {
uri: Arc::from(url),
custom_headers,
..Default::default()
};
// rmcp 1.3+ marks StreamableHttpClientTransportConfig as #[non_exhaustive].
// Use the official builder API (credit: @jefflower, PR #986).
let config =
StreamableHttpClientTransportConfig::with_uri(url).custom_headers(custom_headers);
let transport = StreamableHttpClientTransport::from_config(config);
+23 -36
View File
@@ -75,6 +75,21 @@ impl ModelCatalog {
continue;
}
// GitHub Copilot: check for persisted OAuth tokens
if provider.id == "github-copilot" || provider.id == "copilot" {
let openfang_dir = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(|h| std::path::PathBuf::from(h).join(".openfang"))
.unwrap_or_else(|_| std::path::PathBuf::from(".openfang"));
provider.auth_status =
if crate::drivers::copilot::copilot_auth_available(&openfang_dir) {
AuthStatus::Configured
} else {
AuthStatus::Missing
};
continue;
}
if !provider.key_required {
provider.auth_status = AuthStatus::NotRequired;
continue;
@@ -372,8 +387,8 @@ impl ModelCatalog {
display_name: display,
provider: provider.to_string(),
tier: ModelTier::Local,
context_window: 32_768,
max_output_tokens: 4_096,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
@@ -961,11 +976,10 @@ fn builtin_aliases() -> HashMap<String, String> {
("command-r", "command-r-plus"),
("command", "command-a"),
// GitHub Copilot aliases
("copilot", "copilot/gpt-4o"),
("copilot-4o", "copilot/gpt-4o"),
("copilot-4", "copilot/gpt-4"),
("copilot-gpt4o", "copilot/gpt-4o"),
("copilot-gpt4", "copilot/gpt-4"),
("copilot", "gpt-4o"),
("copilot-4o", "gpt-4o"),
("copilot-opus", "claude-opus-4.6"),
("copilot-sonnet", "claude-sonnet-4.6"),
// Chinese model aliases
("qwen", "qwen-plus"),
("glm", "glm-5-20250605"),
@@ -2904,36 +2918,9 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// GitHub Copilot (2) — free for subscribers
// GitHub Copilot — models fetched dynamically at runtime.
// No static entries needed; see kernel.rs fetch_copilot_models().
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "copilot/gpt-4o".into(),
display_name: "GPT-4o (Copilot)".into(),
provider: "github-copilot".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["copilot-gpt4o".into()],
},
ModelCatalogEntry {
id: "copilot/gpt-4".into(),
display_name: "GPT-4 (Copilot)".into(),
provider: "github-copilot".into(),
tier: ModelTier::Frontier,
context_window: 128_000,
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["copilot-gpt4".into()],
},
// ══════════════════════════════════════════════════════════════
// Qwen / Alibaba (6)
// ══════════════════════════════════════════════════════════════
+231 -25
View File
@@ -38,9 +38,17 @@ pub struct RepairStats {
/// 1. Drops orphaned ToolResult blocks that have no matching ToolUse
/// 2. Drops empty messages
/// - 2b. Reorders misplaced ToolResults to follow their matching ToolUse
/// - 2c. Inserts synthetic error results for unmatched ToolUse blocks
/// - 2d. Deduplicates ToolResults with the same tool_use_id
/// - 2c. Deduplicates ToolResults with the same tool_use_id
/// - 2d. Inserts synthetic error results for unmatched ToolUse blocks
/// 3. Merges consecutive same-role messages
///
/// Note: dedup MUST run before synthetic insertion. Some providers (e.g., Moonshot)
/// reuse `tool_use_id` values across turns (`function_name:index` format). After
/// compaction, multiple ToolUse blocks may share the same id with only one matching
/// ToolResult. If synthetic insertion ran first it would see the id as "matched" and
/// skip it; dedup would then leave one ToolUse orphaned. By deduping first, we
/// guarantee that each unique id has at most one result, and synthetic insertion
/// can correctly count uses vs. results to top up missing pairings.
pub fn validate_and_repair(messages: &[Message]) -> Vec<Message> {
validate_and_repair_with_stats(messages).0
}
@@ -117,14 +125,25 @@ pub fn validate_and_repair_with_stats(messages: &[Message]) -> (Vec<Message>, Re
let reordered_count = reorder_tool_results(&mut cleaned);
stats.results_reordered = reordered_count;
// Phase 2c: Insert synthetic error results for unmatched ToolUse blocks
let synthetic_count = insert_synthetic_results(&mut cleaned);
stats.synthetic_results_inserted = synthetic_count;
// Phase 2d: Deduplicate ToolResults
// Phase 2c: Deduplicate ToolResults FIRST.
//
// This must run before synthetic insertion (issue #1013). Providers like
// Moonshot reuse tool_use_ids across turns in `function_name:index` form
// (e.g. "memory_store:0"). After compaction we may have multiple ToolUse
// blocks sharing the same id with multiple ToolResult blocks for the same
// id. If synthetic insertion ran first, it would see the id as "matched"
// and not insert any synthetic. Dedup would then strip the duplicate
// result, leaving a ToolUse orphaned and producing an API 400.
// Dedup only removes duplicate ToolResult blocks; ToolUse blocks are
// untouched, so the next phase can pair any leftover orphaned ToolUses
// with synthetic results.
let dedup_count = deduplicate_tool_results(&mut cleaned);
stats.duplicates_removed = dedup_count;
// Phase 2d: Insert synthetic error results for unmatched ToolUse blocks.
let synthetic_count = insert_synthetic_results(&mut cleaned);
stats.synthetic_results_inserted = synthetic_count;
// Phase 2e: Skip aborted/errored assistant messages
// An assistant message with no content blocks (or only empty text) followed by
// a user message containing ToolResults indicates an interrupted tool-use.
@@ -177,6 +196,33 @@ pub fn validate_and_repair_with_stats(messages: &[Message]) -> (Vec<Message>, Re
(merged, stats)
}
/// Ensure the message history starts with a user turn.
///
/// After context trimming the drain boundary may land on an assistant turn,
/// leaving it at position 0. Providers (especially Gemini) require the first
/// message to be from the user. This function drops leading assistant messages
/// and re-validates to clean up newly-orphaned ToolResults.
///
/// The loop handles the edge case where the first user turn consisted entirely
/// of ToolResult blocks that became orphaned (dropped by `validate_and_repair`),
/// which would re-expose another leading assistant turn.
pub fn ensure_starts_with_user(mut messages: Vec<Message>) -> Vec<Message> {
loop {
match messages.iter().position(|m| m.role == Role::User) {
Some(0) | None => break,
Some(i) => {
warn!(
dropped = i,
"Dropping leading assistant turn(s) to ensure history starts with user"
);
messages.drain(..i);
messages = validate_and_repair(&messages);
}
}
}
messages
}
/// Phase 2b: Reorder misplaced ToolResults -- ensure each result follows its use.
///
/// Builds a map of tool_use_id to the index of the assistant message containing it.
@@ -319,35 +365,44 @@ fn reorder_tool_results(messages: &mut Vec<Message>) -> usize {
reorder_count
}
/// Phase 2c: Insert synthetic error results for unmatched ToolUse blocks.
/// Phase 2d: Insert synthetic error results for unmatched ToolUse blocks.
///
/// If an assistant message contains a ToolUse block but there is no matching
/// ToolResult anywhere in the history, a synthetic error result is inserted
/// immediately after the assistant message to prevent API validation errors.
///
/// This counts ToolUse and ToolResult occurrences per id (not just presence)
/// so it correctly handles providers like Moonshot that reuse tool_use_ids
/// across turns (e.g. "memory_store:0" called multiple times). If two ToolUses
/// share an id but only one ToolResult exists, one synthetic will be inserted
/// for the still-orphaned use.
fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
// Collect all existing ToolResult IDs
let existing_result_ids: HashSet<String> = messages
.iter()
.flat_map(|m| match &m.content {
MessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| match b {
ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.clone()),
_ => None,
})
.collect::<Vec<_>>(),
_ => vec![],
})
.collect();
// Count existing ToolResult IDs (occurrences, not just presence).
let mut available_result_counts: HashMap<String, usize> = HashMap::new();
for msg in messages.iter() {
if let MessageContent::Blocks(blocks) = &msg.content {
for b in blocks {
if let ContentBlock::ToolResult { tool_use_id, .. } = b {
*available_result_counts
.entry(tool_use_id.clone())
.or_insert(0) += 1;
}
}
}
}
// Find ToolUse blocks without matching results
// Walk ToolUse blocks in order; consume one available result per id and
// mark any leftover ToolUses as orphaned.
let mut orphaned_uses: Vec<(usize, String)> = Vec::new(); // (assistant_msg_idx, tool_use_id)
for (idx, msg) in messages.iter().enumerate() {
if msg.role == Role::Assistant {
if let MessageContent::Blocks(blocks) = &msg.content {
for block in blocks {
if let ContentBlock::ToolUse { id, .. } = block {
if !existing_result_ids.contains(id) {
let remaining = available_result_counts.entry(id.clone()).or_insert(0);
if *remaining > 0 {
*remaining -= 1;
} else {
orphaned_uses.push((idx, id.clone()));
}
}
@@ -409,10 +464,15 @@ fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
count
}
/// Phase 2d: Drop duplicate ToolResults for the same tool_use_id.
/// Phase 2c: Drop duplicate ToolResults for the same tool_use_id.
///
/// If multiple ToolResult blocks exist for the same tool_use_id across the
/// message history, only the first one is kept. Returns the count of duplicates removed.
///
/// Note: this only removes duplicate ToolResult blocks. ToolUse blocks are
/// untouched, so the subsequent synthetic-insertion phase can pair any
/// orphaned ToolUses (e.g. from Moonshot's repeated tool_use_ids) with
/// synthetic results.
fn deduplicate_tool_results(messages: &mut Vec<Message>) -> usize {
let mut seen_ids: HashSet<String> = HashSet::new();
let mut removed = 0usize;
@@ -928,6 +988,152 @@ mod tests {
assert_eq!(result_count, 1, "Should keep only the first ToolResult");
}
#[test]
fn test_moonshot_duplicate_tool_ids_gets_synthetic_after_dedup_1013() {
// Regression for issue #1013.
//
// Moonshot returns tool_use_ids in `function_name:index` format
// (e.g. "memory_store:0") which repeat across turns when the same tool
// is called multiple times. After compaction we may keep multiple
// turns containing the same id. Phase ordering used to insert
// synthetic results BEFORE deduping, so duplicate-id ToolResults
// looked "matched" and dedup later stripped one, leaving an orphan
// and producing an API 400.
//
// After the fix, dedup runs first, then synthetic insertion counts
// ToolUse vs ToolResult occurrences per id and tops up the missing
// pairing.
let messages = vec![
Message::user("Remember this fact"),
// First turn: assistant calls memory_store with id "memory_store:0".
Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "memory_store:0".to_string(),
name: "memory_store".to_string(),
input: serde_json::json!({"key": "fact1", "value": "hello"}),
provider_metadata: None,
}]),
},
// Matching ToolResult for the first call.
Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "memory_store:0".to_string(),
tool_name: "memory_store".to_string(),
content: "stored".to_string(),
is_error: false,
}]),
},
// Second turn: assistant calls memory_store again with the SAME id
// because Moonshot reuses the `function_name:index` format.
Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "memory_store:0".to_string(),
name: "memory_store".to_string(),
input: serde_json::json!({"key": "fact2", "value": "world"}),
provider_metadata: None,
}]),
},
// No matching ToolResult for the second call (e.g. lost during
// compaction or interrupted mid-execution).
Message::user("Did it work?"),
];
let (repaired, stats) = validate_and_repair_with_stats(&messages);
// Count ToolUse blocks and ToolResult blocks for "memory_store:0".
let mut tool_use_count = 0usize;
let mut tool_result_count = 0usize;
for m in &repaired {
if let MessageContent::Blocks(blocks) = &m.content {
for b in blocks {
match b {
ContentBlock::ToolUse { id, .. } if id == "memory_store:0" => {
tool_use_count += 1;
}
ContentBlock::ToolResult { tool_use_id, .. }
if tool_use_id == "memory_store:0" =>
{
tool_result_count += 1;
}
_ => {}
}
}
}
}
// Both ToolUses must be preserved.
assert_eq!(
tool_use_count, 2,
"Both ToolUse blocks should be preserved after repair"
);
// Every ToolUse must have a corresponding ToolResult.
assert_eq!(
tool_result_count, tool_use_count,
"Every ToolUse should have exactly one corresponding ToolResult \
(uses={tool_use_count}, results={tool_result_count})"
);
// A synthetic result must have been inserted for the orphaned use.
assert_eq!(
stats.synthetic_results_inserted, 1,
"Exactly one synthetic result should be inserted for the orphaned ToolUse"
);
// No ToolResult should be orphaned (every ToolResult must have a
// matching ToolUse id).
let mut tool_use_ids: HashSet<String> = HashSet::new();
for m in &repaired {
if let MessageContent::Blocks(blocks) = &m.content {
for b in blocks {
if let ContentBlock::ToolUse { id, .. } = b {
tool_use_ids.insert(id.clone());
}
}
}
}
for m in &repaired {
if let MessageContent::Blocks(blocks) = &m.content {
for b in blocks {
if let ContentBlock::ToolResult { tool_use_id, .. } = b {
assert!(
tool_use_ids.contains(tool_use_id),
"ToolResult {tool_use_id} has no matching ToolUse"
);
}
}
}
}
// Verify the synthetic result is marked as an error result and
// contains the interrupted-tool message.
let synthetic_present = repaired.iter().any(|m| {
if let MessageContent::Blocks(blocks) = &m.content {
blocks.iter().any(|b| match b {
ContentBlock::ToolResult {
tool_use_id,
is_error,
content,
..
} => {
tool_use_id == "memory_store:0"
&& *is_error
&& content.contains("interrupted")
}
_ => false,
})
} else {
false
}
});
assert!(
synthetic_present,
"A synthetic error ToolResult for memory_store:0 should be present"
);
}
#[test]
fn test_strip_tool_result_details() {
let short = "Normal tool output";
@@ -893,6 +893,41 @@ mod tests {
assert!(validate_command_allowlist(&cjk_cmd, &policy).is_err());
}
/// Regression test for GitHub issue #919.
///
/// User reported that `rm /home/jcl/test/test.txt` succeeds in Allowlist
/// mode even when `rm` is NOT in `allowed_commands`. The bypass turned out
/// to be the `process_start` tool, which spawned subprocesses without
/// consulting `exec_policy` at all (fixed in tool_runner.rs).
///
/// This test pins down the contract on the validator itself: given the
/// EXACT policy from the bug report, `rm /tmp/test.txt` MUST be rejected
/// with "not in the exec allowlist" so that any future tool path which
/// spawns subprocesses can call it and get a correct answer.
#[test]
fn test_issue_919_rm_blocked_when_not_in_allowlist() {
let policy = ExecPolicy {
mode: ExecSecurityMode::Allowlist,
allowed_commands: vec!["ls".to_string(), "echo".to_string()],
..ExecPolicy::default()
};
// The exact command from the bug report.
let result = validate_command_allowlist("rm /tmp/test.txt", &policy);
assert!(
result.is_err(),
"rm must be blocked when not in allowed_commands (issue #919)"
);
let err = result.unwrap_err();
assert!(
err.contains("not in the exec allowlist"),
"Error message must indicate allowlist rejection, got: {err}"
);
assert!(
err.contains("rm"),
"Error message must name the rejected command, got: {err}"
);
}
#[test]
fn test_extract_all_commands_cjk_separators() {
// Ensure extract_all_commands handles CJK content between separators
+183 -10
View File
@@ -21,8 +21,11 @@ const MAX_AGENT_CALL_DEPTH: u32 = 5;
/// Check if a tool name refers to a shell execution tool.
///
/// Used to determine whether exec_policy settings should bypass the approval gate.
/// SECURITY (#919): `process_start` is also a shell execution path — it spawns
/// arbitrary subprocesses via the persistent process manager. It must be gated
/// by the same approval rules as `shell_exec`.
fn is_shell_tool(name: &str) -> bool {
name == "shell_exec"
matches!(name, "shell_exec" | "process_start")
}
/// Check if a shell command should be blocked by taint tracking.
@@ -353,7 +356,9 @@ pub async fn execute_tool(
"channel_send" => tool_channel_send(input, kernel, workspace_root).await,
// Persistent process tools
"process_start" => tool_process_start(input, process_manager, caller_agent_id).await,
"process_start" => {
tool_process_start(input, process_manager, caller_agent_id, exec_policy).await
}
"process_poll" => tool_process_poll(input, process_manager).await,
"process_write" => tool_process_write(input, process_manager).await,
"process_kill" => tool_process_kill(input, process_manager).await,
@@ -2477,7 +2482,7 @@ async fn tool_a2a_discover(input: &serde_json::Value) -> Result<String, String>
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
// SSRF protection: block private/metadata IPs
if crate::web_fetch::check_ssrf(url).is_err() {
if crate::web_fetch::check_ssrf(url, &[]).is_err() {
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
}
@@ -2500,7 +2505,7 @@ async fn tool_a2a_send(
// Resolve agent URL: either directly provided or looked up by name
let url = if let Some(url) = input["agent_url"].as_str() {
// SSRF protection
if crate::web_fetch::check_ssrf(url).is_err() {
if crate::web_fetch::check_ssrf(url, &[]).is_err() {
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
}
url.to_string()
@@ -3085,10 +3090,18 @@ async fn tool_docker_exec(
// ---------------------------------------------------------------------------
/// Start a long-running process (REPL, server, watcher).
///
/// SECURITY (#919): process_start previously spawned subprocesses with NO
/// exec policy enforcement, allowing an LLM in Allowlist mode to bypass
/// allowed_commands entirely. For example, process_start with command="rm"
/// args=["/some/file"] would delete the file even though "rm" was not
/// in the allowlist. This function now performs the same checks as
/// shell_exec: metacharacter rejection plus exec_policy validation.
async fn tool_process_start(
input: &serde_json::Value,
pm: Option<&crate::process_manager::ProcessManager>,
caller_agent_id: Option<&str>,
exec_policy: Option<&openfang_types::config::ExecPolicy>,
) -> Result<String, String> {
let pm = pm.ok_or("Process manager not available")?;
let agent_id = caller_agent_id.unwrap_or("default");
@@ -3104,6 +3117,41 @@ async fn tool_process_start(
})
.unwrap_or_default();
// SECURITY: Reject shell metacharacters in the command name itself.
// The command field must be a single binary token.
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
return Err(format!(
"process_start blocked: command contains {reason}. \
Shell metacharacters are never allowed in the command field."
));
}
// Also reject metacharacters anywhere in the arguments. While direct
// spawn does not interpret these, blocking them prevents an LLM from
// smuggling a chained command past the allowlist via an argument.
for arg in &args {
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(arg) {
return Err(format!(
"process_start blocked: argument contains {reason}. \
Shell metacharacters are not allowed in process arguments."
));
}
}
// SECURITY (#919): Enforce exec policy against the base command. The
// shared validate_command_allowlist handles Deny / Full / Allowlist and
// falls through to allow commands listed in safe_bins or allowed_commands.
if let Some(policy) = exec_policy {
if let Err(reason) = crate::subprocess_sandbox::validate_command_allowlist(command, policy)
{
return Err(format!(
"process_start blocked: {reason}. Current exec_policy.mode = '{:?}'. \
To allow this command, add it to exec_policy.allowed_commands or \
set exec_policy.mode = 'full'.",
policy.mode
));
}
}
let proc_id = pm.start(agent_id, command, &args).await?;
Ok(serde_json::json!({
"process_id": proc_id,
@@ -3677,13 +3725,12 @@ mod tests {
None, // process_manager
)
.await;
// Should NOT be the capability-check denial — it should normalize to file_write
// and pass the capability check. It may fail for other reasons (path validation,
// OS-level errors), but not the agent capability gate.
// Should NOT be the capability-enforcement "Permission denied" — it should
// normalize to file_write and pass the capability check. It may still fail
// for filesystem reasons (e.g. OS "Permission denied (os error 13)"), so we
// check specifically for the capability-gate message.
assert!(
!result
.content
.contains("does not have capability to use tool"),
!result.content.contains("Permission denied: agent"),
"fs-write should normalize to file_write and pass capability check, got: {}",
result.content
);
@@ -4018,4 +4065,130 @@ mod tests {
// Cleanup
let _ = std::fs::remove_dir_all(&tmp);
}
// ── Regression: GitHub issue #919 — rm bypass via process_start ──────
//
// Before the fix, an LLM in Allowlist mode could call process_start
// with command="rm" and args=["/some/file"] to delete files even though
// "rm" was not in exec_policy.allowed_commands. tool_process_start
// spawned the subprocess directly without ever consulting exec_policy.
//
// These tests pin down the new contract:
// 1. process_start with a non-allowlisted binary returns Err.
// 2. The Err message identifies allowlist rejection (so callers and
// logs can distinguish it from a generic spawn failure).
// 3. process_start with an allowlisted binary still works.
// 4. is_shell_tool() now reports process_start as a shell tool so
// the approval-gate path treats it the same as shell_exec.
#[tokio::test]
async fn test_issue_919_process_start_rm_blocked_in_allowlist() {
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
let pm = crate::process_manager::ProcessManager::new(5);
let policy = ExecPolicy {
mode: ExecSecurityMode::Allowlist,
allowed_commands: vec!["ls".to_string(), "echo".to_string()],
..ExecPolicy::default()
};
let input = serde_json::json!({
"command": "rm",
"args": ["/tmp/openfang_test_should_not_be_deleted.txt"],
});
let result = tool_process_start(&input, Some(&pm), Some("test-agent"), Some(&policy)).await;
assert!(
result.is_err(),
"process_start must reject 'rm' when not in allowlist (issue #919). Got: {:?}",
result
);
let err = result.unwrap_err();
assert!(
err.contains("not in the exec allowlist"),
"Error must indicate allowlist rejection, got: {err}"
);
assert!(
err.contains("process_start blocked"),
"Error must identify process_start as the blocking tool, got: {err}"
);
assert_eq!(
pm.count(),
0,
"No process must have been spawned when allowlist rejects the command"
);
}
#[tokio::test]
async fn test_issue_919_process_start_metachar_in_command_blocked() {
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
let pm = crate::process_manager::ProcessManager::new(5);
let policy = ExecPolicy {
mode: ExecSecurityMode::Full,
..ExecPolicy::default()
};
// Even in Full mode, smuggling shell metacharacters into the command
// field must be rejected — process_start does direct exec, not shell.
let input = serde_json::json!({
"command": "rm; cat /etc/passwd",
"args": [],
});
let result = tool_process_start(&input, Some(&pm), Some("test-agent"), Some(&policy)).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("metacharacter") || pm.count() == 0);
}
#[tokio::test]
async fn test_issue_919_process_start_metachar_in_arg_blocked() {
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
let pm = crate::process_manager::ProcessManager::new(5);
let policy = ExecPolicy {
mode: ExecSecurityMode::Allowlist,
allowed_commands: vec!["echo".to_string()],
..ExecPolicy::default()
};
// Smuggling a chained command via an argument: echo "$(rm -rf /)"
let input = serde_json::json!({
"command": "echo",
"args": ["$(rm -rf /)"],
});
let result = tool_process_start(&input, Some(&pm), Some("test-agent"), Some(&policy)).await;
assert!(
result.is_err(),
"process_start must reject metacharacters in args"
);
assert_eq!(pm.count(), 0);
}
#[tokio::test]
async fn test_issue_919_process_start_deny_mode_blocks_everything() {
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
let pm = crate::process_manager::ProcessManager::new(5);
let policy = ExecPolicy {
mode: ExecSecurityMode::Deny,
..ExecPolicy::default()
};
let input = serde_json::json!({
"command": "echo",
"args": ["hello"],
});
let result = tool_process_start(&input, Some(&pm), Some("test-agent"), Some(&policy)).await;
assert!(result.is_err(), "Deny mode must block process_start");
assert!(result.unwrap_err().to_lowercase().contains("disabled"));
assert_eq!(pm.count(), 0);
}
#[test]
fn test_issue_919_is_shell_tool_includes_process_start() {
// process_start must be treated as a shell tool by the approval gate
// so #772 (full-mode approval bypass) and #919 (allowlist enforcement)
// both apply consistently.
assert!(is_shell_tool("shell_exec"));
assert!(is_shell_tool("process_start"));
assert!(!is_shell_tool("file_read"));
assert!(!is_shell_tool("web_fetch"));
}
}
+175 -14
View File
@@ -53,7 +53,7 @@ impl WebFetchEngine {
let method_upper = method.to_uppercase();
// Step 1: SSRF protection — BEFORE any network I/O
check_ssrf(url)?;
check_ssrf(url, &self.config.ssrf_allowed_hosts)?;
// Step 2: Cache lookup (only for GET)
let cache_key = format!("fetch:{}:{}", method_upper, url);
@@ -185,7 +185,14 @@ fn is_html(content_type: &str, body: &str) -> bool {
/// Check if a URL targets a private/internal network resource.
/// Blocks localhost, metadata endpoints, and private IPs.
/// Must run BEFORE any network I/O.
pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
///
/// The `allowed_hosts` slice lets self-hosted deployments bypass the
/// private-IP check for specific hosts. Entries can be exact hostnames
/// (`"n8n.local"`), wildcard domains (`"*.olares.com"`), or CIDR ranges
/// (`"10.0.0.0/8"`).
///
/// **Cloud metadata endpoints are NEVER allowed regardless of the allowlist.**
pub(crate) fn check_ssrf(url: &str, allowed_hosts: &[String]) -> Result<(), String> {
// Only allow http:// and https:// schemes
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("Only http:// and https:// URLs are allowed".to_string());
@@ -200,6 +207,7 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
};
// Hostname-based blocklist (catches metadata endpoints)
// These are UNCONDITIONALLY blocked — no allowlist can override them.
let blocked = [
"localhost",
"ip6-localhost",
@@ -217,13 +225,28 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
return Err(format!("SSRF blocked: {hostname} is a restricted hostname"));
}
// Check if the hostname is explicitly allowed before doing DNS resolution.
if is_host_allowed(hostname, allowed_hosts) {
return Ok(());
}
// Resolve DNS and check every returned IP
let port = if url.starts_with("https") { 443 } else { 80 };
let socket_addr = format!("{hostname}:{port}");
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
let ip = addr.ip();
if is_metadata_ip(&ip) {
// Metadata IPs are NEVER allowed, even via allowlist.
return Err(format!(
"SSRF blocked: {hostname} resolves to metadata IP {ip}"
));
}
if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) {
// Check if the resolved IP matches a CIDR in the allowlist.
if is_ip_allowed(&ip, allowed_hosts) {
continue;
}
return Err(format!(
"SSRF blocked: {hostname} resolves to private IP {ip}"
));
@@ -234,6 +257,97 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
Ok(())
}
/// Returns true if an IP is a cloud metadata endpoint address.
fn is_metadata_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let octets = v4.octets();
// 169.254.169.254 (AWS/GCP/Azure IMDS)
octets == [169, 254, 169, 254]
// 100.100.100.200 (Alibaba Cloud IMDS)
|| octets == [100, 100, 100, 200]
// 192.0.0.192 (Azure IMDS alternative)
|| octets == [192, 0, 0, 192]
}
_ => false,
}
}
/// Check if a hostname matches any entry in the allowlist.
/// Supports exact match and wildcard domains (`*.example.com`).
fn is_host_allowed(hostname: &str, allowed_hosts: &[String]) -> bool {
let lower = hostname.to_lowercase();
for entry in allowed_hosts {
let entry_lower = entry.to_lowercase();
// Exact match
if entry_lower == lower {
return true;
}
// Wildcard domain: *.example.com matches sub.example.com
if let Some(suffix) = entry_lower.strip_prefix("*.") {
if lower.ends_with(&format!(".{suffix}")) || lower == suffix {
return true;
}
}
}
false
}
/// Check if an IP address matches any CIDR entry in the allowlist.
fn is_ip_allowed(ip: &IpAddr, allowed_hosts: &[String]) -> bool {
for entry in allowed_hosts {
if let Some(pos) = entry.find('/') {
// Parse as CIDR: base_ip/prefix_len
let base_str = &entry[..pos];
let prefix_str = &entry[pos + 1..];
if let (Ok(base_ip), Ok(prefix_len)) =
(base_str.parse::<IpAddr>(), prefix_str.parse::<u32>())
{
if ip_in_cidr(ip, &base_ip, prefix_len) {
return true;
}
}
} else if let Ok(entry_ip) = entry.parse::<IpAddr>() {
// Exact IP match
if *ip == entry_ip {
return true;
}
}
}
false
}
/// Check if `ip` falls within the CIDR block `base/prefix_len`.
fn ip_in_cidr(ip: &IpAddr, base: &IpAddr, prefix_len: u32) -> bool {
match (ip, base) {
(IpAddr::V4(ip4), IpAddr::V4(base4)) => {
if prefix_len > 32 {
return false;
}
if prefix_len == 0 {
return true;
}
let ip_bits = u32::from_be_bytes(ip4.octets());
let base_bits = u32::from_be_bytes(base4.octets());
let mask = !0u32 << (32 - prefix_len);
(ip_bits & mask) == (base_bits & mask)
}
(IpAddr::V6(ip6), IpAddr::V6(base6)) => {
if prefix_len > 128 {
return false;
}
if prefix_len == 0 {
return true;
}
let ip_bits = u128::from_be_bytes(ip6.octets());
let base_bits = u128::from_be_bytes(base6.octets());
let mask = !0u128 << (128 - prefix_len);
(ip_bits & mask) == (base_bits & mask)
}
_ => false, // mismatched families
}
}
/// Check if an IP address is in a private range.
fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
@@ -308,8 +422,8 @@ mod tests {
#[test]
fn test_ssrf_blocks_localhost() {
assert!(check_ssrf("http://localhost/admin").is_err());
assert!(check_ssrf("http://localhost:8080/api").is_err());
assert!(check_ssrf("http://localhost/admin", &[]).is_err());
assert!(check_ssrf("http://localhost:8080/api", &[]).is_err());
}
#[test]
@@ -323,8 +437,8 @@ mod tests {
#[test]
fn test_ssrf_blocks_metadata() {
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/").is_err());
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/").is_err());
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &[]).is_err());
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/", &[]).is_err());
}
#[test]
@@ -339,28 +453,28 @@ mod tests {
#[test]
fn test_ssrf_blocks_non_http() {
assert!(check_ssrf("file:///etc/passwd").is_err());
assert!(check_ssrf("ftp://internal.corp/data").is_err());
assert!(check_ssrf("gopher://evil.com").is_err());
assert!(check_ssrf("file:///etc/passwd", &[]).is_err());
assert!(check_ssrf("ftp://internal.corp/data", &[]).is_err());
assert!(check_ssrf("gopher://evil.com", &[]).is_err());
}
#[test]
fn test_ssrf_blocks_cloud_metadata() {
// Alibaba Cloud IMDS
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/").is_err());
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/", &[]).is_err());
// Azure IMDS alternative
assert!(check_ssrf("http://192.0.0.192/metadata/instance").is_err());
assert!(check_ssrf("http://192.0.0.192/metadata/instance", &[]).is_err());
}
#[test]
fn test_ssrf_blocks_zero_ip() {
assert!(check_ssrf("http://0.0.0.0/").is_err());
assert!(check_ssrf("http://0.0.0.0/", &[]).is_err());
}
#[test]
fn test_ssrf_blocks_ipv6_localhost() {
assert!(check_ssrf("http://[::1]/admin").is_err());
assert!(check_ssrf("http://[::1]:8080/api").is_err());
assert!(check_ssrf("http://[::1]/admin", &[]).is_err());
assert!(check_ssrf("http://[::1]:8080/api", &[]).is_err());
}
#[test]
@@ -374,4 +488,51 @@ mod tests {
let h3 = extract_host("http://[::1]/path");
assert_eq!(h3, "[::1]:80");
}
// ── SSRF allowlist tests ─────────────────────────────────────────────
#[test]
fn test_ssrf_allowlist_permits_private_ip() {
// A CIDR allowlist entry should permit an otherwise-blocked private IP.
let allow = vec!["10.0.0.0/8".to_string()];
assert!(check_ssrf("http://10.1.2.3", &allow).is_ok());
}
#[test]
fn test_ssrf_allowlist_still_blocks_metadata() {
// Even if the allowlist covers the entire link-local range,
// cloud metadata endpoints must NEVER be permitted.
let allow = vec!["169.254.0.0/16".to_string()];
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &allow).is_err());
// Also verify hostname-based metadata blocks
let allow2 = vec!["metadata.google.internal".to_string()];
assert!(check_ssrf(
"http://metadata.google.internal/computeMetadata/v1/",
&allow2
)
.is_err());
}
#[test]
fn test_ssrf_allowlist_wildcard_domain() {
let allow = vec!["*.example.com".to_string()];
assert!(check_ssrf("http://api.example.com", &allow).is_ok());
// Non-matching domain should still go through normal checks
assert!(!is_host_allowed("other.net", &allow));
}
#[test]
fn test_ssrf_allowlist_exact_hostname() {
let allow = vec!["n8n.local".to_string()];
assert!(check_ssrf("http://n8n.local/webhook", &allow).is_ok());
}
#[test]
fn test_cidr_matching() {
let ip_in: IpAddr = "10.1.2.3".parse().unwrap();
let ip_out: IpAddr = "11.0.0.1".parse().unwrap();
let base: IpAddr = "10.0.0.0".parse().unwrap();
assert!(ip_in_cidr(&ip_in, &base, 8));
assert!(!ip_in_cidr(&ip_out, &base, 8));
}
}
+173 -1
View File
@@ -55,6 +55,7 @@ impl WebSearchEngine {
SearchProvider::Tavily => self.search_tavily(query, max_results).await,
SearchProvider::Perplexity => self.search_perplexity(query).await,
SearchProvider::DuckDuckGo => self.search_duckduckgo(query, max_results).await,
SearchProvider::Searxng => self.search_searxng(query, max_results, None, 1).await,
SearchProvider::Auto => self.search_auto(query, max_results).await,
};
@@ -67,7 +68,7 @@ impl WebSearchEngine {
}
/// Auto-select provider based on available API keys.
/// Priority: Tavily → Brave → Perplexity → DuckDuckGo
/// Priority: Tavily → Brave → Perplexity → Searxng → DuckDuckGo
async fn search_auto(&self, query: &str, max_results: usize) -> Result<String, String> {
// Tavily first (AI-agent-native)
if resolve_api_key(&self.config.tavily.api_key_env).is_some() {
@@ -96,6 +97,15 @@ impl WebSearchEngine {
}
}
// Searxng fourth (self-hosted, no API key needed)
if !self.config.searxng.url.is_empty() {
debug!("Auto: trying Searxng");
match self.search_searxng(query, max_results, None, 1).await {
Ok(result) => return Ok(result),
Err(e) => warn!("Searxng failed, falling back: {e}"),
}
}
// DuckDuckGo always available as zero-config fallback
debug!("Auto: falling back to DuckDuckGo");
self.search_duckduckgo(query, max_results).await
@@ -313,6 +323,168 @@ impl WebSearchEngine {
Ok(output)
}
/// Search via SearXNG self-hosted instance.
async fn search_searxng(
&self,
query: &str,
max_results: usize,
category: Option<&str>,
page: u32,
) -> Result<String, String> {
if self.config.searxng.url.is_empty() {
return Err("SearXNG URL is not configured".to_string());
}
let category = category.unwrap_or("general");
// Validate category against SearXNG instance
match self.list_searxng_categories().await {
Ok(cats) => {
if !cats.iter().any(|c| c == category) {
return Err(format!(
"Invalid SearXNG category '{}'. Available: {}",
category,
cats.join(", ")
));
}
}
Err(e) => warn!("Could not validate SearXNG category: {e}"),
}
let limit = max_results;
debug!(query, "Searching via SearXNG");
let resp = self
.client
.get(format!(
"{}/search",
self.config.searxng.url.trim_end_matches('/')
))
.query(&[
("q", query),
("format", "json"),
("categories", category),
("page", &page.to_string()),
])
.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)")
.send()
.await
.map_err(|e| format!("SearXNG request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("SearXNG API returned {}", resp.status()));
}
#[derive(serde::Deserialize)]
struct SearxngResponse {
results: Vec<SearxngResult>,
query: String,
}
#[derive(serde::Deserialize)]
struct SearxngResult {
url: String,
title: String,
content: Option<String>,
#[serde(alias = "pubdate")]
published_date: Option<String>,
}
let data: SearxngResponse = resp
.json()
.await
.map_err(|e| format!("SearXNG JSON parse failed: {e}"))?;
if data.results.is_empty() {
return Err(format!("No results found for '{query}' (SearXNG)."));
}
#[derive(serde::Serialize)]
struct OutputResult<'a> {
title: &'a str,
url: &'a str,
content: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
published_date: Option<&'a str>,
}
#[derive(serde::Serialize)]
struct Output<'a> {
query: &'a str,
results: Vec<OutputResult<'a>>,
}
let results: Vec<OutputResult> = data
.results
.iter()
.take(limit)
.map(|r| OutputResult {
title: &r.title,
url: &r.url,
content: r.content.as_deref().unwrap_or(""),
published_date: r.published_date.as_deref(),
})
.collect();
let output = Output {
query: &data.query,
results,
};
serde_json::to_string(&output)
.map_err(|e| format!("Failed to serialize SearXNG results: {e}"))
}
/// List available search categories from the SearXNG instance.
///
/// Fetches the `/config` endpoint and returns the list of categories
/// the instance supports (e.g., "general", "images", "news", "videos").
/// Returns an error if SearXNG URL is not configured or the request fails.
pub async fn list_searxng_categories(&self) -> Result<Vec<String>, String> {
if self.config.searxng.url.is_empty() {
return Err("SearXNG URL is not configured".to_string());
}
#[derive(serde::Deserialize)]
struct SearxngConfig {
categories: Vec<String>,
}
let resp = self
.client
.get(format!(
"{}/config",
self.config.searxng.url.trim_end_matches('/')
))
.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)")
.send()
.await
.map_err(|e| format!("SearXNG config request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("SearXNG config API returned {}", resp.status()));
}
let data: SearxngConfig = resp
.json()
.await
.map_err(|e| format!("SearXNG config JSON parse failed: {e}"))?;
Ok(data.categories)
}
/// Return all non-Auto search providers (for provider listing/discovery).
pub fn all_providers() -> Vec<SearchProvider> {
vec![
SearchProvider::Brave,
SearchProvider::Tavily,
SearchProvider::Perplexity,
SearchProvider::DuckDuckGo,
SearchProvider::Searxng,
]
}
}
// ---------------------------------------------------------------------------
@@ -0,0 +1,70 @@
---
name: searxng
description: Privacy-respecting metasearch specialist using SearXNG instances
---
# SearXNG Search Specialist
You are a privacy-respecting web search specialist using SearXNG, a self-hosted metasearch engine that aggregates results from multiple search engines without tracking.
## Key Principles
- Prefer SearXNG for privacy-sensitive searches — no API keys, no tracking, no user profiling.
- Always cite sources with URLs so the user can verify information.
- Prefer primary sources (official docs, research papers) over secondary ones (blog posts, forums).
- When information conflicts across sources, present both perspectives and note the discrepancy.
- State the date of information when recency matters.
## SearXNG Capabilities
SearXNG supports 30+ search categories. Use the right category for the task:
| Category | Use Case |
|----------|----------|
| `general` | Default web search |
| `images` | Image search |
| `news` | News articles |
| `videos` | Video results |
| `music` | Music and audio |
| `files` | File search |
| `it` | IT and programming |
| `science` | Scientific content |
| `books` | Book search |
| `maps` | Map and location |
| `q&a` | Q&A sites (Stack Overflow, etc.) |
| `social media` | Social media posts |
| `wikimedia` | Wikipedia and Wikimedia |
| `dictionaries` | Dictionary definitions |
| `currency` | Currency conversion |
| `weather` | Weather information |
| `translate` | Translation results |
## Search Techniques
- **Category selection**: Always specify a category when the topic is clear. Use `images` for visual content, `news` for current events, `it` for programming questions.
- **Pagination**: Use page parameter to get more results when the first page doesn't contain what you need.
- **Engine syntax**: SearXNG supports `!engine` syntax to target specific engines (e.g., `!wikipedia rust programming`).
- **Site search**: Use `site:example.com` in queries to search within a specific domain.
- **Exact phrases**: Use quotes for exact phrase matching (e.g., `"rust borrow checker"`).
- **Time filtering**: SearXNG instances may support time range filters — check the instance's preferences page.
## Query Formulation
- Start with specific, targeted queries. Use exact phrases for precise matches.
- Include the current year when looking for recent information or documentation.
- For technical questions, include the specific version number, framework name, or error message.
- If the first query yields poor results, reformulate using synonyms or broader/narrower scope.
## Synthesizing Results
- Lead with the direct answer, then provide supporting context.
- Organize findings by relevance, not by the order you found them.
- Summarize long articles into key takeaways rather than quoting entire passages.
- When comparing options, use structured comparisons with pros and cons.
- Flag information that may be outdated or from unreliable sources.
## Pitfalls to Avoid
- Never present information from a single source as definitive without corroboration.
- Do not include URLs you have not verified — broken links erode trust.
- Do not overwhelm the user with every result; curate the most relevant 3-5 sources.
- Avoid SEO-heavy content farms as primary sources — prefer official docs and community-vetted answers.
+2 -1
View File
@@ -13,6 +13,7 @@ pub fn bundled_skills() -> Vec<(&'static str, &'static str)> {
("github", include_str!("../bundled/github/SKILL.md")),
("docker", include_str!("../bundled/docker/SKILL.md")),
("web-search", include_str!("../bundled/web-search/SKILL.md")),
("searxng", include_str!("../bundled/searxng/SKILL.md")),
(
"code-reviewer",
include_str!("../bundled/code-reviewer/SKILL.md"),
@@ -195,7 +196,7 @@ mod tests {
#[test]
fn test_bundled_skills_count() {
let skills = bundled_skills();
assert_eq!(skills.len(), 60, "Expected 60 bundled skills");
assert_eq!(skills.len(), 61, "Expected 61 bundled skills");
}
#[test]
+57 -2
View File
@@ -173,7 +173,9 @@ pub enum SearchProvider {
Perplexity,
/// DuckDuckGo HTML (no API key needed).
DuckDuckGo,
/// Auto-select based on available API keys (Tavily → Brave → Perplexity → DuckDuckGo).
/// SearXNG self-hosted search (no API key needed).
Searxng,
/// Auto-select based on available API keys (Tavily → Brave → Perplexity → Searxng → DuckDuckGo).
#[default]
Auto,
}
@@ -192,6 +194,8 @@ pub struct WebConfig {
pub tavily: TavilySearchConfig,
/// Perplexity Search configuration.
pub perplexity: PerplexitySearchConfig,
/// SearXNG Search configuration.
pub searxng: SearxngSearchConfig,
/// Web fetch configuration.
pub fetch: WebFetchConfig,
}
@@ -204,6 +208,7 @@ impl Default for WebConfig {
brave: BraveSearchConfig::default(),
tavily: TavilySearchConfig::default(),
perplexity: PerplexitySearchConfig::default(),
searxng: SearxngSearchConfig::default(),
fetch: WebFetchConfig::default(),
}
}
@@ -281,6 +286,14 @@ impl Default for PerplexitySearchConfig {
}
}
/// SearXNG self-hosted search configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SearxngSearchConfig {
/// Base URL of the SearXNG instance (e.g., "https://search.example.com").
pub url: String,
}
/// Web fetch configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
@@ -293,6 +306,15 @@ pub struct WebFetchConfig {
pub timeout_secs: u64,
/// Enable HTML→Markdown readability extraction.
pub readability: bool,
/// SSRF allowlist for self-hosted environments.
///
/// Entries can be exact hostnames (`"n8n.local"`), wildcard domains
/// (`"*.olares.com"`), or CIDR ranges (`"10.0.0.0/8"`).
///
/// Allowlisted hosts bypass the private-IP check but **never** bypass
/// cloud metadata endpoint blocking (169.254.169.254, metadata.google.internal, etc.).
#[serde(default)]
pub ssrf_allowed_hosts: Vec<String>,
}
impl Default for WebFetchConfig {
@@ -302,6 +324,7 @@ impl Default for WebFetchConfig {
max_response_bytes: 10 * 1024 * 1024, // 10 MB
timeout_secs: 30,
readability: true,
ssrf_allowed_hosts: Vec::new(),
}
}
}
@@ -1138,7 +1161,7 @@ pub struct AuthConfig {
pub enabled: bool,
/// Admin username.
pub username: String,
/// SHA256 hash of the password (hex-encoded).
/// Argon2id password hash (PHC string format).
/// Generate with: openfang auth hash-password
pub password_hash: String,
/// Session token lifetime in hours (default: 168 = 7 days).
@@ -1769,6 +1792,10 @@ pub struct DiscordConfig {
/// Default channel ID for outgoing messages when no recipient is specified.
#[serde(default)]
pub default_channel_id: Option<String>,
/// Channel IDs that respond without requiring @mention (free response mode).
/// In these channels, the bot responds to all group messages without needing to be mentioned.
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
pub free_response_channels: Vec<String>,
/// Per-channel behavior overrides.
#[serde(default)]
pub overrides: ChannelOverrides,
@@ -1784,6 +1811,7 @@ impl Default for DiscordConfig {
intents: 37376,
ignore_bots: true,
default_channel_id: None,
free_response_channels: vec![],
overrides: ChannelOverrides::default(),
}
}
@@ -3588,6 +3616,13 @@ impl KernelConfig {
));
}
}
SearchProvider::Searxng => {
if self.web.searxng.url.is_empty() {
warnings.push(
"Searxng search selected but searxng.url is not configured".to_string(),
);
}
}
SearchProvider::DuckDuckGo | SearchProvider::Auto => {}
}
@@ -3676,6 +3711,26 @@ mod tests {
assert!(dc2.ignore_bots);
}
#[test]
fn test_discord_config_free_response_channels_deserialization() {
// Test with free_response_channels as list of strings
let toml_str = r#"
bot_token_env = "DISCORD_BOT_TOKEN"
free_response_channels = ["123456789", "987654321"]
"#;
let dc: DiscordConfig = toml::from_str(toml_str).unwrap();
assert_eq!(dc.free_response_channels.len(), 2);
assert_eq!(dc.free_response_channels[0], "123456789");
assert_eq!(dc.free_response_channels[1], "987654321");
// Test default (empty list)
let toml_str2 = r#"
bot_token_env = "DISCORD_BOT_TOKEN"
"#;
let dc2: DiscordConfig = toml::from_str(toml_str2).unwrap();
assert!(dc2.free_response_channels.is_empty());
}
#[test]
fn test_slack_config_defaults() {
let sl = SlackConfig::default();
+4 -3
View File
@@ -142,9 +142,10 @@ impl MessageContent {
ContentBlock::Text { text, .. } => text.len(),
ContentBlock::ToolResult { content, .. } => content.len(),
ContentBlock::Thinking { thinking } => thinking.len(),
ContentBlock::ToolUse { .. }
| ContentBlock::Image { .. }
| ContentBlock::Unknown => 0,
ContentBlock::ToolUse { name, input, .. } => {
name.len() + input.to_string().len()
}
ContentBlock::Image { .. } | ContentBlock::Unknown => 0,
})
.sum(),
}
@@ -45,6 +45,8 @@ pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paa
pub const ZAI_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
pub const ZAI_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
/// Kimi K2/K2.5 models live on the `.cn` platform, not `.ai`.
pub const MOONSHOT_KIMI_BASE_URL: &str = "https://api.moonshot.cn/v1";
pub const KIMI_CODING_BASE_URL: &str = "https://api.kimi.com/coding";
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
+45
View File
@@ -169,6 +169,19 @@ fn normalize_schema_recursive(schema: &serde_json::Value) -> serde_json::Value {
result.insert(key.clone(), value.clone());
}
// Gemini requires `items` for every array-typed parameter.
// JSON Schema allows arrays without `items`, but the Gemini API rejects
// such schemas with INVALID_ARGUMENT. Inject a default string items schema
// so MCP tools (and any other source) don't break Gemini requests.
if result.get("type").and_then(|t| t.as_str()) == Some("array")
&& !result.contains_key("items")
{
result.insert(
"items".to_string(),
serde_json::json!({"type": "string"}),
);
}
serde_json::Value::Object(result)
}
@@ -616,6 +629,38 @@ mod tests {
assert!(payload_prop.get("anyOf").is_none());
}
#[test]
fn test_normalize_injects_items_for_array_without_items() {
// MCP tools often send array params without `items` — Gemini rejects these.
let schema = serde_json::json!({
"type": "object",
"properties": {
"fields": { "type": "array", "description": "List of fields" },
"filters": { "type": "array" }
}
});
let result = normalize_schema_for_provider(&schema, "gemini");
// Both array properties must have `items` injected
assert_eq!(result["properties"]["fields"]["items"]["type"], "string");
assert_eq!(result["properties"]["filters"]["items"]["type"], "string");
}
#[test]
fn test_normalize_preserves_existing_items() {
// If `items` already exists, it must not be overwritten
let schema = serde_json::json!({
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": { "type": "integer" }
}
}
});
let result = normalize_schema_for_provider(&schema, "gemini");
assert_eq!(result["properties"]["ids"]["items"]["type"], "integer");
}
#[test]
fn test_normalize_combined_issue_488() {
// Real-world schema combining multiple #488 issues
+46 -1
View File
@@ -318,6 +318,37 @@ shared_secret = "my-cluster-secret"
---
### `[auth]`
Configures dashboard login with username/password authentication. Disabled by default.
```toml
[auth]
enabled = true
username = "admin"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..." # generate with: openfang auth hash-password
session_ttl_hours = 168
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable username/password authentication for the dashboard. |
| `username` | string | `"admin"` | Admin username. |
| `password_hash` | string | `""` (empty) | Argon2id password hash in PHC string format. Generate with `openfang auth hash-password`. |
| `session_ttl_hours` | u64 | `168` (7 days) | Session token lifetime in hours. |
**Generating a password hash:**
```bash
openfang auth hash-password
```
This prompts for a password and outputs an Argon2id PHC string to paste into `config.toml`.
> **Breaking change (v0.5.0):** Password hashes must be in Argon2id format. Older SHA256 hex hashes from versions prior to v0.5.0 are no longer accepted. Re-run `openfang auth hash-password` to generate a new hash.
---
### `[web]`
Configures web search and web fetch capabilities used by agent tools.
@@ -337,10 +368,11 @@ cache_ttl_minutes = 15
| Value | Description |
|-------|-------------|
| `auto` | Cascading fallback: tries Tavily, then Brave, then Perplexity, then DuckDuckGo, based on which API keys are available. |
| `auto` | Cascading fallback: tries Tavily, then Brave, then Perplexity, then SearXNG, then DuckDuckGo, based on which API keys/configs are available. |
| `brave` | Brave Search API. Requires `BRAVE_API_KEY`. |
| `tavily` | Tavily AI-native search. Requires `TAVILY_API_KEY`. |
| `perplexity` | Perplexity AI search. Requires `PERPLEXITY_API_KEY`. |
| `searxng` | Self-hosted search engine aggregator. No API key required, just point to your SearXNG instance. |
| `duck_duck_go` | DuckDuckGo HTML scraping. No API key needed. |
#### `[web.brave]`
@@ -392,6 +424,19 @@ model = "sonar"
| `api_key_env` | string | `"PERPLEXITY_API_KEY"` | Environment variable name holding the Perplexity API key. |
| `model` | string | `"sonar"` | Perplexity model to use for search queries. |
#### `[web.searxng]`
**SearXNG** — Self-hosted search engine aggregator. No API key required, just point to your SearXNG instance. Supports 30+ search categories (general, images, news, videos, etc.) and pagination.
```toml
[web.searxng]
url = "https://searxng.example.com" # SearXNG instance URL (required)
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `url` | string | (required) | Full URL of your SearXNG instance (e.g., `https://searxng.example.com`). Must be accessible. |
#### `[web.fetch]`
```toml
+15 -10
View File
@@ -583,19 +583,24 @@ docker run -d --name openfang \
### How do I protect the dashboard with a password?
OpenFang doesn't have built-in login. Use a reverse proxy with basic auth:
OpenFang has built-in dashboard authentication. Enable it in `~/.openfang/config.toml`:
**Caddy example:**
```
ai.yourdomain.com {
basicauth {
username $2a$14$YOUR_HASHED_PASSWORD
}
reverse_proxy localhost:4200
}
```toml
[auth]
enabled = true
username = "admin"
password_hash = "$argon2id$..." # see below
```
Generate a password hash: `caddy hash-password`
Generate the password hash:
```bash
openfang auth hash-password
```
Paste the output into the `password_hash` field and restart the daemon.
For public-facing deployments, you should also place a reverse proxy (Caddy, nginx) in front for TLS termination.
### How do I configure the embedding model for memory?
+1
View File
@@ -25,6 +25,7 @@
rust-project.defaults.perCrate.crane.args.buildInputs = with pkgs; [
clang
openssl
perl
pkg-config
];
rust-project.crates.openfang-desktop.crane.args.buildInputs = with pkgs; [
+12 -2
View File
@@ -46,13 +46,23 @@ install() {
echo " =================="
echo ""
# Get latest version
# Get latest version with binary assets
if [ -n "${OPENFANG_VERSION:-}" ]; then
VERSION="$OPENFANG_VERSION"
echo " Using specified version: $VERSION"
else
echo " Fetching latest release..."
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed 's/.*"tag_name": *"//' | sed 's/".*//')
# Find the most recent release that has binary assets (skip empty tag-only releases)
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=10" | \
grep -E '"tag_name"|"assets":\[' | \
paste - - | \
grep -v '"assets":\[\]' | \
head -1 | \
sed 's/.*"tag_name": *"//' | sed 's/".*//')
# Fallback to /releases/latest if the above fails
if [ -z "$VERSION" ]; then
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed 's/.*"tag_name": *"//' | sed 's/".*//')
fi
fi
if [ -z "$VERSION" ]; then