Compare commits

..
222 Commits
Author SHA1 Message Date
jaberjaber23 acf2587e46 bump v0.6.9 2026-05-12 21:42:08 +03:00
jaberjaber23 4583157b49 audit fixes 2026-05-12 21:13:41 +03:00
jaberjaber23 7185ea8808 cargo fmt 2026-05-12 20:57:57 +03:00
jaberjaber23 4aa1508f54 clippy lint 2026-05-12 20:43:56 +03:00
jaberjaber23 5447bf7f1d toctou staging 2026-05-12 17:11:36 +03:00
jaberjaber23 b77ebfb897 thread routing 2026-05-12 17:09:09 +03:00
jaberjaber23 2323cd5e67 audit hands 2026-05-12 17:06:43 +03:00
jaberjaber23 df29e5e8b9 audit endpoint 2026-05-12 17:05:42 +03:00
jaberjaber23 8b411c21c4 codex hardening 2026-05-12 16:38:50 +03:00
jaberjaber23 b00af5eddd add requesty 2026-05-12 16:35:22 +03:00
jaberjaber23 36177425c4 skill tools 2026-05-12 16:34:49 +03:00
jaberjaber23 6bed6c04ff workspace split 2026-05-12 16:33:49 +03:00
jaberjaber23 4c496be02f integration fixes 2026-05-12 16:13:08 +03:00
jaberjaber23 7f7b071528 provider refs 2026-05-12 15:53:15 +03:00
jaberjaber23 2d1fb8171c matrix refresh 2026-05-12 15:50:44 +03:00
Jaber Jaber e683acc565 Merge pull request #1045 from dongtran16092006/fix-mcp-system-prompt
fix: system prompt and identity handling, and config form hydration
2026-05-12 15:49:20 +03:00
jaberjaber23 5396889ff1 bedrock redacted 2026-05-12 15:48:54 +03:00
jaberjaber23 c9e8d31571 ws auth align 2026-05-12 15:47:38 +03:00
Jaber Jaber d8ad91572e Merge pull request #1054 from Hypn0sis/feat/discord-smart-thread
feat(discord): smart auto-thread mode (true/false/smart)
2026-05-12 15:47:05 +03:00
jaberjaber23 c27bfebd17 bump v0.6.7 2026-05-12 15:44:29 +03:00
jaberjaber23 f05ba5e42f tts image urls 2026-05-12 15:38:31 +03:00
jaberjaber23 d9e72abb4b uninstall agent 2026-05-12 15:37:44 +03:00
jaberjaber23 505a8e8080 hand stop 2026-05-12 15:37:02 +03:00
jaberjaber23 5cc865e6e6 shell env 2026-05-12 15:34:39 +03:00
jaberjaber23 6a1ce40d86 require signed 2026-05-12 15:34:01 +03:00
jaberjaber23 fbb7936234 docker docs 2026-05-12 15:30:40 +03:00
jaberjaber23 569e76c79a ws reconnect 2026-05-12 15:29:56 +03:00
jaberjaber23 88ad029999 bump v0.6.6 2026-05-12 15:27:09 +03:00
jaberjaber23 838836b29c integration fixes 2026-05-12 15:25:34 +03:00
jaberjaber23 8564872181 create directory 2026-05-12 15:12:16 +03:00
jaberjaber23 efbefa1682 chat agents 2026-05-12 15:10:59 +03:00
Jaber Jaber bdcd440cd6 Merge pull request #1143 from benhoverter/discord-file-sharing
fix(channels/discord): surface image attachments to text-only providers
2026-05-12 15:08:30 +03:00
Jaber Jaber 25516c7f87 Merge pull request #1168 from nimitbhardwaj/fix/latex-rendering
fix: render LaTeX math in chat messages
2026-05-12 15:08:15 +03:00
Jaber Jaber ae2706bdab Merge pull request #1176 from nimitbhardwaj/fix/new-line-chat
fix(chat): support Shift+Enter for multi-line input and proper newline display
2026-05-12 15:07:58 +03:00
Jaber Jaber 32299bb506 Merge pull request #1147 from benhoverter/harden-channel-id-binding
feat(channels): harden channel_id binding — adapter allowlist, strict validation, single source of truth for routing
2026-05-12 15:07:50 +03:00
jaberjaber23 6f8463fc91 bump v0.6.5 2026-05-12 15:05:53 +03:00
jaberjaber23 6b03cb2e9d test fix 2026-05-12 15:04:47 +03:00
Jaber Jaber 8cb7541678 Merge pull request #1146 from benhoverter/lenient-binding-parse
fix(kernel): lenient binding parsing — one typo no longer drops the entire bindings table
2026-05-12 14:57:09 +03:00
jaberjaber23 6b5b7674d3 server ids 2026-05-12 14:56:32 +03:00
jaberjaber23 247dca508b inferencing flag 2026-05-12 14:55:47 +03:00
Jaber Jaber 90d16e52be Merge pull request #1175 from aqilaziz/docs-fix-getting-started-links
Fix getting started documentation links
2026-05-12 14:55:27 +03:00
jaberjaber23 68bde60fac activate agents 2026-05-12 14:54:22 +03:00
Jaber Jaber a422058049 Merge pull request #1135 from RightNow-AI/dependabot/cargo/open-5.3.4
build(deps): bump open from 5.3.3 to 5.3.4
2026-05-12 14:53:44 +03:00
jaberjaber23 6ba0bfb7ef providers screen 2026-05-12 14:53:17 +03:00
jaberjaber23 7699b86037 clone agent 2026-05-12 14:53:10 +03:00
jaberjaber23 e31216d5ec docs accuracy 2026-05-12 14:51:09 +03:00
jaberjaber23 538e943d3d clippy fix 2026-05-12 14:46:00 +03:00
jaberjaber23 94fca22124 redacted thinking 2026-05-12 14:36:05 +03:00
jaberjaber23 37e2043ed7 another timeout 2026-05-12 14:35:12 +03:00
jaberjaber23 31eb833cdf agent history 2026-05-12 14:34:22 +03:00
jaberjaber23 15da248faf another timeout 2026-05-12 14:33:05 +03:00
jaberjaber23 c27a6f3609 local fallback 2026-05-12 14:26:58 +03:00
jaberjaber23 f792f1a14b ws auth 2026-05-12 14:26:27 +03:00
Nimit Bhardwaj 5e228336e4 fix(chat): support Shift+Enter for multi-line input and proper newline display 2026-05-08 23:12:29 +05:30
aqilaziz 8b10930e40 Fix getting started documentation links 2026-05-08 06:21:25 +07:00
Nimit Bhardwaj 5c1b1508a2 Fix Latex Rendering in Openfang Web 2026-05-07 00:24:36 +05:30
Ben Hoverter 701fcd8e2e channels/bridge: disable transparent decompression on image download
Discord's CDN edges occasionally advertise `content-encoding: gzip` (or
deflate/brotli) on PNG/JPEG passthroughs while the body is raw,
uncompressed image bytes. With the default `reqwest::Client::new()` and
the workspace's gzip/deflate/brotli features all enabled, reqwest's
transparent-decompression layer chokes on the PNG/JPEG header and
returns "error decoding response body" only on `bytes().await` (not on
`send()`), causing `download_image_to_blocks` to silently fall back to a
text-only block — the user's image never reaches the model.

Build the client explicitly with no_gzip/no_deflate/no_brotli so the
request advertises identity encoding and the body is read raw. Also set
a User-Agent (some CDN edges 403 clients without one) and a 30s timeout
aligned with the upstream 5 MB cap.

Repro: send an image attachment via Discord; the daemon logs
`Failed to read image bytes: error decoding response body` and the turn
appends as text-only with `appended_has_image=false`. After this fix the
PNG bytes are read and emitted as an Image content block as intended.
2026-05-04 12:04:31 -07:00
Ben Hoverter 118eacea64 channels: handle Discord image attachments coherently across providers
Discord MESSAGE_CREATE payloads with attachments were previously parsed
in a way that either dropped the attachment (when text was present, only
the text was kept) or dropped the whole message (when text was empty,
the early `content.is_empty()` return killed bare-image posts). The
result on text-only providers like claude-code: silent drops, then
hallucinated acknowledgements of content the model never saw.

This rewires the inbound path end-to-end:

* types: add ChannelContent::Multipart(Vec<ChannelContent>) so a single
  inbound message can carry a caption + one or more attachments as
  sibling blocks. Doc forbids nesting; consumers debug_assert.

* discord: classify attachments by MIME (with extension fallback for
  bot-relayed payloads that omit content_type) and a 5 MB vision-size
  cap matching Anthropic's image block limit. Vision-eligible images
  become ChannelContent::Image; everything else becomes File. Emit
  Multipart whenever text and attachments coexist, or when there are
  multiple attachments.

* bridge: flat-map Multipart in both dispatch paths — into Vec<ContentBlock>
  for multimodal-capable providers, and into a newline-joined text
  descriptor for text-flatten providers.

* telegram: add the Multipart arm to send_to_user for exhaustive-match
  parity; flattens defensively.

* claude_code driver: render Image blocks as
  "[attachment: <mime> image, ~N KB — not viewable on this provider]"
  instead of dropping them. The model still cannot see the image, but
  it can acknowledge it coherently rather than confabulating.

Adds 9 discord parser tests covering all (text, attachment-count) shapes
plus MIME edge cases, and 2 claude_code driver tests covering captioned
and bare-image rendering.
2026-05-02 15:16:28 -07:00
Ben Hoverter aaad1fdf32 discord: log raw MESSAGE_CREATE/UPDATE payloads at debug
Adds a single tracing::debug! at the top of parse_discord_message that
dumps the full payload JSON. Silent at default `info` level; enable with
`RUST_LOG=openfang_channels::discord=debug` to capture real attachment
JSON when developing the file-passing parse code.

Logs before any filters (bot, allowed_users, allowed_guilds, empty
content) so attachment-only messages are visible too.
2026-05-02 15:16:27 -07:00
Ben Hoverter dd8c53026e channels/bridge: support file:// URLs in download_image_to_blocks
Pick 3a-bis of the Discord file-passing plan: teach the multimodal
image fetcher to handle file:// URLs by reading from local disk
instead of going through reqwest. PR-A (Discord inbound) will
materialize attachments to a shared inbox dir and emit
ChannelContent::Image { url: "file://..." }, so this branch is what
unblocks vision on inbox-materialized images after the Discord CDN
URL has expired.

Implementation:
- Branch on url.strip_prefix("file://"); local read uses tokio::fs::read.
- HTTP path unchanged. Both paths converge on (Vec<u8>, Option<String>)
  before the existing 5MB cap, magic-byte sniffing, and base64 path.
- No content-type header on file:// — magic-byte detection and URL
  extension fallback do all the media-type work, which is fine since
  detect_image_magic and media_type_from_url already exist.
- No new deps. Vec<u8> instead of bytes::Bytes to avoid pulling in
  the bytes crate as a direct dep.
- No URL percent-decoding: the inbox writer (PR-A) controls filenames
  and avoids characters that would need encoding.

Refs: projects/openfang-fork/discord-file-passing-plan.md (step 2)
2026-05-02 15:16:27 -07:00
Ben Hoverter 218f2dba1f channels: add mime and size to ChannelContent::File
Pick 3a of the Discord file-passing plan: extend the URL-flavored File
variant with optional mime and size metadata so adapters can pass
attachment context through to bridges. FileData (bytes-flavored) is
unchanged; size is implicit in data.len() and mime_type already exists.

Match-arm sites in bridge.rs, telegram.rs, whatsapp.rs use `..` to stay
forward-compatible. Construction sites in telegram.rs and kernel.rs
pass `mime: None, size: None` for now; Discord inbound (PR-A) will
populate them.

Refs: projects/openfang-fork/discord-file-passing-plan.md
2026-05-02 15:16:27 -07:00
dependabot[bot] 24aca4e31d build(deps): bump open from 5.3.3 to 5.3.4
Bumps [open](https://github.com/Byron/open-rs) from 5.3.3 to 5.3.4.
- [Release notes](https://github.com/Byron/open-rs/releases)
- [Changelog](https://github.com/Byron/open-rs/blob/main/changelog.md)
- [Commits](https://github.com/Byron/open-rs/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: open
  dependency-version: 5.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 10:50:27 +00:00
jaberjaber23 3cce1eb3fb bump v0.6.4 2026-05-01 13:48:18 +03:00
jaberjaber23 c89958b66d firefox sidebar 2026-05-01 13:28:26 +03:00
jaberjaber23 b0a92456bf openrouter free 2026-05-01 13:19:50 +03:00
jaberjaber23 67bbcc623d cachyos build 2026-05-01 13:18:19 +03:00
jaberjaber23 a91bfc0e9c dashboard bind 2026-05-01 13:17:32 +03:00
jaberjaber23 948117d5de bump v0.6.3 2026-05-01 13:12:25 +03:00
jaberjaber23 2dedab2a8b think persist 2026-05-01 13:01:55 +03:00
jaberjaber23 8642c4d442 timeout reload 2026-05-01 12:57:05 +03:00
jaberjaber23 46a6eb33d9 slack dedup 2026-05-01 12:50:38 +03:00
jaberjaber23 99b4ce2931 telegram cache 2026-05-01 12:50:38 +03:00
Jaber Jaber 87932f5da0 Merge pull request #1061 from RightNow-AI/dependabot/github_actions/softprops/action-gh-release-3
build(deps): bump softprops/action-gh-release from 2 to 3
2026-05-01 12:49:07 +03:00
Ben HoverterandClaude Opus 4.7 9130811433 docs(types): drop internal spec reference from KernelConfig comment
Replaces "Spec §5.5 scoped strict-field validation to bindings" with
self-contained wording. The §5.5 reference points to an internal-fork
spec document that means nothing to upstream readers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 17:49:28 -07:00
Ben HoverterandClaude Opus 4.7 325734c6aa fix(kernel): lenient binding parsing — partial-success table parse
A typo in any binding's match_rule no longer drops the entire bindings
table. Each entry is parsed independently; malformed entries log an
ERROR with index, agent name, and the underlying serde error, then are
skipped. A single WARN summarizes total dropped vs. surviving bindings.
Per-entry deny_unknown_fields is preserved so silent typos still fail
loudly — just no longer catastrophically.

Before this change, a single misspelled field anywhere in [[bindings]]
caused the whole table to fail parsing, silently unbinding every
agent — the worst possible failure mode for a routing config.

- New `lenient_extract_bindings` runs after include-merge / [api]
  migration, before `try_into::<KernelConfig>()`.
- 7 new config tests cover the reproducer, happy path, all-malformed,
  no-bindings, missing-agent, survivor-order preservation, and
  top-level field typos:
    * test_lenient_bindings_drops_typo_keeps_rest
    * test_lenient_bindings_all_valid_unchanged
    * test_lenient_bindings_all_malformed_yields_empty_but_keeps_rest_of_config
    * test_lenient_bindings_no_bindings_section_is_noop
    * test_lenient_bindings_missing_agent_field_dropped
    * test_lenient_bindings_preserves_survivor_order — locks in that
      first-match-wins routing semantics cannot silently regress when
      a middle entry is dropped
    * test_lenient_bindings_top_level_field_typo_dropped — locks in
      that deny_unknown_fields catches operator typos at the binding
      top level (e.g. \`agnt = ...\`), not just inside match_rule
- TODO marker added on the remaining \`warn!\` fallback in \`load_config\`
  for the non-binding silent-default path (follow-up work).

Tested live: typo'd \`hannel\` field on binding #2 logged as expected;
remaining 5 bindings loaded and routed correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 17:10:18 -07:00
Ben Hoverter faf2cf9211 feat(channels): harden channel_id binding (extends d336314)
Layers richer config validation, an explicit adapter allowlist, and a
stricter bridge routing path on top of upstream `d336314` ("binding
rule"), which shipped the same `channel_id` field as our PR #1127 in a
parallel implementation. Replaces upstream's `sender_user_id`/
`platform_id` heuristic with a single source of truth shared between
config validation and routing.

## What changes vs upstream `d336314`

**Data model** (`openfang-types/src/config.rs`)
- `#[serde(deny_unknown_fields)]` on `AgentBinding` so a typo at the
  binding level (e.g. `match_rules` plural) fails loudly instead of
  silently leaving the rule defaulted to "match everything". Upstream
  has it on `BindingMatchRule` only.
- New `pub const CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL` (19 adapters:
  discord, slack, telegram, matrix, mattermost, teams, webex,
  rocketchat, nextcloud, pumble, revolt, guilded, feishu, lark,
  keybase, google_chat, line, twist, flock, twitch). Single source of
  truth shared with the bridge — no drift between routing and
  validation paths possible. Hybrid adapters (IRC, Zulip) are
  excluded; see source comment.
- Startup validation: warn when a binding sets `channel_id` for a
  non-supporting adapter, or when `channel_id` is set without
  `channel`. Documents the metadata escape hatch in the warning.
- Top-level `KernelConfig` keeps no `deny_unknown_fields` — comment
  explains the §5.5 scoping decision so a future reader doesn't
  "tighten" it without realizing it would break forward-compat keys.

**Bridge** (`openfang-channels/src/bridge.rs`)
- Replaces upstream's `sender_channel_id()` heuristic ("if metadata has
  `sender_user_id` and it differs from `platform_id`, assume
  `platform_id` IS the channel") with `binding_context_for(message)`,
  which delegates to `ChannelMessage::channel_id()`. The heuristic
  worked for Discord/Slack but would fail silently on Matrix, Teams,
  Mattermost, Telegram, etc. — adapters whose `platform_id` IS the
  channel ID but whose metadata does not happen to set
  `sender_user_id` differently.
- Routes both dispatch paths (text + blocks) through
  `resolve_with_context` so `guild_id` and `channel_id` bindings can
  match. (Upstream's `resolve_with_channel_id` only handled
  channel_id.)

**Channels types** (`openfang-channels/src/types.rs`)
- New `ChannelMessage::channel_id()` accessor: reads `platform_id` for
  allowlisted adapters, falls back to `metadata["channel_id"]` for
  opt-in adapters, else `None`. Case-folds `Custom(...)` variants so
  a stray `Custom("Twitch")` cannot silently slip past the allowlist
  (the validation path lowercases user input — accessor must match).

**Tests** (+8 in `bridge.rs`, +5 in `config.rs`, +1 in `types.rs`)
- Bridge: Discord/Telegram/Matrix/custom-supported/user-id-only-adapter
  /metadata-fallback/guild-id-from-metadata/Email-returns-None
  coverage of `binding_context_for` and `channel_id()`.
- Config: typo rejection on both `BindingMatchRule` and `AgentBinding`;
  channel_id-without-channel warning; unsupported-adapter warning;
  no-warning for discord/slack/telegram.
- Types: `channel_id()` case-insensitivity for Custom variants
  including the Lark/Feishu Intl spelling.

**Docs** (`docs/channel-adapters.md`)
- Routing section rewritten: bindings are step 1 in the resolution
  order. New "Bindings" subsection documents the rule shape, the
  `peer_id` vs `channel_id` distinction (the easy confusion), full
  specificity table, the adapter allowlist with the metadata escape
  hatch, and the strict-field parsing rule.

## Why this layering instead of replacing d336314

Upstream's commit and our PR #1127 are functionally equivalent on
Discord and Slack. Shipping a richer extension on top is less churn
than ripping out the upstream commit and substituting ours, and keeps
the API surface upstream just added (`resolve_with_channel_id`)
intact for any third-party consumers.

`cargo check --workspace` and `cargo test -p openfang-types -p
openfang-channels --lib` (850 tests) pass.
2026-04-30 17:10:16 -07:00
jaberjaber23 15ed29c667 bump v0.6.2 2026-04-29 20:39:59 +03:00
jaberjaber23 1d1bf0fb09 exec full 2026-04-29 16:19:23 +03:00
jaberjaber23 d3363142b2 binding rule 2026-04-29 16:19:23 +03:00
Hypn0sis 76929a41aa fix(clippy): resolve upstream warnings breaking CI
Mechanical clippy fixes for collapsible_match, unnecessary_sort_by, and redundant into_iter. Resolves the 5 errors blocking openfang-runtime in CI.
2026-04-29 16:17:48 +03:00
Jaber Jaber fe34a37e6f Merge pull request #1112 from RightNow-AI/dependabot/cargo/lettre-0.11.21
build(deps): bump lettre from 0.11.20 to 0.11.21
2026-04-29 16:10:44 +03:00
Jaber Jaber 4b63eb18cc Merge pull request #1111 from RightNow-AI/dependabot/cargo/libc-0.2.185
build(deps): bump libc from 0.2.183 to 0.2.185
2026-04-29 16:10:31 +03:00
Jaber Jaber fe21d4b4df Merge pull request #1110 from RightNow-AI/dependabot/cargo/rustls-0.23.39
build(deps): bump rustls from 0.23.37 to 0.23.39
2026-04-29 16:10:19 +03:00
Jaber Jaber f52bc53e47 Merge pull request #1109 from RightNow-AI/dependabot/cargo/uuid-1.23.1
build(deps): bump uuid from 1.23.0 to 1.23.1
2026-04-29 16:10:05 +03:00
Jaber Jaber 10f7ee1885 Merge pull request #1060 from ferr079/fix/unify-ssrf-protection
fix(security): unify SSRF protection for WASM host calls
2026-04-29 16:02:51 +03:00
Jaber Jaber 7bc6591338 Merge pull request #1058 from lc-soft/fix/trader-dashboard-style
fix(hands): correct trader dashboard style
2026-04-29 15:45:05 +03:00
jack-wzandjack-wz01 53f2066945 chore: add health stack and stabilize provider env tests (#1126)
Co-authored-by: jack-wz01 <15474862+jack-wz01@user.noreply.gitee.com>
2026-04-29 15:45:01 +03:00
Jaber Jaber c69dd84184 Merge pull request #1095 from Streamweaver/fix/mcp-stdio-env-passthrough-linux
fix(runtime): pass HOME/TMP/TEMP to stdio MCP servers on all platforms
2026-04-29 15:44:05 +03:00
Jaber Jaber c1356fc95d Merge pull request #1100 from Streamweaver/fix/telegram-silent-failures
channels/telegram: propagate send failures and cache terminal reaction errors
2026-04-29 15:43:42 +03:00
jaberjaber23 aabf83b351 bump v0.6.1 2026-04-29 15:30:46 +03:00
jaberjaber23 da6b567ac3 manifest merge 2026-04-29 15:19:41 +03:00
jaberjaber23 ccdd7943a2 fix clippy 2026-04-29 15:14:42 +03:00
jaberjaber23 7fe87babe6 preserve workspace 2026-04-29 15:08:44 +03:00
jaberjaber23 9c0e1637a5 fmt drift 2026-04-29 15:07:22 +03:00
jaberjaber23 fd450dbfc2 fmt cleanup 2026-04-29 15:06:17 +03:00
Jaber Jaber 81176dc626 Merge pull request #1130 from benhoverter/fix/message-timeout-config
fix(runtime): add subprocess timeout config for claude-code driver
2026-04-29 15:03:10 +03:00
Octopusandocto-patch ef9096f7c5 fix(kernel): sync all agent.toml fields to DB on restart (fixes #1087) (#1118)
The TOML-vs-DB change detection at boot only checked a subset of fields,
causing edits to workspace, schedule, resources, autonomous, and exec_policy
to be silently ignored after a restart.

Add the missing fields to the changed-detection predicate so the kernel
properly reflects all agent.toml edits in the SQL database. The workspace
comparison is intentionally guarded — if the TOML omits workspace (None),
the kernel-assigned default path already stored in the DB is kept rather
than being overwritten with None.

Derive PartialEq on ScheduleMode, AutonomousConfig, ResourceQuota, and
ExecPolicy to enable the comparisons without manual field-by-field expansion.

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-29 14:50:38 +03:00
Jaber Jaber fbb5bb1ae9 Merge pull request #1099 from nimitbhardwaj/fix#1088/websocket-scheduled
fix(ws): broadcast cron job results to WebSocket clients in real-time
2026-04-29 14:47:39 +03:00
Jaber Jaber c435a6adcd Merge pull request #1114 from pandego/fix/1102-idle-heartbeat
fix(kernel): avoid crashing idle reactive agents
2026-04-29 14:46:15 +03:00
Ben Hoverter f67c4e8754 fix(channels): key router default-agent map on user_id, not channel_id (#1123)
* fix(channels): key router on user, not channel (Discord/Slack)

Discord and Slack adapters set sender.platform_id to the channel/conversation
ID (needed for the send path), so router.resolve(channel, sender.platform_id, ..)
was matching peer_id bindings against the channel ID and never finding the
user-keyed binding. The sender_user_id() helper already existed but only the
rate-limit/authz paths used it; the routing reads did not.

Read-path fix:
- discord.rs / slack.rs: stash author/user ID in metadata["sender_user_id"]
- bridge.rs: route the text and audio paths through sender_user_id(message)
- bridge.rs: thread user_id through handle_command() so the 6 CLI slash-command
  resolves (/new, /compact, /model, /stop, /usage, /think) also key on user.
  Tests updated for the new signature.

Write-path follow-up (set_user_default + broadcast routing) deferred to a
separate commit so this change can be validated in isolation.

* fix(channels): close write-path keying gap; broadcast user-scoped

Completes the router keying fix started in 6a90aa0. The read path
resolves on user_id, but four write sites and the broadcast lookup
were still keyed on sender.platform_id (channel ID on Discord/Slack),
producing the split-keying state that surfaced in GAP-008.

- 4 x set_user_default writes (text/audio fallback, /agent existing,
  /agent spawned) now key on sender_user_id(message)
- 2 x broadcast lookups (has_broadcast / resolve_broadcast) switched
  to sender_user_id(message), matching the upstream test's intent
  (router.rs:521-547 keys on "vip_user", not a channel)
- boot-time log warning on Discord/Slack adapter start: any
  pre-existing /agent default may need to be re-run once
- new test test_handle_command_agent_select_keys_on_user_id_not_
  platform_id locks in the round-trip
2026-04-29 14:40:36 +03:00
Jaber Jaber 3b237ac526 Merge pull request #1090 from chrisyoung2005/fix/streaming-heartbeat-touch
Stamp last_active in streaming agent loop to prevent heartbeat false-positives
2026-04-29 14:39:28 +03:00
Jaber Jaber 96c572df32 Merge pull request #1082 from octo-patch/fix/issue-1081-lark-websocket-region
fix(feishu): respect region setting for WebSocket endpoint URL
2026-04-29 14:38:12 +03:00
Jaber Jaber 17e0d519ca Merge pull request #1080 from pandego/fix/1079-minimax-init
fix: expose MiniMax in openfang init
2026-04-29 14:30:05 +03:00
Charles Hakes 37c233d489 fix(flake): NixOS build — nativeBuildInputs, wrapGAppsHook3, libayatana-appindicator runtime closure (#1063)
Closes #1092

Four fixes that together make `nix build .#openfang-cli` and `nix build .#openfang-desktop` work on NixOS:

1. perl / clang / pkg-config moved to nativeBuildInputs (fixes openssl-src build failure — this is the bug filed in #1092)
2. openfang-desktop nativeBuildInputs gets pkg-config + wrapGAppsHook3 (GTK runtime wrappers + webkit2gtk-4.1 .pc discovery)
3. libayatana-appindicator added to desktop buildInputs (tray.rs dlopen at runtime)
4. preFixup hook prefixes LD_LIBRARY_PATH so the dlopen-only library actually ends up in the runtime closure

Authored by @Aypex.

Supersedes #1086 (which only fixed item 1).
2026-04-29 14:29:24 +03:00
guatoc-ecohubandMiguel Guerrero 92f7e996de feat(media): add audio_base_url override for local OpenAI-compat Whisper (#1124)
Adds an optional `audio_base_url` field to `MediaConfig` that overrides
the hardcoded provider URLs in `media_understanding::transcribe_audio`,
allowing the same OpenAI-compatible multipart wire format to be sent to
a local Whisper service (speaches, faster-whisper-server, LM Studio,
etc.) instead of api.openai.com / api.groq.com.

Closes #1051.

## Why

Self-hosted, sovereignty-conscious, or rate-limited deployments often
need to route audio transcription to a local Whisper backend while
keeping `media_transcribe` / `speech_to_text` working as native tools
(no helper scripts, no shell_exec workarounds). Today the URLs in
`media_understanding.rs:118-128` are literal `&'static str` so neither
`OPENAI_BASE_URL` nor `provider_urls` (which the LLM drivers do
respect) is read for audio. The same problem existed for embeddings
and was already addressable via `provider_urls`, so this change keeps
the pattern symmetric for media at the simplest possible surface area.

## Wire format

The endpoint shape and Authorization header remain identical:

  POST <audio_base_url>/v1/audio/transcriptions
  Authorization: Bearer $<provider>_API_KEY
  Content-Type: multipart/form-data
  fields: file (binary), model, response_format=text

This means **any OpenAI-compatible Whisper server is drop-in**
(Speaches, faster-whisper-server, LM Studio's Whisper server, etc.).
Local servers typically accept any non-empty bearer string, so users
can keep `OPENAI_API_KEY=anything` for the auth header.

## Configuration

```toml
[media]
audio_provider = "openai"
audio_base_url = "http://127.0.0.1:8000"
# → POST http://127.0.0.1:8000/v1/audio/transcriptions
```

Or for Groq-compatible local servers:

```toml
[media]
audio_provider = "groq"
audio_base_url = "http://127.0.0.1:9000"
# → POST http://127.0.0.1:9000/v1/audio/transcriptions
```

Trailing slash on the user-supplied base is stripped to avoid double
slashes in the final URL.

## Backward compatibility

- `MediaConfig` already uses `#[serde(default)]`, so existing
  configs without `audio_base_url` deserialize as `None` and behave
  exactly as before (cloud provider URLs).
- `Default` impl extended; `audio_base_url: None`.
- `parakeet-mlx` provider path unaffected (it's a separate code branch).
- No new dependencies, no breaking changes to public API.

## Tests

- `test_media_config_default` extended to assert `audio_base_url.is_none()`.
- `test_media_config_audio_base_url_serde_roundtrip` — set + JSON roundtrip.
- `test_media_config_backward_compat_no_audio_base_url` — legacy JSON
  parses with the new field as None.
- `test_audio_base_url_override_logic` — pure-function test that
  exercises the URL building branch (default URLs preserved when
  unset, override applied for both providers, trailing-slash strip).

The runtime branch in `transcribe_audio` was kept as a straight
`if Some/else default` rather than a helper function to minimize the
diff and keep the patch obviously safe to review.

## Operational note

This change does not affect anyone running the cloud provider URLs
out of the box. The override is opt-in via a single optional config
field. Useful for users like myself running a local Speaches container
behind a reverse proxy and a chat-only LLM key (z.ai Coding Plan)
that can't satisfy openai.com's audio endpoint.

Linked: #1051 (Configurable STT/TTS/image URLs and local backends).

Co-authored-by: Miguel Guerrero <kortux@gmail.com>
2026-04-29 14:28:04 +03:00
Ben Hoverter b1c4061247 fix(runtime): wire subprocess_timeout_secs through config.toml
Follow-up to 79aa34c. The previous commit added the public surface
(DriverConfig field + OPENFANG_SUBPROCESS_TIMEOUT_SECS env var) but
left every DriverConfig construction site hardcoded to None — so the
struct field was wired but had no on-disk source feeding it. The env
var was the only operator-facing knob.

This commit plumbs the missing layer: the timeout is now deserializable
from config.toml on both the primary and global-fallback providers.

Public surface
- DefaultModelConfig.subprocess_timeout_secs: Option<u64>
- FallbackProviderConfig.subprocess_timeout_secs: Option<u64>
- Both fields are #[serde(default)] — existing config.toml files
  without the field deserialize cleanly to None (no breaking change).

Placement rationale
- Per-provider on each config struct, not a top-level field or a new
  [driver] section. This matches the existing per-provider config shape
  and lets operators set different timeouts for primary vs. fallback
  (e.g. tighter timeout on a fast fallback to fail over sooner). If a
  second driver-level setting ever lands, refactoring two struct fields
  into a [driver] section is cheap; we don't pre-pay for it now.

Wiring (kernel.rs)
- L663  primary driver  ........  pulls config.default_model.subprocess_timeout_secs
- L687  auto-detect path  ......  inherits default_model intent (the swap
                                  is replacing the *provider*, not the
                                  timeout policy)
- L736  global fallback loop  ..  pulls fb.subprocess_timeout_secs
- L5031 agent primary  .........  inherits effective_default's value when
                                  agent_provider == default_provider;
                                  None for cross-provider overrides
- L5108 agent manifest fallback   inherits dm's value when the manifest
                                  fallback resolves to "default" (matching
                                  the existing fb.provider sentinel logic);
                                  None for explicit cross-provider entries
- L5139 global fallback (per-agent loop) — pulls fb.subprocess_timeout_secs

Sites kept as None (intentional)
- agent_loop.rs:1146, 1330: ModelNotFound recovery iterates over the
  agent manifest's fallback_models (FallbackModel, not the config-toml
  type) — no per-provider config in scope.
- routes.rs:7701: provider connectivity test endpoint; no config source.
- routes.rs:7529: dashboard hot-update path constructs a fresh DM with
  defaults (None) — operator sets timeout via config.toml, not via the
  set-key flow.

Tests
- test_subprocess_timeout_secs_in_toml: round-trips a TOML doc with
  default_model.subprocess_timeout_secs = 600 and one fallback at 180,
  one fallback omitted; asserts each value (or None) reaches the parsed
  config struct.
- test_subprocess_timeout_secs_omitted_defaults_to_none: asserts a
  legacy-shaped config.toml (no timeout fields) parses cleanly with
  both fields = None — backward-compat guard.
- 4 existing claude_code driver timeout tests still pass.

Mechanical pass-throughs
- 8 test fixtures across openfang-kernel/tests and openfang-api/tests
  gain subprocess_timeout_secs: None on their DefaultModelConfig
  literals.
- 1 production literal in routes.rs gains the same field.
- The existing FallbackProviderConfig serde-roundtrip test gains
  subprocess_timeout_secs: None plus an assertion.

Precedence comment in drivers/mod.rs::create_driver updated to reflect
that the config-field path is now real, with explicit pointers to the
kernel.rs wiring sites for future contributors.

Validated: cargo check --workspace --tests is clean; openfang-types
(362), openfang-runtime (933), and openfang-kernel (260) lib tests
all pass.
2026-04-27 23:40:12 -07:00
Ben Hoverter 79aa34c77a fix(runtime): add subprocess timeout config for claude-code driver
The claude-code driver hardcodes its per-message turn timeout inside
ClaudeCodeDriver and exposed no operator-facing knob, so long-running
CC subprocess turns (large prompt-caches, deep tool chains) hit the
internal default with no escape hatch. Adds a public config surface,
honored today only by the claude-code driver, designed so future
subprocess drivers can opt in without re-shaping the API.

Public surface
- DriverConfig.subprocess_timeout_secs: Option<u64> (llm_driver.rs)
- OPENFANG_SUBPROCESS_TIMEOUT_SECS env var (drivers/mod.rs)
- Precedence in create_driver(): env var > config field > driver default

Naming rationale
- Field/env are scope-flavored, not semantic, on purpose: the name
  telegraphs that HTTP providers (default/Anthropic, openai, bedrock,
  qwen-code) accept-but-silently-ignore the field today. A semantic
  name (message_timeout_secs) would have invited the same silent-no-op
  footgun on those providers.
- Driver-internal field in claude_code.rs intentionally kept as
  message_timeout_secs — it's not on the public boundary and the
  semantic name accurately describes what it stores.

Tests (drivers/mod.rs)
- default_when_unset: no env, no config -> driver default
- config_set: config field flows through
- env_overrides_config: env var wins over config (construction-only
  assertion; trait-object opacity prevents reading the value back)
- malformed_env_falls_through: unparseable env silently falls through
  to config, matching the .parse::<u64>().ok() chain in production
- All four tests scrub OPENFANG_SUBPROCESS_TIMEOUT_SECS pre/post to
  avoid cross-test pollution

Mechanical pass-throughs
- 12 x DriverConfig { .. } test fixtures in drivers/mod.rs gain
  subprocess_timeout_secs: None
- routes.rs (1), kernel.rs (6), agent_loop.rs (2): same pass-through
  fills in DriverConfig literals; no logic touched

Forward-compat note
- A NOTE block in drivers/mod.rs flags the scope-vs-implementation
  gap so the next contributor adding a subprocess driver knows
  exactly where to wire the config in.

Validated end-to-end against a live daemon: dry-run + full deploy
(deploy-local.sh, all 7 phases) + post-swap agent_send round-trip
through the claude-code dispatch path.
2026-04-27 23:17:41 -07:00
Ben Hoverter 4ae2961b1c fix(channels): close write-path keying gap; broadcast user-scoped
Completes the router keying fix started in 6a90aa0. The read path
resolves on user_id, but four write sites and the broadcast lookup
were still keyed on sender.platform_id (channel ID on Discord/Slack),
producing the split-keying state that surfaced in GAP-008.

- 4 x set_user_default writes (text/audio fallback, /agent existing,
  /agent spawned) now key on sender_user_id(message)
- 2 x broadcast lookups (has_broadcast / resolve_broadcast) switched
  to sender_user_id(message), matching the upstream test's intent
  (router.rs:521-547 keys on "vip_user", not a channel)
- boot-time log warning on Discord/Slack adapter start: any
  pre-existing /agent default may need to be re-run once
- new test test_handle_command_agent_select_keys_on_user_id_not_
  platform_id locks in the round-trip
2026-04-26 16:14:17 -07:00
Ben Hoverter 6a90aa08df fix(channels): key router on user, not channel (Discord/Slack)
Discord and Slack adapters set sender.platform_id to the channel/conversation
ID (needed for the send path), so router.resolve(channel, sender.platform_id, ..)
was matching peer_id bindings against the channel ID and never finding the
user-keyed binding. The sender_user_id() helper already existed but only the
rate-limit/authz paths used it; the routing reads did not.

Read-path fix:
- discord.rs / slack.rs: stash author/user ID in metadata["sender_user_id"]
- bridge.rs: route the text and audio paths through sender_user_id(message)
- bridge.rs: thread user_id through handle_command() so the 6 CLI slash-command
  resolves (/new, /compact, /model, /stop, /usage, /think) also key on user.
  Tests updated for the new signature.

Write-path follow-up (set_user_default + broadcast routing) deferred to a
separate commit so this change can be validated in isolation.
2026-04-26 12:41:06 -07:00
pandego 356500bb1e fix(kernel): ignore idle reactive heartbeat silence 2026-04-23 18:42:47 +02:00
dependabot[bot] 40bd7e2c11 build(deps): bump lettre from 0.11.20 to 0.11.21
Bumps [lettre](https://github.com/lettre/lettre) from 0.11.20 to 0.11.21.
- [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.20...v0.11.21)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:24:47 +00:00
dependabot[bot] a7197d7b97 build(deps): bump libc from 0.2.183 to 0.2.185
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.183 to 0.2.185.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.185/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.183...0.2.185)

---
updated-dependencies:
- dependency-name: libc
  dependency-version: 0.2.185
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:24:21 +00:00
dependabot[bot] bc26d5e8c3 build(deps): bump rustls from 0.23.37 to 0.23.39
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.37 to 0.23.39.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.37...v/0.23.39)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:23:42 +00:00
dependabot[bot] 84d90ad342 build(deps): bump uuid from 1.23.0 to 1.23.1
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.23.0 to 1.23.1.
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.0...v1.23.1)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.23.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:23:16 +00:00
Scott Turnbull 9fee63d58c channels/telegram: cache terminal setMessageReaction errors per (chat, emoji)
`fire_reaction` calls `setMessageReaction` fire-and-forget on every
agent lifecycle event. When Telegram returns a terminal error like
`REACTION_INVALID` (emoji not in the bot's free-reaction allowlist),
`REACTION_NOT_AVAILABLE` (chat admin restricted this emoji), or
`REACTION_TOO_MANY` (per-message cap), retrying on every subsequent
turn is pointless log spam and wasted API quota.

This adds a per-bot-instance `HashSet<(i64, String)>` keyed by
`(chat_id, emoji)` that records terminal rejections and short-circuits
future calls for the same pair. Keyed by chat, not just emoji, because
`Chat.available_reactions` varies across chats and is admin-mutable
(https://core.telegram.org/bots/api#setmessagereaction) — an emoji
rejected in chat A may still be valid in chat B. Cache is
per-process; on restart it rebuilds naturally, which handles any
runtime allowlist change without needing persistence.

The terminal-error match uses a small private helper
`is_terminal_reaction_error` that substring-matches the three
permanent errors. Transient errors (429, 5xx, `MESSAGE_NOT_MODIFIED`,
unrelated 400s) are deliberately NOT cached.

Concurrency: the cache uses `std::sync::Mutex` — critical section is
two `HashSet` ops (contains + insert), never held across `.await`.
Endorsed by the Tokio shared-state tutorial
(https://tokio.rs/tokio/tutorial/shared-state) for exactly this shape.
Two concurrent `fire_reaction` calls for the same (chat, emoji) can
both pass the cache check before either rejection lands, producing up
to N duplicate API calls on the first rejection; the duplicate
`insert` is idempotent so this is benign and self-limits on the
second turn. Documented in-code.

Tests: 6 new tests covering terminal-error matching, cache insertion,
per-chat key isolation, and non-caching of transient and successful
responses. Total 47 telegram tests pass (41 existing + 6 new). No new
clippy warnings.
2026-04-21 18:03:23 -04:00
Scott Turnbull 40903cceee channels/telegram: propagate send failures from api_send_* helpers
The six outbound helpers in the Telegram adapter (sendMessage, sendPhoto,
sendDocument, sendDocument_upload, sendVoice, sendLocation) previously
logged a `warn!` on HTTP non-success and still returned `Ok(())`. Callers
interpreted that as successful delivery and told the agent "Message sent"
even when Telegram had rejected the request (e.g. 400 Bad Request from
malformed HTML entities with parse_mode=HTML). The agent recorded phantom
success in its session history, corrupting subsequent behavior.

The fix returns `Err(format!(...).into())` on HTTP non-success in all six
helpers, matching the error-handling convention documented in
CONTRIBUTING.md.

`api_send_message` is slightly different because it splits long messages
into chunks via `split_message(4096)`. Naively returning `Err` on any
chunk failure would create a partial-delivery-then-error regression —
worse than the original silent success. The function now tracks
`delivered_any` across chunks:

- First-chunk failure (nothing delivered yet) → return `Err` to surface
  the failure. This is where the motivating HTML-parse-error bug lives,
  so the fix is fully effective.
- Subsequent-chunk failure (user already received preceding chunks) →
  log `warn!` and continue with best-effort delivery, matching the
  convention used by every other adapter in the crate that calls
  `split_message` (Discord, Gitter, Mattermost, Nextcloud, Twitch,
  Pumble, etc.).

Tests: 4 new tests using a small in-crate stub server (axum on an
ephemeral port, reached via the existing `api_url` constructor seam —
zero new dependencies). 41 telegram tests pass (37 existing + 4 new).
2026-04-21 18:01:24 -04:00
Nimit Bhardwaj 5a86141677 fix(ws): broadcast cron job results to WebSocket clients in real-time
Fixes #1088 - scheduled task results now appear in web UI without page
refresh.
2026-04-21 22:34:36 +05:30
Scott Turnbull e97eb6fff3 fix(runtime): pass HOME/TMP/TEMP to stdio MCP servers on all platforms
Node/npx-backed stdio MCP servers (Gmail, AgentMail, Exa, etc.) need a
usable HOME directory for npm cache and temp-file scratch space. Without
it, npm errors with EACCES on /nonexistent or silently falls over when
trying to write cache entries.

Previously these three variables were only passed on Windows. Linux and
macOS hosts launching stdio MCP servers through npx would get an empty
env for HOME/TMP/TEMP, breaking most community MCP servers.

Move the HOME/TMP/TEMP passthrough above the cfg!(windows) block so it
applies to every platform. Remove the now-redundant entries from the
Windows-only list.
2026-04-20 12:24:33 -04:00
dongtran16092006 93b57bdd52 fix: system prompt and identity handling, and config form hydration #1045 2026-04-20 18:10:16 +07:00
Matteo De Agazio 0227ff1790 fix(discord): cap dedup set on thread delete, add #[cfg(test)] to test mod
- Replace retain(|_| true) no-op with a size-capped clear: when
  threaded_message_ids exceeds MAX_DEDUP_MSG_IDS (2000) it is cleared.
  MESSAGE_UPDATE embed events arrive within seconds so old entries are
  always safe to discard; prevents unbounded growth on busy servers.
- Add #[cfg(test)] to mod tests so empty_threads() helper is only
  compiled in test mode — removes the need for #[allow(dead_code)].
2026-04-20 09:31:09 +02:00
Matteo De Agazio 525d7d844a feat(discord): smart auto-thread mode (true/false/smart) 2026-04-20 09:31:09 +02:00
chris-youngandClaude Sonnet 4.6 f2587995a2 Stamp last_active in streaming agent loop to prevent heartbeat false-positives
Fixes #1089

run_agent_loop_streaming skipped the touch_agent() call that the
non-streaming run_agent_loop performs before every LLM request. On slow
local inference (e.g. Ollama qwen3.5:35b, multi-minute generations),
last_active went stale and the heartbeat monitor flagged the agent as
unresponsive, triggering crash recovery mid-stream. With multiple agents
sharing one Ollama instance, queued agents appeared frozen while the
active one generated.

Mirror the non-streaming behavior: stamp last_active immediately before
stream_with_retry so the heartbeat window covers the full LLM call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:59:47 -07:00
jaberjaber23 e6bab993ae bump v0.6.0 2026-04-19 22:57:55 +03:00
jaberjaber23 a39a675ba9 skill config ui 2026-04-19 22:27:23 +03:00
jaberjaber23 0ce390e09f cron delivery ui 2026-04-19 21:53:20 +03:00
jaberjaber23 88eeaa6a4d commands ui 2026-04-19 21:42:33 +03:00
jaberjaber23 3db5d3a825 cron delivery 2026-04-19 17:10:23 +03:00
jaberjaber23 5a1f372612 command registry 2026-04-19 17:08:11 +03:00
jaberjaber23 a9f15b2d23 skill config 2026-04-19 16:54:11 +03:00
octo-patch 45e7ea7948 fix(feishu): respect region setting for WebSocket endpoint URL
When using Lark international (open.larksuite.com) with WebSocket mode,
the adapter was hardcoding the Chinese Feishu endpoint URL and also
ignoring the configured region entirely when constructing the adapter.

Two bugs fixed:
1. FEISHU_WS_ENDPOINT_URL was hardcoded to open.feishu.cn — international
   Lark apps could not authenticate because their credentials are only
   valid on open.larksuite.com. Changed to FEISHU_WS_ENDPOINT_PATH and
   compute the full URL using self.region.domain() at call time.
2. new_websocket() in FeishuAdapter always set region = FeishuRegion::Cn.
   Added new_websocket_with_region() that accepts an explicit region, and
   updated the call site in channel_bridge.rs to pass the parsed region.

Fixes #1081
2026-04-19 11:23:46 +08:00
Stephane de8a692036 fix(security): unify SSRF protection for WASM host calls
The WASM sandbox host_net_fetch() had its own SSRF implementation
(is_ssrf_target) that was incomplete compared to the canonical
check_ssrf() in web_fetch.rs:

- Missing 6 blocked hostnames (ip6-localhost, Alibaba/Azure IMDS,
  0.0.0.0, ::1, [::1])
- Missing cloud metadata IP detection (is_metadata_ip)
- Missing IPv6 bracket notation support
- Ignoring ssrf_allowed_hosts from config.toml entirely
- Duplicate is_private_ip() and extract_host_from_url() functions

This meant a WASM agent could bypass SSRF protections that the
builtin web_fetch tool correctly enforced.

Changes:
- Remove duplicated is_ssrf_target(), is_private_ip(), and
  extract_host_from_url() from host_functions.rs
- Delegate to web_fetch::check_ssrf() which has the complete
  implementation with allowlist, CIDR matching, and metadata
  IP detection
- Add ssrf_allowed_hosts to SandboxConfig and GuestState so the
  config propagates to WASM host calls
- Make extract_host() pub(crate) for reuse
- Update tests to exercise the unified code path, including new
  coverage for IPv6 and cloud metadata endpoints

All 908 runtime tests pass. Zero clippy warnings.
2026-04-18 14:22:12 +02:00
pandego 8d3d77dd99 fix: expose MiniMax in init provider lists 2026-04-18 13:26:28 +02:00
jaberjaber23 d3d9fa842d release: v0.5.10
Bump workspace version to 0.5.10 and refresh docs.

Bundles the 7 fixes merged on main since v0.5.9:
- #1034 auth fail-closed (#1071)
- #980  channel agent name prefix (#1072)
- #1043 multimodal text+images (#1073)
- #809  openfang hand config subcommand (#1074)
- #843  context.md re-read per turn (#1075)
- #905  config get default_model.base_url (#1076)
- #1069 scheduler unification and migration (#1077)

README: fix stale 0.3.30 badge and March 2026 header to 0.5.10 and April 2026,
drop em dashes throughout.

CHANGELOG: new 0.5.10 section with the above, plus notes on #818 and #819
which were closed as invalid.
2026-04-17 22:54:56 +03:00
Jaber Jaber ff44cfbe87 fix(scheduler): route schedule_* tools and /api/schedules through kernel cron scheduler (#1069) (#1077)
The schedule_create tool, its sibling schedule_list and schedule_delete,
and the matching /api/schedules HTTP routes were all writing to a
shared-memory key that no executor ever read. Jobs registered that way
silently never fired.

Route all three tools and all /api/schedules endpoints through the real
cron scheduler in openfang-kernel. Add a one-shot idempotent migration
at kernel startup that imports legacy __openfang_schedules entries into
the cron scheduler and clears the old key.

Tests:
- Unit tests for sanitize_schedule_name and sanitize_cron_job_name
- Tool wrapper tests using a fake KernelHandle that verify
  schedule_create/list/delete route into cron_create/list/cancel
- Migration tests cover the happy path, idempotency via the marker key,
  and skipping entries whose target agent is not in the registry

Quality gates: cargo check + test + clippy -D warnings + fmt clean on
openfang-kernel, openfang-runtime, openfang-api.

Made-with: Cursor
2026-04-17 22:47:11 +03:00
Jaber Jaber ce89d05987 fix(runtime): combine text and image blocks in multimodal user messages (#1043) (#1073)
When an upload supplied image content blocks alongside the user's text, the agent loop pushed only the image blocks and dropped the text. The LLM saw images with no accompanying prompt.

Build the user turn through a single helper that combines text and image blocks into one multimodal message when both are present, and keeps the existing single-mode representation when only one is supplied. Both the streaming and non-streaming paths now share the same builder.

Closes #1043

Made-with: Cursor
2026-04-17 22:47:08 +03:00
Jaber Jaber 00c0ff60de feat(channels): optional agent name prefix on outbound messages (#980) (#1072)
Adds an opt-in per-channel knob prefix_agent_name on ChannelOverrides
with styles Off (default), Bracket ([agent] text) and BoldBracket
(**[agent]** text).

The bridge wraps the final outbound text once in dispatch_message,
dispatch_with_blocks and the auto-reply path. Off is byte-identical
to pre-feature behavior so existing configs are unaffected.

Platform-native identity overrides (Slack per-message username,
Discord embed author field) are intentionally out of scope here and
tracked as a follow-up.

Made-with: Cursor
2026-04-17 22:47:04 +03:00
Jaber Jaber 07af248a07 fix(cli): config get returns base_url from default_model (#905) (#1076)
Extract the dotted-key lookup in `openfang config get` into a pure
`lookup_config_value` helper with clear outcomes (scalar value, non-scalar
section, or key not found) so the behaviour is covered by unit tests.

Previously the command only had integration coverage, so regressions where
`config get default_model.base_url` silently returned an empty string could
slip through. Tests now pin down every `[default_model]` scalar, including
`base_url`, plus unset, missing, numeric, boolean, and section cases.

Also distinguishes a section-valued key (e.g. `config get default_model`)
from a scalar instead of printing a debug-style `{}`.

Made-with: Cursor
2026-04-17 22:46:08 +03:00
Jaber Jaber 6ab07d155e fix(runtime): re-read agent context.md per turn so external updates take effect (#843) (#1075)
When an agent had a context.md file updated externally (e.g. a cron job
refreshing live market data), the updated content never reached the LLM
during an active session. The file was effectively cached for the
lifetime of the conversation.

The runtime now reads context.md from the agent workspace once per turn,
right before the system prompt is built, and injects it as a dedicated
'Live Context' section. Agents that still want the old cache-at-start
behaviour can opt back in with 'cache_context = true' on the manifest.

- new openfang-runtime::agent_context module with a small per-path cache
- if a re-read fails after a previous success, fall back to the cached
  content with a warning instead of dropping context mid-conversation
- new PromptContext.context_md field wired up in both kernel streaming
  and non-streaming paths
- one small disk read per agent turn (not per streaming token); file
  size capped at 32 KB like the other identity files

Made-with: Cursor
2026-04-17 22:46:05 +03:00
Jaber Jaber e2b0a54720 feat(cli): add hand config subcommand (#809) (#1074)
Docs reference `openfang hand config browser --set headless=true` but
the CLI never shipped that command. Add it as a thin wrapper over the
existing GET/PUT `/api/hands/{id}/settings` routes so the documented
example works.

- `openfang hand config <id>` prints the current settings merged with
  schema defaults.
- `--get KEY` prints one value, `--set KEY=VAL` (repeatable) updates
  values, `--unset KEY` removes them. Empty keys are rejected up front.
- When no instance is active, the daemon's existing 404 is surfaced
  with a hint to run `openfang hand activate <id>` first.

Unit tests cover the KEY=VAL parser (including empty keys, urls with
equals signs, and blank values). Integration test runs the registry
update_config path end-to-end through a persist/load round-trip so
the CLI semantics match what the daemon stores.

Made-with: Cursor
2026-04-17 22:46:01 +03:00
Jaber Jaber 6f519b9122 fix(api): reject unauth requests from non-loopback by default (#1034) (#1071)
Empty api_key used to skip auth for everyone. Now it only skips auth for
loopback origins. Non-loopback requests get 401 unless the operator opts
in with OPENFANG_ALLOW_NO_AUTH=1.

- middleware: check ConnectInfo, fail closed on LAN/public origins
- ws: same fail-closed logic for WebSocket upgrades
- server: loud startup warning when bound to non-loopback with no key
- AuthState: new allow_no_auth flag
- tests: 8 new unit tests covering loopback, LAN, public, missing info

Made-with: Cursor
2026-04-17 22:45:58 +03:00
Jaber Jaber 983519c8e8 Merge pull request #1041 from Hypn0sis/chore/security-deps
fix(deps): upgrade wasmtime 41->43 and rumqttc 0.24->0.25 to resolve active CVEs

Addresses RUSTSEC advisories surfaced by cargo-audit on main. wasmtime
jump required matching adjustments in sandbox.rs error types; rumqttc
switched to `default-features = false` + `use-native-tls` to drop
the vulnerable `rustls-webpki 0.102` path. CI green across Check/
Test/Clippy/Format/Security-Audit on all three platforms.
2026-04-17 21:21:17 +03:00
dependabot[bot] c9701627a9 build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 08:21:33 +00:00
Liu 643a22b295 fix(hands): correct trader dashboard style 2026-04-15 16:59:52 +08:00
Matteo De Agazio 890ab8c177 style: apply cargo fmt 2026-04-14 15:11:29 +02:00
Matteo De Agazio 1b9a68dc0b fix: resolve remaining clippy warnings blocking CI (ptr_arg, dead_code, map_or, collapsible_if) 2026-04-14 15:06:54 +02:00
Matteo De Agazio 2b6286e469 fix: suppress pre-existing dead code and clippy warnings in openfang-runtime 2026-04-12 00:07:13 +02:00
Matteo De Agazio 589f32c8e5 style: apply cargo fmt 2026-04-11 23:55:38 +02:00
Matteo De Agazio 528a7b9ff7 fix(deps): upgrade wasmtime 41->43 and rumqttc 0.24->0.25 to resolve CVEs (RUSTSEC-2026-0049, 0085-0096) 2026-04-11 23:50:55 +02:00
jaberjaber23 6851bbab09 bump v0.5.9 2026-04-10 21:19:33 +03:00
jaberjaber23 9323edccf4 fix config persistence, PowerShell bypass, WS 404 race, feishu panic, Revolt self-hosted 2026-04-10 20:51:27 +03:00
Jaber Jaber 0bccba10cf Merge pull request #1009 from normal-coder/feat-ux-optimize
Refactor UI navigation and improve visual consistency with SVGs
2026-04-10 20:01:16 +03:00
Jaber Jaber 2daaf92ad7 Merge pull request #923 from smitb/feat/add-aws-bedrock-llm-provider-support-with-token-auth
feat: Add AWS Bedrock LLM provider support with token auth
2026-04-10 20:00:44 +03:00
Jaber Jaber 80359b4487 Merge pull request #945 from felix307253927/pr-main-utf8
fix: Fix panic due to UTF-8 character boundary errors
2026-04-10 20:00:33 +03:00
Jaber Jaber b866bb6b21 Merge pull request #965 from chrisyoung2005/fix/ghcr-package-visibility
Set GHCR package visibility to public on every release
2026-04-10 20:00:14 +03:00
Jaber Jaber 3fa8c6c527 Merge pull request #928 from Alex-wuhu/novita-integration
feat: add Novita AI as LLM provider
2026-04-10 19:59:41 +03:00
Jaber Jaber e322afbebd Merge pull request #956 from RightNow-AI/dependabot/cargo/cron-0.16.0
chore(deps): bump cron from 0.15.0 to 0.16.0
2026-04-10 19:59:26 +03:00
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] 6f86f7a857 build(deps): bump cron from 0.15.0 to 0.16.0
Bumps [cron](https://github.com/zslayton/cron) from 0.15.0 to 0.16.0.
- [Release notes](https://github.com/zslayton/cron/releases)
- [Commits](https://github.com/zslayton/cron/commits)

---
updated-dependencies:
- dependency-name: cron
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 16:17:45 +00: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
诺墨 77bef773e5 Merge branch 'main' into feat-ux-optimize 2026-04-09 04:43:52 +08: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
诺墨 c000392093 refactor(ui): replace HTML entity icons with inline SVGs for consistent rendering
- Replace `&times;` close icons with SVG X icons across all modals and panels
- Replace `&#10003;` checkmarks with SVG check icons in setup checklist, step indicators, and success states
- Replace `&bull;` and `&middot;` separators with small SVG dot icons for better visual consistency
- Improve icon sizing, alignment, and stroke properties for crisp rendering

Signed-off-by: 诺墨 <normal@normalcoder.com>
2026-04-07 19:13:38 +08:00
诺墨 ae32af1b06 fix(manifest): align the logo.png with the icon size to resolve browser loading issues.
Signed-off-by: 诺墨 <normal@normalcoder.com>
2026-04-07 18:54:02 +08:00
诺墨 d3972b23c0 refactor(ui): introduce CSS theme tokens for consistent accent text contrast
- Add `--text-on-accent` CSS variable (white) for text on accent backgrounds
- Replace hardcoded `var(--bg-primary)` with `--text-on-accent` across components
- Update dark theme surface, border, and text color tokens for better contrast
- Adjust dark mode surface colors (`#1F1D1C` → `#242221`) for improved depth
- Refine border and text-muted colors for better visual hierarchy

Signed-off-by: 诺墨 <normal@normalcoder.com>
2026-04-07 18:38:47 +08:00
诺墨 ded95f3180 refactor(ui): restructure navigation sidebar with Overview as primary entry
- Move Overview to first navigation item for better UX
- Consolidate `Chat` into `Agents` section
- Increase chevron and section title font sizes for improved readability
- Adjust section title padding for better visual spacing

Signed-off-by: 诺墨 <normal@normalcoder.com>
2026-04-07 18:19:48 +08: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
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
chris-young be149597e6 Set GHCR package visibility to public on every release
GHCR defaults new packages to private, so docker pull
ghcr.io/rightnow-ai/openfang:... returned 401 for unauthenticated
users despite the repo being public.

Two changes to the docker job in release.yml:

1. Add OCI labels to the build — links the package to the repo so
   GHCR associates it correctly, and is standard practice for
   container images.

2. After each push, call the GitHub Packages API (PATCH
   /orgs/RightNow-AI/packages/container/openfang) to set visibility
   to public. The workflow already holds packages: write, which is
   the required scope. This runs on every release tag so visibility
   cannot regress if the package is ever reset.

Fixes #961
2026-04-02 11:31:41 -07:00
nldhuyen0047 3b21494867 fix: inject default items schema for array parameters in Gemini normalization 2026-04-02 17:08:46 +07:00
Felix 4565988e2e fix: Fix panic due to UTF-8 character boundary errors 2026-04-01 18:57:03 +08:00
Alex-wuhuandClaude Opus 4.6 4714efb9e4 test: add unit tests for Novita provider defaults, alias, and driver creation
- test_provider_defaults_novita: verify base_url, api_key_env, key_required
- test_provider_defaults_novita_ai_alias: verify "novita-ai" alias resolves
- test_novita_provider_with_env_key: driver creation succeeds with env key
- test_novita_provider_no_key_errors: driver creation fails without key

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:26:49 +08:00
Alex-wuhu 365bec868c Add Novita AI as LLM provider
- Add NOVITA_BASE_URL constant to model_catalog.rs
- Register novita provider in provider_defaults() with OpenAI-compatible endpoint
- Add novita to known_providers() list
- Add novita to detect_available_provider() auto-detection probe
- Document NOVITA_API_KEY in .env.example

Novita AI uses an OpenAI-compatible API at https://api.novita.ai/openai/v1
Default model: moonshotai/kimi-k2.5
2026-03-31 12:47:41 +08:00
Alexei e421594185 Merge branch 'RightNow-AI:main' into build/armv7 2026-03-30 23:26:29 +03:00
Bas Smit 2fe926c3b9 feat: Add AWS Bedrock LLM provider support with token auth 2026-03-30 22:06:49 +02: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
nldhuyen0047 3c221dc3ca fix: ensure message history starts with a user turn for Gemini compatibility 2026-03-30 17:00:09 +07:00
158 changed files with 26165 additions and 2909 deletions
+3
View File
@@ -32,6 +32,9 @@
# Fireworks AI
# FIREWORKS_API_KEY=...
# Novita AI (multi-model gateway)
# NOVITA_API_KEY=...
# ─── Local LLM Providers (no API key needed) ─────────────────────────
# Ollama (default: http://localhost:11434)
+3 -1
View File
@@ -91,7 +91,9 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check
# Gate every workspace crate on rustfmt to keep `cargo fmt --all --check` clean.
# See issue #1121.
- run: cargo fmt --all -- --check
audit:
name: Security Audit
+21 -6
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'
@@ -201,7 +204,7 @@ jobs:
$hash = (Get-FileHash "openfang-${{ matrix.target }}.zip" -Algorithm SHA256).Hash.ToLower()
"$hash openfang-${{ matrix.target }}.zip" | Out-File -Encoding ASCII "openfang-${{ matrix.target }}.zip.sha256"
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: openfang-${{ matrix.target }}.*
env:
@@ -235,5 +238,17 @@ jobs:
tags: |
ghcr.io/rightnow-ai/openfang:latest
ghcr.io/rightnow-ai/openfang:${{ steps.version.outputs.version }}
labels: |
org.opencontainers.image.source=https://github.com/RightNow-AI/openfang
org.opencontainers.image.licenses=MIT
org.opencontainers.image.description=OpenFang Agent OS — single-binary Rust agent framework
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Set GHCR package visibility to public
run: |
curl -fsSL -X PATCH \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/orgs/RightNow-AI/packages/container/openfang \
-d '{"visibility":"public"}'
+3
View File
@@ -45,3 +45,6 @@ Thumbs.db
*.swo
*~
.serena/
# Personal deploy scripts
scripts/deploy-remote.sh
+6
View File
@@ -0,0 +1,6 @@
## Health Stack
- typecheck: cargo build --workspace --lib
- lint: cargo clippy --workspace --all-targets -- -D warnings
- test: cargo test --workspace
- shell: shellcheck scripts/install.sh
+21
View File
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.10] - 2026-04-17
### Fixed
- Non-loopback requests with no `api_key` configured now return 401 by default. Opt out with `OPENFANG_ALLOW_NO_AUTH=1`. Fixes the B1/B2 authentication bypass from #1034.
- Agent `context.md` is re-read on every turn so external updates take effect mid-session. Opt out per agent with `cache_context = true` on the manifest. Fixes #843.
- `openfang config get default_model.base_url` now prints the configured URL instead of an empty string. Missing keys return a clear "not found" error. Fixes #905.
- `schedule_create`, `schedule_list`, and `schedule_delete` tools plus the `/api/schedules` routes now use the kernel cron scheduler, so scheduled jobs actually fire. One-shot idempotent migration imports legacy shared-memory entries at startup. Fixes #1069.
- Multimodal user messages now combine text and image blocks into a single message so the LLM sees both. Fixes #1043.
### Added
- `openfang hand config <id>` subcommand: get, set, unset, and list settings on an active hand instance. Fixes #809.
- Optional per-channel `prefix_agent_name` setting (`off` / `bracket` / `bold_bracket`). Wraps outbound agent responses so users in multi-agent channels can see which agent replied. Default is off, byte-identical to prior behavior. Fixes #980.
### Closed as invalid
- #818 and #819. Both reference a knowledge-domain API that does not exist on `main`. Filed against an unmerged feature branch (`plan/013-audit-remediation`). Close with a note to build the proposed validation and stale-timestamp surfacing into that feature when it lands.
## [0.5.9] - 2026-04-10
### Changed
- **BREAKING:** Dashboard password hashing switched from SHA256 to Argon2id. Existing `password_hash` values in `config.toml` must be regenerated with `openfang auth hash-password`. Only affects users with `[auth] enabled = true`.
Generated
+239 -372
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.5.7"
version = "0.6.9"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -63,6 +63,7 @@ clap_complete = "4"
# HTTP client (for LLM drivers)
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
# Async trait
async-trait = "0.1"
@@ -75,14 +76,14 @@ bytes = "1"
# Futures
futures = "0.3"
prost = "0.13"
prost = "0.14"
# WebSocket client (for Discord/Slack gateway)
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
url = "2"
# WASM sandbox
wasmtime = "41"
wasmtime = "43"
# HTTP server (for API daemon)
axum = { version = "0.8", features = ["ws", "multipart"] }
@@ -146,7 +147,7 @@ native-tls = { version = "0.2", features = ["vendored"] }
mailparse = "0.16"
# MQTT client
rumqttc = "0.24"
rumqttc = { version = "0.25", default-features = false, features = ["use-native-tls"] }
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
openssl = { version = "0.10", features = ["vendored"] }
+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
+32 -32
View File
@@ -19,25 +19,25 @@
<p align="center">
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<img src="https://img.shields.io/badge/version-0.3.30-green?style=flat-square" alt="v0.3.30" />
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/version-0.6.9-green?style=flat-square" alt="v0.6.9" />
<img src="https://img.shields.io/badge/tests-2,696%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
</p>
---
> **v0.3.30 — Security Hardening Release (March 2026)**
> **v0.5.10 (April 2026)**
>
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
> OpenFang is feature complete but still pre-1.0. Expect rough edges and breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
---
## What is OpenFang?
OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
OpenFang is an **open-source Agent Operating System**. Not a chatbot framework. Not a Python wrapper around an LLM. Not a "multi-agent orchestrator." A full operating system for autonomous agents, built from scratch in Rust.
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you**: on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
The entire system compiles to a **single ~32MB binary**. One install, one command, your agents are live.
@@ -65,13 +65,13 @@ openfang start
<p align="center"><em>"Traditional agents wait for you to type. Hands work <strong>for</strong> you."</em></p>
**Hands** are OpenFang's core innovation — pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
**Hands** are OpenFang's core innovation. Pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
Each Hand bundles:
- **HAND.toml** — Manifest declaring tools, settings, requirements, and dashboard metrics
- **System Prompt** — Multi-phase operational playbook (not a one-liner — these are 500+ word expert procedures)
- **SKILL.md** — Domain expertise reference injected into context at runtime
- **Guardrails** — Approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase)
- **HAND.toml**: manifest declaring tools, settings, requirements, and dashboard metrics.
- **System Prompt**: multi-phase operational playbook. Not a one-liner. These are 500+ word expert procedures.
- **SKILL.md**: domain expertise reference injected into context at runtime.
- **Guardrails**: approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase).
All compiled into the binary. No downloading, no pip install, no Docker pull.
@@ -81,14 +81,14 @@ All compiled into the binary. No downloading, no pip install, no Docker pull.
|------|----------------------|
| **Clip** | Takes a YouTube URL, downloads it, identifies the best moments, cuts them into vertical shorts with captions and thumbnails, optionally adds AI voice-over, and publishes to Telegram and WhatsApp. 8-phase pipeline. FFmpeg + yt-dlp + 5 STT backends. |
| **Lead** | Runs daily. Discovers prospects matching your ICP, enriches them with web research, scores 0-100, deduplicates against your existing database, and delivers qualified leads in CSV/JSON/Markdown. Builds ICP profiles over time. |
| **Collector** | OSINT-grade intelligence. You give it a target (company, person, topic). It monitors continuously change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
| **Collector** | OSINT grade intelligence. You give it a target (company, person, topic). It monitors continuously: change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
| **Predictor** | Superforecasting engine. Collects signals from multiple sources, builds calibrated reasoning chains, makes predictions with confidence intervals, and tracks its own accuracy using Brier scores. Has a contrarian mode that deliberately argues against consensus. |
| **Researcher** | Deep autonomous researcher. Cross-references multiple sources, evaluates credibility using CRAAP criteria (Currency, Relevance, Authority, Accuracy, Purpose), generates cited reports with APA formatting, supports multiple languages. |
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue nothing posts without your OK. |
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate** it will never spend your money without explicit confirmation. |
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue, so nothing posts without your OK. |
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate**: it will never spend your money without explicit confirmation. |
```bash
# Activate the Researcher Hand — it starts working immediately
# Activate the Researcher Hand. It starts working immediately.
openfang hand activate researcher
# Check its progress anytime
@@ -116,7 +116,7 @@ openfang hand list
### Benchmarks: Measured, Not Marketed
All data from official documentation and public repositories February 2026.
All data from official documentation and public repositories, February 2026.
#### Cold Start Time (lower is better)
@@ -203,7 +203,7 @@ AutoGen ███████████░░░░░░░░░░░░
---
## 16 Security Systems Defense in Depth
## 16 Security Systems: Defense in Depth
OpenFang doesn't bolt security on after the fact. Every layer is independently testable and operates without a single point of failure.
@@ -211,19 +211,19 @@ OpenFang doesn't bolt security on after the fact. Every layer is independently t
|---|--------|-------------|
| 1 | **WASM Dual-Metered Sandbox** | Tool code runs in WebAssembly with fuel metering + epoch interruption. A watchdog thread kills runaway code. |
| 2 | **Merkle Hash-Chain Audit Trail** | Every action is cryptographically linked to the previous one. Tamper with one entry and the entire chain breaks. |
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution — secrets are tracked from source to sink. |
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution. Secrets are tracked from source to sink. |
| 4 | **Ed25519 Signed Agent Manifests** | Every agent identity and capability set is cryptographically signed. |
| 5 | **SSRF Protection** | Blocks private IPs, cloud metadata endpoints, and DNS rebinding attacks. |
| 6 | **Secret Zeroization** | `Zeroizing<String>` auto-wipes API keys from memory the instant they're no longer needed. |
| 7 | **OFP Mutual Authentication** | HMAC-SHA256 nonce-based, constant-time verification for P2P networking. |
| 8 | **Capability Gates** | Role-based access control — agents declare required tools, the kernel enforces it. |
| 8 | **Capability Gates** | Role based access control. Agents declare required tools, the kernel enforces it. |
| 9 | **Security Headers** | CSP, X-Frame-Options, HSTS, X-Content-Type-Options on every response. |
| 10 | **Health Endpoint Redaction** | Public health check returns minimal info. Full diagnostics require authentication. |
| 11 | **Subprocess Sandbox** | `env_clear()` + selective variable passthrough. Process tree isolation with cross-platform kill. |
| 12 | **Prompt Injection Scanner** | Detects override attempts, data exfiltration patterns, and shell reference injection in skills. |
| 13 | **Loop Guard** | SHA256-based tool call loop detection with circuit breaker. Handles ping-pong patterns. |
| 14 | **Session Repair** | 7-phase message history validation and automatic recovery from corruption. |
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. `../` doesn't work here. |
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. ``../`` doesn't work here. |
| 16 | **GCRA Rate Limiter** | Cost-aware token bucket rate limiting with per-IP tracking and stale cleanup. |
---
@@ -268,7 +268,7 @@ Each adapter supports per-channel model overrides, DM/group policies, rate limit
## WhatsApp Web Gateway (QR Code)
Connect your personal WhatsApp account to OpenFang via QR code just like WhatsApp Web. No Meta Business account required.
Connect your personal WhatsApp account to OpenFang via QR code, just like WhatsApp Web. No Meta Business account required.
### Prerequisites
@@ -357,7 +357,7 @@ For production workloads, use the [WhatsApp Cloud API](https://developers.facebo
---
## 27 LLM Providers 123+ Models
## 27 LLM Providers, 123+ Models
3 native drivers (Anthropic, Gemini, OpenAI-compatible) route to 27 providers:
@@ -372,7 +372,7 @@ Intelligent routing with task complexity scoring, automatic fallback, cost track
Already running OpenClaw? One command:
```bash
# Migrate everything agents, memory, skills, configs
# Migrate everything: agents, memory, skills, configs.
openfang migrate --from openclaw
# Migrate from a specific path
@@ -410,7 +410,7 @@ curl -X POST localhost:4200/v1/chat/completions \
# 1. Install (macOS/Linux)
curl -fsSL https://openfang.sh/install | sh
# 2. Initialize — walks you through provider setup
# 2. Initialize. Walks you through provider setup.
openfang init
# 3. Start the daemon
@@ -418,7 +418,7 @@ openfang start
# 4. Dashboard is live at http://localhost:4200
# 5. Activate a Hand — it starts working for you
# 5. Activate a Hand. It starts working for you.
openfang hand activate researcher
# 6. Chat with an agent
@@ -462,14 +462,14 @@ cargo fmt --all -- --check
## Stability Notice
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
OpenFang v0.5.10 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is deep. That said:
- **Breaking changes** may occur between minor versions until v1.0
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
- **Edge cases** exist — if you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues)
- **Pin to a specific commit** for production deployments until v1.0
- **Breaking changes** may occur between minor versions until v1.0.
- **Some Hands** are more mature than others. Browser and Researcher are the most battle tested.
- **Edge cases** exist. If you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues).
- **Pin to a specific commit** for production deployments until v1.0.
We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
We ship fast and fix fast. The goal is a rock solid v1.0 by mid 2026.
---
@@ -481,7 +481,7 @@ To report a security vulnerability, email **jaber@rightnowai.co**. We take all r
## License
MIT — use it however you want.
MIT. Use it however you want.
---
+54 -5
View File
@@ -55,6 +55,7 @@ use openfang_channels::ntfy::NtfyAdapter;
use openfang_channels::webhook::WebhookAdapter;
use openfang_channels::wecom::WeComAdapter;
use openfang_kernel::OpenFangKernel;
use openfang_runtime::kernel_handle::KernelHandle;
use openfang_types::agent::AgentId;
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -523,6 +524,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
timeout_secs: None,
},
delivery: openfang_types::scheduler::CronDelivery::None,
delivery_targets: Vec::new(),
created_at: chrono::Utc::now(),
last_run: None,
next_run: None,
@@ -816,6 +818,19 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
}
async fn free_response_channels(&self, channel_type: &str) -> Vec<String> {
let channels = &self.kernel.config.channels;
match channel_type {
"discord" => channels
.discord
.as_ref()
.map(|c| c.free_response_channels.clone())
.unwrap_or_default(),
// Add other channel types here as needed (e.g., "telegram" => ...)
_ => Vec::new(),
}
}
async fn authorize_channel_user(
&self,
channel_type: &str,
@@ -880,6 +895,23 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
}
async fn send_channel_message(
&self,
channel_type: &str,
recipient: &str,
message: &str,
) -> Result<(), String> {
<OpenFangKernel as KernelHandle>::send_channel_message(
&self.kernel,
channel_type,
recipient,
message,
None,
)
.await
.map(|_| ())
}
async fn check_auto_reply(&self, agent_id: AgentId, message: &str) -> Option<String> {
// Check if auto-reply should fire for this message
let channel_type = "bridge"; // Generic; the bridge layer handles specifics
@@ -1134,11 +1166,12 @@ pub async fn start_channel_bridge_with_config(
if let Some(ref tg_config) = config.telegram {
if let Some(token) = read_token(&tg_config.bot_token_env, "Telegram") {
let poll_interval = Duration::from_secs(tg_config.poll_interval_secs);
let adapter = Arc::new(TelegramAdapter::new(
let adapter = Arc::new(TelegramAdapter::with_thread_routes(
token,
tg_config.allowed_users.clone(),
poll_interval,
tg_config.api_url.clone(),
tg_config.thread_routes.clone(),
));
adapters.push((adapter, tg_config.default_agent.clone()));
}
@@ -1153,6 +1186,7 @@ pub async fn start_channel_bridge_with_config(
dc_config.allowed_users.clone(),
dc_config.ignore_bots,
dc_config.intents,
dc_config.auto_thread.clone(),
));
adapters.push((adapter, dc_config.default_agent.clone()));
}
@@ -1217,10 +1251,17 @@ pub async fn start_channel_bridge_with_config(
// Matrix
if let Some(ref mx_config) = config.matrix {
if let Some(token) = read_token(&mx_config.access_token_env, "Matrix") {
let adapter = Arc::new(MatrixAdapter::new(
// MSC2918 refresh-token support: optional env var, when present the
// adapter auto-recovers from M_UNKNOWN_TOKEN 401s.
let refresh = mx_config
.refresh_token_env
.as_deref()
.and_then(|env| read_token(env, "Matrix refresh"));
let adapter = Arc::new(MatrixAdapter::with_refresh_token(
mx_config.homeserver_url.clone(),
mx_config.user_id.clone(),
token,
refresh,
mx_config.allowed_rooms.clone(),
mx_config.auto_accept_invites,
));
@@ -1445,9 +1486,10 @@ pub async fn start_channel_bridge_with_config(
encrypt_key,
fs_config.bot_names.clone(),
)),
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket(
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket_with_region(
fs_config.app_id.clone(),
secret,
region,
)),
};
adapters.push((adapter, fs_config.default_agent.clone()));
@@ -1457,8 +1499,15 @@ pub async fn start_channel_bridge_with_config(
// Revolt
if let Some(ref rv_config) = config.revolt {
if let Some(token) = read_token(&rv_config.bot_token_env, "Revolt") {
let adapter = Arc::new(RevoltAdapter::new(token));
adapters.push((adapter, rv_config.default_agent.clone()));
let mut adapter = RevoltAdapter::with_urls(
token,
rv_config.api_url.clone(),
rv_config.ws_url.clone(),
);
if !rv_config.allowed_channels.is_empty() {
adapter.set_allowed_channels(rv_config.allowed_channels.clone());
}
adapters.push((Arc::new(adapter), rv_config.default_agent.clone()));
}
}
+29
View File
@@ -3,6 +3,35 @@
//! Exposes agent management, status, and chat via JSON REST endpoints.
//! The kernel runs in-process; the CLI connects over HTTP.
/// Decode percent-encoded strings (e.g. `%2B` → `+`).
/// Used to normalise `?token=` values that browsers encode with `encodeURIComponent`.
pub(crate) fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
out.push(hi << 4 | lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| input.to_string())
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
pub mod channel_bridge;
pub mod middleware;
pub mod openai_compat;
+162 -35
View File
@@ -49,14 +49,23 @@ pub struct AuthState {
pub api_key: String,
pub auth_enabled: bool,
pub session_secret: String,
/// Set from `OPENFANG_ALLOW_NO_AUTH=1` to permit running without an api_key
/// on a non-loopback bind. Off by default so empty keys fail closed.
pub allow_no_auth: bool,
}
/// Bearer token authentication middleware.
///
/// When `api_key` is non-empty (after trimming), requests to non-public
/// endpoints must include `Authorization: Bearer <api_key>`.
/// If the key is empty or whitespace-only, auth is disabled entirely
/// (public/local development mode).
///
/// When `api_key` is empty (no key configured) the server defaults to
/// fail-closed for any request that does NOT originate from loopback.
/// Loopback traffic (127.0.0.1 / ::1) is always allowed through with no
/// key so single-user local setups keep zero-config UX. To explicitly
/// run a no-auth server on a LAN/WAN address, set
/// `OPENFANG_ALLOW_NO_AUTH=1`; this opts out of fail-closed and is
/// reported loudly at startup.
///
/// When dashboard auth is enabled, session cookies are also accepted.
pub async fn auth(
@@ -67,17 +76,17 @@ pub async fn auth(
// SECURITY: Capture method early for method-aware public endpoint checks.
let method = request.method().clone();
// Shutdown is loopback-only (CLI on same machine) — skip token auth
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false); // SECURITY: default-deny; unknown origin is NOT loopback
// Shutdown is loopback-only (CLI on same machine). Skip token auth only
// when the request is from loopback.
let path = request.uri().path();
if path == "/api/shutdown" {
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
if is_loopback {
return next.run(request).await;
}
if path == "/api/shutdown" && is_loopback {
return next.run(request).await;
}
// Public endpoints that don't require auth (dashboard needs these).
@@ -117,6 +126,7 @@ pub async fn auth(
|| (path == "/api/hands/active" && is_get)
|| (path.starts_with("/api/hands/") && is_get)
|| (path == "/api/skills" && is_get)
|| (path.starts_with("/api/skills/") && path.ends_with("/config") && is_get)
|| (path == "/api/sessions" && is_get)
|| (path == "/api/integrations" && is_get)
|| (path == "/api/integrations/available" && is_get)
@@ -133,12 +143,29 @@ pub async fn auth(
return next.run(request).await;
}
// If no API key configured (empty, whitespace-only, or missing), skip auth
// entirely. Users who don't set api_key accept that all endpoints are open.
// To secure the dashboard, set a non-empty api_key in config.toml.
// If no API key configured and no dashboard login is active, fail closed
// for anything that did not come from loopback. Opting out of this
// behavior requires setting `OPENFANG_ALLOW_NO_AUTH=1`, which is logged
// loudly at startup.
//
// See issue #1034 (B1/B2): empty api_key previously bypassed auth for
// all origins, exposing agent config, channel tokens, and LLM keys on
// any LAN-reachable bind.
let api_key_trimmed = auth_state.api_key.trim().to_string();
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
return next.run(request).await;
if is_loopback || auth_state.allow_no_auth {
return next.run(request).await;
}
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("www-authenticate", "Bearer")
.body(Body::from(
serde_json::json!({
"error": "API key required for non-loopback requests. Set OPENFANG_API_KEY or bind to 127.0.0.1."
})
.to_string(),
))
.unwrap_or_default();
}
let api_key = api_key_trimmed.as_str();
@@ -167,13 +194,14 @@ pub async fn auth(
// Also check ?token= query parameter (for EventSource/SSE clients that
// cannot set custom headers, same approach as WebSocket auth).
let query_token = request
let query_token_decoded = request
.uri()
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")));
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(crate::percent_decode);
// SECURITY: Use constant-time comparison to prevent timing attacks.
let query_auth = query_token.map(|token| {
let query_auth = query_token_decoded.as_deref().map(|token| {
use subtle::ConstantTimeEq;
if token.len() != api_key.len() {
return false;
@@ -188,7 +216,7 @@ pub async fn auth(
// Check session cookie (dashboard login sessions)
if auth_state.auth_enabled {
if let Some(token) = extract_session_cookie(&request) {
if let Some(token) = crate::session_auth::extract_session_cookie(request.headers()) {
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
.is_some()
{
@@ -214,21 +242,6 @@ pub async fn auth(
.unwrap_or_default()
}
/// Extract the `openfang_session` cookie value from a request.
fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
c.trim()
.strip_prefix("openfang_session=")
.map(|v| v.to_string())
})
})
}
/// Security headers middleware — applied to ALL API responses.
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(request).await;
@@ -264,9 +277,123 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Method, Request};
use axum::routing::get;
use axum::Router;
use std::net::SocketAddr;
use tower::ServiceExt;
#[test]
fn test_request_id_header_constant() {
assert_eq!(REQUEST_ID_HEADER, "x-request-id");
}
fn auth_state_empty() -> AuthState {
AuthState {
api_key: String::new(),
auth_enabled: false,
session_secret: String::new(),
allow_no_auth: false,
}
}
fn auth_state_with_key(key: &str) -> AuthState {
AuthState {
api_key: key.to_string(),
auth_enabled: false,
session_secret: key.to_string(),
allow_no_auth: false,
}
}
async fn ok_handler() -> &'static str {
"ok"
}
fn router(state: AuthState) -> Router {
Router::new()
.route("/api/agents/1", get(ok_handler))
.route_layer(axum::middleware::from_fn_with_state(state, auth))
}
fn req_from(ip: &str) -> Request<Body> {
let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
let mut req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(addr));
req
}
#[tokio::test]
async fn empty_key_allows_loopback() {
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn empty_key_blocks_lan_origin() {
// Issue #1034 B1: previously 192.168/10/... could hit every non-public
// endpoint when api_key was unset. Must now be 401.
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("192.168.1.50")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_blocks_public_origin() {
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("203.0.113.5")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_blocks_unknown_connect_info() {
// Paranoia: if ConnectInfo is missing for any reason, we must fail
// closed, not open.
let app = router(auth_state_empty());
let req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_with_allow_no_auth_opens_everything() {
let mut s = auth_state_empty();
s.allow_no_auth = true;
let app = router(s);
let resp = app.oneshot(req_from("10.0.0.9")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn configured_key_rejects_missing_token_from_loopback() {
let app = router(auth_state_with_key("secret"));
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn configured_key_accepts_bearer() {
let app = router(auth_state_with_key("secret"));
let addr: SocketAddr = "127.0.0.1:40000".parse().unwrap();
let mut req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.header("authorization", "Bearer secret")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(addr));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
}
+6 -1
View File
@@ -235,7 +235,12 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
OaiContent::Null => return None,
};
Some(Message { role, content })
Some(Message {
msg_id: uuid::Uuid::new_v4().to_string(),
provider_msg_id: None,
role,
content,
})
})
.collect()
}
+9
View File
@@ -30,6 +30,15 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
("GET", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
NonZeroU32::new(3).unwrap()
}
("PUT", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
NonZeroU32::new(10).unwrap()
}
("DELETE", p) if p.starts_with("/api/skills/") && p.contains("/config/") => {
NonZeroU32::new(10).unwrap()
}
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
_ => NonZeroU32::new(5).unwrap(),
File diff suppressed because it is too large Load Diff
+58
View File
@@ -54,6 +54,10 @@ pub async fn build_router(
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
});
// Start WS cron broadcaster — subscribes to kernel event bus and pushes
// cron job results to all connected WebSocket clients in real-time.
ws::start_ws_cron_broadcaster(kernel.clone());
// CORS: allow localhost origins by default. If API key is set, the API
// is protected anyway. For development, permissive CORS is convenient.
let cors = if state.kernel.config.api_key.trim().is_empty() {
@@ -115,6 +119,32 @@ pub async fn build_router(
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
let api_key = state.kernel.config.api_key.trim().to_string();
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
.unwrap_or(false);
// Fail-closed warning: if no api_key and no dashboard auth, and the
// server is bound to a non-loopback address without an explicit opt-in,
// shout about it. The middleware will reject non-loopback traffic.
let bind_is_loopback = listen_addr.ip().is_loopback();
if api_key.is_empty() && !state.kernel.config.auth.enabled && !bind_is_loopback {
if allow_no_auth {
tracing::warn!(
"OPENFANG_ALLOW_NO_AUTH=1 is set. Running WITHOUT authentication on {}. \
Anyone reachable at this address can read/write agents, channels, and keys.",
listen_addr
);
} else {
tracing::warn!(
"No api_key configured and server is bound to {} (non-loopback). \
Non-loopback requests will be rejected with 401. \
Set OPENFANG_API_KEY (or api_key in config.toml), or bind to 127.0.0.1, \
or set OPENFANG_ALLOW_NO_AUTH=1 to explicitly run open.",
listen_addr
);
}
}
let auth_state = crate::middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
@@ -125,6 +155,7 @@ pub async fn build_router(
} else {
String::new()
},
allow_no_auth,
};
let gcra_limiter = rate_limiter::create_rate_limiter();
@@ -155,6 +186,10 @@ pub async fn build_router(
.delete(routes::kill_agent)
.patch(routes::patch_agent),
)
.route(
"/api/agents/{id}/uninstall",
axum::routing::delete(routes::uninstall_agent),
)
.route(
"/api/agents/{id}/mode",
axum::routing::put(routes::set_agent_mode),
@@ -168,6 +203,12 @@ pub async fn build_router(
"/api/agents/{id}/start",
axum::routing::post(routes::restart_agent),
)
.route(
// Issue #890 — alias so dashboards and external orchestrators can
// wake an inactive agent via a verb that matches the agent_activate tool.
"/api/agents/{id}/activate",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/message",
axum::routing::post(routes::send_message),
@@ -316,6 +357,10 @@ pub async fn build_router(
"/api/schedules/{id}/run",
axum::routing::post(routes::run_schedule),
)
.route(
"/api/schedules/{id}/delivery-log",
axum::routing::get(routes::schedule_delivery_log),
)
// Workflow endpoints
.route(
"/api/workflows",
@@ -349,6 +394,19 @@ pub async fn build_router(
"/api/skills/reload",
axum::routing::post(routes::reload_skills),
)
// Audit trail (issue #1174 — instance-side wrapper integration)
.route(
"/api/audit/append",
axum::routing::post(routes::audit_append),
)
.route(
"/api/skills/{id}/config",
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
)
.route(
"/api/skills/{id}/config/{var_name}",
axum::routing::delete(routes::delete_skill_config_var),
)
.route(
"/api/marketplace/search",
axum::routing::get(routes::marketplace_search),
+51
View File
@@ -17,6 +17,25 @@ pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> Str
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
}
/// Extract the `openfang_session` cookie value from a `Cookie` header string.
///
/// Returns `None` if the header is absent or the cookie is not present.
/// Used by both the HTTP auth middleware and the WebSocket upgrade handler so
/// that browser sessions established via `sessionLogin()` are honored on both
/// surfaces (issue #1085).
pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
c.trim()
.strip_prefix("openfang_session=")
.map(|v| v.to_string())
})
})
}
/// Verify a session token. Returns the username if valid and not expired.
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
use base64::Engine;
@@ -141,4 +160,36 @@ mod tests {
// Starts with $argon2 but is not a valid PHC string.
assert!(!verify_password("x", "$argon2id$garbage"));
}
#[test]
fn test_extract_session_cookie_present() {
let mut h = axum::http::HeaderMap::new();
h.insert(
"cookie",
"foo=bar; openfang_session=abc.def.ghi; baz=qux"
.parse()
.unwrap(),
);
assert_eq!(extract_session_cookie(&h).as_deref(), Some("abc.def.ghi"));
}
#[test]
fn test_extract_session_cookie_absent() {
let mut h = axum::http::HeaderMap::new();
h.insert("cookie", "foo=bar; baz=qux".parse().unwrap());
assert_eq!(extract_session_cookie(&h), None);
}
#[test]
fn test_extract_session_cookie_no_header() {
let h = axum::http::HeaderMap::new();
assert_eq!(extract_session_cookie(&h), None);
}
#[test]
fn test_extract_session_cookie_only_value() {
let mut h = axum::http::HeaderMap::new();
h.insert("cookie", "openfang_session=lonely".parse().unwrap());
assert_eq!(extract_session_cookie(&h).as_deref(), Some("lonely"));
}
}
+110
View File
@@ -65,6 +65,15 @@ pub struct MessageResponse {
#[derive(Debug, Deserialize)]
pub struct SkillInstallRequest {
pub name: String,
/// When true, reject the install unless the bundle ships a valid
/// Ed25519 SignedManifest envelope bound to the on-disk manifest.
/// Maps to `InstallOptions::require_signed` (issue #1170).
#[serde(default)]
pub require_signed: bool,
/// Optional hex-encoded allow-list of acceptable signer public keys.
/// Empty = TOFU (any valid signature accepted).
#[serde(default)]
pub allowed_signer_keys: Vec<String>,
}
/// Request to uninstall a skill.
@@ -107,3 +116,104 @@ pub struct ClawHubInstallRequest {
/// ClawHub skill slug (e.g., "github-helper").
pub slug: String,
}
/// Query parameters for `GET /api/commands`.
#[derive(Debug, Deserialize)]
pub struct CommandsQuery {
/// Surface filter: `web` (default), `cli`, `channel`, or `all`.
#[serde(default)]
pub surface: Option<String>,
}
/// Request body for `POST /api/audit/append` (issue #1174).
///
/// Lets external (instance-side) wrappers append entries to the Merkle hash
/// chain audit log. The handler maps `event_type` to an `AuditAction` and
/// records the entry through `kernel.audit_log`.
#[derive(Debug, Deserialize)]
pub struct AuditAppendRequest {
/// Operator-supplied event category. Case-insensitive, matched against the
/// `AuditAction` enum variants (e.g. `tool_invoke`, `ConfigChange`,
/// `agent_message`). Unknown values fall back to `ToolInvoke`.
pub event_type: String,
/// Agent or wrapper identifier responsible for the event. When empty,
/// recorded as `"external-wrapper"`.
#[serde(default)]
pub agent_id: String,
/// Free-form detail string (e.g. tool name, URL, file path).
#[serde(default)]
pub detail: String,
/// Optional arbitrary payload. When present it is serialised to JSON and
/// appended onto the entry's detail so the wrapper retains structured
/// context without changing the on-chain schema.
#[serde(default)]
pub payload: Option<serde_json::Value>,
/// Optional outcome string (`"ok"`, `"denied"`, or an error). Defaults to
/// `"ok"` when omitted.
#[serde(default)]
pub outcome: Option<String>,
/// Optional operator-supplied signing context (e.g. wrapper identity, key
/// fingerprint). Mixed into the detail when present so the chain captures
/// who attested to the event.
#[serde(default)]
pub signing_context: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_install_request_defaults_back_compat() {
// Existing callers send `{"name": "..."}` only. New optional fields
// must default cleanly (issue #1170).
let req: SkillInstallRequest = serde_json::from_str(r#"{"name":"github-helper"}"#).unwrap();
assert_eq!(req.name, "github-helper");
assert!(!req.require_signed);
assert!(req.allowed_signer_keys.is_empty());
}
#[test]
fn skill_install_request_parses_require_signed() {
let req: SkillInstallRequest = serde_json::from_str(
r#"{"name":"x","require_signed":true,"allowed_signer_keys":["abc123"]}"#,
)
.unwrap();
assert!(req.require_signed);
assert_eq!(req.allowed_signer_keys, vec!["abc123".to_string()]);
}
#[test]
fn audit_append_request_required_only() {
// Only `event_type` is required; everything else must default.
let req: AuditAppendRequest =
serde_json::from_str(r#"{"event_type":"ToolInvoke"}"#).unwrap();
assert_eq!(req.event_type, "ToolInvoke");
assert!(req.agent_id.is_empty());
assert!(req.detail.is_empty());
assert!(req.payload.is_none());
assert!(req.outcome.is_none());
assert!(req.signing_context.is_none());
}
#[test]
fn audit_append_request_full_payload() {
let body = r#"{
"event_type": "config_change",
"agent_id": "wrapper-1",
"detail": "rotated key",
"payload": {"key_id": "k-42", "ts": 1700000000},
"outcome": "ok",
"signing_context": "ed25519:deadbeef"
}"#;
let req: AuditAppendRequest = serde_json::from_str(body).unwrap();
assert_eq!(req.event_type, "config_change");
assert_eq!(req.agent_id, "wrapper-1");
assert_eq!(req.detail, "rotated key");
assert_eq!(req.outcome.as_deref(), Some("ok"));
assert_eq!(req.signing_context.as_deref(), Some("ed25519:deadbeef"));
let payload = req.payload.expect("payload present");
assert_eq!(payload["key_id"], "k-42");
assert_eq!(payload["ts"], 1_700_000_000);
}
}
+5 -4
View File
@@ -90,11 +90,11 @@ pub async fn webchat_page() -> impl IntoResponse {
let html = WEBCHAT_HTML.replace(NONCE_PLACEHOLDER, &nonce);
let csp = format!(
"default-src 'self'; \
script-src 'self' 'nonce-{nonce}' 'unsafe-eval'; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; \
script-src 'self' 'nonce-{nonce}' 'unsafe-eval' https://cdn.jsdelivr.net; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; \
img-src 'self' data: blob:; \
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; \
font-src 'self' https://fonts.gstatic.com; \
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:* https://cdn.jsdelivr.net; \
font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; \
media-src 'self' blob:; \
frame-src 'self' blob:; \
object-src 'none'; \
@@ -120,6 +120,7 @@ pub async fn webchat_page() -> impl IntoResponse {
/// All vendor libraries (Alpine.js, marked.js, highlight.js) are bundled
/// locally — no CDN dependency. Alpine.js is included LAST because it
/// immediately processes x-data directives and fires alpine:init on load.
/// KaTeX is loaded dynamically from jsdelivr CDN when needed for LaTeX rendering.
const WEBCHAT_HTML: &str = concat!(
include_str!("../static/index_head.html"),
"<style>\n",
+698 -45
View File
@@ -19,17 +19,19 @@ use axum::response::IntoResponse;
use dashmap::DashMap;
use futures::stream::SplitSink;
use futures::{SinkExt, StreamExt};
use openfang_kernel::OpenFangKernel;
use openfang_runtime::kernel_handle::KernelHandle;
use openfang_runtime::llm_driver::StreamEvent;
use openfang_runtime::llm_errors;
use openfang_types::agent::AgentId;
use openfang_types::commands::{self, Surfaces};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn};
/// Per-IP WebSocket connection tracker.
@@ -97,6 +99,62 @@ fn ws_tracker() -> &'static DashMap<IpAddr, AtomicUsize> {
TRACKER.get_or_init(DashMap::new)
}
/// Per-agent WebSocket sender entry.
struct WsSender {
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
}
/// Global registry: agent_id → active WebSocket senders.
/// Uses RwLock for fine-grained read/write access to the sender list.
fn ws_agent_connections() -> &'static DashMap<AgentId, RwLock<Vec<WsSender>>> {
static REGISTRY: std::sync::OnceLock<DashMap<AgentId, RwLock<Vec<WsSender>>>> =
std::sync::OnceLock::new();
REGISTRY.get_or_init(DashMap::new)
}
/// Register a WebSocket connection for an agent (async).
pub async fn register_ws_connection(
agent_id: AgentId,
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
) {
let entry = ws_agent_connections().entry(agent_id).or_default();
let mut senders = entry.value().write().await;
senders.push(WsSender { sender });
}
/// Deregister a WebSocket connection for an agent.
/// Returns the number of remaining connections for this agent.
pub async fn deregister_ws_connection(
agent_id: AgentId,
sender: &Arc<Mutex<SplitSink<WebSocket, Message>>>,
) -> usize {
let entry = match ws_agent_connections().get(&agent_id) {
Some(e) => e,
None => return 0,
};
let mut senders = entry.value().write().await;
senders.retain(|s| !Arc::ptr_eq(&s.sender, sender));
senders.len()
}
/// Broadcast a JSON message to all active WebSocket connections for an agent.
/// Returns the number of connections the message was sent to.
pub async fn broadcast_to_ws(agent_id: AgentId, msg: serde_json::Value) -> usize {
let entry = match ws_agent_connections().get(&agent_id) {
Some(e) => e,
None => return 0,
};
let senders = entry.value().read().await;
let mut success_count = 0;
for ws_sender in senders.iter() {
let sender = &ws_sender.sender;
if send_json(sender, &msg).await.is_ok() {
success_count += 1;
}
}
success_count
}
/// RAII guard that decrements the connection count on drop.
struct WsConnectionGuard {
ip: IpAddr,
@@ -132,11 +190,121 @@ fn try_acquire_ws_slot(ip: IpAddr) -> Option<WsConnectionGuard> {
// WS Upgrade Handler
// ---------------------------------------------------------------------------
/// Parameters for [`check_ws_auth`]. Kept as a struct so the auth gate stays
/// pure and unit-testable without an `AppState` or live socket.
pub(crate) struct WsAuthCtx<'a> {
/// Trimmed API key from kernel config. Empty string means no key configured.
pub api_key: &'a str,
/// Whether dashboard session login is enabled in config.
pub auth_enabled: bool,
/// Secret used to verify session cookies (api_key when set, else password hash).
pub session_secret: &'a str,
/// Whether the request originated from a loopback address.
pub is_loopback: bool,
/// True iff `OPENFANG_ALLOW_NO_AUTH=1` is set (loose mode for LAN binds).
pub allow_no_auth: bool,
pub headers: &'a axum::http::HeaderMap,
pub uri: &'a axum::http::Uri,
}
/// Pure auth gate for WebSocket upgrades.
///
/// Returns `Ok(())` if the request should be allowed through, or
/// `Err(StatusCode::UNAUTHORIZED)` otherwise. Accepts:
/// 1. `Authorization: Bearer <api_key>` header
/// 2. `?token=<api_key>` query parameter
/// 3. `openfang_session=<token>` cookie when dashboard auth is enabled
/// 4. Loopback origin when no api_key is configured
/// 5. Any origin when `OPENFANG_ALLOW_NO_AUTH=1`
///
/// Fix for issue #1085: previously only (1), (2), and (4) were honored, so
/// dashboard users logged in via session cookie saw "No active connection"
/// because the WS upgrade rejected them even though HTTP requests succeeded.
pub(crate) fn check_ws_auth(ctx: &WsAuthCtx<'_>) -> Result<(), axum::http::StatusCode> {
use axum::http::StatusCode;
// No api_key configured: behavior depends on whether dashboard auth is on.
//
// Issue #1189: previously this path allowed any loopback request through
// when api_key was empty, EVEN IF dashboard auth was enabled. That diverged
// from the HTTP middleware (which only opens the loopback no-auth path
// when api_key is empty AND auth.enabled is false). A local attacker with
// loopback access could chat with agents over WS even when the operator
// had configured dashboard credentials. Now mirror HTTP exactly.
if ctx.api_key.is_empty() {
// When dashboard auth is configured, require a valid session cookie
// regardless of bind address. Loopback no longer bypasses login.
if ctx.auth_enabled {
if !ctx.session_secret.is_empty() {
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
if crate::session_auth::verify_session_token(&token, ctx.session_secret)
.is_some()
{
return Ok(());
}
}
}
return Err(StatusCode::UNAUTHORIZED);
}
// No api_key AND dashboard auth disabled: keep the dev convenience
// path (loopback or explicit OPENFANG_ALLOW_NO_AUTH=1).
if ctx.is_loopback || ctx.allow_no_auth {
return Ok(());
}
return Err(StatusCode::UNAUTHORIZED);
}
// SECURITY: constant-time comparison to prevent timing attacks on API key.
let ct_eq = |token: &str, key: &str| -> bool {
use subtle::ConstantTimeEq;
if token.len() != key.len() {
return false;
}
token.as_bytes().ct_eq(key.as_bytes()).into()
};
let header_auth = ctx
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|token| ct_eq(token, ctx.api_key))
.unwrap_or(false);
if header_auth {
return Ok(());
}
let query_auth = ctx
.uri
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(crate::percent_decode)
.map(|token| ct_eq(&token, ctx.api_key))
.unwrap_or(false);
if query_auth {
return Ok(());
}
// Dashboard session cookie (issue #1085). When auth_enabled is on the
// session_secret is set by server.rs to either the api_key or the
// configured password hash, mirroring the HTTP auth middleware.
if ctx.auth_enabled && !ctx.session_secret.is_empty() {
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
if crate::session_auth::verify_session_token(&token, ctx.session_secret).is_some() {
return Ok(());
}
}
}
Err(StatusCode::UNAUTHORIZED)
}
/// GET /api/agents/:id/ws — Upgrade to WebSocket for real-time chat.
///
/// SECURITY: Authenticates via Bearer token in Authorization header
/// or `?token=` query parameter (for browser WebSocket clients that
/// cannot set custom headers).
/// SECURITY: Authenticates via Bearer token in Authorization header,
/// `?token=` query parameter (for browser WebSocket clients that cannot
/// set custom headers), or the `openfang_session` cookie set by the
/// dashboard's session login flow (issue #1085).
pub async fn agent_ws(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
@@ -145,37 +313,43 @@ pub async fn agent_ws(
headers: axum::http::HeaderMap,
uri: axum::http::Uri,
) -> impl IntoResponse {
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
// Trim whitespace so empty/whitespace-only api_key disables auth.
// SECURITY: Authenticate WebSocket upgrades (bypasses HTTP middleware).
// Trim whitespace so empty/whitespace-only api_key still triggers the
// fail-closed path for non-loopback origins (see issue #1034 B2).
let api_key_raw = &state.kernel.config.api_key;
let api_key = api_key_raw.trim();
if !api_key.is_empty() {
// SECURITY: Use constant-time comparison to prevent timing attacks on API key
let ct_eq = |token: &str, key: &str| -> bool {
use subtle::ConstantTimeEq;
if token.len() != key.len() {
return false;
}
token.as_bytes().ct_eq(key.as_bytes()).into()
};
let is_loopback = addr.ip().is_loopback();
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
.unwrap_or(false);
let header_auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|token| ct_eq(token, api_key))
.unwrap_or(false);
// Mirror the session_secret derivation in server.rs::AuthState so cookies
// issued by /api/auth/login verify the same way over HTTP and WS.
let auth_enabled = state.kernel.config.auth.enabled;
let session_secret_owned: String = if !api_key.is_empty() {
api_key.to_string()
} else if auth_enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
};
let query_auth = uri
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(|token| ct_eq(token, api_key))
.unwrap_or(false);
let auth_ctx = WsAuthCtx {
api_key,
auth_enabled,
session_secret: &session_secret_owned,
is_loopback,
allow_no_auth,
headers: &headers,
uri: &uri,
};
if !header_auth && !query_auth {
warn!("WebSocket upgrade rejected: invalid auth");
return axum::http::StatusCode::UNAUTHORIZED.into_response();
}
if let Err(status) = check_ws_auth(&auth_ctx) {
warn!(
ip = %addr.ip(),
"WebSocket upgrade rejected: no valid Bearer token, ?token=, or openfang_session cookie"
);
return status.into_response();
}
// SECURITY: Enforce per-IP WebSocket connection limit
@@ -196,9 +370,29 @@ pub async fn agent_ws(
}
};
// Verify agent exists
if state.kernel.registry.get(agent_id).is_none() {
return axum::http::StatusCode::NOT_FOUND.into_response();
// Verify agent exists.
// Retry up to 5 times with 200ms backoff to handle a timing race where
// the client connects before the agent finishes registering (#804).
{
let mut found = state.kernel.registry.get(agent_id).is_some();
if !found {
for attempt in 1..=4 {
debug!(
agent_id = %id,
attempt,
"Agent not found yet, retrying in 200ms"
);
tokio::time::sleep(Duration::from_millis(200)).await;
if state.kernel.registry.get(agent_id).is_some() {
found = true;
break;
}
}
}
if !found {
warn!(agent_id = %id, "Agent not found after 5 lookup attempts");
return axum::http::StatusCode::NOT_FOUND.into_response();
}
}
let id_str = id.clone();
@@ -226,6 +420,9 @@ async fn handle_agent_ws(
let (sender, mut receiver) = socket.split();
let sender = Arc::new(Mutex::new(sender));
// Register this connection in the global agent-WS registry
register_ws_connection(agent_id, Arc::clone(&sender)).await;
// Per-connection verbose level (default: Full)
let verbose = Arc::new(AtomicU8::new(VerboseLevel::Full as u8));
@@ -378,7 +575,8 @@ async fn handle_agent_ws(
}
}
// Cleanup
// Cleanup: deregister from agent-WS registry and abort background tasks
deregister_ws_connection(agent_id, &sender).await;
update_handle.abort();
info!(agent_id = %id_str, "WebSocket disconnected");
}
@@ -816,8 +1014,17 @@ async fn handle_command(
args: &str,
verbose: &Arc<AtomicU8>,
) -> serde_json::Value {
match cmd {
"new" | "reset" => match state.kernel.reset_session(agent_id) {
// Canonicalise through the unified command registry. This resolves aliases
// (e.g. `reset` -> `new`) and is case-insensitive. If the command is not
// registered on the WEB surface, fall through to the existing match so any
// legacy/un-registered handlers still work byte-identically.
let canonical: &str = commands::resolve(cmd)
.filter(|def| def.surfaces.contains(Surfaces::WEB))
.map(|def| def.name)
.unwrap_or(cmd);
match canonical {
"new" => match state.kernel.reset_session(agent_id) {
Ok(()) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": "Session reset. Chat history cleared."})
}
@@ -831,15 +1038,34 @@ async fn handle_command(
serde_json::json!({"type": "error", "content": format!("Compaction failed: {e}")})
}
},
"stop" => match state.kernel.stop_agent_run(agent_id) {
Ok(true) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": "Run cancelled."})
"stop" => {
// If this agent is owned by an active hand instance, deactivate the
// hand entirely so the user can re-activate it (issue #1164).
if let Some(instance) = state.kernel.hand_registry.find_by_agent(agent_id) {
match state.kernel.deactivate_hand(instance.instance_id) {
Ok(()) => serde_json::json!({
"type": "command_result",
"command": cmd,
"message": format!("Hand '{}' deactivated.", instance.hand_id),
}),
Err(e) => {
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
}
}
} else {
match state.kernel.stop_agent_run(agent_id) {
Ok(true) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": "Run cancelled."})
}
Ok(false) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": "No active run to cancel."})
}
Err(e) => {
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
}
}
}
Ok(false) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": "No active run to cancel."})
}
Err(e) => serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")}),
},
}
"model" => {
if args.is_empty() {
if let Some(entry) = state.kernel.registry.get(agent_id) {
@@ -986,7 +1212,20 @@ async fn handle_command(
};
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
}
_ => serde_json::json!({"type": "error", "content": format!("Unknown command: {cmd}")}),
"help" => {
serde_json::json!({
"type": "command_result",
"command": cmd,
"message": commands::render_help(Surfaces::WEB),
})
}
_ => serde_json::json!({
"type": "error",
"content": format!(
"Unknown command: /{cmd}\n\n{}",
commands::render_help(Surfaces::WEB)
),
}),
}
}
@@ -1273,6 +1512,110 @@ pub fn strip_think_tags(text: &str) -> String {
result
}
// ---------------------------------------------------------------------------
// Cron Job WS Broadcasting
// ---------------------------------------------------------------------------
/// Start a background task that subscribes to the kernel's event bus and
/// broadcasts cron job results to all connected WebSocket clients for the
/// relevant agent.
///
/// This runs independently of the channel bridge — it uses the kernel's
/// event bus to receive `CronJobExecuted` events and pushes them to WS.
pub fn start_ws_cron_broadcaster(kernel: Arc<OpenFangKernel>) {
tokio::spawn(async move {
let mut rx = kernel.event_bus.subscribe_all();
loop {
let event = rx.recv().await;
match event {
Ok(event) => {
if let openfang_types::event::EventPayload::System(
openfang_types::event::SystemEvent::CronJobExecuted {
agent_id,
job_id,
job_name,
trigger_message,
response,
delivered_to_channel: _,
},
) = event.payload
{
// Build the trigger message (synthetic user message from cron)
let trigger_msg = serde_json::json!({
"type": "message",
"content": trigger_message,
"source": "cron",
"job_id": job_id,
"job_name": job_name
});
let _ = broadcast_to_ws(agent_id, trigger_msg).await;
// Send typing start
let _ = broadcast_to_ws(
agent_id,
serde_json::json!({"state": "start", "type": "typing"}),
)
.await;
// Send streaming phase
let _ = broadcast_to_ws(
agent_id,
serde_json::json!({"detail": null, "phase": "streaming", "type": "phase"}),
)
.await;
// Send text delta (full response since we don't have streaming chunks)
let text_delta = serde_json::json!({
"content": response,
"type": "text_delta"
});
let _ = broadcast_to_ws(agent_id, text_delta).await;
// Send done phase
let _ = broadcast_to_ws(
agent_id,
serde_json::json!({"detail": null, "phase": "done", "type": "phase"}),
)
.await;
// Send typing stop
let _ = broadcast_to_ws(
agent_id,
serde_json::json!({"state": "stop", "type": "typing"}),
)
.await;
// Send final response (mimics the format from agent_loop)
let response_msg = serde_json::json!({
"type": "response",
"content": response,
"context_pressure": "low",
"cost_usd": null,
"input_tokens": 0,
"iterations": 0,
"output_tokens": 0
});
let _ = broadcast_to_ws(agent_id, response_msg).await;
info!(
agent_id = %agent_id,
job_id = %job_id,
"Cron job result broadcast to WS"
);
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(lagged_messages = n, "WS cron broadcaster lagged, skipping");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
info!("WS cron broadcaster channel closed, stopping");
break;
}
}
}
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1372,4 +1715,314 @@ mod tests {
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
}
// -----------------------------------------------------------------------
// WebSocket auth gate (issue #1085)
// -----------------------------------------------------------------------
fn empty_uri() -> axum::http::Uri {
"/api/agents/x/ws".parse().unwrap()
}
fn uri_with_token(tok: &str) -> axum::http::Uri {
format!("/api/agents/x/ws?token={tok}").parse().unwrap()
}
#[test]
fn ws_auth_accepts_bearer_token() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("authorization", "Bearer secret".parse().unwrap());
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: false,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
#[test]
fn ws_auth_accepts_query_token() {
let headers = axum::http::HeaderMap::new();
let uri = uri_with_token("secret");
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: false,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
#[test]
fn ws_auth_accepts_session_cookie() {
// Issue #1085: the dashboard logs in via cookie, so WS must accept it.
let secret = "shared-secret";
let token = crate::session_auth::create_session_token("alice", secret, 1);
let cookie = format!("foo=bar; openfang_session={token}");
let mut headers = axum::http::HeaderMap::new();
headers.insert("cookie", cookie.parse().unwrap());
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: secret,
auth_enabled: true,
session_secret: secret,
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(
check_ws_auth(&ctx).is_ok(),
"valid session cookie should authorize WS upgrade"
);
}
#[test]
fn ws_auth_session_cookie_rejected_when_auth_disabled() {
// If dashboard auth is off, cookies must not grant access.
let secret = "shared-secret";
let token = crate::session_auth::create_session_token("alice", secret, 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"cookie",
format!("openfang_session={token}").parse().unwrap(),
);
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: secret,
auth_enabled: false,
session_secret: secret,
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_rejects_wrong_session_cookie() {
// Cookie signed with the wrong secret must fail.
let bad = crate::session_auth::create_session_token("alice", "other-secret", 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert("cookie", format!("openfang_session={bad}").parse().unwrap());
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: true,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_rejects_when_no_credentials() {
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: true,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_rejects_wrong_bearer() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("authorization", "Bearer wrong".parse().unwrap());
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: false,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_empty_key_loopback_ok() {
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: false,
session_secret: "",
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
#[test]
fn ws_auth_empty_key_non_loopback_rejected() {
// Issue #1034 B2 regression guard.
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: false,
session_secret: "",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_empty_key_allow_no_auth_opens() {
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: false,
session_secret: "",
is_loopback: false,
allow_no_auth: true,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
#[test]
fn ws_auth_empty_key_session_cookie_grants_non_loopback() {
// When only dashboard login is configured (no api_key, auth_enabled=true),
// a valid session cookie must allow non-loopback WS upgrades.
let secret = "password-hash-style-secret";
let token = crate::session_auth::create_session_token("admin", secret, 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"cookie",
format!("openfang_session={token}").parse().unwrap(),
);
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: true,
session_secret: secret,
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
// -----------------------------------------------------------------------
// Issue #1189: WS auth must mirror HTTP middleware. When dashboard auth
// is enabled, loopback + empty api_key + no cookie must NOT bypass.
// -----------------------------------------------------------------------
#[test]
fn ws_auth_dashboard_on_loopback_empty_key_no_cookie_rejected() {
// Issue #1189 regression guard: previously this returned Ok(()) because
// the empty-api_key branch allowed any loopback request through, even
// when dashboard credentials were configured. HTTP middleware rejects
// this path; WS must too.
let secret = "password-hash-style-secret";
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: true,
session_secret: secret,
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED,
"loopback must not bypass dashboard auth when api_key is empty"
);
}
#[test]
fn ws_auth_dashboard_on_loopback_valid_cookie_accepted() {
// With dashboard auth on, a valid session cookie is the supported
// credential and must upgrade successfully from loopback too.
let secret = "password-hash-style-secret";
let token = crate::session_auth::create_session_token("admin", secret, 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"cookie",
format!("openfang_session={token}").parse().unwrap(),
);
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: true,
session_secret: secret,
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(
check_ws_auth(&ctx).is_ok(),
"valid session cookie should authorize loopback WS upgrade"
);
}
#[test]
fn ws_auth_dashboard_off_loopback_empty_key_accepted() {
// Preserve the development convenience path: when dashboard auth is
// NOT configured AND api_key is empty, loopback still upgrades.
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: false,
session_secret: "",
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(
check_ws_auth(&ctx).is_ok(),
"loopback dev path must work when dashboard auth is disabled"
);
}
}
+20 -13
View File
@@ -23,7 +23,7 @@
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.btn-primary {
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
box-shadow: var(--shadow-xs), var(--shadow-inset);
}
.btn-primary:hover { background: var(--accent-dim); box-shadow: var(--shadow-sm), var(--shadow-accent); transform: translateY(-1px); }
@@ -312,6 +312,12 @@ tr:hover td { background: var(--surface2); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* Issue #1026: live indicator for agents currently calling the LLM */
@keyframes agent-inferencing-pulse {
0%, 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 var(--accent); }
50% { transform: scale(1.25); opacity: 0.85; box-shadow: 0 0 0 4px rgba(255, 92, 0, 0); }
}
.message.user {
flex-direction: row-reverse;
}
@@ -538,7 +544,7 @@ tr:hover td { background: var(--surface2); }
height: 14px;
border-radius: 50%;
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
font-size: 9px;
font-weight: 700;
display: flex;
@@ -864,7 +870,7 @@ mark.search-highlight {
border-radius: 50%;
border: none;
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
cursor: pointer;
display: flex;
align-items: center;
@@ -1994,7 +2000,7 @@ mark.search-highlight {
}
.filter-pill:hover { border-color: var(--accent); color: var(--text); }
.filter-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
.filter-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
/* ── Difficulty badges ── */
.difficulty-badge {
@@ -2258,7 +2264,7 @@ mark.search-highlight {
.wizard-progress-step.wiz-active .wizard-progress-circle {
border-color: var(--accent);
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
box-shadow: 0 0 0 4px var(--accent-glow);
}
@@ -2485,7 +2491,7 @@ mark.search-highlight {
/* ── Try-It Mini Chat ── */
.tryit-messages { max-height: 200px; overflow-y: auto; margin: 12px 0; }
.tryit-msg { padding: 6px 10px; border-radius: 6px; margin: 4px 0; font-size: 12px; line-height: 1.5; word-break: break-word; }
.tryit-msg-user { background: var(--accent); color: var(--bg-primary); margin-left: 40px; }
.tryit-msg-user { background: var(--accent); color: var(--text-on-accent); margin-left: 40px; }
.tryit-msg-agent { background: var(--surface2); margin-right: 40px; }
/* ── Suggested Message Chips ── */
@@ -2503,7 +2509,7 @@ mark.search-highlight {
.channel-steps { display: flex; align-items: center; gap: 0; margin-bottom: 20px; }
.channel-step-item { display: flex; align-items: center; gap: 6px; flex: 1; }
.channel-step-num { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; border: 2px solid var(--border); color: var(--text-dim); flex-shrink: 0; transition: all 0.2s; }
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--bg-primary); }
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--text-on-accent); }
.channel-step-num.done { border-color: var(--success); background: var(--success); color: #000; }
.channel-step-label { font-size: 11px; color: var(--text-dim); }
.channel-step-label.active { color: var(--accent); font-weight: 600; }
@@ -2520,7 +2526,7 @@ mark.search-highlight {
.wizard-category-pills { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
.wizard-category-pill { padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--text-dim); transition: all 0.15s; font-family: var(--font-mono); }
.wizard-category-pill:hover { border-color: var(--accent); color: var(--text); }
.wizard-category-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
.wizard-category-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
/* ── Capability Preview Panel ── */
.capability-preview { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px; margin-top: 12px; }
@@ -2626,7 +2632,7 @@ mark.search-highlight {
.personality-pill.active {
border-color: var(--accent);
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
box-shadow: 0 0 12px var(--accent-subtle);
}
@@ -2693,7 +2699,7 @@ mark.search-highlight {
justify-content: space-between;
}
.nav-section-chevron {
font-size: 8px;
font-size: 16px;
transition: transform var(--transition-fast);
color: var(--text-muted);
}
@@ -2799,7 +2805,7 @@ mark.search-highlight {
.hand-step-item.active .hand-step-num {
border-color: var(--accent);
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
}
.hand-step-item.done .hand-step-num {
border-color: var(--success);
@@ -3253,7 +3259,7 @@ mark.search-highlight {
*/
.trader-dashboard {
background: var(--bg-card);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
width: 96vw;
@@ -3270,7 +3276,7 @@ mark.search-highlight {
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-card);
background: var(--surface);
z-index: 10;
border-radius: 12px 12px 0 0;
}
@@ -3330,6 +3336,7 @@ mark.search-highlight {
border-radius: 8px;
padding: 14px 16px;
min-width: 0;
position: relative;
}
.trader-chart-title {
font-size: 0.75rem;
+8 -3
View File
@@ -1,5 +1,10 @@
/* OpenFang Layout — Grid + Sidebar + Responsive */
/* Firefox compat: hide x-cloak elements until Alpine.js initializes.
Without this, the sidebar flashes hidden in Firefox while Alpine
processes the nested x-data scopes for nav sections. */
[x-cloak] { display: none !important; }
.app-layout {
display: flex;
height: 100vh;
@@ -120,11 +125,11 @@
}
.nav-section-title {
font-size: 9px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted);
padding: 12px 12px 4px;
padding: 12px 12px 6px 3px;
font-weight: 600;
}
@@ -154,7 +159,7 @@
.nav-item.active {
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
font-weight: 600;
box-shadow: var(--shadow-sm), 0 2px 8px rgba(255, 92, 0, 0.2);
}
+11 -9
View File
@@ -50,6 +50,7 @@
/* Chat-specific */
--agent-bg: #F5F4F2;
--user-bg: #FFF3E6;
--text-on-accent: #FFFFFF;
/* Layout */
--sidebar-width: 240px;
@@ -91,16 +92,17 @@
--bg: #080706;
--bg-primary: #0F0E0E;
--bg-elevated: #161413;
--surface: #1F1D1C;
--surface2: #2A2725;
--surface: #242221;
--surface2: #2F2D2C;
--surface3: #1A1817;
--border: #2D2A28;
--border-light: #3D3A38;
--border-subtle: #232120;
--text: #F0EFEE;
--text-secondary: #C4C0BC;
--text-dim: #8A8380;
--text-muted: #5C5754;
--border: #363230;
--border-light: #4A4644;
--border-subtle: #2D2A28;
--text: #FFFFFF;
--text-secondary: #D1D1D1;
--text-dim: #9FA0A0;
--text-muted: #6B6663;
--text-on-accent: #FFFFFF;
--accent: #FF5C00;
--accent-light: #FF7A2E;
--accent-dim: #E05200;
+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": "Глобально"
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -18,7 +18,7 @@ if (typeof marked !== 'undefined') {
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
return div.innerHTML.replace(/\n/g, '<br>');
}
function renderMarkdown(text) {
+106 -53
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 || []
];
@@ -316,29 +337,38 @@ function agentsPage() {
OpenFangAPI.wsDisconnect();
},
buildConfigForm(agent) {
var identity = (agent && agent.identity) || {};
return {
name: (agent && agent.name) || '',
system_prompt: (agent && agent.system_prompt) || '',
emoji: identity.emoji || '',
color: identity.color || '#FF5C00',
archetype: identity.archetype || '',
vibe: identity.vibe || ''
};
},
async showDetail(agent) {
this.detailAgent = agent;
this.detailAgent._fallbacks = [];
this.detailTab = 'info';
this.agentFiles = [];
this.editingFile = null;
this.fileContent = '';
this.editingFallback = false;
this.newFallbackValue = '';
this.configForm = {
name: agent.name || '',
system_prompt: agent.system_prompt || '',
emoji: (agent.identity && agent.identity.emoji) || '',
color: (agent.identity && agent.identity.color) || '#FF5C00',
archetype: (agent.identity && agent.identity.archetype) || '',
vibe: (agent.identity && agent.identity.vibe) || ''
};
this.showDetailModal = true;
// Fetch full agent detail to get fallback_models
// Load the full detail payload before opening the modal so editable
// fields such as system_prompt and identity metadata are hydrated.
var detail = agent;
try {
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
this.detailAgent._fallbacks = full.fallback_models || [];
} catch(e) { /* ignore */ }
detail = Object.assign({}, agent, full, {
identity: Object.assign({}, (agent && agent.identity) || {}, (full && full.identity) || {})
});
} catch(e) { /* fall back to list payload */ }
this.detailAgent = detail;
this.detailAgent._fallbacks = detail.fallback_models || [];
this.configForm = this.buildConfigForm(detail);
this.showDetailModal = true;
},
killAgent(agent) {
@@ -355,6 +385,29 @@ function agentsPage() {
});
},
// Issue #1163: uninstall an agent (kill + remove ~/.openfang/agents/<name>/).
uninstallAgent(agent) {
var self = this;
OpenFangToast.confirm(
'Uninstall Agent',
'Uninstall agent "' + agent.name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
async function() {
try {
var res = await OpenFangAPI.del('/api/agents/' + agent.id + '/uninstall');
var msg = 'Agent "' + agent.name + '" uninstalled';
if (res && res.dir_removed === false) {
msg += ' (no on-disk files found)';
}
OpenFangToast.success(msg);
self.showDetailModal = false;
await Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
}
}
);
},
killAllAgents() {
var list = this.filteredAgents;
if (!list.length) return;
@@ -383,8 +436,8 @@ function agentsPage() {
this.selectedPreset = '';
this.soulContent = '';
this.spawnForm.name = '';
this.spawnForm.provider = 'groq';
this.spawnForm.model = 'llama-3.3-70b-versatile';
this.spawnForm.provider = 'default';
this.spawnForm.model = 'default';
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
this.spawnForm.profile = 'full';
// Fetch status defaults and dynamic provider list concurrently
+229 -59
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,12 +134,38 @@ function chatPage() {
init() {
var self = this;
// Initialize slash commands with i18n
this.initSlashCommands();
// Start tip cycle
this.startTipCycle();
// Fetch dynamic commands from server
this.fetchCommands();
// Observe DOM for new messages and render LaTeX
this._latexObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
var bubbles = node.querySelector ? node.querySelectorAll('.message-bubble') : [];
if (node.classList && node.classList.contains('message-bubble')) {
bubbles = [node];
}
bubbles.forEach(function(bubble) {
if (bubble.textContent && hasLatexDelimiters(bubble.textContent)) {
renderLatex(bubble);
}
});
}
});
});
});
this._latexObserver.observe(document.getElementById('messages') || document.body, {
childList: true,
subtree: true
});
// Ctrl+/ keyboard shortcut
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
@@ -175,6 +198,10 @@ function chatPage() {
if (store.pendingAgent) {
self.selectAgent(store.pendingAgent);
store.pendingAgent = null;
} else {
// Restore previously active agent after page refresh (#1179).
// The agent list may not be loaded yet, so resolve once it appears.
self._restoreActiveAgent();
}
// Watch for future pending agent selections (e.g., user clicks agent while on chat)
@@ -185,6 +212,13 @@ function chatPage() {
}
});
// Re-attempt restore once the agent list arrives from the server
this.$watch('$store.app.agents', function(agents) {
if (!self.currentAgent && agents && agents.length) {
self._restoreActiveAgent();
}
});
// Watch for slash commands + model autocomplete
this.$watch('inputText', function(val) {
var modelMatch = val.match(/^\/model\s+(.*)$/i);
@@ -264,35 +298,78 @@ function chatPage() {
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
var self = this;
this.modelSwitching = true;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
self.currentAgent.model_name = (resp && resp.model) || model.id;
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
OpenFangToast.success(t('chat.model_switched') + ' ' + (model.display_name || model.id));
self.showModelSwitcher = false;
self.modelSwitching = false;
}).catch(function(e) {
OpenFangToast.error('Switch failed: ' + e.message);
OpenFangToast.error(t('chat.model_switch_failed') + ': ' + e.message);
self.modelSwitching = false;
});
},
// Fetch dynamic slash commands from server
// Initialize slash commands with i18n translations
initSlashCommands: function() {
if (this._slashCommandsLoaded) return;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
this.slashCommands = [
{ cmd: '/help', desc: t('chat.slash.help') },
{ cmd: '/agents', desc: t('chat.slash.agents') },
{ cmd: '/new', desc: t('chat.slash.new') },
{ cmd: '/compact', desc: t('chat.slash.compact') },
{ cmd: '/model', desc: t('chat.slash.model') },
{ cmd: '/stop', desc: t('chat.slash.stop') },
{ cmd: '/usage', desc: t('chat.slash.usage') },
{ cmd: '/think', desc: t('chat.slash.think') },
{ cmd: '/context', desc: t('chat.slash.context') },
{ cmd: '/verbose', desc: t('chat.slash.verbose') },
{ cmd: '/queue', desc: t('chat.slash.queue') },
{ cmd: '/status', desc: t('chat.slash.status') },
{ cmd: '/clear', desc: t('chat.slash.clear') },
{ cmd: '/exit', desc: t('chat.slash.exit') },
{ cmd: '/budget', desc: t('chat.slash.budget') },
{ cmd: '/peers', desc: t('chat.slash.peers') },
{ cmd: '/a2a', desc: t('chat.slash.a2a') }
];
this._slashCommandsLoaded = true;
},
// Fetch slash commands from the unified registry (/api/commands?surface=web).
// Replaces the hardcoded initSlashCommands() list once loaded — ensures
// the help panel and autocomplete stay in sync with the backend registry.
fetchCommands: function() {
var self = this;
OpenFangAPI.get('/api/commands').then(function(data) {
if (data.commands && data.commands.length) {
// Build a set of known cmds to avoid duplicates
var existing = {};
self.slashCommands.forEach(function(c) { existing[c.cmd] = true; });
data.commands.forEach(function(c) {
if (!existing[c.cmd]) {
self.slashCommands.push({ cmd: c.cmd, desc: c.desc || '', source: c.source || 'server' });
existing[c.cmd] = true;
}
});
}
}).catch(function() { /* silent — use hardcoded list */ });
OpenFangAPI.get('/api/commands?surface=web').then(function(data) {
var cmds = (data && data.commands) || [];
if (!cmds.length) return;
self.slashCommands = cmds.map(function(c) {
// Prefer unified-registry shape { name, aliases, description, category, requires_agent }.
// Fall back to legacy { cmd, desc } shape so older shims keep working.
if (c.name) {
return {
cmd: '/' + c.name,
desc: c.description || '',
category: c.category || 'general',
aliases: c.aliases || [],
requires_agent: !!c.requires_agent,
source: 'registry'
};
}
return {
cmd: c.cmd,
desc: c.desc || '',
category: c.category || 'general',
aliases: c.aliases || [],
requires_agent: !!c.requires_agent,
source: c.source || 'server'
};
});
self._slashCommandsLoaded = true;
}).catch(function() { /* silent — keep hardcoded fallback list */ });
},
get filteredSlashCommands() {
@@ -303,6 +380,44 @@ function chatPage() {
});
},
// Render `/help` output grouped by category, mirroring the
// backend's render_help(Surfaces::WEB). Falls back to a flat list if
// categories are not populated (pre-fetch hardcoded list).
renderHelpText: function() {
var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring'];
var labels = {
general: 'General', session: 'Session', model: 'Model', control: 'Control',
memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring'
};
var anyCategorised = this.slashCommands.some(function(c) { return c.category; });
if (!anyCategorised) {
return this.slashCommands.map(function(c) {
return '`' + c.cmd + '` \u2014 ' + c.desc;
}).join('\n');
}
var groups = {};
this.slashCommands.forEach(function(c) {
var cat = c.category || 'general';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(c);
});
var lines = ['**Available commands:**'];
order.forEach(function(cat) {
var list = groups[cat];
if (!list || !list.length) return;
lines.push('');
lines.push('**' + (labels[cat] || cat) + '**');
list.forEach(function(c) {
var aliasText = '';
if (c.aliases && c.aliases.length) {
aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')';
}
lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc);
});
});
return lines.join('\n');
},
// Clear any stuck typing indicator after 120s
_resetTypingTimeout: function() {
var self = this;
@@ -328,7 +443,7 @@ function chatPage() {
cmdArgs = cmdArgs || '';
switch (cmd) {
case '/help':
self.messages.push({ id: ++msgId, role: 'system', text: self.slashCommands.map(function(c) { return '`' + c.cmd + '` — ' + c.desc; }).join('\n'), meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] });
self.scrollToBottom();
break;
case '/agents':
@@ -392,7 +507,7 @@ function chatPage() {
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
OpenFangAPI.wsSend({ type: 'command', command: 'context', args: '' });
} else {
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
self.scrollToBottom();
}
break;
@@ -400,7 +515,7 @@ function chatPage() {
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
OpenFangAPI.wsSend({ type: 'command', command: 'verbose', args: cmdArgs });
} else {
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
self.scrollToBottom();
}
break;
@@ -408,7 +523,7 @@ function chatPage() {
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
OpenFangAPI.wsSend({ type: 'command', command: 'queue', args: '' });
} else {
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected.', meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + ').', meta: '', tools: [] });
self.scrollToBottom();
}
break;
@@ -447,6 +562,7 @@ function chatPage() {
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
window.dispatchEvent(new Event('close-chat'));
break;
case '/budget':
@@ -482,25 +598,35 @@ function chatPage() {
}
},
// Restore the previously-active agent (set in selectAgent) after a page
// refresh, so the WebSocket re-attaches to the same session and any
// in-flight tool output streams back into the chat (#1179).
_restoreActiveAgent: function() {
var storedId = null;
try { storedId = localStorage.getItem('of-active-agent'); } catch(e) { /* ignore */ }
if (!storedId) return;
var agents = (Alpine.store('app') && Alpine.store('app').agents) || [];
var match = null;
for (var i = 0; i < agents.length; i++) {
if (agents[i] && agents[i].id === storedId) { match = agents[i]; break; }
}
if (match) {
this.selectAgent(match);
}
},
selectAgent(agent) {
this.currentAgent = agent;
this.messages = [];
try { localStorage.setItem('of-active-agent', agent.id); } catch(e) { /* ignore */ }
this.connectWs(agent.id);
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
// Show welcome tips on first use
if (!localStorage.getItem('of-chat-tips-seen')) {
var localMsgId = 0;
this.messages.push({
id: ++localMsgId,
id: ++msgId,
role: 'system',
text: '**Welcome to OpenFang Chat!**\n\n' +
'- Type `/` to see available commands\n' +
'- `/help` shows all commands\n' +
'- `/think on` enables extended reasoning\n' +
'- `/context` shows context window usage\n' +
'- `/verbose off` hides tool details\n' +
'- `Ctrl+Shift+F` toggles focus mode\n' +
'- Drag & drop files to attach them\n' +
'- `Ctrl+/` opens the command palette',
text: t('chat.welcome_message'),
meta: '',
tools: []
});
@@ -564,7 +690,8 @@ function chatPage() {
// Multi-session: create a new session
async createSession() {
if (!this.currentAgent) return;
var label = prompt('Session name (optional):');
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
var label = prompt(t('chat.session_name_prompt'));
if (label === null) return; // cancelled
try {
await OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/sessions', {
@@ -574,9 +701,9 @@ function chatPage() {
await this.loadSession(this.currentAgent.id);
this.messages = [];
this.scrollToBottom();
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success('New session created');
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success(t('chat.session_created'));
} catch(e) {
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error('Failed to create session');
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error(t('chat.session_create_failed'));
}
},
@@ -621,6 +748,15 @@ function chatPage() {
switch (data.type) {
case 'connected': break;
// Incoming message from server (e.g., cron trigger) — display as user message
case 'message':
if (data.content) {
var meta = data.source === 'cron' ? '[Scheduled: ' + (data.job_name || data.job_id || '') + ']' : '';
this.messages.push({ id: ++msgId, role: 'user', text: data.content, meta: meta, tools: [], images: [], ts: Date.now() });
this.scrollToBottom();
}
break;
// Legacy thinking event (backward compat)
case 'thinking':
if (!this.messages.length || !this.messages[this.messages.length - 1].thinking) {
@@ -1013,8 +1149,9 @@ function chatPage() {
}
// HTTP fallback
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
if (!OpenFangAPI.isWsConnected()) {
OpenFangToast.info('Using HTTP mode (no streaming)');
OpenFangToast.info(t('chat.using_http_mode'));
}
this.messages.push({ id: ++msgId, role: 'agent', text: '', meta: '', thinking: true, tools: [], ts: Date.now() });
this.scrollToBottom();
@@ -1057,22 +1194,55 @@ function chatPage() {
killAgent() {
if (!this.currentAgent) return;
var self = this;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
var name = this.currentAgent.name;
OpenFangToast.confirm('Stop Agent', 'Stop agent "' + name + '"? The agent will be shut down.', async function() {
OpenFangToast.confirm(t('chat.stop_agent_title'), t('chat.stop_agent_confirm') + ' "' + name + '"?', async function() {
try {
await OpenFangAPI.del('/api/agents/' + self.currentAgent.id);
OpenFangAPI.wsDisconnect();
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
OpenFangToast.success('Agent "' + name + '" stopped');
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
OpenFangToast.success(t('chat.agent_stopped') + ' "' + name + '"');
Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to stop agent: ' + e.message);
OpenFangToast.error(t('chat.stop_agent_failed') + ': ' + e.message);
}
});
},
// Permanently uninstall the agent: kill + remove ~/.openfang/agents/<name>/
// Issue #1163.
uninstallAgent: function() {
if (!this.currentAgent) return;
var self = this;
var name = this.currentAgent.name;
var agentId = this.currentAgent.id;
OpenFangToast.confirm(
'Uninstall Agent',
'Uninstall agent "' + name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
async function() {
try {
var res = await OpenFangAPI.del('/api/agents/' + agentId + '/uninstall');
OpenFangAPI.wsDisconnect();
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
var msg = 'Agent "' + name + '" uninstalled';
if (res && res.dir_removed === false) {
msg += ' (no on-disk files found)';
}
OpenFangToast.success(msg);
Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
}
}
);
},
_latexTimer: null,
scrollToBottom() {
var self = this;
+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) {
@@ -26,13 +26,33 @@ function schedulerPage() {
cron: '',
agent_id: '',
message: '',
enabled: true
enabled: true,
delivery_targets: []
},
creating: false,
// -- Run Now state --
runningJobId: '',
// -- Delivery targets picker (create modal) --
showTargetPicker: false,
pickerType: 'channel',
draftTarget: null,
// -- Expanded job / delivery log state --
expandedJobId: '',
deliveryLog: { targets: [], entries: [] },
deliveryLogLoading: false,
deliveryLogError: '',
// -- Edit targets state (per-existing-job) --
editingTargetsJobId: '',
editingTargets: [],
savingTargets: false,
// -- Available channel types (populated from /api/channels) --
channelTypes: [],
// Cron presets
cronPresets: [
{ label: 'Every minute', cron: '* * * * *' },
@@ -55,12 +75,32 @@ function schedulerPage() {
this.loadError = '';
try {
await this.loadJobs();
// Channels are optional — failure is non-fatal for the scheduler page.
this.loadChannelTypes();
} catch(e) {
this.loadError = e.message || 'Could not load scheduler data.';
}
this.loading = false;
},
async loadChannelTypes() {
try {
var data = await OpenFangAPI.get('/api/channels');
// /api/channels returns an array of channel descriptors; pull names.
var list = Array.isArray(data) ? data : (data && data.channels) || [];
var names = [];
for (var i = 0; i < list.length; i++) {
var ch = list[i];
var name = ch && (ch.name || ch.display_name || ch.channel_type);
if (name && names.indexOf(name) === -1) names.push(name);
}
this.channelTypes = names;
} catch(e) {
// Fall through silently — the form uses a plain input as fallback.
this.channelTypes = [];
}
},
async loadJobs() {
var data = await OpenFangAPI.get('/api/cron/jobs');
var raw = data.jobs || [];
@@ -82,6 +122,7 @@ function schedulerPage() {
last_run: j.last_run,
next_run: j.next_run,
delivery: j.delivery ? j.delivery.kind || '' : '',
delivery_targets: Array.isArray(j.delivery_targets) ? j.delivery_targets : [],
created_at: j.created_at
};
});
@@ -162,9 +203,12 @@ function schedulerPage() {
delivery: { kind: 'last_channel' },
enabled: this.newJob.enabled
};
if (this.newJob.delivery_targets && this.newJob.delivery_targets.length) {
body.delivery_targets = this.newJob.delivery_targets.map(this.sanitizeTarget);
}
await OpenFangAPI.post('/api/cron/jobs', body);
this.showCreateForm = false;
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true, delivery_targets: [] };
OpenFangToast.success('Schedule "' + jobName + '" created');
await this.loadJobs();
} catch(e) {
@@ -216,6 +260,210 @@ function schedulerPage() {
this.runningJobId = '';
},
// ── Delivery target editing (create modal) ──
openTargetPicker() {
this.pickerType = 'channel';
this.draftTarget = this.blankTarget('channel');
this.showTargetPicker = true;
},
cancelTargetPicker() {
this.showTargetPicker = false;
this.draftTarget = null;
},
onPickerTypeChange() {
this.draftTarget = this.blankTarget(this.pickerType);
},
blankTarget(type) {
if (type === 'channel') {
return { type: 'channel', channel_type: '', recipient: '' };
}
if (type === 'webhook') {
return { type: 'webhook', url: '', auth_header: '' };
}
if (type === 'local_file') {
return { type: 'local_file', path: '', append: false };
}
if (type === 'email') {
return { type: 'email', to: '', subject_template: '' };
}
return null;
},
addDraftTarget() {
var err = this.validateTarget(this.draftTarget);
if (err) {
OpenFangToast.warn(err);
return;
}
if (!Array.isArray(this.newJob.delivery_targets)) this.newJob.delivery_targets = [];
this.newJob.delivery_targets.push(this.sanitizeTarget(this.draftTarget));
this.showTargetPicker = false;
this.draftTarget = null;
},
removeTarget(idx) {
if (!Array.isArray(this.newJob.delivery_targets)) return;
this.newJob.delivery_targets.splice(idx, 1);
},
validateTarget(t) {
if (!t || !t.type) return 'Pick a target type';
if (t.type === 'channel') {
if (!t.channel_type || !t.channel_type.trim()) return 'Channel type is required';
if (!t.recipient || !t.recipient.trim()) return 'Recipient is required';
} else if (t.type === 'webhook') {
if (!t.url || !t.url.trim()) return 'Webhook URL is required';
if (t.url.indexOf('http://') !== 0 && t.url.indexOf('https://') !== 0) {
return 'Webhook URL must start with http:// or https://';
}
} else if (t.type === 'local_file') {
if (!t.path || !t.path.trim()) return 'File path is required';
} else if (t.type === 'email') {
if (!t.to || !t.to.trim()) return 'Recipient email is required';
}
return null;
},
// Strip empty-string optional fields so serde accepts the payload cleanly.
sanitizeTarget(t) {
if (!t) return null;
var out = { type: t.type };
if (t.type === 'channel') {
out.channel_type = (t.channel_type || '').trim();
out.recipient = (t.recipient || '').trim();
} else if (t.type === 'webhook') {
out.url = (t.url || '').trim();
if (t.auth_header && t.auth_header.trim()) out.auth_header = t.auth_header.trim();
} else if (t.type === 'local_file') {
out.path = (t.path || '').trim();
out.append = !!t.append;
} else if (t.type === 'email') {
out.to = (t.to || '').trim();
if (t.subject_template && t.subject_template.trim()) {
out.subject_template = t.subject_template.trim();
}
}
return out;
},
// ── Chip rendering helpers ──
targetChipLabel(t) {
if (!t || !t.type) return '?';
if (t.type === 'channel') return 'CHANNEL: ' + (t.channel_type || '?');
if (t.type === 'webhook') return 'WEBHOOK';
if (t.type === 'local_file') return 'FILE: ' + this.truncate(t.path || '', 28);
if (t.type === 'email') return 'EMAIL: ' + this.truncate(t.to || '', 24);
return t.type.toUpperCase();
},
targetChipClass(t) {
if (!t || !t.type) return 'badge-dim';
if (t.type === 'channel') return 'badge-info';
if (t.type === 'webhook') return 'badge-created';
if (t.type === 'local_file') return 'badge-muted';
if (t.type === 'email') return 'badge-warn';
return 'badge-dim';
},
targetSummary(t) {
if (!t) return '';
if (t.type === 'channel') return (t.channel_type || '?') + ' -> ' + (t.recipient || '?');
if (t.type === 'webhook') return t.url || '(no url)';
if (t.type === 'local_file') return (t.append ? 'append ' : 'overwrite ') + (t.path || '');
if (t.type === 'email') {
var base = t.to || '';
if (t.subject_template) base += ' · subject: ' + t.subject_template;
return base;
}
return JSON.stringify(t);
},
// ── Expand row / delivery log ──
async toggleExpand(job) {
if (this.expandedJobId === job.id) {
this.expandedJobId = '';
return;
}
this.expandedJobId = job.id;
this.deliveryLog = { targets: [], entries: [] };
this.deliveryLogError = '';
this.deliveryLogLoading = true;
try {
var data = await OpenFangAPI.get('/api/schedules/' + job.id + '/delivery-log');
this.deliveryLog = {
targets: Array.isArray(data.targets) ? data.targets : [],
entries: Array.isArray(data.entries) ? data.entries : []
};
} catch(e) {
this.deliveryLogError = e.message || 'Could not load delivery log.';
}
this.deliveryLogLoading = false;
},
// ── Edit targets on existing job ──
startEditTargets(job) {
this.editingTargetsJobId = job.id;
// Clone so cancel doesn't mutate the loaded list.
this.editingTargets = (job.delivery_targets || []).map(function(t) {
return JSON.parse(JSON.stringify(t));
});
this.pickerType = 'channel';
this.draftTarget = null;
this.showTargetPicker = false;
},
cancelEditTargets() {
this.editingTargetsJobId = '';
this.editingTargets = [];
this.draftTarget = null;
this.showTargetPicker = false;
},
addEditTarget() {
this.pickerType = 'channel';
this.draftTarget = this.blankTarget('channel');
this.showTargetPicker = true;
},
addDraftTargetToEdit() {
var err = this.validateTarget(this.draftTarget);
if (err) {
OpenFangToast.warn(err);
return;
}
this.editingTargets.push(this.sanitizeTarget(this.draftTarget));
this.showTargetPicker = false;
this.draftTarget = null;
},
removeEditTarget(idx) {
this.editingTargets.splice(idx, 1);
},
async saveEditTargets() {
if (!this.editingTargetsJobId) return;
this.savingTargets = true;
try {
var clean = this.editingTargets.map(this.sanitizeTarget);
await OpenFangAPI.put('/api/schedules/' + this.editingTargetsJobId, {
delivery_targets: clean
});
OpenFangToast.success('Delivery targets updated');
this.cancelEditTargets();
await this.loadJobs();
} catch(e) {
OpenFangToast.error('Failed to update targets: ' + (e.message || e));
}
this.savingTargets = false;
},
// ── Trigger helpers ──
triggerType(pattern) {
@@ -376,6 +624,12 @@ function schedulerPage() {
} catch(e) { return 'never'; }
},
truncate(s, n) {
if (!s) return '';
if (s.length <= n) return s;
return s.substring(0, n - 1) + '…';
},
jobCount() {
var enabled = 0;
for (var i = 0; i < this.jobs.length; i++) {
+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) {
@@ -25,6 +25,9 @@ function settingsPage() {
providerUrlSaving: {},
providerTesting: {},
providerTestResults: {},
providerSearch: '',
providerStatusFilter: '',
providerCategoryFilter: '',
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
customProviderName: '',
customProviderUrl: '',
@@ -338,6 +341,94 @@ function settingsPage() {
return Object.keys(seen).sort();
},
/// Coarse category for a provider used to group the Providers tab.
/// Returns: 'frontier' | 'oss' | 'local' | 'aggregator' | 'regional' | 'other'.
providerCategory(p) {
if (!p) return 'other';
if (p.is_local || p.key_required === false) return 'local';
var id = (p.id || '').toLowerCase();
var FRONTIER = ['anthropic','openai','gemini','google','xai','bedrock','azure','vertex'];
var OSS = ['groq','together','fireworks','cerebras','sambanova','deepseek','mistral','perplexity','cohere','ai21','huggingface','replicate','nvidia','venice','novita','chutes'];
var AGG = ['openrouter','litellm','github-copilot','claude-code'];
var REGIONAL = ['qwen','minimax','zhipu','zai','moonshot','qianfan','volcengine','kimi'];
if (FRONTIER.indexOf(id) !== -1) return 'frontier';
if (REGIONAL.indexOf(id) !== -1) return 'regional';
if (AGG.indexOf(id) !== -1) return 'aggregator';
if (OSS.indexOf(id) !== -1) return 'oss';
return 'other';
},
providerCategoryLabel(cat) {
switch (cat) {
case 'frontier': return 'Frontier (Anthropic, OpenAI, Google, xAI, Bedrock)';
case 'oss': return 'Open-Weight Hosts (Groq, Together, Fireworks, DeepSeek, etc.)';
case 'aggregator': return 'Aggregators & Gateways (OpenRouter, GitHub Copilot)';
case 'regional': return 'Regional / China (Qwen, Zhipu, Moonshot, MiniMax)';
case 'local': return 'Local / Self-Hosted (Ollama, vLLM, LM Studio, Lemonade)';
default: return 'Other Providers';
}
},
/// Stable category order for grouped rendering.
get providerCategoriesOrdered() {
return ['frontier', 'oss', 'aggregator', 'regional', 'local', 'other'];
},
/// Returns filter-matched providers grouped by category, preserving order.
/// Each entry: { category, label, items: [...] }. Empty groups are omitted.
get providersGrouped() {
var self = this;
var filtered = this.filteredProviders;
var by = {};
filtered.forEach(function(p) {
var c = self.providerCategory(p);
if (!by[c]) by[c] = [];
by[c].push(p);
});
// Sort each group: configured first, then alphabetical
Object.keys(by).forEach(function(c) {
by[c].sort(function(a, b) {
var ac = a.auth_status === 'configured' ? 0 : 1;
var bc = b.auth_status === 'configured' ? 0 : 1;
if (ac !== bc) return ac - bc;
return (a.display_name || a.id).localeCompare(b.display_name || b.id);
});
});
var out = [];
this.providerCategoriesOrdered.forEach(function(c) {
if (by[c] && by[c].length) {
out.push({ category: c, label: self.providerCategoryLabel(c), items: by[c] });
}
});
return out;
},
get filteredProviders() {
var self = this;
return this.providers.filter(function(p) {
if (self.providerStatusFilter === 'configured' && p.auth_status !== 'configured') return false;
if (self.providerStatusFilter === 'unconfigured' && p.auth_status === 'configured') return false;
if (self.providerCategoryFilter && self.providerCategory(p) !== self.providerCategoryFilter) return false;
if (self.providerSearch) {
var q = self.providerSearch.toLowerCase();
if ((p.display_name || '').toLowerCase().indexOf(q) === -1 &&
(p.id || '').toLowerCase().indexOf(q) === -1 &&
(p.api_key_env || '').toLowerCase().indexOf(q) === -1) return false;
}
return true;
});
},
get configuredProviderCount() {
return this.providers.filter(function(p) { return p.auth_status === 'configured'; }).length;
},
clearProviderFilters() {
this.providerSearch = '';
this.providerStatusFilter = '';
this.providerCategoryFilter = '';
},
get uniqueTiers() {
var seen = {};
this.models.forEach(function(m) { if (m.tier) seen[m.tier] = true; });
+204 -30
View File
@@ -30,31 +30,44 @@ function skillsPage() {
skillCodeFilename: '',
skillCodeLoading: false,
// Skill config modal (local skill configuration from SKILL.md frontmatter)
configSkill: null, // skill object whose config is being edited
configDeclared: {}, // { var_name: { description, env, default, required } }
configResolved: {}, // { var_name: { value, source, is_secret } }
configDraft: {}, // { var_name: user-edited string value }
configRevealed: {}, // { var_name: bool } — toggle password reveal per row
configLoading: false,
configSaving: false,
configError: '',
// MCP servers
mcpServers: [],
mcpLoading: false,
// Category definitions from the OpenClaw ecosystem
categories: [
{ id: 'coding', name: 'Coding & IDEs' },
{ id: 'git', name: 'Git & GitHub' },
{ id: 'web', name: 'Web & Frontend' },
{ id: 'devops', name: 'DevOps & Cloud' },
{ id: 'browser', name: 'Browser & Automation' },
{ id: 'search', name: 'Search & Research' },
{ id: 'ai', name: 'AI & LLMs' },
{ id: 'data', name: 'Data & Analytics' },
{ id: 'productivity', name: 'Productivity' },
{ id: 'communication', name: 'Communication' },
{ id: 'media', name: 'Media & Streaming' },
{ id: 'notes', name: 'Notes & PKM' },
{ id: 'security', name: 'Security' },
{ id: 'cli', name: 'CLI Utilities' },
{ id: 'marketing', name: 'Marketing & Sales' },
{ id: 'finance', name: 'Finance' },
{ id: 'smart-home', name: 'Smart Home & IoT' },
{ id: 'docs', name: 'PDF & Documents' },
],
// Category definitions from the OpenClaw ecosystem (loaded from i18n)
get categories() {
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
return [
{ id: 'coding', name: t('skills.cat_coding') || 'Coding & IDEs' },
{ id: 'git', name: t('skills.cat_git') || 'Git & GitHub' },
{ id: 'web', name: t('skills.cat_frontend') || 'Web & Frontend' },
{ id: 'devops', name: t('skills.cat_devops') || 'DevOps & Cloud' },
{ id: 'browser', name: t('skills.cat_browser') || 'Browser & Automation' },
{ id: 'search', name: t('skills.cat_search') || 'Search & Research' },
{ id: 'ai', name: t('skills.cat_ai') || 'AI & ML' },
{ id: 'data', name: t('skills.cat_data') || 'Data & Analytics' },
{ id: 'productivity', name: t('skills.cat_productivity') || 'Productivity' },
{ id: 'communication', name: t('skills.cat_communication') || 'Communication' },
{ id: 'media', name: t('skills.cat_media') || 'Media & Streaming' },
{ id: 'notes', name: t('skills.cat_notes') || 'Notes & PKM' },
{ id: 'security', name: t('skills.cat_security') || 'Security' },
{ id: 'cli', name: t('skills.cat_cli') || 'CLI Utilities' },
{ id: 'marketing', name: t('skills.cat_marketing') || 'Marketing & Sales' },
{ id: 'finance', name: t('skills.cat_finance') || 'Finance' },
{ id: 'smart-home', name: t('skills.cat_smarthome') || 'Smart Home & IoT' },
{ id: 'docs', name: t('skills.cat_docs') || 'Documentation' },
];
},
runtimeBadge: function(rt) {
var r = (rt || '').toLowerCase();
@@ -98,7 +111,8 @@ function skillsPage() {
tags: s.tags || [],
enabled: s.enabled !== false,
source: s.source || { type: 'local' },
has_prompt_context: !!s.has_prompt_context
has_prompt_context: !!s.has_prompt_context,
config_declared_count: s.config_declared_count || 0
};
});
} catch(e) {
@@ -108,6 +122,161 @@ function skillsPage() {
this.loading = false;
},
// ── Skill config editing ────────────────────────────────────────────
async openSkillConfig(skill) {
this.configSkill = skill;
this.configDeclared = {};
this.configResolved = {};
this.configDraft = {};
this.configRevealed = {};
this.configError = '';
this.configLoading = true;
try {
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(skill.name) + '/config');
this.configDeclared = data.declared || {};
this.configResolved = data.resolved || {};
// Pre-populate draft values only for vars the user has already
// overridden — never copy redacted responses back into inputs or
// they'd be re-saved as "****redacted****" strings.
var names = Object.keys(this.configDeclared);
for (var i = 0; i < names.length; i++) {
var n = names[i];
var res = this.configResolved[n] || {};
if (res.source === 'user' && !res.is_secret) {
this.configDraft[n] = res.value == null ? '' : String(res.value);
} else {
this.configDraft[n] = '';
}
}
} catch(e) {
this.configError = e.message || 'Failed to load skill config.';
}
this.configLoading = false;
},
closeSkillConfig() {
this.configSkill = null;
this.configDeclared = {};
this.configResolved = {};
this.configDraft = {};
this.configRevealed = {};
this.configError = '';
},
configRowInvalid(name) {
// A required var is invalid iff the user hasn't entered anything AND
// no env/default resolves it. Source from the server tells us where
// the current value came from; if it's "unresolved" and the draft is
// blank, the save would write an empty string over the required var.
var decl = this.configDeclared[name] || {};
if (!decl.required) return false;
var draft = (this.configDraft[name] || '').trim();
if (draft) return false;
var res = this.configResolved[name] || {};
if (res.source === 'env' || res.source === 'default') return false;
// If the user has an existing secret override we don't want to force
// them to re-type it — treat that as "currently resolved".
if (res.source === 'user') return false;
return true;
},
hasInvalidConfig() {
var names = Object.keys(this.configDeclared);
for (var i = 0; i < names.length; i++) {
if (this.configRowInvalid(names[i])) return true;
}
return false;
},
toggleReveal(name) {
this.configRevealed[name] = !this.configRevealed[name];
},
sourceBadgeClass(source) {
switch (source) {
case 'user': return 'badge-success';
case 'env': return 'badge-info';
case 'default': return 'badge-dim';
default: return 'badge-danger';
}
},
sourceBadgeLabel(res) {
if (!res) return 'unresolved';
switch (res.source) {
case 'user': return 'user override';
case 'env': return 'env' + ((this.configDeclared[res.__name] && this.configDeclared[res.__name].env) ? ':' + this.configDeclared[res.__name].env : '');
case 'default': return 'default';
default: return 'unresolved';
}
},
async saveSkillConfig() {
if (!this.configSkill) return;
if (this.hasInvalidConfig()) {
OpenFangToast.error('Fill in all required variables before saving.');
return;
}
this.configSaving = true;
this.configError = '';
// Only PUT values the user actually typed. Empty strings are dropped
// so we don't silently clobber an env/default with "".
var payload = {};
var names = Object.keys(this.configDeclared);
for (var i = 0; i < names.length; i++) {
var n = names[i];
var v = (this.configDraft[n] || '').trim();
if (v.length > 0) payload[n] = v;
}
try {
await OpenFangAPI.put('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config', { values: payload });
OpenFangToast.success('Saved, reloading agents\u2026');
// Refresh the modal contents so the new source/value shows up.
var refreshed = this.configSkill;
await this.loadSkills();
this.closeSkillConfig();
// Find the possibly-refreshed skill object and reopen.
var self = this;
var updated = this.skills.find(function(s) { return s.name === refreshed.name; });
if (updated) await self.openSkillConfig(updated);
} catch(e) {
this.configError = e.message || 'Save failed.';
OpenFangToast.error('Save failed: ' + (e.message || 'unknown error'));
}
this.configSaving = false;
},
async resetSkillConfigVar(name) {
if (!this.configSkill) return;
var decl = this.configDeclared[name] || {};
var res = this.configResolved[name] || {};
// If the server is already reporting a non-user source there's nothing
// to remove; just clear the draft so the input disappears.
if (res.source !== 'user') {
this.configDraft[name] = '';
return;
}
try {
await OpenFangAPI.del('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config/' + encodeURIComponent(name));
OpenFangToast.success('Reset ' + name);
// Refresh modal state from server.
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config');
this.configResolved = data.resolved || {};
this.configDraft[name] = '';
} catch(e) {
var msg = e.message || 'Reset failed';
if (msg.indexOf('required') !== -1 || msg.indexOf('409') !== -1) {
OpenFangToast.error('Cannot reset: ' + decl.description + ' is required with no fallback.');
} else {
OpenFangToast.error('Reset failed: ' + msg);
}
}
},
get configDeclaredNames() {
return Object.keys(this.configDeclared).sort();
},
async loadData() {
await this.loadSkills();
},
@@ -264,15 +433,20 @@ function skillsPage() {
// Uninstall
uninstallSkill: function(name) {
var self = this;
OpenFangToast.confirm('Uninstall Skill', 'Uninstall skill "' + name + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
OpenFangToast.success('Skill "' + name + '" uninstalled');
await self.loadSkills();
} catch(e) {
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
OpenFangToast.confirm(
t('skills.uninstall_skill') || 'Uninstall Skill',
t('skills.uninstall_confirm') + ' "' + name + '"? This cannot be undone.',
async function() {
try {
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
OpenFangToast.success('Skill "' + name + '" uninstalled');
await self.loadSkills();
} catch(e) {
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
}
}
});
);
},
// Create prompt-only skill
@@ -191,6 +191,33 @@ function analyticsPage() {
return segments;
},
donutSegmentsSvg() {
var segments = this.donutSegments();
if (!segments.length) return '';
function escapeXml(value) {
return String(value)
.replace(/&/g, '&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;
},
+1 -2
View File
@@ -7,7 +7,6 @@
"background_color": "#0a0a0f",
"theme_color": "#6366f1",
"icons": [
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
{"src": "/logo.png", "sizes": "128x128", "type": "image/png"}
]
}
@@ -61,6 +61,7 @@ async fn start_test_server_with_provider(
model: model.to_string(),
api_key_env: api_key_env.to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -101,6 +102,10 @@ async fn start_test_server_with_provider(
"/api/agents/{id}",
axum::routing::delete(routes::kill_agent),
)
.route(
"/api/agents/{id}/clone",
axum::routing::post(routes::clone_agent),
)
.route(
"/api/triggers",
axum::routing::get(routes::list_triggers).post(routes::create_trigger),
@@ -122,6 +127,23 @@ async fn start_test_server_with_provider(
axum::routing::get(routes::list_workflow_runs),
)
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.route("/api/commands", axum::routing::get(routes::list_commands))
.route(
"/api/schedules",
axum::routing::get(routes::list_schedules).post(routes::create_schedule),
)
.route(
"/api/schedules/{id}",
axum::routing::delete(routes::delete_schedule).put(routes::update_schedule),
)
.route(
"/api/schedules/{id}/delivery-log",
axum::routing::get(routes::schedule_delivery_log),
)
.route(
"/api/cron/jobs",
axum::routing::get(routes::list_cron_jobs).post(routes::create_cron_job),
)
.layer(axum::middleware::from_fn(middleware::request_logging))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
@@ -284,6 +306,86 @@ async fn test_spawn_list_kill_agent() {
assert_eq!(agents[0]["name"], "assistant");
}
/// Regression test for issue #1026: GET /api/agents returns `is_inferencing`
/// reflecting whether the agent has an in-flight LLM task. This drives the
/// live dashboard indicator that shows which agents are calling the LLM.
#[tokio::test]
async fn test_list_agents_includes_inferencing_flag() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Spawn a test agent.
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let agent_id_str = body["agent_id"].as_str().unwrap().to_string();
let agent_id: openfang_types::agent::AgentId = agent_id_str.parse().unwrap();
// Baseline: idle agent must report is_inferencing = false.
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
let test_agent = agents
.iter()
.find(|a| a["id"] == agent_id_str)
.expect("spawned agent should appear in list");
assert_eq!(
test_agent["is_inferencing"], false,
"freshly spawned agent should not be inferencing"
);
// Simulate an in-flight LLM call by inserting a real AbortHandle into
// the kernel's running_tasks map. This is exactly what the agent loop
// does when it starts processing a message.
let handle = tokio::spawn(async {
// Long-lived task we will abort at end of test.
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
});
server
.state
.kernel
.running_tasks
.insert(agent_id, handle.abort_handle());
// Now list_agents should report is_inferencing = true for that agent.
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
let test_agent = agents
.iter()
.find(|a| a["id"] == agent_id_str)
.expect("spawned agent should still appear in list");
assert_eq!(
test_agent["is_inferencing"], true,
"agent with an entry in running_tasks must be flagged is_inferencing"
);
// Other agents (the default assistant) must NOT be flagged.
if let Some(other) = agents.iter().find(|a| a["id"] != agent_id_str) {
assert_eq!(
other["is_inferencing"], false,
"agents without a running task must not be flagged"
);
}
// Cleanup so the spawned future does not outlive the test.
server.state.kernel.running_tasks.remove(&agent_id);
handle.abort();
}
#[tokio::test]
async fn test_agent_session_empty() {
let server = start_test_server().await;
@@ -362,6 +464,7 @@ async fn test_agent_session_filters_system_messages() {
content: openfang_types::message::MessageContent::Text(
"INTERNAL SYSTEM PROMPT — must not leak to UI".to_string(),
),
..Default::default()
},
Message::user("hello"),
Message::assistant("hi there"),
@@ -418,7 +521,10 @@ async fn test_agent_session_filters_system_messages() {
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!(
messages[0]["content"],
"INTERNAL SYSTEM PROMPT — must not leak to UI"
);
assert_eq!(body["message_count"], 3);
assert_eq!(body["raw_message_count"], 3);
}
@@ -800,6 +906,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
model: "test-model".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -831,6 +938,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
} else {
String::new()
},
allow_no_auth: true,
};
let app = Router::new()
@@ -853,6 +961,10 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
"/api/agents/{id}",
axum::routing::delete(routes::kill_agent),
)
.route(
"/api/agents/{id}/clone",
axum::routing::post(routes::clone_agent),
)
.route(
"/api/triggers",
axum::routing::get(routes::list_triggers).post(routes::create_trigger),
@@ -979,3 +1091,710 @@ async fn test_auth_disabled_when_no_key() {
.unwrap();
assert_eq!(resp.status(), 200);
}
// ---------------------------------------------------------------------------
// /api/commands — unified command registry endpoint
// ---------------------------------------------------------------------------
/// Default (no surface query) returns web-surface commands.
#[tokio::test]
async fn test_commands_default_returns_web() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "web");
let commands = body["commands"].as_array().expect("commands is array");
assert!(!commands.is_empty(), "web surface should have commands");
// Every entry has the documented shape.
for c in commands {
assert!(c["name"].is_string());
assert!(c["aliases"].is_array());
assert!(c["description"].is_string());
assert!(c["category"].is_string());
assert!(c["requires_agent"].is_boolean());
}
// Sanity: web surface must include `/help` and `/verbose` and must NOT
// include CLI-only `/kill`.
let names: Vec<&str> = commands
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"help"));
assert!(names.contains(&"verbose"));
assert!(!names.contains(&"kill"));
}
/// `?surface=cli` returns CLI-only commands and includes the alias array.
#[tokio::test]
async fn test_commands_cli_surface() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=cli", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "cli");
let commands = body["commands"].as_array().unwrap();
let names: Vec<&str> = commands
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"kill"));
assert!(names.contains(&"clear"));
assert!(names.contains(&"exit"));
// `start` is channel-only — must not appear on CLI.
assert!(!names.contains(&"start"));
// `/exit` carries the `quit` alias.
let exit = commands
.iter()
.find(|c| c["name"] == "exit")
.expect("exit command must be present on CLI");
let aliases = exit["aliases"].as_array().unwrap();
assert!(
aliases.iter().any(|a| a == "quit"),
"quit alias should be attached to /exit"
);
}
/// `?surface=all` includes commands from every surface.
#[tokio::test]
async fn test_commands_all_surface() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=all", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "all");
let names: Vec<&str> = body["commands"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
// Surface-specific probes: all three unique-per-surface commands appear.
assert!(names.contains(&"kill"), "CLI-only /kill missing from /all");
assert!(
names.contains(&"start"),
"channel-only /start missing from /all"
);
assert!(
names.contains(&"verbose"),
"web-only /verbose missing from /all"
);
}
/// `?surface=channel` returns channel commands only.
#[tokio::test]
async fn test_commands_channel_surface() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=channel", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "channel");
let names: Vec<&str> = body["commands"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"start"));
// CLI-only must not appear here.
assert!(!names.contains(&"kill"));
}
/// Unknown surface returns 400 with a JSON error body.
#[tokio::test]
async fn test_commands_invalid_surface_400() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=bogus", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("bogus"),
"error should mention the bad value: {err}"
);
}
// ---------------------------------------------------------------------------
// Schedule delivery_targets round-trip tests
// ---------------------------------------------------------------------------
//
// These exercise the `/api/schedules` and `/api/cron/jobs` endpoints to
// confirm `CronDeliveryTarget` variants round-trip cleanly through create /
// list / update / delivery-log, and that bad input is rejected at the API
// layer rather than silently dropped.
async fn spawn_test_agent(server: &TestServer) -> String {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
body["agent_id"].as_str().unwrap().to_string()
}
/// POST /api/schedules with all four `CronDeliveryTarget` variants should
/// store them and return them on GET /api/schedules.
#[tokio::test]
async fn test_schedules_delivery_targets_roundtrip() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let delivery_targets = serde_json::json!([
{ "type": "channel", "channel_type": "telegram", "recipient": "chat_12345" },
{ "type": "webhook", "url": "https://example.com/hook", "auth_header": "Bearer abc" },
{ "type": "local_file", "path": "/tmp/openfang-test.log", "append": true },
{ "type": "email", "to": "alice@example.com", "subject_template": "Cron: {job}" },
]);
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "multi-destination-test",
"cron": "0 9 * * 1-5",
"agent_id": agent_id,
"message": "Generate the daily brief.",
"enabled": true,
"delivery_targets": delivery_targets,
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let sched_id = body["id"]
.as_str()
.expect("created schedule id")
.to_string();
let got = body["delivery_targets"]
.as_array()
.expect("response must include delivery_targets");
assert_eq!(got.len(), 4, "all four targets should round-trip");
assert_eq!(got[0]["type"], "channel");
assert_eq!(got[0]["channel_type"], "telegram");
assert_eq!(got[0]["recipient"], "chat_12345");
assert_eq!(got[1]["type"], "webhook");
assert_eq!(got[1]["url"], "https://example.com/hook");
assert_eq!(got[1]["auth_header"], "Bearer abc");
assert_eq!(got[2]["type"], "local_file");
assert_eq!(got[2]["append"], true);
assert_eq!(got[3]["type"], "email");
assert_eq!(got[3]["subject_template"], "Cron: {job}");
let resp = client
.get(format!("{}/api/schedules", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let schedules = body["schedules"].as_array().unwrap();
let created = schedules
.iter()
.find(|s| s["id"] == sched_id)
.expect("created schedule must appear in list");
let listed = created["delivery_targets"].as_array().unwrap();
assert_eq!(listed.len(), 4);
assert_eq!(listed[0]["channel_type"], "telegram");
let _ = client
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
.send()
.await;
}
/// PUT /api/schedules/{id} with `delivery_targets` should fully replace the
/// target list.
#[tokio::test]
async fn test_schedules_delivery_targets_update() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "update-target-test",
"cron": "*/15 * * * *",
"agent_id": agent_id,
"message": "hi",
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let sched_id = body["id"].as_str().unwrap().to_string();
assert_eq!(
body["delivery_targets"].as_array().map(|a| a.len()),
Some(0)
);
let resp = client
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
.json(&serde_json::json!({
"delivery_targets": [
{ "type": "webhook", "url": "https://new.example.com/hook" },
{ "type": "local_file", "path": "/tmp/new.log" },
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "updated");
let echoed = &body["schedule"]["delivery_targets"];
let arr = echoed
.as_array()
.expect("schedule.delivery_targets must be array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["type"], "webhook");
assert_eq!(arr[1]["type"], "local_file");
let resp = client
.get(format!("{}/api/schedules", server.base_url))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let created = body["schedules"]
.as_array()
.unwrap()
.iter()
.find(|s| s["id"] == sched_id)
.unwrap();
let listed = created["delivery_targets"].as_array().unwrap();
assert_eq!(listed.len(), 2);
let resp = client
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
.json(&serde_json::json!({"delivery_targets": []}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = client
.get(format!("{}/api/schedules", server.base_url))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let created = body["schedules"]
.as_array()
.unwrap()
.iter()
.find(|s| s["id"] == sched_id)
.unwrap();
let listed = created["delivery_targets"].as_array().unwrap();
assert_eq!(listed.len(), 0);
let _ = client
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
.send()
.await;
}
/// Malformed `delivery_targets` should return 400, not silently succeed.
#[tokio::test]
async fn test_schedules_rejects_bad_delivery_target() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "bad-target-test",
"cron": "*/10 * * * *",
"agent_id": agent_id,
"message": "hi",
"delivery_targets": [
{ "type": "channel" /* missing channel_type + recipient */ }
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("delivery_targets"),
"error should mention delivery_targets, got: {err}"
);
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "bad-array-test",
"cron": "*/10 * * * *",
"agent_id": agent_id,
"message": "hi",
"delivery_targets": "not-an-array",
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
}
/// GET /api/schedules/{id}/delivery-log returns the configured targets and an
/// empty entries array for a known schedule, and 404 for a random UUID.
#[tokio::test]
async fn test_schedules_delivery_log_endpoint() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "log-test",
"cron": "0 * * * *",
"agent_id": agent_id,
"message": "x",
"delivery_targets": [
{ "type": "webhook", "url": "https://example.com/h" }
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let sched_id = resp.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let resp = client
.get(format!(
"{}/api/schedules/{}/delivery-log",
server.base_url, sched_id
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["schedule_id"], sched_id);
let targets = body["targets"].as_array().expect("targets array");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0]["type"], "webhook");
let entries = body["entries"].as_array().expect("entries array");
assert!(
entries.is_empty(),
"delivery history is not persisted yet — entries must be empty"
);
let random = "550e8400-e29b-41d4-a716-446655440000";
let resp = client
.get(format!(
"{}/api/schedules/{}/delivery-log",
server.base_url, random
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
let resp = client
.get(format!(
"{}/api/schedules/not-a-uuid/delivery-log",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let _ = client
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
.send()
.await;
}
/// POST /api/cron/jobs with `delivery_targets` should persist them and they
/// should appear on the subsequent GET.
#[tokio::test]
async fn test_cron_jobs_delivery_targets_roundtrip() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/cron/jobs", server.base_url))
.json(&serde_json::json!({
"agent_id": agent_id,
"name": "cron-fanout",
"schedule": { "kind": "cron", "expr": "*/20 * * * *" },
"action": { "kind": "agent_turn", "message": "pulse" },
"delivery": { "kind": "none" },
"delivery_targets": [
{ "type": "local_file", "path": "/tmp/pulse.log", "append": true },
{ "type": "webhook", "url": "http://example.com/pulse" }
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let resp = client
.get(format!(
"{}/api/cron/jobs?agent_id={}",
server.base_url, agent_id
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let jobs = body["jobs"].as_array().unwrap();
let job = jobs
.iter()
.find(|j| j["name"] == "cron-fanout")
.expect("created job must be listed");
let targets = job["delivery_targets"].as_array().expect("targets array");
assert_eq!(targets.len(), 2);
assert_eq!(targets[0]["type"], "local_file");
assert_eq!(targets[0]["path"], "/tmp/pulse.log");
assert_eq!(targets[0]["append"], true);
assert_eq!(targets[1]["type"], "webhook");
assert_eq!(targets[1]["url"], "http://example.com/pulse");
}
// ---------------------------------------------------------------------------
// Clone agent endpoint tests (issue #868)
// ---------------------------------------------------------------------------
/// Happy path: clone an existing template agent into a new agent with a
/// distinct name. The clone must get a fresh ID, fresh workspace path, and
/// inherit non-name manifest fields from the template.
#[tokio::test]
async fn test_clone_agent_happy_path() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Spawn a template agent.
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let template_id = body["agent_id"].as_str().unwrap().to_string();
// Clone it.
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({
"new_name": "cloned-user-1",
"overrides": {
"description": "Cloned for user 1",
"tags": ["clone", "user-1"]
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "clone should succeed");
let body: serde_json::Value = resp.json().await.unwrap();
let new_id = body["agent_id"].as_str().unwrap();
assert_ne!(new_id, template_id, "clone must have a fresh agent ID");
assert_eq!(body["name"], "cloned-user-1");
// The full manifest should be returned and reflect the new name + overrides.
let manifest = &body["manifest"];
assert!(manifest.is_object(), "manifest must be returned");
assert_eq!(manifest["name"], "cloned-user-1");
assert_eq!(manifest["description"], "Cloned for user 1");
assert_eq!(
manifest["tags"].as_array().unwrap(),
&vec![serde_json::json!("clone"), serde_json::json!("user-1"),]
);
// Inherited from template — the system_prompt should match.
assert_eq!(
manifest["model"]["system_prompt"],
"You are a test agent. Reply concisely."
);
// The agent list should now contain both template and clone.
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
let names: Vec<&str> = agents.iter().map(|a| a["name"].as_str().unwrap()).collect();
assert!(names.contains(&"test-agent"));
assert!(names.contains(&"cloned-user-1"));
}
/// Cloning into a name that's already taken must fail with 409 Conflict.
#[tokio::test]
async fn test_clone_agent_name_collision() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Spawn a template agent named "test-agent".
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let template_id = body["agent_id"].as_str().unwrap().to_string();
// First clone — succeeds.
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({"new_name": "duplicate-name"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
// Second clone with the same name — must be rejected.
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({"new_name": "duplicate-name"}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
409,
"duplicate name must return 409 Conflict"
);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"].as_str().unwrap().contains("already exists"));
// Cloning into the template's own name must also be rejected.
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({"new_name": "test-agent"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 409);
}
/// Cloning a non-existent template must return 404.
#[tokio::test]
async fn test_clone_agent_template_not_found() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Random valid UUID that does not match any agent.
let bogus_id = "00000000-0000-0000-0000-000000000000";
let resp = client
.post(format!("{}/api/agents/{}/clone", server.base_url, bogus_id))
.json(&serde_json::json!({"new_name": "ghost-clone"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"]
.as_str()
.unwrap()
.contains("Template agent not found"));
// Malformed agent id → 400.
let resp = client
.post(format!("{}/api/agents/not-a-uuid/clone", server.base_url))
.json(&serde_json::json!({"new_name": "ghost-clone"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
}
/// Empty new_name must be rejected with 400.
#[tokio::test]
async fn test_clone_agent_empty_name_rejected() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Spawn a template agent.
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let template_id = body["agent_id"].as_str().unwrap().to_string();
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({"new_name": " "}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
}
@@ -98,6 +98,7 @@ async fn test_full_daemon_lifecycle() {
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -225,6 +226,7 @@ async fn test_server_immediate_responsiveness() {
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
+1
View File
@@ -42,6 +42,7 @@ async fn start_test_server() -> TestServer {
model: "test-model".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -0,0 +1,385 @@
//! Integration tests for the `/api/skills/{id}/config` surface.
//!
//! These boot a real kernel, start a real axum server on a random port, plant
//! a synthetic skill on disk whose SKILL.md declares a `config:` section, and
//! exercise GET / PUT / DELETE end to end. Bundled skills currently declare
//! no runtime config, so the synthetic skill fixture is what lets us prove
//! the wire contract.
//!
//! Run: cargo test -p openfang-api --test skill_config_api_test -- --nocapture
use axum::Router;
use openfang_api::middleware;
use openfang_api::routes::{self, AppState};
use openfang_kernel::OpenFangKernel;
use openfang_types::config::{DefaultModelConfig, KernelConfig};
use std::sync::Arc;
use std::time::Instant;
use tower_http::cors::CorsLayer;
// ---------------------------------------------------------------------------
// Test server harness
// ---------------------------------------------------------------------------
struct TestServer {
base_url: String,
home_dir: std::path::PathBuf,
#[allow(dead_code)]
state: Arc<AppState>,
_tmp: tempfile::TempDir,
}
impl Drop for TestServer {
fn drop(&mut self) {
self.state.kernel.shutdown();
}
}
/// Write a skill fixture under `<home>/skills/<name>/SKILL.md` that declares
/// a `config:` section. This matches the on-disk format that OpenClaw skills
/// use, so the loader's real `parse_skillmd_str` path is exercised.
fn plant_skill_with_config(home: &std::path::Path, skill_name: &str) {
let skill_dir = home.join("skills").join(skill_name);
std::fs::create_dir_all(&skill_dir).unwrap();
// Leading four spaces inside YAML lists matter — keep them.
let skillmd = format!(
"---
name: {skill_name}
description: Synthetic skill for config endpoint tests
config:
github_token:
description: GitHub personal access token
env: OPENFANG_TEST_SKILLCFG_GH_TOKEN
required: true
default_branch:
description: Default branch name
default: main
required: false
---
# Test Skill
Placeholder body so the parser accepts this as a valid prompt-only skill.
"
);
std::fs::write(skill_dir.join("SKILL.md"), skillmd).unwrap();
}
async fn start_test_server() -> TestServer {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().to_path_buf();
let config = KernelConfig {
home_dir: home.clone(),
data_dir: home.join("data"),
default_model: DefaultModelConfig {
provider: "ollama".to_string(),
model: "test-model".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
// Plant synthetic skill BEFORE booting so the initial skill load picks it up.
plant_skill_with_config(&home, "test-config-skill");
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boot");
let kernel = Arc::new(kernel);
kernel.set_self_handle();
let state = Arc::new(AppState {
kernel,
started_at: Instant::now(),
peer_registry: None,
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
.route("/api/skills", axum::routing::get(routes::list_skills))
.route(
"/api/skills/{id}/config",
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
)
.route(
"/api/skills/{id}/config/{var_name}",
axum::routing::delete(routes::delete_skill_config_var),
)
.layer(axum::middleware::from_fn(middleware::request_logging))
.layer(CorsLayer::permissive())
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test port");
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestServer {
base_url: format!("http://{}", addr),
home_dir: home,
state,
_tmp: tmp,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn get_config_returns_declared_and_resolved() {
// Make sure no host leak from prior tests interferes.
// SAFETY: single-threaded test, env var is unique to this test suite.
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["skill"], "test-config-skill");
// Both vars declared
assert!(body["declared"]["github_token"].is_object());
assert!(body["declared"]["default_branch"].is_object());
assert_eq!(body["declared"]["github_token"]["required"], true);
assert_eq!(body["declared"]["default_branch"]["required"], false);
// github_token has no user override, no env, no default -> unresolved.
assert_eq!(
body["resolved"]["github_token"]["source"], "unresolved",
"github_token should be unresolved without env"
);
assert!(body["resolved"]["github_token"]["is_secret"]
.as_bool()
.unwrap());
// default_branch falls back to default "main".
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
}
#[tokio::test]
async fn get_config_redacts_secret_values_after_put() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Write a real-looking token via PUT.
let payload = serde_json::json!({
"values": {
"github_token": "ghp_realsecretvalue_DO_NOT_LEAK",
"default_branch": "develop"
}
});
let resp = client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&payload)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "PUT should succeed");
// GET back and confirm the secret is redacted and non-secret is visible.
let resp = client
.get(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let returned_token = body["resolved"]["github_token"]["value"]
.as_str()
.unwrap()
.to_string();
assert!(
!returned_token.contains("realsecretvalue"),
"secret leaked on the wire: {returned_token}"
);
assert!(
returned_token.contains("redacted"),
"expected redaction marker, got: {returned_token}"
);
assert_eq!(body["resolved"]["github_token"]["source"], "user");
// Non-secret var kept as-is.
assert_eq!(body["resolved"]["default_branch"]["value"], "develop");
assert_eq!(body["resolved"]["default_branch"]["source"], "user");
// config.toml persisted the change, including the full secret value
// (redaction is only on the wire — disk is the source of truth).
let cfg = std::fs::read_to_string(server.home_dir.join("config.toml")).unwrap();
assert!(
cfg.contains("[skills.test-config-skill]"),
"skills section missing: {cfg}"
);
assert!(cfg.contains("realsecretvalue"));
assert!(cfg.contains("develop"));
}
#[tokio::test]
async fn put_rejects_unknown_variable() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let payload = serde_json::json!({
"values": { "nonexistent_var": "value" }
});
let resp = client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&payload)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"].as_str().unwrap().contains("nonexistent_var"));
}
#[tokio::test]
async fn delete_override_reverts_to_default() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Set override first.
client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&serde_json::json!({
"values": { "default_branch": "develop" }
}))
.send()
.await
.unwrap();
// Remove it.
let resp = client
.delete(format!(
"{}/api/skills/test-config-skill/config/default_branch",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// Now source should be "default" again with value "main".
let body: serde_json::Value = client
.get(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
}
#[tokio::test]
async fn delete_refuses_to_strand_required_var() {
// github_token is required, has no default, and no env — so removing an
// override would leave it unresolvable. The endpoint must refuse.
// SAFETY: single-threaded test.
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
let server = start_test_server().await;
let client = reqwest::Client::new();
// Set an override.
client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&serde_json::json!({
"values": { "github_token": "ghp_value" }
}))
.send()
.await
.unwrap();
// Try to delete — should 409.
let resp = client
.delete(format!(
"{}/api/skills/test-config-skill/config/github_token",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 409);
}
#[tokio::test]
async fn get_unknown_skill_returns_404() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!(
"{}/api/skills/this-skill-does-not-exist/config",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn put_reloads_registry_so_agents_see_change() {
let server = start_test_server().await;
let client = reqwest::Client::new();
client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&serde_json::json!({
"values": {
"github_token": "ghp_new",
"default_branch": "release"
}
}))
.send()
.await
.unwrap();
// The kernel's live override map must now hold the new values.
let guard = server.state.kernel.skill_config_overrides.read().unwrap();
let overrides = guard.as_ref().expect("override map set after PUT");
let skill_cfg = overrides.get("test-config-skill").expect("skill present");
assert_eq!(skill_cfg.get("github_token").unwrap(), "ghp_new");
assert_eq!(skill_cfg.get("default_branch").unwrap(), "release");
}
File diff suppressed because it is too large Load Diff
+633 -27
View File
@@ -8,7 +8,7 @@ use crate::types::{
};
use async_trait::async_trait;
use futures::{SinkExt, Stream, StreamExt};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -22,6 +22,10 @@ const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
const MAX_BACKOFF: Duration = Duration::from_secs(60);
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const DISCORD_MSG_LIMIT: usize = 2000;
/// Maximum number of seen message IDs kept in the dedup set.
/// MESSAGE_UPDATE (embed resolution) events arrive within seconds of the
/// original CREATE; entries older than this cap are safe to discard.
const MAX_DEDUP_MSG_IDS: usize = 2_000;
/// Discord Gateway opcodes.
mod opcode {
@@ -56,6 +60,8 @@ pub struct DiscordAdapter {
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
/// Auto-thread behavior: "true", "false", or "smart"
auto_thread: String,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Bot's own user ID (populated after READY event).
@@ -64,6 +70,13 @@ pub struct DiscordAdapter {
session_id: Arc<RwLock<Option<String>>>,
/// Resume gateway URL.
resume_gateway_url: Arc<RwLock<Option<String>>>,
/// Thread channel IDs created by this bot (thread_id → parent_channel_id).
/// Used to detect when incoming messages are inside a bot-created thread.
created_thread_ids: Arc<RwLock<HashMap<String, String>>>,
/// Message IDs seen via MESSAGE_CREATE (used to drop duplicate MESSAGE_UPDATE events).
/// Populated immediately when MESSAGE_CREATE is forwarded — before bridge processing —
/// to eliminate the race window where MESSAGE_UPDATE arrives before thread creation completes.
threaded_message_ids: Arc<RwLock<HashSet<String>>>,
}
impl DiscordAdapter {
@@ -73,6 +86,7 @@ impl DiscordAdapter {
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
auto_thread: String,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
@@ -82,11 +96,14 @@ impl DiscordAdapter {
allowed_users,
ignore_bots,
intents,
auto_thread,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
bot_user_id: Arc::new(RwLock::new(None)),
session_id: Arc::new(RwLock::new(None)),
resume_gateway_url: Arc::new(RwLock::new(None)),
created_thread_ids: Arc::new(RwLock::new(HashMap::new())),
threaded_message_ids: Arc::new(RwLock::new(HashSet::new())),
}
}
@@ -147,6 +164,79 @@ impl DiscordAdapter {
.await?;
Ok(())
}
/// Create a thread from a message in a Discord channel.
async fn api_create_thread(
&self,
channel_id: &str,
message_id: &str,
name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let url = format!(
"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/threads",
channel_id = channel_id,
message_id = message_id
);
let body = serde_json::json!({
"name": name,
"auto_archive_duration": 1440 // 24 hours
});
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bot {}", self.token.as_str()))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
return Err(format!("Discord createThread failed: {}", body_text).into());
}
let response: serde_json::Value = resp.json().await?;
let thread_id = response["id"].as_str().unwrap_or("").to_string();
// Track thread_id → parent channel_id so we can recognise messages
// that arrive inside this thread.
if !thread_id.is_empty() {
self.created_thread_ids
.write()
.await
.insert(thread_id.clone(), channel_id.to_string());
}
Ok(thread_id)
}
/// Send a message to an existing thread.
/// Discord threads are channels — post directly to channels/{thread_id}/messages.
async fn api_send_thread_message(
&self,
_channel_id: &str,
thread_id: &str,
text: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!("{DISCORD_API_BASE}/channels/{thread_id}/messages");
let chunks = split_message(text, DISCORD_MSG_LIMIT);
for chunk in chunks {
let body = serde_json::json!({ "content": chunk });
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bot {}", self.token.as_str()))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Discord sendThreadMessage failed: {body_text}");
}
}
Ok(())
}
}
#[async_trait]
@@ -159,6 +249,33 @@ impl ChannelAdapter for DiscordAdapter {
ChannelType::Discord
}
async fn should_auto_thread(&self, message: &ChannelMessage) -> Option<String> {
// Only auto-thread in group channels (servers), not DMs
if !message.is_group {
return None;
}
// Check auto_thread mode
match self.auto_thread.as_str() {
"true" => Some(thread_name_from_message(message)),
"false" => None,
"smart" => {
// Only create thread if bot was @mentioned
let was_mentioned = message
.metadata
.get("was_mentioned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if was_mentioned {
Some(thread_name_from_message(message))
} else {
None
}
}
_ => None,
}
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
@@ -176,6 +293,8 @@ impl ChannelAdapter for DiscordAdapter {
let bot_user_id = self.bot_user_id.clone();
let session_id_store = self.session_id.clone();
let resume_url_store = self.resume_gateway_url.clone();
let created_thread_ids = self.created_thread_ids.clone();
let threaded_message_ids = self.threaded_message_ids.clone();
let mut shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
@@ -414,19 +533,66 @@ impl ChannelAdapter for DiscordAdapter {
&allowed_guilds,
&allowed_users,
ignore_bots,
&created_thread_ids,
)
.await
{
// MESSAGE_UPDATE must be suppressed if we already
// forwarded a MESSAGE_CREATE for this message ID.
// The check uses `seen_message_ids` (tracked below)
// which is populated the moment MESSAGE_CREATE is
// forwarded — before the bridge even processes it.
// This closes the race window where MESSAGE_UPDATE
// arrives before adapter.create_thread() completes.
if event_name == "MESSAGE_UPDATE"
&& threaded_message_ids
.read()
.await
.contains(&msg.platform_message_id)
{
debug!(
"Discord MESSAGE_UPDATE skipped (already seen {})",
msg.platform_message_id
);
continue;
}
debug!(
"Discord {event_name} from {}: {:?}",
msg.sender.display_name, msg.content
);
// Mark this message as seen immediately so any
// concurrent or subsequent MESSAGE_UPDATE is dropped.
if event_name == "MESSAGE_CREATE" {
threaded_message_ids
.write()
.await
.insert(msg.platform_message_id.clone());
}
if tx.send(msg).await.is_err() {
return;
}
}
}
"THREAD_DELETE" | "CHANNEL_DELETE" => {
// Clean up tracking when a thread is deleted so the
// next message in the parent channel is treated fresh.
if let Some(tid) = d["id"].as_str() {
created_thread_ids.write().await.remove(tid);
// Prune the dedup set to prevent unbounded growth.
// Entries older than MAX_DEDUP_MSG_IDS are safe to
// discard — embed UPDATE events arrive within seconds.
let mut ids = threaded_message_ids.write().await;
if ids.len() > MAX_DEDUP_MSG_IDS {
ids.clear();
}
debug!("Discord thread/channel deleted: {tid}");
}
}
"RESUMED" => {
info!("Discord session resumed successfully");
}
@@ -532,12 +698,123 @@ impl ChannelAdapter for DiscordAdapter {
self.api_send_typing(&user.platform_id).await
}
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_thread_message(channel_id, thread_id, &text)
.await?;
}
_ => {
self.api_send_thread_message(channel_id, thread_id, "(Unsupported content type)")
.await?;
}
}
Ok(())
}
async fn create_thread(
&self,
user: &ChannelUser,
message_id: &str,
thread_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
let thread_id = self
.api_create_thread(channel_id, message_id, thread_name)
.await?;
// Also ensure the message_id is marked as seen (belt-and-suspenders:
// the gateway loop already inserts on MESSAGE_CREATE, but keep this
// in case create_thread is ever called from another path).
self.threaded_message_ids
.write()
.await
.insert(message_id.to_string());
Ok(thread_id)
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
}
}
/// Maximum byte size for an attachment to be classified as a vision-eligible
/// image. Anthropic's image content blocks are capped at 5 MB; oversize images
/// fall through to `File` so the bridge passes the URL as text instead of
/// attempting an inline image block.
const VISION_IMAGE_MAX_BYTES: u64 = 5 * 1024 * 1024;
/// Best-effort MIME inference from a filename extension. Used as a fallback
/// when Discord's `content_type` field is missing or empty (we've observed
/// this on some bot-relayed attachments).
fn mime_from_extension(filename: &str) -> Option<&'static str> {
let ext = filename.rsplit('.').next()?.to_ascii_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => Some("image/jpeg"),
"png" => Some("image/png"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
"heic" => Some("image/heic"),
"heif" => Some("image/heif"),
"pdf" => Some("application/pdf"),
"txt" => Some("text/plain"),
"md" => Some("text/markdown"),
"json" => Some("application/json"),
"mp4" => Some("video/mp4"),
"mov" => Some("video/quicktime"),
"mp3" => Some("audio/mpeg"),
"wav" => Some("audio/wav"),
"ogg" => Some("audio/ogg"),
_ => None,
}
}
/// Classify a single Discord attachment JSON object into a `ChannelContent`
/// block. Vision-eligible image MIME types (jpeg/png/gif/webp) under
/// `VISION_IMAGE_MAX_BYTES` become `Image`; everything else becomes `File`
/// (URL-pass-through; the bridge will surface it as a text descriptor in v1).
///
/// MIME resolution chain: `attachments[].content_type` (if non-empty) →
/// extension lookup → `application/octet-stream`.
fn classify_discord_attachment(att: &serde_json::Value) -> ChannelContent {
let url = att["url"].as_str().unwrap_or("").to_string();
let filename = att["filename"].as_str().unwrap_or("file").to_string();
let size = att["size"].as_u64();
let resolved_mime: String = att["content_type"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| mime_from_extension(&filename).map(str::to_string))
.unwrap_or_else(|| "application/octet-stream".to_string());
let is_vision_mime = matches!(
resolved_mime.as_str(),
"image/jpeg" | "image/png" | "image/gif" | "image/webp"
);
// If size is unknown, optimistically allow the image — the bridge will
// surface a 4xx if Anthropic rejects it, which is better than silently
// demoting to a text URL.
let within_vision_limit = size.map(|s| s <= VISION_IMAGE_MAX_BYTES).unwrap_or(true);
if is_vision_mime && within_vision_limit {
ChannelContent::Image { url, caption: None }
} else {
ChannelContent::File {
url,
filename,
mime: Some(resolved_mime),
size,
}
}
}
/// Parse a Discord MESSAGE_CREATE or MESSAGE_UPDATE payload into a `ChannelMessage`.
async fn parse_discord_message(
d: &serde_json::Value,
@@ -545,7 +822,13 @@ async fn parse_discord_message(
allowed_guilds: &[String],
allowed_users: &[String],
ignore_bots: bool,
created_thread_ids: &Arc<RwLock<HashMap<String, String>>>,
) -> Option<ChannelMessage> {
// Diagnostic: dump the raw Discord payload so we can ground attachment
// parsing in real JSON. Gated by RUST_LOG; silent at default `info` level.
// Enable with: RUST_LOG=openfang_channels::discord=debug
debug!(target: "openfang_channels::discord", payload = %d, "discord raw message payload");
let author = d.get("author")?;
let author_id = author["id"].as_str()?;
@@ -577,12 +860,22 @@ async fn parse_discord_message(
}
let content_text = d["content"].as_str().unwrap_or("");
if content_text.is_empty() {
return None;
}
let channel_id = d["channel_id"].as_str()?;
let message_id = d["id"].as_str().unwrap_or("0");
// Detect if this message is inside a bot-created thread.
// In Discord, a thread is its own channel — channel_id will be the thread's ID.
// If so, use the parent channel as platform_id and set thread_id so that:
// (a) auto-thread logic is skipped (message.thread_id.is_some())
// (b) responses are sent back into the same thread
let (effective_channel_id, parsed_thread_id) = {
let threads = created_thread_ids.read().await;
if let Some(parent_channel_id) = threads.get(channel_id) {
(parent_channel_id.clone(), Some(channel_id.to_string()))
} else {
(channel_id.to_string(), None)
}
};
let username = author["username"].as_str().unwrap_or("Unknown");
let discriminator = author["discriminator"].as_str().unwrap_or("0000");
let display_name = if discriminator == "0" {
@@ -597,7 +890,8 @@ async fn parse_discord_message(
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(chrono::Utc::now);
// Parse commands (messages starting with /)
// Parse commands (messages starting with /). Commands do not carry
// attachments in v1; attachment processing only runs in the non-command path.
let content = if content_text.starts_with('/') {
let parts: Vec<&str> = content_text.splitn(2, ' ').collect();
let cmd_name = &parts[0][1..];
@@ -611,7 +905,50 @@ async fn parse_discord_message(
args,
}
} else {
ChannelContent::Text(content_text.to_string())
let attachment_blocks: Vec<ChannelContent> = d["attachments"]
.as_array()
.map(|arr| arr.iter().map(classify_discord_attachment).collect())
.unwrap_or_default();
match (content_text.is_empty(), attachment_blocks.len()) {
// No text, no attachments → nothing to ingest.
(true, 0) => return None,
// Text only.
(false, 0) => ChannelContent::Text(content_text.to_string()),
// Single attachment, no caption.
(true, 1) => attachment_blocks.into_iter().next().unwrap(),
// Single attachment + caption: emit Multipart with the caption as
// a sibling Text block. This keeps the caption visible to providers
// that flatten content to text only (e.g. claude-code/*, which
// currently drops Image blocks) — the user gets a coherent
// text-only response instead of a hallucination. Vision-capable
// providers see the same blocks and dispatch multimodally.
(false, 1) => {
let block = attachment_blocks.into_iter().next().unwrap();
let normalized = match block {
// Drop any caption that classify_discord_attachment may have
// attached; the sibling Text block is now the caption.
ChannelContent::Image { url, caption: _ } => {
ChannelContent::Image { url, caption: None }
}
other => other,
};
ChannelContent::Multipart(vec![
ChannelContent::Text(content_text.to_string()),
normalized,
])
}
// Multiple attachments, no caption.
(true, _) => ChannelContent::Multipart(attachment_blocks),
// Multiple attachments + caption: text first, then attachments
// (matches Discord's visual ordering: text above attachments).
(false, _) => {
let mut blocks = Vec::with_capacity(attachment_blocks.len() + 1);
blocks.push(ChannelContent::Text(content_text.to_string()));
blocks.extend(attachment_blocks);
ChannelContent::Multipart(blocks)
}
}
};
// Determine if this is a group message (guild_id present = server channel)
@@ -636,12 +973,15 @@ async fn parse_discord_message(
if was_mentioned {
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
// Stash the Discord author ID so the router can key bindings on user, not channel.
// (`sender.platform_id` below is the channel ID, used for the send path.)
metadata.insert("sender_user_id".to_string(), serde_json::json!(author_id));
Some(ChannelMessage {
channel: ChannelType::Discord,
platform_message_id: message_id.to_string(),
sender: ChannelUser {
platform_id: channel_id.to_string(),
platform_id: effective_channel_id,
display_name,
openfang_user: None,
},
@@ -649,15 +989,50 @@ async fn parse_discord_message(
target_agent: None,
timestamp,
is_group,
thread_id: None,
thread_id: parsed_thread_id,
metadata,
})
}
/// Build a Discord thread name from the message content.
/// Strips @mention prefixes (`<@...>`), trims whitespace, and truncates to
/// Discord's 100-character thread name limit. Falls back to the sender's
/// display name if the message has no usable text (e.g. image-only).
fn thread_name_from_message(message: &ChannelMessage) -> String {
let raw = match &message.content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Image { caption, .. } => caption.clone().unwrap_or_default(),
_ => String::new(),
};
// Strip leading Discord mention tokens (<@id> / <@!id>)
let stripped = regex_lite::Regex::new(r"^(<@!?\d+>\s*)+")
.map(|re| re.replace(&raw, "").into_owned())
.unwrap_or(raw);
let trimmed = stripped.trim().to_string();
if trimmed.is_empty() {
return message.sender.display_name.clone();
}
// Truncate to Discord's 100-char limit
if trimmed.chars().count() <= 100 {
trimmed
} else {
trimmed.chars().take(97).collect::<String>() + ""
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Convenience helper: empty thread-tracking map for tests that don't exercise threading.
fn empty_threads() -> Arc<RwLock<HashMap<String, String>>> {
Arc::new(RwLock::new(HashMap::new()))
}
#[tokio::test]
async fn test_parse_discord_message_basic() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
@@ -674,7 +1049,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
@@ -698,7 +1073,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -718,7 +1093,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -739,7 +1114,7 @@ mod tests {
});
// With ignore_bots=false, other bots' messages should be allowed
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
assert!(msg.is_some());
let msg = msg.unwrap();
assert_eq!(msg.sender.display_name, "somebot");
@@ -763,7 +1138,7 @@ mod tests {
});
// Even with ignore_bots=false, the bot's own messages must still be filtered
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -784,12 +1159,20 @@ mod tests {
});
// Not in allowed guilds
let msg =
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
let msg = parse_discord_message(
&d,
&bot_id,
&["111".into(), "222".into()],
&[],
true,
&empty_threads(),
)
.await;
assert!(msg.is_none());
// In allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
let msg =
parse_discord_message(&d, &bot_id, &["999".into()], &[], true, &empty_threads()).await;
assert!(msg.is_some());
}
@@ -808,7 +1191,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match &msg.content {
@@ -835,7 +1218,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -854,7 +1237,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
@@ -878,7 +1261,7 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
@@ -909,16 +1292,25 @@ mod tests {
&[],
&["user111".into(), "user222".into()],
true,
&empty_threads(),
)
.await;
assert!(msg.is_none());
// In allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
let msg = parse_discord_message(
&d,
&bot_id,
&[],
&["user999".into()],
true,
&empty_threads(),
)
.await;
assert!(msg.is_some());
// Empty allowed_users = allow all
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_some());
}
@@ -941,7 +1333,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(msg.is_group);
@@ -964,7 +1356,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(msg2.is_group);
@@ -986,7 +1378,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(!msg.is_group);
@@ -1011,7 +1403,10 @@ mod tests {
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}));
assert_eq!(
parsed,
serde_json::json!({"op": 1, "d": serde_json::Value::Null})
);
}
#[test]
@@ -1022,8 +1417,219 @@ mod tests {
vec![],
true,
37376,
"true".to_string(),
);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
}
// -- Multipart / attachment parsing tests (commit 4) ----------------------
fn att(filename: &str, content_type: Option<&str>, size: u64) -> serde_json::Value {
let mut obj = serde_json::json!({
"url": format!("https://cdn.discordapp.com/attachments/1/2/{filename}"),
"filename": filename,
"size": size,
});
if let Some(ct) = content_type {
obj["content_type"] = serde_json::Value::String(ct.to_string());
}
obj
}
fn payload_with(content: &str, attachments: Vec<serde_json::Value>) -> serde_json::Value {
serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"content": content,
"author": {
"id": "user456",
"username": "alice",
"discriminator": "0",
"bot": false
},
"timestamp": "2024-01-01T00:00:00+00:00",
"attachments": attachments,
})
}
#[tokio::test]
async fn test_parse_image_only_no_caption() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with("", vec![att("photo.png", Some("image/png"), 100_000)]);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
ChannelContent::Image { caption, url } => {
assert!(caption.is_none());
assert!(url.contains("photo.png"));
}
other => panic!("expected Image, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_image_with_caption() {
// Single image + caption is emitted as Multipart([Text, Image]) so the
// caption survives providers that flatten content blocks to text only
// (e.g. claude-code/*). The Image carries no caption of its own; the
// sibling Text block IS the caption.
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with(
"look at this",
vec![att("photo.jpg", Some("image/jpeg"), 50_000)],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
ChannelContent::Multipart(parts) => {
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0], ChannelContent::Text(t) if t == "look at this"));
match &parts[1] {
ChannelContent::Image { caption, url } => {
assert!(
caption.is_none(),
"image caption should be None; the sibling Text block is the caption"
);
assert!(url.contains("photo.jpg"));
}
other => panic!("expected Image as second part, got {other:?}"),
}
}
other => panic!("expected Multipart, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_multi_image_no_caption() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with(
"",
vec![
att("a.png", Some("image/png"), 10_000),
att("b.png", Some("image/png"), 20_000),
],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
ChannelContent::Multipart(parts) => {
assert_eq!(parts.len(), 2);
assert!(parts
.iter()
.all(|p| matches!(p, ChannelContent::Image { .. })));
}
other => panic!("expected Multipart, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_multi_image_with_caption() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with(
"two pics",
vec![
att("a.png", Some("image/png"), 10_000),
att("b.png", Some("image/png"), 20_000),
],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
ChannelContent::Multipart(parts) => {
assert_eq!(parts.len(), 3);
// Text first, then images.
assert!(matches!(&parts[0], ChannelContent::Text(t) if t == "two pics"));
assert!(matches!(&parts[1], ChannelContent::Image { .. }));
assert!(matches!(&parts[2], ChannelContent::Image { .. }));
}
other => panic!("expected Multipart, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_heic_falls_to_file() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with("", vec![att("photo.heic", Some("image/heic"), 100_000)]);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
ChannelContent::File { mime, filename, .. } => {
assert_eq!(filename, "photo.heic");
assert_eq!(mime.as_deref(), Some("image/heic"));
}
other => panic!("expected File, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_oversize_image_falls_to_file() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
// 6 MB exceeds VISION_IMAGE_MAX_BYTES (5 MB).
let d = payload_with(
"",
vec![att("huge.png", Some("image/png"), 6 * 1024 * 1024)],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
ChannelContent::File {
filename,
mime,
size,
..
} => {
assert_eq!(filename, "huge.png");
assert_eq!(mime.as_deref(), Some("image/png"));
assert_eq!(size, Some(6 * 1024 * 1024));
}
other => panic!("expected File, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_file_with_caption_yields_multipart() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with(
"see attached",
vec![att("doc.pdf", Some("application/pdf"), 200_000)],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
ChannelContent::Multipart(parts) => {
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0], ChannelContent::Text(t) if t == "see attached"));
assert!(matches!(&parts[1], ChannelContent::File { .. }));
}
other => panic!("expected Multipart, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_extension_fallback_when_content_type_missing() {
// Discord occasionally omits content_type on bot-relayed attachments;
// we should fall back to the filename extension.
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with("", vec![att("pic.png", None, 50_000)]);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(matches!(msg.content, ChannelContent::Image { .. }));
}
#[tokio::test]
async fn test_parse_empty_message_with_no_attachments_returns_none() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with("", vec![]);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
}
+36 -9
View File
@@ -42,8 +42,8 @@ const MAX_MESSAGE_LEN: usize = 4000;
/// Token refresh buffer — refresh 5 minutes before actual expiry.
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
/// Feishu websocket endpoint discovery API.
const FEISHU_WS_ENDPOINT_URL: &str = "https://open.feishu.cn/callback/ws/endpoint";
/// WebSocket endpoint path (appended to the region domain).
const FEISHU_WS_ENDPOINT_PATH: &str = "/callback/ws/endpoint";
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const MAX_BACKOFF: Duration = Duration::from_secs(60);
@@ -160,7 +160,14 @@ impl DedupCache {
/// Returns `true` if the ID was already seen (duplicate).
fn check_and_insert(&self, id: &str) -> bool {
let mut ids = self.ids.lock().unwrap();
let mut ids = match self.ids.lock() {
Ok(guard) => guard,
Err(poisoned) => {
// Recover from a poisoned mutex rather than panicking.
warn!("Dedup cache mutex was poisoned, recovering");
poisoned.into_inner()
}
};
if ids.iter().any(|s| s == id) {
return true;
}
@@ -262,13 +269,24 @@ impl FeishuAdapter {
///
/// WebSocket mode does not require a public IP or webhook configuration.
pub fn new_websocket(app_id: String, app_secret: String) -> Self {
Self::new_websocket_with_region(app_id, app_secret, FeishuRegion::Cn)
}
/// Create a new Feishu adapter in WebSocket mode with an explicit region.
///
/// Use this when the app is registered on Lark international (`open.larksuite.com`).
pub fn new_websocket_with_region(
app_id: String,
app_secret: String,
region: FeishuRegion,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_id,
app_secret: Zeroizing::new(app_secret),
connection_mode: FeishuConnectionMode::WebSocket,
webhook_port: 0,
region: FeishuRegion::Cn,
region,
webhook_path: String::new(),
verification_token: None,
encrypt_key: None,
@@ -911,9 +929,10 @@ struct FeishuAdapterClone {
impl FeishuAdapterClone {
/// Get WebSocket endpoint from Feishu API.
async fn get_websocket_endpoint(&self) -> Result<FeishuWsEndpoint, Box<dyn std::error::Error>> {
let url = format!("{}{}", self.region.domain(), FEISHU_WS_ENDPOINT_PATH);
let resp = self
.client
.post(FEISHU_WS_ENDPOINT_URL)
.post(&url)
.json(&serde_json::json!({
"AppID": self.app_id,
"AppSecret": self.app_secret.as_str(),
@@ -1030,7 +1049,10 @@ fn combine_payload(
*entry = vec![Vec::new(); sum];
}
entry[seq] = payload;
match entry.get_mut(seq) {
Some(slot) => *slot = payload,
None => return None,
}
if entry.iter().any(|part| part.is_empty()) {
return None;
@@ -1105,7 +1127,10 @@ fn extract_text_from_post(content: &serde_json::Value) -> Option<String> {
let mut text_parts = Vec::new();
for paragraph in paragraphs {
let elements = paragraph.as_array()?;
let elements = match paragraph.as_array() {
Some(elems) => elems,
None => continue,
};
for element in elements {
let tag = element["tag"].as_str().unwrap_or("");
match tag {
@@ -1165,8 +1190,10 @@ fn should_respond_in_group(text: &str, mentions: &serde_json::Value, bot_names:
/// Strip @mention placeholders from text (`@_user_N` format).
fn strip_mention_placeholders(text: &str) -> String {
let re = regex_lite::Regex::new(r"@_user_\d+\s*").unwrap();
re.replace_all(text, "").trim().to_string()
match regex_lite::Regex::new(r"@_user_\d+\s*") {
Ok(re) => re.replace_all(text, "").trim().to_string(),
Err(_) => text.trim().to_string(),
}
}
/// Decrypt an AES-256-CBC encrypted event payload.
+9 -11
View File
@@ -326,19 +326,17 @@ impl ChannelAdapter for IrcAdapter {
}
// RPL_WELCOME (001) — registration complete, join channels
"001" => {
if !joined {
info!("IRC registered as {nick_clone}");
for ch in &channels_clone {
let join_cmd = format!("JOIN {ch}\r\n");
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
warn!("IRC JOIN send failed: {e}");
break 'inner true;
}
info!("IRC joining {ch}");
"001" if !joined => {
info!("IRC registered as {nick_clone}");
for ch in &channels_clone {
let join_cmd = format!("JOIN {ch}\r\n");
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
warn!("IRC JOIN send failed: {e}");
break 'inner true;
}
joined = true;
info!("IRC joining {ch}");
}
joined = true;
}
// PRIVMSG — incoming message
+304 -36
View File
@@ -18,14 +18,20 @@ use zeroize::Zeroizing;
const SYNC_TIMEOUT_MS: u64 = 30000;
const MAX_MESSAGE_LEN: usize = 4096;
/// Shared access + refresh token pair. Tokens are zeroized on drop and rotated
/// in place when MSC2918 refresh succeeds.
type TokenPair = Arc<RwLock<(Zeroizing<String>, Option<Zeroizing<String>>)>>;
/// Matrix channel adapter using the Client-Server API.
pub struct MatrixAdapter {
/// Matrix homeserver URL (e.g., `"https://matrix.org"`).
homeserver_url: String,
/// Bot's user ID (e.g., "@openfang:matrix.org").
user_id: String,
/// SECURITY: Access token is zeroized on drop.
access_token: Zeroizing<String>,
/// SECURITY: Access + refresh tokens are zeroized on drop. Stored behind
/// an RwLock so the sync loop and send paths see rotated tokens after a
/// MSC2918 /refresh call (matrix.org/MAS rotates both tokens every refresh).
tokens: TokenPair,
/// HTTP client.
client: reqwest::Client,
/// Allowed room IDs (empty = all joined rooms).
@@ -40,19 +46,47 @@ pub struct MatrixAdapter {
}
impl MatrixAdapter {
/// Create a new Matrix adapter.
/// Create a new Matrix adapter without a refresh token.
pub fn new(
homeserver_url: String,
user_id: String,
access_token: String,
allowed_rooms: Vec<String>,
auto_accept_invites: bool,
) -> Self {
Self::with_refresh_token(
homeserver_url,
user_id,
access_token,
None,
allowed_rooms,
auto_accept_invites,
)
}
/// Create a new Matrix adapter with an optional refresh token (MSC2918).
///
/// When `refresh_token` is `Some`, the adapter will automatically call
/// `POST /_matrix/client/v3/refresh` on `401 M_UNKNOWN_TOKEN` responses
/// and retry the failed request once. Both tokens rotate on each refresh
/// under Matrix Authentication Service (MAS).
pub fn with_refresh_token(
homeserver_url: String,
user_id: String,
access_token: String,
refresh_token: Option<String>,
allowed_rooms: Vec<String>,
auto_accept_invites: bool,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let tokens: TokenPair = Arc::new(RwLock::new((
Zeroizing::new(access_token),
refresh_token.map(Zeroizing::new),
)));
Self {
homeserver_url,
user_id,
access_token: Zeroizing::new(access_token),
tokens,
client: reqwest::Client::new(),
allowed_rooms,
shutdown_tx: Arc::new(shutdown_tx),
@@ -62,6 +96,11 @@ impl MatrixAdapter {
}
}
/// Read the current access token (cloned).
async fn current_access_token(&self) -> String {
self.tokens.read().await.0.as_str().to_string()
}
/// Send a text message to a Matrix room.
async fn api_send_message(
&self,
@@ -81,18 +120,46 @@ impl MatrixAdapter {
"body": chunk,
});
let resp = self
.client
.put(&url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
let mut attempt = 0;
loop {
attempt += 1;
let token = self.current_access_token().await;
let resp = self
.client
.put(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await?;
if resp.status().is_success() {
break;
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("Matrix API error {status}: {body}").into());
let body_text = resp.text().await.unwrap_or_default();
// Try a single refresh+retry on M_UNKNOWN_TOKEN (MSC2918).
if attempt == 1
&& status == reqwest::StatusCode::UNAUTHORIZED
&& is_unknown_token_body(&body_text)
{
match try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens).await
{
Ok(()) => {
info!("Matrix: access token refreshed via MSC2918, retrying send");
continue;
}
Err(e) => {
return Err(format!(
"Matrix API error {status}: {body_text} (refresh failed: {e})"
)
.into());
}
}
}
return Err(format!("Matrix API error {status}: {body_text}").into());
}
}
@@ -103,21 +170,32 @@ impl MatrixAdapter {
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver_url);
let resp = self
.client
.get(&url)
.bearer_auth(&*self.access_token)
.send()
.await?;
let mut attempt = 0;
loop {
attempt += 1;
let token = self.current_access_token().await;
let resp = self.client.get(&url).bearer_auth(&token).send().await?;
if !resp.status().is_success() {
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
return Ok(user_id);
}
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
if attempt == 1
&& status == reqwest::StatusCode::UNAUTHORIZED
&& is_unknown_token_body(&body_text)
&& try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens)
.await
.is_ok()
{
info!("Matrix: access token refreshed via MSC2918, retrying /whoami");
continue;
}
return Err("Matrix authentication failed".into());
}
let body: serde_json::Value = resp.json().await?;
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
Ok(user_id)
}
#[cfg(test)]
@@ -126,6 +204,87 @@ impl MatrixAdapter {
}
}
/// Detect `M_UNKNOWN_TOKEN` errors in a Matrix response body.
///
/// Matrix returns 401 for multiple reasons; we only want to refresh on
/// `M_UNKNOWN_TOKEN` (the access token expired or was revoked). See
/// <https://spec.matrix.org/latest/client-server-api/#soft-logout>.
fn is_unknown_token_body(body: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("errcode").and_then(|c| c.as_str()).map(String::from))
.map(|c| c == "M_UNKNOWN_TOKEN")
.unwrap_or(false)
}
/// Whether a Matrix 401 body indicates a hard logout (operator must re-login).
///
/// `soft_logout: true` (or absent — default per spec) means the device is still
/// known to the server and a refresh-token grant is valid. `soft_logout: false`
/// means the device was invalidated and the operator must perform a new
/// `m.login.password` flow.
fn is_hard_logout(body: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("soft_logout").and_then(|s| s.as_bool()))
.map(|soft| !soft)
.unwrap_or(false)
}
/// Call `POST /_matrix/client/v3/refresh` (MSC2918) and rotate the stored tokens.
///
/// On success, replaces the access token and (if the server returned one) the
/// refresh token. MAS (matrix.org since 2025-04-07) rotates the refresh token
/// on every call, so callers must use the new value next time.
async fn try_refresh_tokens(
client: &reqwest::Client,
homeserver: &str,
tokens: &TokenPair,
) -> Result<(), String> {
let refresh_token = {
let guard = tokens.read().await;
match guard.1.as_ref() {
Some(rt) => rt.as_str().to_string(),
None => return Err("no refresh token configured".to_string()),
}
};
let url = format!("{homeserver}/_matrix/client/v3/refresh");
let resp = client
.post(&url)
.json(&serde_json::json!({ "refresh_token": refresh_token }))
.send()
.await
.map_err(|e| format!("refresh request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("refresh returned {status}: {body}"));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("refresh response parse error: {e}"))?;
let new_access = body
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| "refresh response missing access_token".to_string())?;
let new_refresh = body
.get("refresh_token")
.and_then(|v| v.as_str())
.map(String::from);
let mut guard = tokens.write().await;
guard.0 = Zeroizing::new(new_access.to_string());
if let Some(rt) = new_refresh {
guard.1 = Some(Zeroizing::new(rt));
}
Ok(())
}
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
async fn accept_invite(
client: &reqwest::Client,
@@ -218,7 +377,7 @@ impl ChannelAdapter for MatrixAdapter {
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let homeserver = self.homeserver_url.clone();
let access_token = self.access_token.clone();
let tokens = Arc::clone(&self.tokens);
// Use the validated user ID from /whoami instead of the config value.
// Matrix server delegation or casing differences can cause self.user_id
// to not match the sender field in timeline events, making the bot
@@ -232,9 +391,10 @@ impl ChannelAdapter for MatrixAdapter {
// FIX #4: Do an initial sync to get the since token, skipping old messages.
if since_token.read().await.is_none() {
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
let token = self.current_access_token().await;
if let Some(next) = initial_sync(&client, &homeserver, &token).await {
info!("Matrix: initial sync complete, skipping old messages");
*since_token.write().await = Some(token);
*since_token.write().await = Some(next);
}
}
@@ -257,12 +417,13 @@ impl ChannelAdapter for MatrixAdapter {
url.push_str(&format!("&since={token}"));
}
let current_token = tokens.read().await.0.as_str().to_string();
let resp = tokio::select! {
_ = shutdown_rx.changed() => {
info!("Matrix adapter shutting down");
break;
}
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
result = client.get(&url).bearer_auth(&current_token).send() => {
match result {
Ok(r) => r,
Err(e) => {
@@ -276,7 +437,38 @@ impl ChannelAdapter for MatrixAdapter {
};
if !resp.status().is_success() {
warn!("Matrix sync returned {}", resp.status());
let status = resp.status();
// MSC2918: on 401 M_UNKNOWN_TOKEN with a refresh token configured,
// try refreshing once and loop again immediately. Hard logout
// (soft_logout:false) is unrecoverable here — the operator must
// perform a fresh m.login.password.
if status == reqwest::StatusCode::UNAUTHORIZED {
let body_text = resp.text().await.unwrap_or_default();
if is_unknown_token_body(&body_text) {
if is_hard_logout(&body_text) {
warn!(
"Matrix: hard logout (soft_logout=false), operator must re-login"
);
} else {
match try_refresh_tokens(&client, &homeserver, &tokens).await {
Ok(()) => {
info!(
"Matrix: access token refreshed via MSC2918, resuming /sync"
);
backoff = Duration::from_secs(1);
continue;
}
Err(e) => {
warn!("Matrix: token refresh failed: {e}");
}
}
}
} else {
warn!("Matrix sync returned {status}: {body_text}");
}
} else {
warn!("Matrix sync returned {status}");
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(60));
continue;
@@ -309,8 +501,8 @@ impl ChannelAdapter for MatrixAdapter {
);
continue;
}
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
.await;
let tok = tokens.read().await.0.as_str().to_string();
accept_invite(&client, &homeserver, &tok, room_id).await;
}
}
}
@@ -380,10 +572,11 @@ impl ChannelAdapter for MatrixAdapter {
}
// FIX #3: Determine if room is a DM (2 members) or group.
let tok_for_count = tokens.read().await.0.as_str().to_string();
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
&tok_for_count,
room_id,
)
.await
@@ -409,10 +602,11 @@ impl ChannelAdapter for MatrixAdapter {
}
// FIX #3: Determine if room is a DM (2 members) or group.
let tok_for_count = tokens.read().await.0.as_str().to_string();
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
&tok_for_count,
room_id,
)
.await
@@ -485,10 +679,11 @@ impl ChannelAdapter for MatrixAdapter {
"timeout": 5000,
});
let token = self.current_access_token().await;
let _ = self
.client
.put(&url)
.bearer_auth(&*self.access_token)
.bearer_auth(&token)
.json(&body)
.send()
.await;
@@ -518,6 +713,79 @@ mod tests {
assert_eq!(adapter.name(), "matrix");
}
#[test]
fn test_is_unknown_token_body() {
// Real matrix.org body for M_UNKNOWN_TOKEN under MAS.
let body =
r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Token is not active","soft_logout":true}"#;
assert!(is_unknown_token_body(body));
assert!(!is_hard_logout(body));
let hard = r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Invalidated","soft_logout":false}"#;
assert!(is_unknown_token_body(hard));
assert!(is_hard_logout(hard));
let other = r#"{"errcode":"M_FORBIDDEN","error":"You are not allowed"}"#;
assert!(!is_unknown_token_body(other));
assert!(!is_hard_logout(other));
// Empty / non-JSON must not trigger refresh.
assert!(!is_unknown_token_body(""));
assert!(!is_unknown_token_body("not json"));
}
#[tokio::test]
async fn test_refresh_tokens_rotates_pair() {
// Spin up a tiny axum server that mimics MSC2918 /refresh: rotates both
// access and refresh tokens and returns the new pair.
use axum::{routing::post, Json, Router};
async fn refresh_handler(Json(body): Json<serde_json::Value>) -> Json<serde_json::Value> {
let incoming = body
.get("refresh_token")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(incoming, "old_refresh");
Json(serde_json::json!({
"access_token": "new_access",
"refresh_token": "new_refresh",
"expires_in_ms": 3_600_000u64,
}))
}
let app = Router::new().route("/_matrix/client/v3/refresh", post(refresh_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let homeserver = format!("http://{addr}");
let tokens: TokenPair = Arc::new(RwLock::new((
Zeroizing::new("old_access".to_string()),
Some(Zeroizing::new("old_refresh".to_string())),
)));
let client = reqwest::Client::new();
try_refresh_tokens(&client, &homeserver, &tokens)
.await
.expect("refresh succeeds");
let guard = tokens.read().await;
assert_eq!(guard.0.as_str(), "new_access");
assert_eq!(guard.1.as_ref().map(|s| s.as_str()), Some("new_refresh"));
drop(guard);
// Refresh with no refresh token configured must fail cleanly.
let no_refresh: TokenPair = Arc::new(RwLock::new((Zeroizing::new("a".to_string()), None)));
let err = try_refresh_tokens(&client, &homeserver, &no_refresh)
.await
.unwrap_err();
assert!(err.contains("no refresh token"));
server.abort();
}
#[test]
fn test_matrix_allowed_rooms() {
let adapter = MatrixAdapter::new(
+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
);
+5
View File
@@ -94,6 +94,11 @@ impl RevoltAdapter {
adapter
}
/// Set allowed channel IDs (empty = all channels the bot is in).
pub fn set_allowed_channels(&mut self, channels: Vec<String>) {
self.allowed_channels = channels;
}
/// Add the bot token header to a request builder.
fn auth_header(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("x-bot-token", self.bot_token.as_str())
+146 -1
View File
@@ -18,6 +18,10 @@ pub struct BindingContext {
pub peer_id: String,
/// Guild/server ID.
pub guild_id: Option<String>,
/// Channel/conversation ID (e.g. Discord channel, Slack conversation,
/// Telegram chat, IRC channel name). Populated by bridges so bindings can
/// route by room independent of which user posted.
pub channel_id: Option<String>,
/// User's roles.
pub roles: Vec<String>,
}
@@ -143,6 +147,19 @@ impl AgentRouter {
channel_type: &ChannelType,
platform_user_id: &str,
user_key: Option<&str>,
) -> Option<AgentId> {
self.resolve_with_channel_id(channel_type, platform_user_id, user_key, None)
}
/// Resolve with an explicit channel/conversation ID, so bindings whose
/// `match_rule.channel_id` is set can match. Used by bridges that know the
/// room/conversation the message arrived in (Discord/Slack/Telegram/IRC).
pub fn resolve_with_channel_id(
&self,
channel_type: &ChannelType,
platform_user_id: &str,
user_key: Option<&str>,
channel_id: Option<&str>,
) -> Option<AgentId> {
let channel_key = format!("{channel_type:?}");
@@ -152,6 +169,7 @@ impl AgentRouter {
account_id: None,
peer_id: platform_user_id.to_string(),
guild_id: None,
channel_id: channel_id.map(|s| s.to_string()),
roles: Vec::new(),
};
if let Some(agent_id) = self.resolve_binding(&ctx) {
@@ -329,6 +347,11 @@ impl AgentRouter {
return false;
}
}
if let Some(ref cid) = rule.channel_id {
if ctx.channel_id.as_ref() != Some(cid) {
return false;
}
}
if !rule.roles.is_empty() {
// User must have at least one of the specified roles
let has_role = rule.roles.iter().any(|r| ctx.roles.contains(r));
@@ -639,7 +662,129 @@ mod tests {
guild_id: Some("guild".to_string()),
roles: vec!["admin".to_string()],
account_id: Some("bot".to_string()),
channel_id: Some("ch_42".to_string()),
};
assert_eq!(full.specificity(), 17); // 8+4+2+2+1
assert_eq!(full.specificity(), 25); // 8+8+4+2+2+1
// peer_id alone vs channel_id alone — both worth 8.
let peer_only = BindingMatchRule {
peer_id: Some("u".to_string()),
..Default::default()
};
let channel_id_only = BindingMatchRule {
channel_id: Some("c".to_string()),
..Default::default()
};
assert_eq!(peer_only.specificity(), 8);
assert_eq!(channel_id_only.specificity(), 8);
// Combined peer_id + channel_id (16) outranks either alone (8).
let peer_and_channel = BindingMatchRule {
peer_id: Some("u".to_string()),
channel_id: Some("c".to_string()),
..Default::default()
};
assert_eq!(peer_and_channel.specificity(), 16);
}
#[test]
fn test_binding_channel_id_match() {
// A binding scoped to a specific Discord channel should match messages
// from that channel and reject messages from other channels.
let router = AgentRouter::new();
let agent_id = AgentId::new();
router.register_agent("ops-bot".to_string(), agent_id);
router.load_bindings(&[AgentBinding {
agent: "ops-bot".to_string(),
match_rule: openfang_types::config::BindingMatchRule {
channel: Some("discord".to_string()),
channel_id: Some("1477803840265781391".to_string()),
..Default::default()
},
}]);
// Same channel, any user — matches.
let resolved = router.resolve_with_channel_id(
&ChannelType::Discord,
"any-user",
None,
Some("1477803840265781391"),
);
assert_eq!(resolved, Some(agent_id));
// Different channel — no match.
let resolved = router.resolve_with_channel_id(
&ChannelType::Discord,
"any-user",
None,
Some("9999999999999999999"),
);
assert_eq!(resolved, None);
// Missing channel_id on the wire — no match (the binding is restrictive).
let resolved =
router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
assert_eq!(resolved, None);
}
#[test]
fn test_binding_channel_id_plus_peer_outranks_channel_id_alone() {
// user A in #medical → researcher; anyone else in #medical → general.
let router = AgentRouter::new();
let researcher = AgentId::new();
let general = AgentId::new();
router.register_agent("researcher".to_string(), researcher);
router.register_agent("general".to_string(), general);
router.load_bindings(&[
AgentBinding {
agent: "general".to_string(),
match_rule: openfang_types::config::BindingMatchRule {
channel_id: Some("ch-medical".to_string()),
..Default::default()
},
},
AgentBinding {
agent: "researcher".to_string(),
match_rule: openfang_types::config::BindingMatchRule {
channel_id: Some("ch-medical".to_string()),
peer_id: Some("user-a".to_string()),
..Default::default()
},
},
]);
// user-a in #medical → researcher (more specific wins)
let r = router.resolve_with_channel_id(
&ChannelType::Discord,
"user-a",
None,
Some("ch-medical"),
);
assert_eq!(r, Some(researcher));
// user-b in #medical → general (channel_id alone matches)
let r = router.resolve_with_channel_id(
&ChannelType::Discord,
"user-b",
None,
Some("ch-medical"),
);
assert_eq!(r, Some(general));
}
#[test]
fn test_binding_match_rule_unknown_field_rejected() {
// Typos like `channnel_id` must fail loudly at deserialization rather
// than silently producing a wide-open binding. This is the highest-
// leverage line in the patch from issue #1127.
let bad = r#"{ "channnel_id": "ch-1" }"#;
let r: Result<openfang_types::config::BindingMatchRule, _> = serde_json::from_str(bad);
assert!(r.is_err(), "unknown field must be rejected by serde");
// Sanity: known fields still parse.
let good = r#"{ "channel_id": "ch-1", "channel": "discord" }"#;
let r: openfang_types::config::BindingMatchRule = serde_json::from_str(good).unwrap();
assert_eq!(r.channel_id.as_deref(), Some("ch-1"));
assert_eq!(r.channel.as_deref(), Some("discord"));
}
}
+102
View File
@@ -21,6 +21,38 @@ const SLACK_API_BASE: &str = "https://slack.com/api";
const MAX_BACKOFF: Duration = Duration::from_secs(60);
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const SLACK_MSG_LIMIT: usize = 3000;
/// TTL for envelope_id dedup entries. Well above the typical Slack
/// connection-rotation overlap window (< 10s).
const ENVELOPE_TTL: Duration = Duration::from_secs(60);
/// Soft cap on the dedup cache size. When exceeded we GC expired entries.
/// Recent envelope IDs are not reused by Slack, so 10k is more than enough.
const ENVELOPE_CACHE_CAP: usize = 10_000;
/// Returns true if `envelope_id` was already seen within `ENVELOPE_TTL`.
/// On first sight, records the timestamp and returns false. Performs
/// opportunistic GC of expired entries when the cache grows large.
///
/// Slack Socket Mode delivers the same event to multiple active WebSocket
/// connections during connection rotation. Apps must dedupe on `envelope_id`
/// to avoid double-processing.
fn is_duplicate_envelope(cache: &DashMap<String, Instant>, envelope_id: &str) -> bool {
if envelope_id.is_empty() {
return false;
}
// Opportunistic GC: bound growth without per-call work.
if cache.len() > ENVELOPE_CACHE_CAP {
cache.retain(|_, ts| ts.elapsed() < ENVELOPE_TTL);
}
if let Some(prev) = cache.get(envelope_id) {
if prev.elapsed() < ENVELOPE_TTL {
return true;
}
}
cache.insert(envelope_id.to_string(), Instant::now());
false
}
/// Slack Socket Mode adapter.
pub struct SlackAdapter {
@@ -41,6 +73,9 @@ pub struct SlackAdapter {
auto_thread_reply: bool,
/// Whether to unfurl (expand previews for) links in posted messages.
unfurl_links: bool,
/// Recently-seen envelope_ids. Slack Socket Mode redelivers the same event
/// across rotated WebSocket connections; this prevents double-processing.
seen_envelopes: Arc<DashMap<String, Instant>>,
}
impl SlackAdapter {
@@ -65,6 +100,7 @@ impl SlackAdapter {
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
auto_thread_reply,
unfurl_links,
seen_envelopes: Arc::new(DashMap::new()),
}
}
@@ -161,6 +197,7 @@ impl ChannelAdapter for SlackAdapter {
let mut shutdown = self.shutdown_rx.clone();
let active_threads = self.active_threads.clone();
let auto_thread_reply = self.auto_thread_reply;
let seen_envelopes = self.seen_envelopes.clone();
// Spawn periodic cleanup of expired thread entries.
{
@@ -288,6 +325,14 @@ impl ChannelAdapter for SlackAdapter {
}
}
// Dedup: Slack redelivers the same event on the new
// connection during the rotation overlap. Ack on
// both, but only forward to the agent once.
if is_duplicate_envelope(&seen_envelopes, envelope_id) {
debug!("Slack: skipping duplicate envelope_id {envelope_id}");
continue;
}
// Extract the event
let event = &payload["payload"]["event"];
if let Some(msg) = parse_slack_event(
@@ -501,6 +546,9 @@ async fn parse_slack_event(
// Check if the bot was @-mentioned (for group_policy = "mention_only")
let mut metadata = HashMap::new();
// Stash the Slack user ID so the router can key bindings on user, not channel.
// (`sender.platform_id` below is the channel ID, used for the send path.)
metadata.insert("sender_user_id".to_string(), serde_json::json!(user_id));
if event_type == "app_mention" {
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
}
@@ -742,4 +790,58 @@ mod tests {
);
assert!(!adapter.unfurl_links);
}
#[test]
fn test_envelope_dedup_skips_second_delivery() {
// Simulates Slack redelivering the same event across a connection
// rotation: the envelope is acked on both connections but the agent
// must only see it once.
let cache: DashMap<String, Instant> = DashMap::new();
let envelope_id = "8d2e1c5a-4f3b-49a1-b6e2-7c0a9f1234ab";
// First delivery on the old connection: not a duplicate, forward.
assert!(
!is_duplicate_envelope(&cache, envelope_id),
"first sight of envelope must not be flagged as duplicate"
);
// Second delivery on the new connection: duplicate, skip.
assert!(
is_duplicate_envelope(&cache, envelope_id),
"second sight of same envelope must be flagged as duplicate"
);
// Simulate the receive-loop pattern: count how many times the agent
// would actually be invoked across two deliveries.
let mut agent_invocations = 0;
for _delivery in 0..2 {
if !is_duplicate_envelope(&cache, envelope_id) {
agent_invocations += 1;
}
}
assert_eq!(
agent_invocations, 0,
"after initial double-delivery, no further invocations should occur within TTL"
);
}
#[test]
fn test_envelope_dedup_distinct_ids_pass_through() {
let cache: DashMap<String, Instant> = DashMap::new();
assert!(!is_duplicate_envelope(&cache, "envelope-a"));
assert!(!is_duplicate_envelope(&cache, "envelope-b"));
assert!(!is_duplicate_envelope(&cache, "envelope-c"));
// Each unique envelope_id should be seen exactly once.
assert_eq!(cache.len(), 3);
}
#[test]
fn test_envelope_dedup_empty_id_never_dedupes() {
// Defensive: malformed payloads with no envelope_id should not poison
// the cache or short-circuit forwarding.
let cache: DashMap<String, Instant> = DashMap::new();
assert!(!is_duplicate_envelope(&cache, ""));
assert!(!is_duplicate_envelope(&cache, ""));
assert_eq!(cache.len(), 0);
}
}
File diff suppressed because it is too large Load Diff
+116
View File
@@ -50,6 +50,16 @@ pub enum ChannelContent {
File {
url: String,
filename: String,
/// Best-effort MIME type from the source platform (e.g. Discord's
/// `attachments[].content_type`). `None` if the platform did not
/// provide one; downstream consumers may sniff bytes or fall back
/// to extension-based detection.
#[serde(default, skip_serializing_if = "Option::is_none")]
mime: Option<String>,
/// Size in bytes, when known. Useful for capacity gating before
/// the bridge attempts to materialize or transmit the file.
#[serde(default, skip_serializing_if = "Option::is_none")]
size: Option<u64>,
},
/// Local file data (bytes read from disk). Used by the proactive `channel_send`
/// tool when `file_path` is provided instead of `file_url`.
@@ -70,6 +80,12 @@ pub enum ChannelContent {
name: String,
args: Vec<String>,
},
/// A composite message carrying multiple content blocks (e.g. a Discord
/// message with several attachments, or an image with a separate file
/// sibling). Blocks are flat-mapped by the bridge into multiple LLM
/// content blocks. Implementations should not produce nested `Multipart`
/// values; consumers may `debug_assert!` against nesting.
Multipart(Vec<ChannelContent>),
}
/// A unified message from any channel.
@@ -97,6 +113,60 @@ pub struct ChannelMessage {
pub metadata: HashMap<String, serde_json::Value>,
}
// Re-export the adapter allowlist from openfang-types so config validation
// and routing share a single source of truth (no drift between the two).
pub use openfang_types::config::CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL;
impl ChannelMessage {
/// Return the platform-native channel/conversation ID for this message,
/// suitable for matching against an `AgentBinding`'s `channel_id` field.
///
/// Resolution order:
/// 1. For adapters in [`CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL`],
/// `sender.platform_id` already *is* the channel ID (these adapters
/// overload the field because it doubles as the send target).
/// 2. Otherwise, fall back to `metadata["channel_id"]` if present (any
/// adapter can opt in by populating that key).
/// 3. Otherwise, `None`.
///
/// This is the central routing-time accessor — config validation and the
/// router both consult it (directly or via the same allowlist) so the two
/// cannot drift.
pub fn channel_id(&self) -> Option<String> {
// For builtin variants the string is already lowercase by construction.
// For `Custom(s)`, adapters _should_ register lowercase names but we
// case-fold here so a stray `Custom("Twitch")` cannot silently slip
// past the allowlist (and out of step with the validation path, which
// already lowercases user input). Allocates only on the Custom arm.
let channel_str: std::borrow::Cow<'_, str> = match &self.channel {
ChannelType::Telegram => "telegram".into(),
ChannelType::Discord => "discord".into(),
ChannelType::Slack => "slack".into(),
ChannelType::WhatsApp => "whatsapp".into(),
ChannelType::Signal => "signal".into(),
ChannelType::Matrix => "matrix".into(),
ChannelType::Email => "email".into(),
ChannelType::Teams => "teams".into(),
ChannelType::Mattermost => "mattermost".into(),
ChannelType::WebChat => "webchat".into(),
ChannelType::CLI => "cli".into(),
ChannelType::Mqtt => "mqtt".into(),
ChannelType::Custom(s) => s.to_lowercase().into(),
};
if CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL
.iter()
.any(|c| *c == channel_str.as_ref())
{
Some(self.sender.platform_id.clone())
} else {
self.metadata
.get("channel_id")
.and_then(|v| v.as_str())
.map(String::from)
}
}
}
/// Agent lifecycle phase for UX indicators.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
@@ -271,6 +341,24 @@ pub trait ChannelAdapter: Send + Sync {
self.send(user, content).await
}
/// Determine whether to auto-create a thread for an incoming message.
/// Returns Some(thread_name) to create a thread, or None to reply directly.
/// Default implementation returns None (no auto-threading).
async fn should_auto_thread(&self, _message: &ChannelMessage) -> Option<String> {
None
}
/// Create a new thread (typically triggered after should_auto_thread returns Some).
/// Returns the new thread ID on success.
async fn create_thread(
&self,
_user: &ChannelUser,
_message_id: &str,
_thread_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
Err("Thread creation not supported for this adapter".into())
}
/// Whether this adapter should suppress sending internal agent errors back to the user.
///
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
@@ -365,6 +453,34 @@ mod tests {
assert_eq!(back, ChannelType::Email);
}
#[test]
fn test_channel_id_custom_arm_is_case_insensitive() {
// A stray capitalized Custom variant must still resolve through the
// allowlist. The validation path lowercases user input; the routing
// path needs the same case-fold to stay in sync.
let make = |name: &str| ChannelMessage {
channel: ChannelType::Custom(name.to_string()),
platform_message_id: "m".to_string(),
sender: ChannelUser {
platform_id: "C123".to_string(),
display_name: "x".to_string(),
openfang_user: None,
},
content: ChannelContent::Text("hi".to_string()),
target_agent: None,
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: HashMap::new(),
};
assert_eq!(make("twitch").channel_id().as_deref(), Some("C123"));
assert_eq!(make("Twitch").channel_id().as_deref(), Some("C123"));
assert_eq!(make("TWITCH").channel_id().as_deref(), Some("C123"));
// Lark spelling (Feishu Intl) must also match.
assert_eq!(make("lark").channel_id().as_deref(), Some("C123"));
assert_eq!(make("Lark").channel_id().as_deref(), Some("C123"));
}
#[test]
fn test_channel_content_variants() {
let text = ChannelContent::Text("hello".to_string());
+1 -1
View File
@@ -271,7 +271,7 @@ impl ChannelAdapter for WhatsAppAdapter {
return Err(format!("WhatsApp API error {status}: {body}").into());
}
}
ChannelContent::File { url, filename } => {
ChannelContent::File { url, filename, .. } => {
let body = serde_json::json!({
"messaging_product": "whatsapp",
"to": user.platform_id,
+470 -18
View File
@@ -440,6 +440,27 @@ enum HandCommands {
/// Instance ID (from `hand active`).
id: String,
},
/// Get, set, or list settings for an active hand instance.
///
/// With no flags, prints the current settings. Use `--set KEY=VAL`
/// (repeatable) to update values, `--unset KEY` to remove a value,
/// or `--get KEY` to print a single value.
Config {
/// Hand ID (e.g. "browser", "clip").
id: String,
/// Print a single setting value.
#[arg(long, value_name = "KEY", conflicts_with_all = ["set", "unset", "list"])]
get: Option<String>,
/// Set a setting value. Format: `KEY=VALUE`. May be repeated.
#[arg(long, value_name = "KEY=VALUE")]
set: Vec<String>,
/// Unset a setting key. May be repeated.
#[arg(long, value_name = "KEY")]
unset: Vec<String>,
/// List the current settings (default when no other flag is given).
#[arg(long)]
list: bool,
},
}
#[derive(Subcommand)]
@@ -1010,6 +1031,13 @@ fn main() {
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
HandCommands::Pause { id } => cmd_hand_pause(&id),
HandCommands::Resume { id } => cmd_hand_resume(&id),
HandCommands::Config {
id,
get,
set,
unset,
list,
} => cmd_hand_config(&id, get.as_deref(), &set, &unset, list),
},
Some(Commands::Config(sub)) => match sub {
ConfigCommands::Show => cmd_config_show(),
@@ -1448,9 +1476,30 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
"openrouter/google/gemini-2.5-flash",
"OpenRouter",
),
("minimax", "MINIMAX_API_KEY", "MiniMax-M2.7", "MiniMax"),
]
}
#[cfg(test)]
mod provider_list_tests {
use super::provider_list;
#[test]
fn provider_list_includes_minimax() {
let minimax = provider_list()
.into_iter()
.find(|(provider, _, _, _)| *provider == "minimax");
assert!(
minimax.is_some(),
"MiniMax should be exposed by provider_list()"
);
let (_, env_var, model, display) = minimax.unwrap();
assert_eq!(env_var, "MINIMAX_API_KEY");
assert_eq!(model, "MiniMax-M2.7");
assert_eq!(display, "MiniMax");
}
}
/// Quick probe to check if Ollama is running on localhost.
fn check_ollama_available() -> bool {
std::net::TcpStream::connect_timeout(
@@ -2429,6 +2478,7 @@ decay_rate = 0.05
("TOGETHER_API_KEY", "Together", "together"),
("MISTRAL_API_KEY", "Mistral", "mistral"),
("FIREWORKS_API_KEY", "Fireworks", "fireworks"),
("AWS_BEARER_TOKEN_BEDROCK", "AWS Bedrock", "bedrock"),
];
let mut any_key_set = false;
@@ -2457,6 +2507,20 @@ decay_rate = 0.05
}
}
// Check GitHub Copilot auth (separate from env var checks)
{
let openfang_dir = cli_openfang_home();
if openfang_runtime::drivers::copilot::copilot_auth_available(&openfang_dir) {
any_key_set = true;
if !json {
ui::check_ok("GitHub Copilot (authenticated via device flow)");
}
checks.push(
serde_json::json!({"check": "provider", "name": "GitHub Copilot", "status": "ok"}),
);
}
}
if !any_key_set {
if !json {
println!();
@@ -4550,6 +4614,167 @@ fn cmd_hand_resume(id: &str) {
}
}
/// Parse a `KEY=VALUE` pair passed to `--set`.
///
/// Empty keys are rejected so `--set =foo` or `--set =bar` surface a clear
/// error rather than silently writing a blank setting name.
fn parse_hand_config_pair(pair: &str) -> Result<(String, String), String> {
let (key, value) = pair
.split_once('=')
.ok_or_else(|| format!("Invalid --set '{pair}': expected KEY=VALUE"))?;
let key = key.trim();
if key.is_empty() {
return Err(format!("Invalid --set '{pair}': empty key"));
}
Ok((key.to_string(), value.to_string()))
}
fn cmd_hand_config(
id: &str,
get: Option<&str>,
set_pairs: &[String],
unset_keys: &[String],
list: bool,
) {
let base = require_daemon("hand config");
let client = daemon_client();
// Always fetch current state first so we can merge updates and print
// a useful view even when the target hand has no active instance.
let url = format!("{base}/api/hands/{id}/settings");
let body = daemon_json(client.get(&url).send());
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
ui::error(&format!("Hand '{id}': {err}"));
std::process::exit(1);
}
let mut current: std::collections::BTreeMap<String, serde_json::Value> = body
.get("current_values")
.and_then(|v| v.as_object())
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
let schema_defaults: std::collections::BTreeMap<String, String> = body
.get("settings")
.and_then(|v| v.get("settings"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| {
let key = s.get("key").and_then(|v| v.as_str())?.to_string();
let default = s
.get("default")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some((key, default))
})
.collect()
})
.unwrap_or_default();
// Pure read paths — no mutation, no daemon round-trip beyond the GET.
if let Some(key) = get {
match current
.get(key)
.map(value_to_display)
.or_else(|| schema_defaults.get(key).cloned())
{
Some(val) => println!("{val}"),
None => {
ui::error(&format!("No setting '{key}' on hand '{id}'"));
std::process::exit(1);
}
}
return;
}
let is_mutation = !set_pairs.is_empty() || !unset_keys.is_empty();
if !is_mutation {
print_hand_config(id, &current, &schema_defaults, list);
return;
}
for pair in set_pairs {
match parse_hand_config_pair(pair) {
Ok((k, v)) => {
current.insert(k, serde_json::Value::String(v));
}
Err(e) => {
ui::error(&e);
std::process::exit(1);
}
}
}
for key in unset_keys {
let key = key.trim();
if key.is_empty() {
ui::error("Invalid --unset: empty key");
std::process::exit(1);
}
current.remove(key);
}
let payload: serde_json::Map<String, serde_json::Value> = current.clone().into_iter().collect();
let resp = daemon_json(
client
.put(&url)
.json(&serde_json::Value::Object(payload))
.send(),
);
if let Some(err) = resp.get("error").and_then(|v| v.as_str()) {
ui::error(&format!("Failed to update hand '{id}' settings: {err}"));
if err.contains("No active instance") {
ui::hint(&format!(
"Activate the hand first: openfang hand activate {id}"
));
}
std::process::exit(1);
}
ui::success(&format!("Updated settings for hand '{id}'."));
print_hand_config(id, &current, &schema_defaults, true);
}
/// Human-readable display for a JSON setting value.
fn value_to_display(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}
}
fn print_hand_config(
id: &str,
current: &std::collections::BTreeMap<String, serde_json::Value>,
schema_defaults: &std::collections::BTreeMap<String, String>,
_list: bool,
) {
if current.is_empty() && schema_defaults.is_empty() {
println!("No settings configured for hand '{id}'.");
return;
}
println!("Settings for hand '{id}':");
let mut keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for k in current.keys() {
keys.insert(k.as_str());
}
for k in schema_defaults.keys() {
keys.insert(k.as_str());
}
for key in keys {
match current.get(key) {
Some(v) => println!(" {key} = {}", value_to_display(v)),
None => {
let default = schema_defaults.get(key).map(|s| s.as_str()).unwrap_or("");
println!(" {key} = {default} (default)");
}
}
}
}
// ---------------------------------------------------------------------------
// Provider / API key helpers
// ---------------------------------------------------------------------------
@@ -4621,6 +4846,9 @@ pub(crate) fn test_api_key(provider: &str, env_var: &str) -> bool {
.get("https://openrouter.ai/api/v1/models")
.bearer_auth(&key)
.send(),
// Bedrock bearer tokens are only valid against bedrock-runtime, not the
// management plane. There is no cheap region-agnostic probe, so skip.
"bedrock" => return true,
_ => return true, // unknown provider — skip test
};
@@ -4733,6 +4961,38 @@ fn cmd_config_edit() {
}
}
/// Outcome of looking up a dotted key path in a parsed TOML config.
#[derive(Debug, PartialEq)]
enum ConfigGetOutcome {
/// Scalar value formatted for display (may be the empty string).
Value(String),
/// Key exists but resolves to a non-scalar (table or array).
NonScalar,
/// Key path does not exist.
NotFound,
}
/// Look up a dotted key path inside a parsed TOML document and format the
/// resulting scalar for display. Pure function so the behaviour can be tested
/// without touching the filesystem.
fn lookup_config_value(table: &toml::Value, key: &str) -> ConfigGetOutcome {
let mut current = table;
for part in key.split('.') {
match current.get(part) {
Some(v) => current = v,
None => return ConfigGetOutcome::NotFound,
}
}
match current {
toml::Value::String(s) => ConfigGetOutcome::Value(s.clone()),
toml::Value::Integer(i) => ConfigGetOutcome::Value(i.to_string()),
toml::Value::Float(f) => ConfigGetOutcome::Value(f.to_string()),
toml::Value::Boolean(b) => ConfigGetOutcome::Value(b.to_string()),
toml::Value::Datetime(d) => ConfigGetOutcome::Value(d.to_string()),
toml::Value::Array(_) | toml::Value::Table(_) => ConfigGetOutcome::NonScalar,
}
}
fn cmd_config_get(key: &str) {
let home = openfang_home();
let config_path = home.join("config.toml");
@@ -4755,25 +5015,19 @@ fn cmd_config_get(key: &str) {
std::process::exit(1);
});
// Navigate dotted path
let mut current = &table;
for part in key.split('.') {
match current.get(part) {
Some(v) => current = v,
None => {
ui::error(&format!("Key not found: {key}"));
std::process::exit(1);
}
match lookup_config_value(&table, key) {
ConfigGetOutcome::Value(s) => println!("{s}"),
ConfigGetOutcome::NonScalar => {
ui::error_with_fix(
&format!("'{key}' is a section, not a scalar value"),
"Use a deeper dotted key (e.g. `section.field`)",
);
std::process::exit(1);
}
ConfigGetOutcome::NotFound => {
ui::error(&format!("Key not found: {key}"));
std::process::exit(1);
}
}
// Print value
match current {
toml::Value::String(s) => println!("{s}"),
toml::Value::Integer(i) => println!("{i}"),
toml::Value::Float(f) => println!("{f}"),
toml::Value::Boolean(b) => println!("{b}"),
other => println!("{other}"),
}
}
@@ -4969,6 +5223,29 @@ fn cmd_config_unset(key: &str) {
}
fn cmd_config_set_key(provider: &str) {
// GitHub Copilot uses OAuth device flow, not a simple API key paste.
if provider == "github-copilot" || provider == "copilot" {
let openfang_dir = cli_openfang_home();
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
ui::error(&format!("Failed to create async runtime: {e}"));
std::process::exit(1);
});
match rt.block_on(openfang_runtime::drivers::copilot::run_interactive_setup(
&openfang_dir,
)) {
Ok(_) => {
ui::success("GitHub Copilot configured successfully");
ui::hint("Restart the daemon: openfang stop && openfang start");
}
Err(e) => {
ui::error(&format!("Copilot setup failed: {e}"));
ui::hint("Check your Client ID/Secret and try again");
std::process::exit(1);
}
}
return;
}
let env_var = provider_to_env_var(provider);
let key = prompt_input(&format!(" Paste your {provider} API key: "));
@@ -6984,8 +7261,183 @@ args = ["-y", "@modelcontextprotocol/server-github"]
assert_eq!(events.len(), 4);
}
// --- Config get command unit tests ---
fn sample_config_with_base_url() -> &'static str {
r#"api_listen = "127.0.0.1:4200"
[default_model]
provider = "openai"
model = "qwen3-coder-30b/qwen3-coder-30b"
api_key_env = "OPENAI_API_KEY"
base_url = "http://localhost:8991/v1"
api_key = "sk-bf-test"
[memory]
decay_rate = 0.05
"#
}
fn lookup(toml_str: &str, key: &str) -> super::ConfigGetOutcome {
let table: toml::Value = toml::from_str(toml_str).expect("valid toml");
super::lookup_config_value(&table, key)
}
// Regression test for issue #905: `config get default_model.base_url`
// must return the configured base_url string, not an empty string.
#[test]
fn config_get_returns_default_model_base_url() {
let out = lookup(sample_config_with_base_url(), "default_model.base_url");
assert_eq!(
out,
super::ConfigGetOutcome::Value("http://localhost:8991/v1".to_string())
);
}
#[test]
fn config_get_returns_each_default_model_scalar() {
let cfg = sample_config_with_base_url();
assert_eq!(
lookup(cfg, "default_model.provider"),
super::ConfigGetOutcome::Value("openai".to_string())
);
assert_eq!(
lookup(cfg, "default_model.model"),
super::ConfigGetOutcome::Value("qwen3-coder-30b/qwen3-coder-30b".to_string())
);
assert_eq!(
lookup(cfg, "default_model.api_key_env"),
super::ConfigGetOutcome::Value("OPENAI_API_KEY".to_string())
);
assert_eq!(
lookup(cfg, "default_model.api_key"),
super::ConfigGetOutcome::Value("sk-bf-test".to_string())
);
}
#[test]
fn config_get_top_level_scalar() {
assert_eq!(
lookup(sample_config_with_base_url(), "api_listen"),
super::ConfigGetOutcome::Value("127.0.0.1:4200".to_string())
);
}
#[test]
fn config_get_unset_base_url_is_not_found() {
let cfg = r#"
[default_model]
provider = "openai"
model = "gpt-4o"
api_key_env = "OPENAI_API_KEY"
"#;
assert_eq!(
lookup(cfg, "default_model.base_url"),
super::ConfigGetOutcome::NotFound
);
}
#[test]
fn config_get_explicit_empty_string_round_trips_as_empty() {
let cfg = r#"
[default_model]
provider = "openai"
base_url = ""
"#;
assert_eq!(
lookup(cfg, "default_model.base_url"),
super::ConfigGetOutcome::Value(String::new())
);
}
#[test]
fn config_get_missing_key_returns_not_found() {
assert_eq!(
lookup(sample_config_with_base_url(), "default_model.nope"),
super::ConfigGetOutcome::NotFound
);
}
#[test]
fn config_get_section_reports_non_scalar() {
assert_eq!(
lookup(sample_config_with_base_url(), "default_model"),
super::ConfigGetOutcome::NonScalar
);
}
#[test]
fn config_get_numeric_and_boolean_scalars() {
let cfg = r#"
retries = 3
ratio = 0.25
enabled = true
"#;
assert_eq!(
lookup(cfg, "retries"),
super::ConfigGetOutcome::Value("3".to_string())
);
assert_eq!(
lookup(cfg, "ratio"),
super::ConfigGetOutcome::Value("0.25".to_string())
);
assert_eq!(
lookup(cfg, "enabled"),
super::ConfigGetOutcome::Value("true".to_string())
);
}
// --- Uninstall command unit tests ---
// --- hand config command unit tests ---
#[test]
fn test_hand_config_parse_pair_ok() {
let (k, v) = super::parse_hand_config_pair("headless=true").unwrap();
assert_eq!(k, "headless");
assert_eq!(v, "true");
}
#[test]
fn test_hand_config_parse_pair_value_may_contain_equals() {
let (k, v) = super::parse_hand_config_pair("url=https://example.com?a=b").unwrap();
assert_eq!(k, "url");
assert_eq!(v, "https://example.com?a=b");
}
#[test]
fn test_hand_config_parse_pair_value_may_be_empty() {
// Empty values are valid (useful to explicitly blank a setting before
// PUT). Empty keys are the failure case.
let (k, v) = super::parse_hand_config_pair("foo=").unwrap();
assert_eq!(k, "foo");
assert_eq!(v, "");
}
#[test]
fn test_hand_config_parse_pair_rejects_empty_key() {
assert!(super::parse_hand_config_pair("=bar").is_err());
assert!(super::parse_hand_config_pair(" =bar").is_err());
}
#[test]
fn test_hand_config_parse_pair_requires_equals() {
assert!(super::parse_hand_config_pair("headless").is_err());
}
#[test]
fn test_hand_config_parse_multiple_pairs_round_trip() {
let inputs = ["a=1", "b=two", "c=http://x.y"];
let mut map = std::collections::BTreeMap::new();
for pair in inputs {
let (k, v) = super::parse_hand_config_pair(pair).unwrap();
map.insert(k, v);
}
assert_eq!(map.get("a"), Some(&"1".to_string()));
assert_eq!(map.get("b"), Some(&"two".to_string()));
assert_eq!(map.get("c"), Some(&"http://x.y".to_string()));
}
#[test]
fn test_uninstall_path_line_filter() {
use super::is_openfang_path_line;
+96
View File
@@ -121,6 +121,11 @@ pub enum AppEvent {
SkillUninstalled(String),
/// MCP servers loaded.
McpServersLoaded(Vec<McpServerInfo>),
/// Skill config details loaded (installed skill `c` key).
SkillConfigLoaded {
skill: String,
rows: Vec<crate::tui::screens::skills::SkillConfigVarDetail>,
},
/// Templates providers loaded (auth status).
TemplateProvidersLoaded(Vec<ProviderAuth>),
/// Security features loaded.
@@ -1439,6 +1444,14 @@ pub fn spawn_fetch_skills(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
.as_str()
.unwrap_or("")
.to_string(),
config_declared: s["config_declared_count"]
.as_u64()
.unwrap_or(0)
as usize,
config_resolved: s["config_resolved_count"]
.as_u64()
.unwrap_or(0)
as usize,
})
.collect()
})
@@ -1604,6 +1617,89 @@ pub fn spawn_fetch_mcp_servers(backend: BackendRef, tx: mpsc::Sender<AppEvent>)
});
}
/// Fetch declared + resolved config for a specific installed skill.
///
/// Pulls `GET /api/skills/{id}/config` and flattens the response into the
/// `SkillConfigVarDetail` rows that the TUI details pane renders. Secret
/// values are already redacted by the daemon so nothing sensitive crosses
/// the wire here.
pub fn spawn_fetch_skill_config(
backend: BackendRef,
skill_name: String,
tx: mpsc::Sender<AppEvent>,
) {
use crate::tui::screens::skills::SkillConfigVarDetail;
std::thread::spawn(move || match backend {
BackendRef::Daemon(base_url) => {
let client = daemon_client();
let encoded: String = skill_name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
c.to_string()
} else {
format!("%{:02X}", c as u32)
}
})
.collect();
let url = format!("{base_url}/api/skills/{encoded}/config");
if let Ok(resp) = client.get(&url).send() {
if let Ok(body) = resp.json::<serde_json::Value>() {
let declared = body.get("declared").cloned().unwrap_or_default();
let resolved = body.get("resolved").cloned().unwrap_or_default();
let mut rows: Vec<SkillConfigVarDetail> = Vec::new();
if let Some(obj) = declared.as_object() {
// Sort keys for deterministic output.
let mut keys: Vec<&String> = obj.keys().collect();
keys.sort();
for k in keys {
let d = &obj[k];
let r = resolved.get(k).cloned().unwrap_or_default();
let value_hint = r
.get("value")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
rows.push(SkillConfigVarDetail {
name: k.clone(),
description: d
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
required: d
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false),
source: r
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("unresolved")
.to_string(),
value_hint,
is_secret: r
.get("is_secret")
.and_then(|v| v.as_bool())
.unwrap_or(false),
});
}
}
let _ = tx.send(AppEvent::SkillConfigLoaded {
skill: skill_name,
rows,
});
}
}
}
BackendRef::InProcess(_) => {
let _ = tx.send(AppEvent::SkillConfigLoaded {
skill: skill_name,
rows: Vec::new(),
});
}
});
}
/// Fetch provider auth status for templates screen.
pub fn spawn_fetch_template_providers(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
std::thread::spawn(move || match backend {
+23 -17
View File
@@ -11,6 +11,7 @@ use event::{AppEvent, BackendRef};
use openfang_kernel::OpenFangKernel;
use openfang_runtime::llm_driver::StreamEvent;
use openfang_types::agent::AgentId;
use openfang_types::commands::{self, Surfaces};
use screens::{
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
@@ -434,6 +435,10 @@ impl App {
}
self.skills.loading = false;
}
AppEvent::SkillConfigLoaded { skill, rows } => {
self.skills.selected_config_details = Some((skill.clone(), rows));
self.skills.status_msg = format!("Loaded config for '{skill}'");
}
AppEvent::TemplateProvidersLoaded(providers) => {
self.templates.providers = providers;
}
@@ -1562,6 +1567,11 @@ impl App {
event::spawn_fetch_mcp_servers(backend, self.event_tx.clone());
}
}
skills::SkillsAction::LoadSkillConfig(name) => {
if let Some(backend) = self.backend.to_ref() {
event::spawn_fetch_skill_config(backend, name, self.event_tx.clone());
}
}
}
}
@@ -2003,23 +2013,18 @@ impl App {
fn handle_slash_command(&mut self, cmd: &str) {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
match parts[0] {
"/exit" | "/quit" => self.handle_chat_action(chat::ChatAction::Back),
// Canonicalise through the unified command registry: `/quit` -> `exit`,
// `/NEW` -> `new`, etc. Unregistered commands fall through unchanged so
// this refactor does not break any existing surface-specific behaviour.
let canonical_head: String = commands::resolve(parts[0])
.filter(|def| def.surfaces.contains(Surfaces::CLI))
.map(|def| format!("/{}", def.name))
.unwrap_or_else(|| parts[0].to_string());
match canonical_head.as_str() {
"/exit" => self.handle_chat_action(chat::ChatAction::Back),
"/help" => {
self.chat.push_message(
chat::Role::System,
[
"/help \u{2014} show this help",
"/model \u{2014} open model picker (Ctrl+M)",
"/model <name> \u{2014} switch to model directly",
"/status \u{2014} connection & agent info",
"/agents \u{2014} list running agents",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent",
"/exit \u{2014} end chat session",
]
.join("\n"),
);
self.chat
.push_message(chat::Role::System, commands::render_help(Surfaces::CLI));
}
"/status" => {
let mut s = Vec::new();
@@ -2185,9 +2190,10 @@ impl App {
}
},
_ => {
let help = commands::render_help(Surfaces::CLI);
self.chat.push_message(
chat::Role::System,
format!("Unknown command: {}. Type /help", parts[0]),
format!("Unknown command: {}\n\n{}", parts[0], help),
);
}
}
+36 -66
View File
@@ -576,13 +576,11 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::CreateMethod;
}
KeyCode::Enter => {
if !self.custom_name.is_empty() {
if self.custom_desc.is_empty() {
self.custom_desc = format!("A custom {} agent", self.custom_name);
}
self.sub = AgentSubScreen::CustomDesc;
KeyCode::Enter if !self.custom_name.is_empty() => {
if self.custom_desc.is_empty() {
self.custom_desc = format!("A custom {} agent", self.custom_name);
}
self.sub = AgentSubScreen::CustomDesc;
}
KeyCode::Char(c) => {
self.custom_name.push(c);
@@ -641,15 +639,11 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::CustomPrompt;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.tool_cursor > 0 {
self.tool_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.tool_cursor > 0 => {
self.tool_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if self.tool_cursor < TOOL_OPTIONS.len() - 1 {
self.tool_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if self.tool_cursor < TOOL_OPTIONS.len() - 1 => {
self.tool_cursor += 1;
}
KeyCode::Char(' ') => {
self.tool_checks[self.tool_cursor] = !self.tool_checks[self.tool_cursor];
@@ -674,21 +668,15 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::CustomTools;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.skill_cursor > 0 {
self.skill_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
self.skill_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 && self.skill_cursor < len - 1 {
self.skill_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
self.skill_cursor += 1;
}
KeyCode::Char(' ') => {
if len > 0 {
let checked = &mut self.available_skills[self.skill_cursor].1;
*checked = !*checked;
}
KeyCode::Char(' ') if len > 0 => {
let checked = &mut self.available_skills[self.skill_cursor].1;
*checked = !*checked;
}
KeyCode::Enter => {
// Advance to MCP server selection
@@ -706,21 +694,15 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::CustomSkills;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.mcp_cursor > 0 {
self.mcp_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
self.mcp_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 && self.mcp_cursor < len - 1 {
self.mcp_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
self.mcp_cursor += 1;
}
KeyCode::Char(' ') => {
if len > 0 {
let checked = &mut self.available_mcp[self.mcp_cursor].1;
*checked = !*checked;
}
KeyCode::Char(' ') if len > 0 => {
let checked = &mut self.available_mcp[self.mcp_cursor].1;
*checked = !*checked;
}
KeyCode::Enter => {
let toml = self.build_custom_toml();
@@ -737,21 +719,15 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::AgentDetail;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.skill_cursor > 0 {
self.skill_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
self.skill_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 && self.skill_cursor < len - 1 {
self.skill_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
self.skill_cursor += 1;
}
KeyCode::Char(' ') => {
if len > 0 {
let checked = &mut self.available_skills[self.skill_cursor].1;
*checked = !*checked;
}
KeyCode::Char(' ') if len > 0 => {
let checked = &mut self.available_skills[self.skill_cursor].1;
*checked = !*checked;
}
KeyCode::Enter => {
// Save — collect checked skill names (none checked = "all")
@@ -780,21 +756,15 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::AgentDetail;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.mcp_cursor > 0 {
self.mcp_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
self.mcp_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 && self.mcp_cursor < len - 1 {
self.mcp_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
self.mcp_cursor += 1;
}
KeyCode::Char(' ') => {
if len > 0 {
let checked = &mut self.available_mcp[self.mcp_cursor].1;
*checked = !*checked;
}
KeyCode::Char(' ') if len > 0 => {
let checked = &mut self.available_mcp[self.mcp_cursor].1;
*checked = !*checked;
}
KeyCode::Enter => {
// Save — collect checked server names (none checked = "all")
+8 -12
View File
@@ -164,19 +164,15 @@ impl AuditState {
let total = self.filtered.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Char('f') => {
self.action_filter = self.action_filter.next();
+28 -31
View File
@@ -155,19 +155,19 @@ impl CommsState {
self.task_field = 0;
}
KeyCode::Char('r') => return CommsAction::Refresh,
KeyCode::Up | KeyCode::Char('k') => {
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
let i = self.event_list_state.selected().unwrap_or(0);
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
self.event_list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k')
if self.focus == CommsFocus::EventList && !self.events.is_empty() =>
{
let i = self.event_list_state.selected().unwrap_or(0);
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
self.event_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
let i = self.event_list_state.selected().unwrap_or(0);
let next = (i + 1) % self.events.len();
self.event_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j')
if self.focus == CommsFocus::EventList && !self.events.is_empty() =>
{
let i = self.event_list_state.selected().unwrap_or(0);
let next = (i + 1) % self.events.len();
self.event_list_state.select(Some(next));
}
_ => {}
}
@@ -189,18 +189,17 @@ impl CommsState {
self.send_field - 1
};
}
KeyCode::Enter => {
KeyCode::Enter
if !self.send_from.is_empty()
&& !self.send_to.is_empty()
&& !self.send_msg.is_empty()
{
self.show_send_modal = false;
return CommsAction::SendMessage {
from: self.send_from.clone(),
to: self.send_to.clone(),
msg: self.send_msg.clone(),
};
}
&& !self.send_msg.is_empty() =>
{
self.show_send_modal = false;
return CommsAction::SendMessage {
from: self.send_from.clone(),
to: self.send_to.clone(),
msg: self.send_msg.clone(),
};
}
KeyCode::Char(c) => match self.send_field {
0 => self.send_from.push(c),
@@ -238,15 +237,13 @@ impl CommsState {
self.task_field - 1
};
}
KeyCode::Enter => {
if !self.task_title.is_empty() {
self.show_task_modal = false;
return CommsAction::PostTask {
title: self.task_title.clone(),
desc: self.task_desc.clone(),
assign: self.task_assign.clone(),
};
}
KeyCode::Enter if !self.task_title.is_empty() => {
self.show_task_modal = false;
return CommsAction::PostTask {
title: self.task_title.clone(),
desc: self.task_desc.clone(),
assign: self.task_assign.clone(),
};
}
KeyCode::Char(c) => match self.task_field {
0 => self.task_title.push(c),
@@ -152,12 +152,10 @@ impl ExtensionsState {
self.sub = ExtSub::Health;
return ExtensionsAction::RefreshHealth;
}
KeyCode::Char('/') => {
if self.sub == ExtSub::Browse {
self.searching = true;
self.search_query.clear();
return ExtensionsAction::Continue;
}
KeyCode::Char('/') if self.sub == ExtSub::Browse => {
self.searching = true;
self.search_query.clear();
return ExtensionsAction::Continue;
}
_ => {}
}
@@ -172,19 +170,15 @@ impl ExtensionsState {
fn handle_browse(&mut self, key: KeyEvent) -> ExtensionsAction {
let total = self.filtered().len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.browse_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.browse_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.browse_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.browse_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.browse_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.browse_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.browse_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.browse_list.select(Some(next));
}
KeyCode::Enter => {
let filtered = self.filtered();
@@ -222,24 +216,18 @@ impl ExtensionsState {
let total = self.installed_list_data().len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.installed_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.installed_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.installed_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.installed_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.installed_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.installed_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.installed_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.installed_list.select(Some(next));
}
KeyCode::Char('d') | KeyCode::Delete => {
if self.installed_list.selected().is_some() {
self.confirm_remove = true;
}
KeyCode::Char('d') | KeyCode::Delete if self.installed_list.selected().is_some() => {
self.confirm_remove = true;
}
KeyCode::Char('r') => return ExtensionsAction::RefreshAll,
_ => {}
@@ -250,19 +238,15 @@ impl ExtensionsState {
fn handle_health(&mut self, key: KeyEvent) -> ExtensionsAction {
let total = self.health_entries.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.health_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.health_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.health_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.health_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.health_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.health_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.health_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.health_list.select(Some(next));
}
KeyCode::Char('r') | KeyCode::Enter => {
if let Some(sel) = self.health_list.selected() {
+19 -32
View File
@@ -109,28 +109,21 @@ impl HandsState {
fn handle_marketplace(&mut self, key: KeyEvent) -> HandsAction {
let total = self.definitions.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.marketplace_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.marketplace_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.marketplace_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.marketplace_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.marketplace_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.marketplace_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.marketplace_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.marketplace_list.select(Some(next));
}
KeyCode::Enter | KeyCode::Char('a') => {
if let Some(sel) = self.marketplace_list.selected() {
if sel < self.definitions.len() {
// TODO: add text-input modal for custom instance names (#878 follow-up)
return HandsAction::ActivateHand(
self.definitions[sel].id.clone(),
None,
);
return HandsAction::ActivateHand(self.definitions[sel].id.clone(), None);
}
}
}
@@ -160,24 +153,18 @@ impl HandsState {
let total = self.instances.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.active_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.active_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.active_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.active_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.active_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.active_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.active_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.active_list.select(Some(next));
}
KeyCode::Char('d') | KeyCode::Delete => {
if self.active_list.selected().is_some() {
self.confirm_deactivate = true;
}
KeyCode::Char('d') | KeyCode::Delete if self.active_list.selected().is_some() => {
self.confirm_deactivate = true;
}
KeyCode::Char('p') => {
if let Some(sel) = self.active_list.selected() {
@@ -148,6 +148,14 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: true,
hint: "",
},
ProviderInfo {
name: "minimax",
display: "MiniMax",
env_var: "MINIMAX_API_KEY",
default_model: "MiniMax-M2.7",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "huggingface",
display: "Hugging Face",
@@ -159,10 +167,10 @@ const PROVIDERS: &[ProviderInfo] = &[
ProviderInfo {
name: "github-copilot",
display: "GitHub Copilot",
env_var: "GITHUB_TOKEN",
default_model: "gpt-4o",
needs_key: true,
hint: "via PAT",
env_var: "",
default_model: "claude-sonnet-4.6",
needs_key: false, // Auth handled via OAuth device flow after init
hint: "free with subscription",
},
ProviderInfo {
name: "replicate",
@@ -196,6 +204,14 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: true,
hint: "",
},
ProviderInfo {
name: "bedrock",
display: "AWS Bedrock",
env_var: "AWS_BEARER_TOKEN_BEDROCK",
default_model: "anthropic.claude-sonnet-4-6",
needs_key: true,
hint: "bearer token",
},
ProviderInfo {
name: "claude-code",
display: "Claude Code",
@@ -250,6 +266,23 @@ pub enum InitResult {
Cancelled,
}
#[cfg(test)]
mod tests {
use super::PROVIDERS;
#[test]
fn init_wizard_lists_minimax_provider() {
let minimax = PROVIDERS.iter().find(|provider| provider.name == "minimax");
assert!(
minimax.is_some(),
"MiniMax should be selectable in openfang init"
);
let minimax = minimax.unwrap();
assert_eq!(minimax.env_var, "MINIMAX_API_KEY");
assert_eq!(minimax.default_model, "MiniMax-M2.7");
}
}
// ── Internal state ─────────────────────────────────────────────────────────
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -257,6 +290,7 @@ enum Step {
Welcome,
Migration,
Provider,
CopilotAuth,
ApiKey,
Model,
Routing,
@@ -288,6 +322,29 @@ enum KeyTestState {
Warn,
}
#[derive(Clone, PartialEq, Eq)]
enum CopilotAuthStatus {
/// Requesting device code from GitHub.
Starting,
/// Waiting for user to authorize in browser.
WaitingForUser,
/// Authorized, fetching models.
FetchingModels,
/// Done — models loaded.
Done,
/// Error.
Failed(String),
}
enum CopilotAuthEvent {
DeviceCode {
user_code: String,
verification_uri: String,
},
Authenticated,
Models(Vec<String>),
}
/// A model entry for list display.
struct ModelEntry {
id: String,
@@ -348,6 +405,11 @@ struct State {
daemon_url: String,
daemon_error: String,
saving_done: bool,
// Copilot auth
copilot_user_code: String,
copilot_verification_uri: String,
copilot_auth_status: CopilotAuthStatus,
save_error: String,
}
@@ -386,6 +448,9 @@ impl State {
daemon_error: String::new(),
saving_done: false,
save_error: String::new(),
copilot_user_code: String::new(),
copilot_verification_uri: String::new(),
copilot_auth_status: CopilotAuthStatus::Starting,
};
s.build_provider_order();
s.provider_list.select(Some(0));
@@ -431,6 +496,7 @@ impl State {
Step::Welcome => "1 of 7",
Step::Migration => "2 of 7",
Step::Provider => "3 of 7",
Step::CopilotAuth => "4 of 7",
Step::ApiKey => "4 of 7",
Step::Model => "5 of 7",
Step::Routing => "6 of 7",
@@ -610,12 +676,52 @@ pub fn run() -> InitResult {
let (test_tx, test_rx) = std::sync::mpsc::channel::<bool>();
let (migrate_tx, migrate_rx) =
std::sync::mpsc::channel::<Result<openfang_migrate::report::MigrationReport, String>>();
let (copilot_tx, copilot_rx) = std::sync::mpsc::channel::<Result<CopilotAuthEvent, String>>();
let result = loop {
terminal
.draw(|f| draw(f, f.area(), &mut state))
.expect("draw failed");
// Check for Copilot auth events
if state.step == Step::CopilotAuth {
while let Ok(event) = copilot_rx.try_recv() {
match event {
Ok(CopilotAuthEvent::DeviceCode {
user_code,
verification_uri,
}) => {
state.copilot_user_code = user_code;
state.copilot_verification_uri = verification_uri;
state.copilot_auth_status = CopilotAuthStatus::WaitingForUser;
}
Ok(CopilotAuthEvent::Authenticated) => {
state.copilot_auth_status = CopilotAuthStatus::FetchingModels;
}
Ok(CopilotAuthEvent::Models(models)) => {
state.copilot_auth_status = CopilotAuthStatus::Done;
state.model_entries.clear();
for model_id in &models {
state.model_entries.push(ModelEntry {
id: model_id.clone(),
display_name: model_id.clone(),
tier: "copilot",
cost: "free".to_string(),
});
}
if !state.model_entries.is_empty() {
state.model_list.select(Some(0));
}
// Auto-advance to model picker
state.step = Step::Model;
}
Err(e) => {
state.copilot_auth_status = CopilotAuthStatus::Failed(e);
}
}
}
}
// Check for background key-test result
if state.key_test == KeyTestState::Testing {
if let Ok(ok) = test_rx.try_recv() {
@@ -750,7 +856,117 @@ pub fn run() -> InitResult {
state.selected_provider = Some(prov_idx);
let p = &PROVIDERS[prov_idx];
if !p.needs_key {
if p.name == "github-copilot" {
// Start Copilot device flow in background
state.copilot_auth_status = CopilotAuthStatus::Starting;
state.api_key_from_env = false;
state.step = Step::CopilotAuth;
// Kick off background auth
let copilot_tx = copilot_tx.clone();
std::thread::spawn(move || {
let openfang_dir = crate::cli_openfang_home();
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
let _ = copilot_tx
.send(Err(format!("Runtime error: {e}")));
return;
}
};
rt.block_on(async {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("HTTP error: {e}"));
let http = match http {
Ok(h) => h,
Err(e) => {
let _ = copilot_tx.send(Err(e));
return;
}
};
// Step 1: request device code
use openfang_runtime::drivers::copilot;
let device =
match copilot::request_device_code(&http).await {
Ok(d) => d,
Err(e) => {
let _ = copilot_tx.send(Err(e));
return;
}
};
// Send device code to TUI for display
let _ =
copilot_tx.send(Ok(CopilotAuthEvent::DeviceCode {
user_code: device.user_code.clone(),
verification_uri: device
.verification_uri
.clone(),
}));
// Browser will be opened by user pressing Enter in TUI
// Step 2: poll for token
let tokens = match copilot::poll_for_token(
&http,
&device.device_code,
device.interval,
)
.await
{
Ok(t) => t,
Err(e) => {
let _ = copilot_tx.send(Err(e));
return;
}
};
// Save tokens
if let Err(e) = tokens.save(&openfang_dir) {
let _ = copilot_tx.send(Err(e));
return;
}
let _ = copilot_tx
.send(Ok(CopilotAuthEvent::Authenticated));
// Step 3: fetch models
let ct = match copilot::exchange_copilot_token(
&http,
&tokens.access_token,
)
.await
{
Ok(ct) => ct,
Err(e) => {
let _ = copilot_tx
.send(Err(format!("Token exchange: {e}")));
return;
}
};
match copilot::fetch_models(
&http,
&ct.base_url,
&ct.token,
)
.await
{
Ok(models) => {
let _ = copilot_tx
.send(Ok(CopilotAuthEvent::Models(models)));
}
Err(e) => {
let _ = copilot_tx
.send(Err(format!("Model fetch: {e}")));
}
}
});
});
} else if !p.needs_key {
state.api_key_from_env = false;
state.load_models_for_provider();
state.step = Step::Model;
@@ -769,6 +985,25 @@ pub fn run() -> InitResult {
_ => {}
},
Step::CopilotAuth => match key.code {
KeyCode::Esc => {
if matches!(state.copilot_auth_status, CopilotAuthStatus::Failed(_)) {
state.step = Step::Provider;
}
}
KeyCode::Enter
if matches!(
state.copilot_auth_status,
CopilotAuthStatus::WaitingForUser
) && !state.copilot_verification_uri.is_empty() =>
{
let _ = openfang_runtime::drivers::copilot::open_verification_url(
&state.copilot_verification_uri,
);
}
_ => {}
},
Step::ApiKey => {
if matches!(state.key_test, KeyTestState::Ok | KeyTestState::Warn) {
continue;
@@ -779,41 +1014,36 @@ pub fn run() -> InitResult {
state.key_test = KeyTestState::Idle;
state.step = Step::Provider;
}
KeyCode::Enter => {
KeyCode::Enter
if !state.api_key_input.is_empty()
&& state.key_test == KeyTestState::Idle
{
if let Some(p) = state.provider() {
let _ = crate::dotenv::save_env_key(
p.env_var,
&state.api_key_input,
);
}
state.key_test = KeyTestState::Testing;
let provider_name = state
.provider()
.map(|p| p.name.to_string())
.unwrap_or_default();
let env_var = state
.provider()
.map(|p| p.env_var.to_string())
.unwrap_or_default();
let tx = test_tx.clone();
std::thread::spawn(move || {
let ok = crate::test_api_key(&provider_name, &env_var);
let _ = tx.send(ok);
});
&& state.key_test == KeyTestState::Idle =>
{
if let Some(p) = state.provider() {
let _ = crate::dotenv::save_env_key(
p.env_var,
&state.api_key_input,
);
}
state.key_test = KeyTestState::Testing;
let provider_name = state
.provider()
.map(|p| p.name.to_string())
.unwrap_or_default();
let env_var = state
.provider()
.map(|p| p.env_var.to_string())
.unwrap_or_default();
let tx = test_tx.clone();
std::thread::spawn(move || {
let ok = crate::test_api_key(&provider_name, &env_var);
let _ = tx.send(ok);
});
}
KeyCode::Char(c) => {
if state.key_test == KeyTestState::Idle {
state.api_key_input.push(c);
}
KeyCode::Char(c) if state.key_test == KeyTestState::Idle => {
state.api_key_input.push(c);
}
KeyCode::Backspace => {
if state.key_test == KeyTestState::Idle {
state.api_key_input.pop();
}
KeyCode::Backspace if state.key_test == KeyTestState::Idle => {
state.api_key_input.pop();
}
_ => {}
}
@@ -1242,6 +1472,7 @@ fn draw(f: &mut Frame, area: Rect, state: &mut State) {
Step::Welcome => draw_welcome(f, chunks[3]),
Step::Migration => draw_migration(f, chunks[3], state),
Step::Provider => draw_provider(f, chunks[3], state),
Step::CopilotAuth => draw_copilot_auth(f, chunks[3], state),
Step::ApiKey => draw_api_key(f, chunks[3], state),
Step::Model => draw_model(f, chunks[3], state),
Step::Routing => draw_routing(f, chunks[3], state),
@@ -1743,6 +1974,12 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
} else {
"no API key needed".to_string()
}
} else if p.name == "github-copilot" {
if detected {
format!("{} detected", p.env_var)
} else {
"run set-key after init".to_string()
}
} else if detected {
format!("{} detected", p.env_var)
} else if !p.needs_key {
@@ -1772,6 +2009,110 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
f.render_widget(hints, chunks[2]);
}
fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
let chunks = Layout::vertical([
Constraint::Length(2), // title
Constraint::Length(1), // blank
Constraint::Length(1), // status line 1
Constraint::Length(1), // status line 2
Constraint::Length(1), // blank
Constraint::Length(1), // code label
Constraint::Length(1), // code value
Constraint::Length(1), // blank
Constraint::Length(1), // url
Constraint::Min(0), // spacer
Constraint::Length(1), // hint
])
.split(area);
let title = Paragraph::new(Line::from(vec![Span::styled(
" GitHub Copilot Authentication",
Style::default().fg(theme::ACCENT),
)]));
f.render_widget(title, chunks[0]);
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
match &state.copilot_auth_status {
CopilotAuthStatus::Starting => {
let line = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
Span::raw(" Requesting device code..."),
]));
f.render_widget(line, chunks[2]);
}
CopilotAuthStatus::WaitingForUser => {
let line1 = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
Span::raw(" Waiting for authorization..."),
]));
f.render_widget(line1, chunks[2]);
let code_label = Paragraph::new(Line::from(vec![Span::raw(" Enter this code:")]));
f.render_widget(code_label, chunks[5]);
let code_value = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(
&state.copilot_user_code,
Style::default()
.fg(theme::GREEN)
.add_modifier(Modifier::BOLD),
),
]));
f.render_widget(code_value, chunks[6]);
let url = Paragraph::new(Line::from(vec![
Span::raw(" at "),
Span::styled(&state.copilot_verification_uri, theme::dim_style()),
]));
f.render_widget(url, chunks[8]);
let hint = Paragraph::new(Line::from(vec![Span::styled(
" [Enter] Open browser",
theme::dim_style(),
)]));
f.render_widget(hint, chunks[10]);
}
CopilotAuthStatus::FetchingModels => {
let line = Paragraph::new(Line::from(vec![
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
Span::raw("Authenticated"),
]));
f.render_widget(line, chunks[2]);
let line2 = Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
Span::raw(" Fetching available models..."),
]));
f.render_widget(line2, chunks[3]);
}
CopilotAuthStatus::Done => {
let line = Paragraph::new(Line::from(vec![
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
Span::raw("Models loaded"),
]));
f.render_widget(line, chunks[2]);
}
CopilotAuthStatus::Failed(err) => {
let line = Paragraph::new(Line::from(vec![
Span::styled(" \u{2718} ", Style::default().fg(theme::RED)),
Span::raw(err.as_str()),
]));
f.render_widget(line, chunks[2]);
let hint = Paragraph::new(Line::from(vec![Span::styled(
" Esc to go back",
theme::dim_style(),
)]));
f.render_widget(hint, chunks[10]);
}
}
}
fn draw_api_key(f: &mut Frame, area: Rect, state: &mut State) {
let p = match state.provider() {
Some(p) => p,
@@ -2343,6 +2684,23 @@ fn draw_complete(f: &mut Frame, area: Rect, state: &mut State) {
);
}
// ── Bedrock credentials note ──
if p.name == "bedrock" {
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
" AWS bearer token required \u{2014} set:",
Style::default().fg(theme::YELLOW),
)]),
Line::from(vec![Span::styled(
" AWS_BEARER_TOKEN_BEDROCK",
theme::dim_style(),
)]),
]),
chunks[14],
);
}
// ── Bottom hints ──
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
+12 -20
View File
@@ -211,19 +211,15 @@ impl LogsState {
let total = self.filtered.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Char('f') => {
self.level_filter = self.level_filter.next();
@@ -237,15 +233,11 @@ impl LogsState {
self.auto_refresh = !self.auto_refresh;
}
KeyCode::Char('r') => return LogsAction::Refresh,
KeyCode::End => {
if total > 0 {
self.list_state.select(Some(total - 1));
}
KeyCode::End if total > 0 => {
self.list_state.select(Some(total - 1));
}
KeyCode::Home => {
if total > 0 {
self.list_state.select(Some(0));
}
KeyCode::Home if total > 0 => {
self.list_state.select(Some(0));
}
_ => {}
}
+18 -28
View File
@@ -106,19 +106,15 @@ impl MemoryState {
fn handle_agent_select(&mut self, key: KeyEvent) -> MemoryAction {
let total = self.agents.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.agent_list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.agent_list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.agent_list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.agent_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.agent_list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.agent_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.agent_list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.agent_list_state.select(Some(next));
}
KeyCode::Enter => {
if let Some(sel) = self.agent_list_state.selected() {
@@ -166,19 +162,15 @@ impl MemoryState {
self.kv_pairs.clear();
self.selected_agent = None;
}
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.kv_list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.kv_list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.kv_list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.kv_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.kv_list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.kv_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.kv_list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.kv_list_state.select(Some(next));
}
KeyCode::Char('a') => {
self.sub = MemorySub::AddKey;
@@ -196,10 +188,8 @@ impl MemoryState {
}
}
}
KeyCode::Char('d') => {
if self.kv_list_state.selected().is_some() {
self.confirm_delete = true;
}
KeyCode::Char('d') if self.kv_list_state.selected().is_some() => {
self.confirm_delete = true;
}
KeyCode::Char('r') => {
if let Some(agent) = &self.selected_agent {
+8 -12
View File
@@ -62,19 +62,15 @@ impl PeersState {
}
let total = self.peers.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Char('r') => return PeersAction::Refresh,
_ => {}
+10 -16
View File
@@ -130,19 +130,15 @@ impl SessionsState {
let total = self.filtered.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Enter => {
if let Some(sel) = self.list_state.selected() {
@@ -155,10 +151,8 @@ impl SessionsState {
}
}
}
KeyCode::Char('d') => {
if self.list_state.selected().is_some() {
self.confirm_delete = true;
}
KeyCode::Char('d') if self.list_state.selected().is_some() => {
self.confirm_delete = true;
}
KeyCode::Char('/') => {
self.search_mode = true;
+26 -38
View File
@@ -174,21 +174,17 @@ impl SettingsState {
fn handle_providers(&mut self, key: KeyEvent) -> SettingsAction {
let total = self.providers.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.provider_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.provider_list.select(Some(next));
self.test_result = None;
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.provider_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.provider_list.select(Some(next));
self.test_result = None;
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.provider_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.provider_list.select(Some(next));
self.test_result = None;
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.provider_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.provider_list.select(Some(next));
self.test_result = None;
}
KeyCode::Char('e') => {
if let Some(sel) = self.provider_list.selected() {
@@ -223,19 +219,15 @@ impl SettingsState {
fn handle_models(&mut self, key: KeyEvent) -> SettingsAction {
let total = self.models.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.model_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.model_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.model_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.model_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.model_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.model_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.model_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.model_list.select(Some(next));
}
KeyCode::Char('r') => return SettingsAction::RefreshModels,
_ => {}
@@ -246,19 +238,15 @@ impl SettingsState {
fn handle_tools(&mut self, key: KeyEvent) -> SettingsAction {
let total = self.tools.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.tool_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.tool_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.tool_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.tool_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.tool_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.tool_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.tool_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.tool_list.select(Some(next));
}
KeyCode::Char('r') => return SettingsAction::RefreshTools,
_ => {}
+181 -55
View File
@@ -16,6 +16,23 @@ pub struct SkillInfo {
pub runtime: String,
pub source: String,
pub description: String,
/// Number of config vars the skill's SKILL.md declares. Zero = no badge.
pub config_declared: usize,
/// Number of declared vars that are currently resolved (user override,
/// env, or default). When `< config_declared` the badge shows a warning.
pub config_resolved: usize,
}
/// Declared + resolved config for a single variable, shown on the Skills
/// detail pane when a skill with a non-empty `config:` section is selected.
#[derive(Clone, Default)]
pub struct SkillConfigVarDetail {
pub name: String,
pub description: String,
pub required: bool,
pub source: String, // "user" | "env" | "default" | "unresolved"
pub value_hint: String,
pub is_secret: bool,
}
#[derive(Clone, Default)]
@@ -82,6 +99,9 @@ pub struct SkillsState {
pub tick: usize,
pub confirm_uninstall: bool,
pub status_msg: String,
/// Config variable details for the currently selected installed skill.
/// Keyed by skill name so refreshing the list doesn't strand stale data.
pub selected_config_details: Option<(String, Vec<SkillConfigVarDetail>)>,
}
pub enum SkillsAction {
@@ -92,6 +112,10 @@ pub enum SkillsAction {
InstallSkill(String),
UninstallSkill(String),
RefreshMcp,
/// Fetch declared + resolved config for the named skill and display it
/// in the details pane. Consumed by the caller which drives the API
/// request.
LoadSkillConfig(String),
}
impl SkillsState {
@@ -111,6 +135,7 @@ impl SkillsState {
tick: 0,
confirm_uninstall: false,
status_msg: String::new(),
selected_config_details: None,
}
}
@@ -167,23 +192,30 @@ impl SkillsState {
let total = self.installed.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.installed_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.installed_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.installed_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.installed_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.installed_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.installed_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.installed_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.installed_list.select(Some(next));
}
KeyCode::Char('u') => {
if self.installed_list.selected().is_some() {
self.confirm_uninstall = true;
KeyCode::Char('u') if self.installed_list.selected().is_some() => {
self.confirm_uninstall = true;
}
KeyCode::Char('c') => {
if let Some(sel) = self.installed_list.selected() {
if sel < self.installed.len() {
let name = self.installed[sel].name.clone();
// Only skills that declare config are worth fetching.
if self.installed[sel].config_declared > 0 {
return SkillsAction::LoadSkillConfig(name);
} else {
self.status_msg = format!("'{}' declares no runtime config.", name);
}
}
}
}
KeyCode::Char('r') => return SkillsAction::RefreshInstalled,
@@ -217,19 +249,15 @@ impl SkillsState {
let total = self.clawhub_results.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.clawhub_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.clawhub_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.clawhub_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.clawhub_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.clawhub_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.clawhub_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.clawhub_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.clawhub_list.select(Some(next));
}
KeyCode::Char('i') => {
if let Some(sel) = self.clawhub_list.selected() {
@@ -257,19 +285,15 @@ impl SkillsState {
fn handle_mcp(&mut self, key: KeyEvent) -> SkillsAction {
let total = self.mcp_servers.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.mcp_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.mcp_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.mcp_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.mcp_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.mcp_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.mcp_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.mcp_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.mcp_list.select(Some(next));
}
KeyCode::Char('r') => return SkillsAction::RefreshMcp,
_ => {}
@@ -336,9 +360,11 @@ fn draw_sub_tabs(f: &mut Frame, area: Rect, active: SkillsSub) {
}
fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
let chunks = Layout::vertical([
// Two panes: list on the left, config details on the right, with a
// single-line hint bar underneath.
let outer = Layout::vertical([
Constraint::Length(1), // header
Constraint::Min(3), // list
Constraint::Min(3), // list + details
Constraint::Length(1), // hints
])
.split(area);
@@ -346,14 +372,26 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
format!(
" {:<20} {:<8} {:<12} {}",
"Name", "Runtime", "Source", "Description"
" {:<18} {:<7} {:<10} {:<11} {}",
"Name", "Runtime", "Source", "Config", "Description"
),
theme::table_header(),
)])),
chunks[0],
outer[0],
);
// Only show the details pane when there's room and a selection with
// declared config exists. Below ~80 cols we collapse to list-only.
let has_details = state.selected_config_details.is_some();
let body_chunks: Vec<Rect> = if has_details && outer[1].width >= 70 {
Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(outer[1])
.to_vec()
} else {
vec![outer[1]]
};
// Skills list (left pane or full width)
if state.loading {
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
f.render_widget(
@@ -361,7 +399,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
Span::styled("Loading skills\u{2026}", theme::dim_style()),
])),
chunks[1],
body_chunks[0],
);
} else if state.installed.is_empty() {
f.render_widget(
@@ -369,7 +407,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
" No skills installed. Press [2] to browse ClawHub.",
theme::dim_style(),
)),
chunks[1],
body_chunks[0],
);
} else {
let items: Vec<ListItem> = state
@@ -394,15 +432,29 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
"builtin" | "built-in" => Style::default().fg(theme::GREEN),
_ => theme::dim_style(),
};
let (config_text, config_style) = if s.config_declared == 0 {
(String::from("-"), theme::dim_style())
} else if s.config_resolved >= s.config_declared {
(
format!("[{}/{}]", s.config_resolved, s.config_declared),
Style::default().fg(theme::GREEN),
)
} else {
(
format!("[{}/{} \u{26A0}]", s.config_resolved, s.config_declared),
Style::default().fg(theme::YELLOW),
)
};
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<20}", truncate(&s.name, 19)),
format!(" {:<18}", truncate(&s.name, 17)),
Style::default().fg(theme::CYAN),
),
Span::styled(format!(" {:<8}", runtime_badge), runtime_style),
Span::styled(format!(" {:<12}", &s.source), source_style),
Span::styled(format!(" {:<7}", runtime_badge), runtime_style),
Span::styled(format!(" {:<10}", &s.source), source_style),
Span::styled(format!(" {:<11}", config_text), config_style),
Span::styled(
format!(" {}", truncate(&s.description, 30)),
format!(" {}", truncate(&s.description, 24)),
theme::dim_style(),
),
]))
@@ -412,7 +464,12 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
let list = List::new(items)
.highlight_style(theme::selected_style())
.highlight_symbol("> ");
f.render_stateful_widget(list, chunks[1], &mut state.installed_list);
f.render_stateful_widget(list, body_chunks[0], &mut state.installed_list);
}
// Config details pane (right side)
if has_details && body_chunks.len() > 1 {
draw_skill_config_details(f, body_chunks[1], state);
}
if state.confirm_uninstall {
@@ -421,7 +478,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
" Uninstall this skill? [y] Yes [any] Cancel",
Style::default().fg(theme::YELLOW),
)])),
chunks[2],
outer[2],
);
} else if !state.status_msg.is_empty() {
f.render_widget(
@@ -429,19 +486,88 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
format!(" {}", state.status_msg),
Style::default().fg(theme::GREEN),
)])),
chunks[2],
outer[2],
);
} else {
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" [\u{2191}\u{2193}] Navigate [u] Uninstall [r] Refresh",
" [\u{2191}\u{2193}] Navigate [c] View config [u] Uninstall [r] Refresh",
theme::hint_style(),
)])),
chunks[2],
outer[2],
);
}
}
fn draw_skill_config_details(f: &mut Frame, area: Rect, state: &SkillsState) {
let Some((name, rows)) = state.selected_config_details.as_ref() else {
return;
};
let block = Block::default()
.title(Line::from(vec![Span::styled(
format!(" Config: {name} "),
Style::default().fg(theme::ACCENT),
)]))
.borders(Borders::LEFT)
.border_style(theme::dim_style())
.padding(Padding::horizontal(1));
let inner = block.inner(area);
f.render_widget(block, area);
if rows.is_empty() {
f.render_widget(
Paragraph::new(Span::styled("No config declared.", theme::dim_style())),
inner,
);
return;
}
let mut lines: Vec<Line> = Vec::new();
for row in rows {
let src_style = match row.source.as_str() {
"user" => Style::default().fg(theme::GREEN),
"env" => Style::default().fg(theme::CYAN),
"default" => theme::dim_style(),
_ => Style::default().fg(theme::RED),
};
let required_marker = if row.required { "*" } else { " " };
let mut header = vec![
Span::styled(
format!("{required_marker} {}", truncate(&row.name, 22)),
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(format!("[{}]", row.source), src_style),
];
if row.is_secret {
header.push(Span::raw(" "));
header.push(Span::styled(
"(secret)",
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::ITALIC),
));
}
lines.push(Line::from(header));
if !row.value_hint.is_empty() {
lines.push(Line::from(vec![Span::styled(
format!(" = {}", truncate(&row.value_hint, 32)),
theme::dim_style(),
)]));
}
if !row.description.is_empty() {
lines.push(Line::from(vec![Span::styled(
format!(" {}", truncate(&row.description, 36)),
theme::dim_style(),
)]));
}
lines.push(Line::from(vec![Span::raw("")]));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_clawhub(f: &mut Frame, area: Rect, state: &mut SkillsState) {
let chunks = Layout::vertical([
Constraint::Length(1), // search / sort
@@ -194,19 +194,15 @@ impl TemplatesState {
let total = self.filtered.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Enter => {
if let Some(sel) = self.list_state.selected() {
@@ -156,19 +156,15 @@ impl TriggerState {
self.create_step -= 1;
}
}
KeyCode::Enter => {
if self.create_step < 5 {
self.create_step += 1;
}
KeyCode::Enter if self.create_step < 5 => {
self.create_step += 1;
}
KeyCode::Char(c) => match self.create_step {
0 => self.create_agent_id.push(c),
2 => self.create_pattern_param.push(c),
3 => self.create_prompt.push(c),
4 => {
if c.is_ascii_digit() {
self.create_max_fires.push(c);
}
4 if c.is_ascii_digit() => {
self.create_max_fires.push(c);
}
_ => {}
},
+16 -24
View File
@@ -111,19 +111,15 @@ impl UsageState {
UsageSub::ByModel => {
let total = self.by_model.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.model_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.model_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.model_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.model_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.model_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.model_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.model_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.model_list.select(Some(next));
}
KeyCode::Char('r') => return UsageAction::Refresh,
_ => {}
@@ -132,19 +128,15 @@ impl UsageState {
UsageSub::ByAgent => {
let total = self.by_agent.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.agent_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.agent_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.agent_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.agent_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.agent_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.agent_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.agent_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.agent_list.select(Some(next));
}
KeyCode::Char('r') => return UsageAction::Refresh,
_ => {}
+27 -6
View File
@@ -85,6 +85,12 @@ const PROVIDERS: &[ProviderInfo] = &[
default_model: "qwen-plus",
needs_key: true,
},
ProviderInfo {
name: "minimax",
env_var: "MINIMAX_API_KEY",
default_model: "MiniMax-M2.7",
needs_key: true,
},
ProviderInfo {
name: "perplexity",
env_var: "PERPLEXITY_API_KEY",
@@ -322,13 +328,11 @@ impl WizardState {
KeyCode::Esc => {
self.step = WizardStep::Provider;
}
KeyCode::Enter => {
if !self.api_key_input.is_empty() {
if let Some(p) = self.selected_provider_info() {
self.model_input = p.default_model.to_string();
}
self.step = WizardStep::Model;
KeyCode::Enter if !self.api_key_input.is_empty() => {
if let Some(p) = self.selected_provider_info() {
self.model_input = p.default_model.to_string();
}
self.step = WizardStep::Model;
}
KeyCode::Char(c) => {
self.api_key_input.push(c);
@@ -689,3 +693,20 @@ fn draw_done(f: &mut Frame, area: Rect, state: &WizardState) {
f.render_widget(cont, chunks[1]);
}
}
#[cfg(test)]
mod tests {
use super::PROVIDERS;
#[test]
fn wizard_lists_minimax_provider() {
let minimax = PROVIDERS.iter().find(|provider| provider.name == "minimax");
assert!(
minimax.is_some(),
"MiniMax should be selectable in wizard provider list"
);
let minimax = minimax.unwrap();
assert_eq!(minimax.env_var, "MINIMAX_API_KEY");
assert_eq!(minimax.default_model, "MiniMax-M2.7");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenFang",
"version": "0.5.7",
"version": "0.6.9",
"identifier": "ai.openfang.desktop",
"build": {},
"app": {
+2
View File
@@ -16,6 +16,8 @@ uuid = { workspace = true }
chrono = { workspace = true }
dashmap = { workspace = true }
dirs = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+224 -8
View File
@@ -8,10 +8,27 @@ use crate::{
use dashmap::DashMap;
use openfang_types::agent::AgentId;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tracing::{info, warn};
use uuid::Uuid;
/// Callback signature invoked on every successful hand load / reload.
///
/// Arguments: `(hand_id, sha256_hex_of_hand_toml)`. The kernel wires this
/// into the Merkle audit chain so reload events leave a tamper-evident
/// record (issue #1172). The callback must be cheap and non-blocking; it
/// runs inline on the loader thread.
pub type HandAuditCallback = Arc<dyn Fn(&str, &str) + Send + Sync>;
/// Compute the SHA-256 hex digest of raw HAND.toml content.
fn hand_toml_sha256(toml_content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(toml_content.as_bytes());
hex::encode(hasher.finalize())
}
// ─── Settings availability types ────────────────────────────────────────────
/// Availability status of a single setting option.
@@ -41,6 +58,10 @@ pub struct HandRegistry {
definitions: DashMap<String, HandDefinition>,
/// Active hand instances, keyed by instance UUID.
instances: DashMap<Uuid, HandInstance>,
/// Optional callback invoked on every successful HAND.toml load with
/// the computed SHA-256 of the file content. Wired by the kernel into
/// the Merkle audit chain (issue #1172).
audit_callback: RwLock<Option<HandAuditCallback>>,
}
impl HandRegistry {
@@ -49,9 +70,37 @@ impl HandRegistry {
Self {
definitions: DashMap::new(),
instances: DashMap::new(),
audit_callback: RwLock::new(None),
}
}
/// Install a callback invoked on every successful HAND.toml load /
/// reload with the file's SHA-256. The kernel wires this to
/// `AuditLog::record(AuditAction::ConfigChange, ...)` so reload events
/// leave a tamper-evident audit record (issue #1172).
pub fn set_audit_callback(&self, callback: HandAuditCallback) {
let mut guard = self
.audit_callback
.write()
.unwrap_or_else(|e| e.into_inner());
*guard = Some(callback);
}
/// Compute SHA-256 of the given HAND.toml content and invoke the audit
/// callback if one is registered. Returns the hex digest for callers
/// that want to log or compare it.
fn emit_hand_loaded_audit(&self, hand_id: &str, toml_content: &str) -> String {
let hash = hand_toml_sha256(toml_content);
let guard = self
.audit_callback
.read()
.unwrap_or_else(|e| e.into_inner());
if let Some(cb) = guard.as_ref() {
cb(hand_id, &hash);
}
hash
}
/// Persist active hand state to disk so it survives restarts.
pub fn persist_state(&self, path: &std::path::Path) -> HandResult<()> {
let entries: Vec<serde_json::Value> = self
@@ -112,7 +161,8 @@ impl HandRegistry {
for (id, toml_content, skill_content) in bundled {
match bundled::parse_bundled(id, toml_content, skill_content) {
Ok(def) => {
info!(hand = %def.id, name = %def.name, "Loaded bundled hand");
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Loaded bundled hand");
self.definitions.insert(def.id.clone(), def);
count += 1;
}
@@ -173,7 +223,8 @@ impl HandRegistry {
match bundled::parse_bundled("custom", &contents, &skill_content) {
Ok(def) => {
let hand_id = def.id.clone();
info!(hand = %hand_id, path = %path.display(), "Loaded workspace hand");
let hash = self.emit_hand_loaded_audit(&hand_id, &contents);
info!(hand = %hand_id, path = %path.display(), sha256 = %hash, "Loaded workspace hand");
self.definitions.insert(hand_id, def);
count += 1;
}
@@ -204,7 +255,8 @@ impl HandRegistry {
)));
}
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
let hash = self.emit_hand_loaded_audit(&def.id, &toml_content);
info!(hand = %def.id, name = %def.name, path = %path.display(), sha256 = %hash, "Installed hand from path");
self.definitions.insert(def.id.clone(), def.clone());
// Persist the hand to the user's data dir so it survives daemon
@@ -251,7 +303,8 @@ impl HandRegistry {
)));
}
info!(hand = %def.id, name = %def.name, "Installed hand from content");
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Installed hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
@@ -269,7 +322,8 @@ impl HandRegistry {
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
let existed = self.definitions.contains_key(&def.id);
let verb = if existed { "Updated" } else { "Installed" };
info!(hand = %def.id, name = %def.name, "{verb} hand from content");
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
info!(hand = %def.id, name = %def.name, sha256 = %hash, "{verb} hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
@@ -1141,9 +1195,171 @@ metrics = []
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();
let err = reg.activate("test-hand", HashMap::new(), None).unwrap_err();
assert!(matches!(err, HandError::AlreadyActive(_)));
}
/// Issue #1172: HAND.toml SHA-256 must be emitted to the audit
/// callback on every successful load / reload.
#[test]
fn audit_callback_records_hand_toml_hash_on_load() {
use std::sync::Mutex;
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&captured);
let reg = HandRegistry::new();
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
sink.lock()
.unwrap()
.push((hand_id.to_string(), hash.to_string()));
}));
let toml_str = r#"
id = "audit-hand"
name = "Audit Hand"
description = "Used to verify audit-trail wiring"
category = "other"
tools = []
[agent]
name = "audit-agent"
description = "audit"
system_prompt = "audit."
"#;
// Precompute the expected hash so the test fails loudly if the
// registry ever changes how it digests the TOML content.
let expected_hash = {
let mut h = Sha256::new();
h.update(toml_str.as_bytes());
hex::encode(h.finalize())
};
let def = reg.install_from_content(toml_str, "").unwrap();
assert_eq!(def.id, "audit-hand");
let events = captured.lock().unwrap().clone();
assert_eq!(
events.len(),
1,
"exactly one audit event should be emitted per load"
);
assert_eq!(events[0].0, "audit-hand", "hand id propagated to callback");
assert_eq!(
events[0].1, expected_hash,
"callback received SHA-256 of the HAND.toml content"
);
assert_eq!(events[0].1.len(), 64, "SHA-256 hex is 64 chars");
}
/// Issue #1172: reloading the same HAND.toml via upsert must emit a
/// fresh audit event so the chain records when the swap took effect.
/// A content change must surface a different hash.
#[test]
fn audit_callback_fires_on_reload_with_new_hash() {
use std::sync::Mutex;
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&captured);
let reg = HandRegistry::new();
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
sink.lock()
.unwrap()
.push((hand_id.to_string(), hash.to_string()));
}));
let v1 = r#"
id = "reload-hand"
name = "Reload Hand v1"
description = "v1"
category = "other"
tools = []
[agent]
name = "reload-agent"
description = "reload"
system_prompt = "v1."
"#;
let v2 = r#"
id = "reload-hand"
name = "Reload Hand v2"
description = "v2"
category = "other"
tools = []
[agent]
name = "reload-agent"
description = "reload"
system_prompt = "v2 — schedule changed."
"#;
reg.upsert_from_content(v1, "").unwrap();
reg.upsert_from_content(v2, "").unwrap();
let events = captured.lock().unwrap().clone();
assert_eq!(events.len(), 2, "one event per upsert (load + reload)");
assert_eq!(events[0].0, "reload-hand");
assert_eq!(events[1].0, "reload-hand");
assert_ne!(
events[0].1, events[1].1,
"different HAND.toml content must yield different SHA-256"
);
}
/// Integration test for issue #809: `hand config` round-trip.
///
/// Simulates what `openfang hand config <id> --set KEY=VAL` does against
/// the registry: read current config, merge updates, write back, read
/// again. Persists to a tempdir so the restart path also sees the change.
#[test]
fn test_hand_config_round_trip_via_registry() {
let reg = test_registry_with_dummy_hand("browser");
let inst = reg.activate("browser", HashMap::new(), None).unwrap();
// Read-modify-write cycle mirroring the CLI's --set behavior.
let mut cfg = reg.get_instance(inst.instance_id).unwrap().config;
cfg.insert(
"headless".to_string(),
serde_json::Value::String("true".into()),
);
cfg.insert(
"user_agent".to_string(),
serde_json::Value::String("openfang/1".into()),
);
reg.update_config(inst.instance_id, cfg.clone()).unwrap();
let after = reg.get_instance(inst.instance_id).unwrap();
assert_eq!(
after.config.get("headless"),
Some(&serde_json::Value::String("true".into()))
);
assert_eq!(
after.config.get("user_agent"),
Some(&serde_json::Value::String("openfang/1".into()))
);
// --unset path: drop a key and confirm it's gone.
cfg.remove("user_agent");
reg.update_config(inst.instance_id, cfg).unwrap();
let after_unset = reg.get_instance(inst.instance_id).unwrap();
assert!(!after_unset.config.contains_key("user_agent"));
assert_eq!(
after_unset.config.get("headless"),
Some(&serde_json::Value::String("true".into()))
);
// State survives a persist+load round-trip through a tempdir sidecar.
let tmp = tempfile::tempdir().unwrap();
let state_file = tmp.path().join("hands.json");
reg.persist_state(&state_file).unwrap();
let reloaded = HandRegistry::load_state(&state_file);
assert_eq!(reloaded.len(), 1);
let (hand_id, config, _agent_id) = &reloaded[0];
assert_eq!(hand_id, "browser");
assert_eq!(
config.get("headless"),
Some(&serde_json::Value::String("true".into()))
);
}
}
+3 -1
View File
@@ -32,8 +32,10 @@ futures = { workspace = true }
subtle = { workspace = true }
rand = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
reqwest = { workspace = true }
cron = "0.15"
rustls = { workspace = true }
cron = "0.16"
zeroize = { workspace = true }
[target.'cfg(unix)'.dependencies]
+315
View File
@@ -69,12 +69,26 @@ pub fn load_config(path: Option<&Path>) -> KernelConfig {
}
}
// GAP-012 (Tier 1): pre-validate the [[bindings]] array so a
// single malformed entry doesn't poison the whole config and
// force a fall-back to defaults (which would silently unbind
// every agent). Bad entries are logged at ERROR and dropped;
// survivors are passed through to typed deserialization.
lenient_extract_bindings(&mut root_value);
match root_value.try_into::<KernelConfig>() {
Ok(config) => {
info!(path = %config_path.display(), "Loaded configuration");
return config;
}
Err(e) => {
// TODO(GAP-012-Tier-2): this fallback still silently
// swaps the user's intent for `KernelConfig::default()`
// on any non-binding deserialization failure. Tier 1
// closes the binding-shape footgun; Tier 2 should
// surface remaining failures via a health endpoint
// and/or stderr banner so the silent-default path
// can't hide a broken config.
tracing::warn!(
error = %e,
path = %config_path.display(),
@@ -242,6 +256,89 @@ pub fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
}
}
/// Lenient pre-pass over the `[[bindings]]` array (GAP-012 Tier 1).
///
/// Strict whole-config deserialization is fragile: any one malformed binding
/// (e.g. a typo'd field that trips `deny_unknown_fields`) causes
/// `try_into::<KernelConfig>()` to fail, which the caller then handles by
/// falling back to `KernelConfig::default()` — silently unbinding *every*
/// agent. That's the worst possible failure mode for a routing config: the
/// user's intent is silently discarded, with only a single line in the logs.
///
/// This pass runs *before* typed deserialization. It walks the bindings
/// array entry-by-entry, attempts to deserialize each into `AgentBinding`,
/// logs malformed entries at ERROR with index + agent name + serde error,
/// and replaces the array with the survivors. The downstream
/// `try_into::<KernelConfig>()` then sees a clean array and succeeds.
///
/// `deny_unknown_fields` on `AgentBinding`/`BindingMatchRule` still applies
/// per-entry — typos in surviving bindings would still produce errors here
/// and be dropped. The strict-field guarantee is preserved at the entry
/// level; only the all-or-nothing behavior is relaxed.
///
/// No-op if `root_value` is not a table or has no `bindings` array.
fn lenient_extract_bindings(root_value: &mut toml::Value) {
use openfang_types::config::AgentBinding;
let tbl = match root_value {
toml::Value::Table(t) => t,
_ => return,
};
// Replace the array in place if (and only if) `bindings` is present
// and is an array. Anything else (missing, wrong type) we leave alone
// so the typed deserializer can produce its own targeted error.
let original = match tbl.get("bindings") {
Some(toml::Value::Array(arr)) => arr.clone(),
_ => return,
};
let mut survivors: Vec<toml::Value> = Vec::with_capacity(original.len());
let mut dropped = 0usize;
for (idx, entry) in original.into_iter().enumerate() {
match entry.clone().try_into::<AgentBinding>() {
Ok(_) => survivors.push(entry),
Err(e) => {
dropped += 1;
// Lazy: only allocate the agent-name fallback string when we
// actually need it for an error log. The happy path skips this.
let agent_name = entry
.get("agent")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>")
.to_string();
tracing::error!(
binding_index = idx,
agent = %agent_name,
error = %e,
"Skipping malformed binding #{} (agent='{}'): {}. \
Other bindings will continue to load. \
Fix the entry and reload to restore routing.",
idx,
agent_name,
e
);
}
}
}
if dropped > 0 {
// Per-entry ERRORs above carry the root cause; this summary is a
// grep-friendly one-liner, so WARN keeps ERROR == per-binding cause.
tracing::warn!(
dropped,
survivors = survivors.len(),
"Dropped {} malformed binding(s); {} binding(s) will load. \
See preceding ERROR lines for per-binding details.",
dropped,
survivors.len()
);
}
tbl.insert("bindings".to_string(), toml::Value::Array(survivors));
}
/// Get the default config file path.
///
/// Respects `OPENFANG_HOME` env var (e.g. `OPENFANG_HOME=/opt/openfang`).
@@ -442,6 +539,224 @@ mod tests {
assert_eq!(config.log_level, "info"); // defaults
}
// ─── GAP-012 Tier 1: lenient bindings extraction ───────────────────
#[test]
fn test_lenient_bindings_drops_typo_keeps_rest() {
// Two bindings; the first has a typo'd field (`channnel_id`) that
// `BindingMatchRule`'s `deny_unknown_fields` would reject. The second
// is well-formed. Pre-fix behavior: whole config falls back to
// defaults (zero bindings). Post-fix: bad one dropped, good one loads.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
log_level = "info"
[[bindings]]
agent = "researcher-broken"
match_rule = {{ channel = "discord", channnel_id = "123" }}
[[bindings]]
agent = "researcher-good"
match_rule = {{ channel = "discord", channel_id = "456" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(
config.bindings.len(),
1,
"expected exactly the well-formed binding to survive"
);
assert_eq!(config.bindings[0].agent, "researcher-good");
assert_eq!(
config.bindings[0].match_rule.channel_id.as_deref(),
Some("456")
);
}
#[test]
fn test_lenient_bindings_all_valid_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
log_level = "info"
[[bindings]]
agent = "a"
match_rule = {{ channel = "discord", channel_id = "1" }}
[[bindings]]
agent = "b"
match_rule = {{ channel = "telegram", channel_id = "2" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(config.bindings.len(), 2);
assert_eq!(config.bindings[0].agent, "a");
assert_eq!(config.bindings[1].agent, "b");
}
#[test]
fn test_lenient_bindings_all_malformed_yields_empty_but_keeps_rest_of_config() {
// Every binding is broken, but the rest of the config (log_level,
// api_listen) must still load. Pre-fix: total fallback to defaults.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
log_level = "trace"
api_listen = "127.0.0.1:9999"
[[bindings]]
agent = "broken-1"
match_rule = {{ channnel_id = "1" }}
[[bindings]]
agent = "broken-2"
match_rule = {{ peer_idd = "u" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert!(config.bindings.is_empty(), "all bindings should be dropped");
assert_eq!(
config.log_level, "trace",
"non-binding config must still load"
);
assert_eq!(config.api_listen, "127.0.0.1:9999");
}
#[test]
fn test_lenient_bindings_no_bindings_section_is_noop() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "log_level = \"info\"").unwrap();
drop(f);
let config = load_config(Some(&path));
assert!(config.bindings.is_empty());
assert_eq!(config.log_level, "info");
}
#[test]
fn test_lenient_bindings_missing_agent_field_dropped() {
// A binding missing the required `agent` field can't deserialize at
// all; it should be dropped (logged as agent='<unknown>') and the
// good one should still load.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[[bindings]]
match_rule = {{ channel = "discord" }}
[[bindings]]
agent = "good"
match_rule = {{ channel = "discord", channel_id = "1" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(config.bindings.len(), 1);
assert_eq!(config.bindings[0].agent, "good");
}
#[test]
fn test_lenient_bindings_preserves_survivor_order() {
// Three bindings with the *middle* one malformed. Survivors must
// retain their original relative order (1st, 3rd) — match-rule
// routing can be order-sensitive (first-match-wins), so silently
// reshuffling on a drop would be a subtle regression.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[[bindings]]
agent = "first"
match_rule = {{ channel = "discord", channel_id = "1" }}
[[bindings]]
agent = "middle-broken"
match_rule = {{ channnel_id = "2" }}
[[bindings]]
agent = "third"
match_rule = {{ channel = "telegram", channel_id = "3" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(config.bindings.len(), 2, "middle binding should be dropped");
assert_eq!(
config.bindings[0].agent, "first",
"first survivor must remain first"
);
assert_eq!(
config.bindings[1].agent, "third",
"third must remain after first (order preserved)"
);
}
#[test]
fn test_lenient_bindings_top_level_field_typo_dropped() {
// Operator typos `agnt` instead of `agent` on the binding itself
// (not inside `match_rule`). `AgentBinding`'s `deny_unknown_fields`
// should reject the entry, the lenient pass should drop it, and
// the well-formed sibling should still load. This is the more
// common operator mistake than missing-field-entirely, so we lock
// the behavior in explicitly.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[[bindings]]
agnt = "typo-at-top-level"
match_rule = {{ channel = "discord", channel_id = "1" }}
[[bindings]]
agent = "good"
match_rule = {{ channel = "discord", channel_id = "2" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(
config.bindings.len(),
1,
"binding with top-level field typo should be dropped"
);
assert_eq!(config.bindings[0].agent, "good");
}
#[test]
fn test_no_includes_works() {
let dir = tempfile::tempdir().unwrap();
@@ -483,6 +483,79 @@ mod tests {
assert!(plan.hot_actions.contains(&HotAction::ReloadProviderUrls));
}
/// #1129: editing `[default_model].subprocess_timeout_secs` must produce
/// a hot-reload action so cross-message timeout retunes don't require
/// a daemon bounce. The whole `default_model` block round-trips through
/// `UpdateDefaultModel`, which carries the new timeout into the override
/// slot read by `resolve_driver`.
#[test]
fn test_default_model_subprocess_timeout_hot_reload() {
let a = default_cfg();
let mut b = default_cfg();
b.default_model.subprocess_timeout_secs = Some(900);
let plan = build_reload_plan(&a, &b);
assert!(
!plan.restart_required,
"subprocess_timeout_secs edits on default_model must be hot-reloadable"
);
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
}
/// #1129: editing `[[fallback_providers]]` (including
/// `subprocess_timeout_secs` on a non-default provider) must produce a
/// `ReloadFallbackProviders` hot-action. Without this, mixed-fleet
/// operators have no live tuning knob for their non-default driver.
#[test]
fn test_fallback_providers_subprocess_timeout_hot_reload() {
use openfang_types::config::FallbackProviderConfig;
let mut a = default_cfg();
let mut b = default_cfg();
a.fallback_providers.push(FallbackProviderConfig {
provider: "codex".to_string(),
model: "gpt-5-codex".to_string(),
api_key_env: String::new(),
base_url: None,
subprocess_timeout_secs: Some(120),
});
b.fallback_providers.push(FallbackProviderConfig {
provider: "codex".to_string(),
model: "gpt-5-codex".to_string(),
api_key_env: String::new(),
base_url: None,
// Operator raises the ceiling for slow Codex turns.
subprocess_timeout_secs: Some(900),
});
let plan = build_reload_plan(&a, &b);
assert!(
!plan.restart_required,
"[[fallback_providers]] edits must be hot-reloadable"
);
assert!(plan
.hot_actions
.contains(&HotAction::ReloadFallbackProviders));
}
/// #1129: adding a brand-new `[[fallback_providers]]` entry on reload also
/// emits the hot-action so the new provider is picked up without bounce.
#[test]
fn test_fallback_providers_add_entry_hot_reload() {
use openfang_types::config::FallbackProviderConfig;
let a = default_cfg();
let mut b = default_cfg();
b.fallback_providers.push(FallbackProviderConfig {
provider: "ollama".to_string(),
model: "llama3.2:latest".to_string(),
api_key_env: String::new(),
base_url: None,
subprocess_timeout_secs: Some(300),
});
let plan = build_reload_plan(&a, &b);
assert!(!plan.restart_required);
assert!(plan
.hot_actions
.contains(&HotAction::ReloadFallbackProviders));
}
// -----------------------------------------------------------------------
// Mixed changes
// -----------------------------------------------------------------------
+20
View File
@@ -198,6 +198,25 @@ impl CronScheduler {
}
}
/// Replace the multi-destination delivery targets on an existing job.
///
/// The schedule, action, and primary `delivery` field are left untouched;
/// only the `delivery_targets` fan-out list is swapped in. Call
/// [`persist`] afterwards to write the change to disk.
pub fn set_delivery_targets(
&self,
id: CronJobId,
targets: Vec<openfang_types::scheduler::CronDeliveryTarget>,
) -> OpenFangResult<()> {
match self.jobs.get_mut(&id) {
Some(mut meta) => {
meta.job.delivery_targets = targets;
Ok(())
}
None => Err(OpenFangError::Internal(format!("Cron job {id} not found"))),
}
}
// -- Queries ------------------------------------------------------------
/// Get a single job by ID.
@@ -504,6 +523,7 @@ mod tests {
text: "ping".into(),
},
delivery: CronDelivery::None,
delivery_targets: Vec::new(),
created_at: Utc::now(),
last_run: None,
next_run: None,
+739
View File
@@ -0,0 +1,739 @@
//! Multi-destination cron output delivery.
//!
//! A single [`CronJob`] may declare zero or more [`CronDeliveryTarget`]s on
//! its `delivery_targets` field. After the job fires and produces output,
//! the [`CronDeliveryEngine`] fans out the same payload to every target
//! concurrently. Failures in one target do not abort delivery to the
//! others — every target's outcome is returned in a [`DeliveryResult`].
//!
//! This is the OpenFang port of the Hermes Agent multi-destination cron
//! pattern: one job → N destinations (channels / webhooks / files / email).
use futures::future::join_all;
use openfang_channels::bridge::ChannelBridgeHandle;
use openfang_types::scheduler::CronDeliveryTarget;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, warn};
/// Webhook HTTP timeout. Matches the legacy single-target cron webhook.
const WEBHOOK_TIMEOUT_SECS: u64 = 30;
/// Per-target delivery outcome returned by [`CronDeliveryEngine::deliver`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliveryResult {
/// Human-readable target description (`"channel:telegram -> chat_123"`,
/// `"webhook:https://..."`, `"file:/tmp/out.log"`, `"email:alice@x"`).
pub target: String,
/// Whether delivery succeeded.
pub success: bool,
/// Error message if `success` is `false`.
pub error: Option<String>,
}
impl DeliveryResult {
fn ok(target: String) -> Self {
Self {
target,
success: true,
error: None,
}
}
fn err(target: String, msg: String) -> Self {
Self {
target,
success: false,
error: Some(msg),
}
}
}
/// Fan-out delivery engine for cron job output.
///
/// Holds a reference to the channel bridge (for adapter-based delivery) and
/// a shared HTTP client (for webhook delivery). Constructed once per kernel
/// and reused across every cron firing.
pub struct CronDeliveryEngine {
/// Bridge used to invoke `send_channel_message` on registered adapters.
channel_bridge: Arc<dyn ChannelBridgeHandle>,
/// Shared HTTP client for webhook delivery.
http: reqwest::Client,
}
impl CronDeliveryEngine {
/// Build a new engine using the given channel bridge and a fresh
/// `reqwest::Client`. Falls back to the default client if the builder
/// fails (which effectively never happens on supported platforms).
pub fn new(channel_bridge: Arc<dyn ChannelBridgeHandle>) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(WEBHOOK_TIMEOUT_SECS))
.build()
.unwrap_or_default();
Self {
channel_bridge,
http,
}
}
/// Build a new engine with an explicit HTTP client — used by tests.
pub fn with_http_client(
channel_bridge: Arc<dyn ChannelBridgeHandle>,
http: reqwest::Client,
) -> Self {
Self {
channel_bridge,
http,
}
}
/// Deliver `output` to every target concurrently.
///
/// Returns a `Vec<DeliveryResult>` with one entry per target in the same
/// order as the input slice. One target failing does not short-circuit
/// the others — the job already succeeded, delivery is best-effort.
pub async fn deliver(
&self,
targets: &[CronDeliveryTarget],
job_name: &str,
output: &str,
) -> Vec<DeliveryResult> {
if targets.is_empty() {
return Vec::new();
}
let futures = targets
.iter()
.map(|t| self.deliver_one(t, job_name, output));
join_all(futures).await
}
/// Deliver to a single target. Never panics.
async fn deliver_one(
&self,
target: &CronDeliveryTarget,
job_name: &str,
output: &str,
) -> DeliveryResult {
match target {
CronDeliveryTarget::Channel {
channel_type,
recipient,
} => {
let desc = format!("channel:{channel_type} -> {recipient}");
match self
.channel_bridge
.send_channel_message(channel_type, recipient, output)
.await
{
Ok(()) => {
debug!(target = %desc, "Cron fan-out: channel delivery ok");
DeliveryResult::ok(desc)
}
Err(e) => {
warn!(target = %desc, error = %e, "Cron fan-out: channel delivery failed");
DeliveryResult::err(desc, e)
}
}
}
CronDeliveryTarget::Webhook { url, auth_header } => {
let desc = format!("webhook:{url}");
match deliver_webhook(&self.http, url, auth_header.as_deref(), job_name, output)
.await
{
Ok(()) => {
debug!(target = %desc, "Cron fan-out: webhook delivery ok");
DeliveryResult::ok(desc)
}
Err(e) => {
warn!(target = %desc, error = %e, "Cron fan-out: webhook delivery failed");
DeliveryResult::err(desc, e)
}
}
}
CronDeliveryTarget::LocalFile { path, append } => {
let desc = format!("file:{path}");
match deliver_local_file(Path::new(path), *append, output).await {
Ok(()) => {
debug!(target = %desc, "Cron fan-out: file write ok");
DeliveryResult::ok(desc)
}
Err(e) => {
warn!(target = %desc, error = %e, "Cron fan-out: file write failed");
DeliveryResult::err(desc, e)
}
}
}
CronDeliveryTarget::Email {
to,
subject_template,
} => {
let desc = format!("email:{to}");
let subject = render_subject(subject_template.as_deref(), job_name);
// The existing email channel adapter sends via SMTP and does
// not expose a subject/to pair on the trait, so we route a
// formatted message through it. Most adapters treat the
// recipient as a destination identifier; the email adapter
// uses it as the RCPT TO address.
let body = format!("{subject}\n\n{output}");
match self
.channel_bridge
.send_channel_message("email", to, &body)
.await
{
Ok(()) => {
debug!(target = %desc, "Cron fan-out: email delivery ok");
DeliveryResult::ok(desc)
}
Err(e) => {
warn!(target = %desc, error = %e, "Cron fan-out: email delivery failed");
DeliveryResult::err(desc, e)
}
}
}
}
}
}
/// Render an email subject from an optional template. `{job}` is the only
/// supported placeholder; everything else passes through unchanged.
fn render_subject(template: Option<&str>, job_name: &str) -> String {
match template {
Some(t) if !t.is_empty() => t.replace("{job}", job_name),
_ => format!("Cron: {job_name}"),
}
}
/// POST a JSON payload `{ job, output, timestamp }` to `url` and optionally
/// attach an `Authorization` header. Returns `Err(msg)` on non-2xx or
/// network failure.
async fn deliver_webhook(
http: &reqwest::Client,
url: &str,
auth_header: Option<&str>,
job_name: &str,
output: &str,
) -> Result<(), String> {
let payload = serde_json::json!({
"job": job_name,
"output": output,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let mut req = http.post(url).json(&payload);
if let Some(auth) = auth_header {
req = req.header("Authorization", auth);
}
let resp = req
.send()
.await
.map_err(|e| format!("webhook send failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("webhook returned HTTP {status}"));
}
Ok(())
}
/// Append or overwrite `output` at `path`. Creates parent directories when
/// missing. Returns `Err(msg)` on any I/O failure.
async fn deliver_local_file(path: &Path, append: bool, output: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("create parent dir failed: {e}"))?;
}
}
if append {
use tokio::io::AsyncWriteExt;
let mut f = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await
.map_err(|e| format!("open failed: {e}"))?;
f.write_all(output.as_bytes())
.await
.map_err(|e| format!("write failed: {e}"))?;
// Newline separator between runs makes tailing nicer.
f.write_all(b"\n")
.await
.map_err(|e| format!("write newline failed: {e}"))?;
} else {
tokio::fs::write(path, output.as_bytes())
.await
.map_err(|e| format!("write failed: {e}"))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use openfang_channels::bridge::ChannelBridgeHandle;
use openfang_types::agent::AgentId;
use std::sync::Mutex;
/// Mock bridge that records every channel send. Optionally fails for
/// specific channel names.
struct MockBridge {
calls: Mutex<Vec<(String, String, String)>>,
fail_on_channel: Option<String>,
}
impl MockBridge {
fn new() -> Arc<Self> {
Arc::new(Self {
calls: Mutex::new(Vec::new()),
fail_on_channel: None,
})
}
fn failing_on(channel: &str) -> Arc<Self> {
Arc::new(Self {
calls: Mutex::new(Vec::new()),
fail_on_channel: Some(channel.to_string()),
})
}
fn calls(&self) -> Vec<(String, String, String)> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait]
impl ChannelBridgeHandle for MockBridge {
async fn send_message(&self, _: AgentId, _: &str) -> Result<String, String> {
Ok(String::new())
}
async fn find_agent_by_name(&self, _: &str) -> Result<Option<AgentId>, String> {
Ok(None)
}
async fn list_agents(&self) -> Result<Vec<(AgentId, String)>, String> {
Ok(Vec::new())
}
async fn spawn_agent_by_name(&self, _: &str) -> Result<AgentId, String> {
Err("not implemented".into())
}
async fn send_channel_message(
&self,
channel_type: &str,
recipient: &str,
message: &str,
) -> Result<(), String> {
self.calls.lock().unwrap().push((
channel_type.to_string(),
recipient.to_string(),
message.to_string(),
));
if let Some(ref failing) = self.fail_on_channel {
if failing == channel_type {
return Err(format!("mock: forced failure on '{channel_type}'"));
}
}
Ok(())
}
}
fn test_engine(bridge: Arc<MockBridge>) -> CronDeliveryEngine {
CronDeliveryEngine::new(bridge)
}
// -- LocalFile: overwrite ------------------------------------------------
#[tokio::test]
async fn localfile_overwrite_creates_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("out.txt");
let target = CronDeliveryTarget::LocalFile {
path: path.to_string_lossy().to_string(),
append: false,
};
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[target], "job-x", "hello world").await;
assert_eq!(results.len(), 1);
assert!(results[0].success, "error: {:?}", results[0].error);
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn localfile_overwrite_replaces_existing() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("replace.txt");
std::fs::write(&path, "OLD CONTENT").unwrap();
let target = CronDeliveryTarget::LocalFile {
path: path.to_string_lossy().to_string(),
append: false,
};
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[target], "job-x", "NEW").await;
assert!(results[0].success);
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "NEW");
}
// -- LocalFile: append ---------------------------------------------------
#[tokio::test]
async fn localfile_append_adds_lines() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("log.txt");
let target = CronDeliveryTarget::LocalFile {
path: path.to_string_lossy().to_string(),
append: true,
};
let engine = test_engine(MockBridge::new());
// Two sequential deliveries should accumulate.
engine
.deliver(std::slice::from_ref(&target), "job", "first")
.await;
engine.deliver(&[target], "job", "second").await;
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("first") && content.contains("second"),
"expected both lines in appended file, got: {content:?}"
);
}
#[tokio::test]
async fn localfile_append_creates_missing_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested/deep/out.log");
let target = CronDeliveryTarget::LocalFile {
path: path.to_string_lossy().to_string(),
append: true,
};
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[target], "job", "payload").await;
assert!(results[0].success, "error: {:?}", results[0].error);
assert!(path.exists(), "nested file should have been created");
}
// -- Webhook: success ----------------------------------------------------
#[tokio::test]
async fn webhook_sends_payload() {
let (port, rx) = spawn_mock_http_server(200, "OK").await;
let url = format!("http://127.0.0.1:{port}/hook");
let target = CronDeliveryTarget::Webhook {
url: url.clone(),
auth_header: Some("Bearer test-token".to_string()),
};
let engine = test_engine(MockBridge::new());
let results = engine
.deliver(&[target], "daily-report", "result body")
.await;
assert!(results[0].success, "error: {:?}", results[0].error);
let captured = rx.await.expect("mock server never received a request");
assert!(
captured.body.contains("\"job\":\"daily-report\""),
"payload missing job name, got: {}",
captured.body
);
assert!(
captured.body.contains("\"output\":\"result body\""),
"payload missing output, got: {}",
captured.body
);
assert!(
captured.body.contains("\"timestamp\""),
"payload missing timestamp, got: {}",
captured.body
);
assert!(
captured
.headers
.iter()
.any(|h| h.eq_ignore_ascii_case("authorization: Bearer test-token")),
"missing auth header, got: {:?}",
captured.headers
);
}
#[tokio::test]
async fn webhook_reports_non_2xx() {
let (port, _rx) = spawn_mock_http_server(500, "Internal Server Error").await;
let url = format!("http://127.0.0.1:{port}/hook");
let target = CronDeliveryTarget::Webhook {
url,
auth_header: None,
};
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[target], "job", "output").await;
assert!(!results[0].success);
let err = results[0].error.as_deref().unwrap_or("");
assert!(err.contains("500"), "expected 500 in error, got: {err}");
}
// -- Channel target ------------------------------------------------------
#[tokio::test]
async fn channel_target_invokes_bridge() {
let bridge = MockBridge::new();
let engine = test_engine(bridge.clone());
let target = CronDeliveryTarget::Channel {
channel_type: "slack".to_string(),
recipient: "C12345".to_string(),
};
let results = engine.deliver(&[target], "alerts", "fire").await;
assert!(results[0].success, "error: {:?}", results[0].error);
let calls = bridge.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "slack");
assert_eq!(calls[0].1, "C12345");
assert_eq!(calls[0].2, "fire");
}
// -- Mixed success/failure ----------------------------------------------
#[tokio::test]
async fn mixed_targets_one_success_one_failure() {
let tmp = tempfile::tempdir().unwrap();
let ok_path = tmp.path().join("ok.txt");
let targets = vec![
// Will succeed (file write).
CronDeliveryTarget::LocalFile {
path: ok_path.to_string_lossy().to_string(),
append: false,
},
// Will fail (mock bridge rejects 'slack').
CronDeliveryTarget::Channel {
channel_type: "slack".to_string(),
recipient: "C1".to_string(),
},
];
let bridge = MockBridge::failing_on("slack");
let engine = test_engine(bridge);
let results = engine.deliver(&targets, "job", "payload").await;
assert_eq!(results.len(), 2);
assert!(
results[0].success,
"file delivery should succeed: {:?}",
results[0].error
);
assert!(
!results[1].success,
"channel delivery should fail, but got success"
);
assert!(results[1]
.error
.as_deref()
.unwrap_or("")
.contains("forced failure"));
// File was still written even though the other target failed.
assert_eq!(std::fs::read_to_string(&ok_path).unwrap(), "payload");
}
#[tokio::test]
async fn empty_targets_returns_empty_vec() {
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[], "job", "x").await;
assert!(results.is_empty());
}
// -- Serde round-trip ---------------------------------------------------
#[test]
fn serde_roundtrip_channel() {
let t = CronDeliveryTarget::Channel {
channel_type: "telegram".into(),
recipient: "12345".into(),
};
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"type\":\"channel\""), "tag missing: {s}");
assert!(s.contains("telegram"));
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
assert_eq!(t, back);
}
#[test]
fn serde_roundtrip_webhook() {
let t = CronDeliveryTarget::Webhook {
url: "https://example.com/hook".into(),
auth_header: Some("Bearer x".into()),
};
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"type\":\"webhook\""), "tag missing: {s}");
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
assert_eq!(t, back);
}
#[test]
fn serde_roundtrip_webhook_without_auth() {
// auth_header should default to None when omitted.
let json = r#"{"type":"webhook","url":"https://x.test/h"}"#;
let back: CronDeliveryTarget = serde_json::from_str(json).unwrap();
assert_eq!(
back,
CronDeliveryTarget::Webhook {
url: "https://x.test/h".into(),
auth_header: None,
}
);
}
#[test]
fn serde_roundtrip_localfile() {
let t = CronDeliveryTarget::LocalFile {
path: "/var/log/cron-out.log".into(),
append: true,
};
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"type\":\"local_file\""), "tag missing: {s}");
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
assert_eq!(t, back);
}
#[test]
fn serde_roundtrip_localfile_default_append() {
// append should default to false when omitted.
let json = r#"{"type":"local_file","path":"/tmp/out.log"}"#;
let back: CronDeliveryTarget = serde_json::from_str(json).unwrap();
assert_eq!(
back,
CronDeliveryTarget::LocalFile {
path: "/tmp/out.log".into(),
append: false,
}
);
}
#[test]
fn serde_roundtrip_email() {
let t = CronDeliveryTarget::Email {
to: "alice@example.com".into(),
subject_template: Some("Report: {job}".into()),
};
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"type\":\"email\""), "tag missing: {s}");
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
assert_eq!(t, back);
}
#[test]
fn render_subject_substitutes_placeholder() {
assert_eq!(render_subject(Some("Cron: {job}"), "daily"), "Cron: daily");
assert_eq!(
render_subject(Some("no placeholder"), "x"),
"no placeholder"
);
assert_eq!(render_subject(None, "daily"), "Cron: daily");
assert_eq!(render_subject(Some(""), "daily"), "Cron: daily");
}
// -- Minimal HTTP mock ---------------------------------------------------
struct CapturedRequest {
headers: Vec<String>,
body: String,
}
/// Spawn a tiny TCP server that serves exactly one request, parses the
/// HTTP/1.1 request line + headers + body, then responds with the given
/// status code and reason phrase. Returns `(port, oneshot_rx)` where the
/// oneshot resolves once the request has been received.
async fn spawn_mock_http_server(
status: u16,
reason: &'static str,
) -> (u16, tokio::sync::oneshot::Receiver<CapturedRequest>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let (mut stream, _) = match listener.accept().await {
Ok(s) => s,
Err(_) => return,
};
// Read until we have full headers and the declared body.
let mut buf = Vec::with_capacity(4096);
let mut tmp = [0u8; 1024];
let mut headers_end = None;
let mut content_length: Option<usize> = None;
loop {
let n = match stream.read(&mut tmp).await {
Ok(0) => break,
Ok(n) => n,
Err(_) => return,
};
buf.extend_from_slice(&tmp[..n]);
if headers_end.is_none() {
if let Some(pos) = find_subsequence(&buf, b"\r\n\r\n") {
headers_end = Some(pos + 4);
// Parse Content-Length.
let head_str = String::from_utf8_lossy(&buf[..pos]);
for line in head_str.lines() {
if let Some(v) = line.strip_prefix("Content-Length: ") {
content_length = v.trim().parse::<usize>().ok();
} else if let Some(v) = line.strip_prefix("content-length: ") {
content_length = v.trim().parse::<usize>().ok();
}
}
}
}
if let (Some(end), Some(cl)) = (headers_end, content_length) {
if buf.len() >= end + cl {
break;
}
}
if headers_end.is_some() && content_length.is_none() {
break;
}
}
// Split into headers + body.
let head_end = headers_end.unwrap_or(buf.len());
let head_str = String::from_utf8_lossy(&buf[..head_end.saturating_sub(4)]).to_string();
let body_bytes = if head_end < buf.len() {
&buf[head_end..]
} else {
&[][..]
};
let body = String::from_utf8_lossy(body_bytes).to_string();
let headers: Vec<String> = head_str.lines().skip(1).map(|l| l.to_string()).collect();
// Send response.
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
let _ = tx.send(CapturedRequest { headers, body });
});
(port, rx)
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
}
+41 -1
View File
@@ -12,7 +12,7 @@
use crate::registry::AgentRegistry;
use chrono::Utc;
use dashmap::DashMap;
use openfang_types::agent::{AgentId, AgentState};
use openfang_types::agent::{AgentEntry, AgentId, AgentState, ScheduleMode};
use tracing::{debug, warn};
/// Default heartbeat check interval (seconds).
@@ -132,6 +132,14 @@ impl Default for RecoveryTracker {
/// and the initial `set_state(Running)` call.
const IDLE_GRACE_SECS: i64 = 10;
/// Reactive agents are healthy while idle between user messages.
///
/// They should only participate in heartbeat failure detection while a turn is
/// actively running. Otherwise silence is the expected steady state.
pub(crate) fn should_exempt_idle_reactive_agent(entry: &AgentEntry, is_running_task: bool) -> bool {
matches!(entry.manifest.schedule, ScheduleMode::Reactive) && !is_running_task
}
/// Check all running and crashed agents and return their heartbeat status.
///
/// This is a pure function — it doesn't start a background task.
@@ -331,10 +339,13 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
max_history_messages: None,
},
state,
mode: AgentMode::default(),
@@ -375,6 +386,35 @@ mod tests {
);
}
#[test]
fn test_idle_reactive_agent_is_exempt_when_not_processing() {
let mut agent = make_entry(
"reactive-idle",
AgentState::Running,
Utc::now() - Duration::seconds(600),
Utc::now() - Duration::seconds(300),
);
agent.manifest.schedule = ScheduleMode::Reactive;
assert!(should_exempt_idle_reactive_agent(&agent, false));
assert!(!should_exempt_idle_reactive_agent(&agent, true));
}
#[test]
fn test_periodic_agent_is_not_exempt_when_idle() {
let mut agent = make_entry(
"periodic-idle",
AgentState::Running,
Utc::now() - Duration::seconds(600),
Utc::now() - Duration::seconds(300),
);
agent.manifest.schedule = ScheduleMode::Periodic {
cron: "0 * * * *".to_string(),
};
assert!(!should_exempt_idle_reactive_agent(&agent, false));
}
#[test]
fn test_active_agent_detected_unresponsive() {
// An agent that WAS active (last_active >> created_at) but has gone
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11,6 +11,7 @@ pub mod capabilities;
pub mod config;
pub mod config_reload;
pub mod cron;
pub mod cron_delivery;
pub mod error;
pub mod event_bus;
pub mod heartbeat;
+6
View File
@@ -234,6 +234,12 @@ pub struct BudgetStatus {
/// Order matters: more specific patterns must come before generic ones
/// (e.g. "gpt-4o-mini" before "gpt-4o", "gpt-4.1-mini" before "gpt-4.1").
fn estimate_cost_rates(model: &str) -> (f64, f64) {
// ── Requesty (issue #995) ──────────────────────────────────
// Router-style gateway. IDs are `requesty/<upstream>/<model>` and
// resolve via substring match on the upstream model name below
// (e.g. "sonnet", "gpt-4o", "gemini", "deepseek", "llama").
// No early-return here — fall through to upstream patterns.
// ── Anthropic ──────────────────────────────────────────────
if model.contains("haiku") {
return (0.25, 1.25);
+20
View File
@@ -134,6 +134,23 @@ impl AgentRegistry {
Ok(())
}
/// Update an agent's private state directory path. The state directory
/// holds identity files, sessions, and per-agent memory and is always
/// kept separate from the user-facing workspace. See issue #1097.
pub fn update_state_dir(
&self,
id: AgentId,
state_dir: Option<std::path::PathBuf>,
) -> OpenFangResult<()> {
let mut entry = self
.agents
.get_mut(&id)
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
entry.manifest.state_dir = state_dir;
entry.last_active = chrono::Utc::now();
Ok(())
}
/// Update an agent's visual identity (emoji, avatar, color).
pub fn update_identity(
&self,
@@ -391,10 +408,13 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
max_history_messages: None,
},
state: AgentState::Created,
mode: AgentMode::default(),
+8
View File
@@ -449,6 +449,14 @@ fn describe_event(event: &Event) -> String {
"Health check failed: agent {agent_id}, unresponsive for {unresponsive_secs}s"
)
}
SystemEvent::CronJobExecuted {
agent_id,
job_id,
job_name,
..
} => {
format!("Cron job executed: {job_name} ({job_id}) for agent {agent_id}")
}
},
EventPayload::Custom(data) => {
format!("Custom event ({} bytes)", data.len())
+3
View File
@@ -176,12 +176,15 @@ impl SetupWizard {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
profile: None,
fallback_models: vec![],
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
max_history_messages: None,
};
let skills_to_install: Vec<String> = intent
@@ -19,6 +19,7 @@ fn test_config() -> KernelConfig {
model: "llama-3.3-70b-versatile".to_string(),
api_key_env: "GROQ_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
@@ -19,6 +19,7 @@ fn test_config() -> KernelConfig {
model: "llama-3.3-70b-versatile".to_string(),
api_key_env: "GROQ_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
@@ -115,6 +115,7 @@ fn test_config(tmp: &tempfile::TempDir) -> KernelConfig {
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
@@ -24,6 +24,7 @@ fn test_config(provider: &str, model: &str, api_key_env: &str) -> KernelConfig {
model: model.to_string(),
api_key_env: api_key_env.to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
+5 -2
View File
@@ -496,7 +496,7 @@ impl SessionStore {
.conn
.lock()
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
let messages_blob = rmp_serde::to_vec(&canonical.messages)
let messages_blob = rmp_serde::to_vec_named(&canonical.messages)
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
conn.execute(
"INSERT INTO canonical_sessions (agent_id, messages, compaction_cursor, compacted_summary, updated_at)
@@ -586,12 +586,15 @@ impl SessionStore {
ContentBlock::Image { media_type, .. } => {
text_parts.push(format!("[image: {media_type}]"));
}
ContentBlock::Thinking { thinking } => {
ContentBlock::Thinking { thinking, .. } => {
text_parts.push(format!(
"[thinking: {}]",
openfang_types::truncate_str(thinking, 200)
));
}
ContentBlock::RedactedThinking { .. } => {
text_parts.push("[redacted_thinking]".to_string());
}
ContentBlock::Unknown => {}
}
}
+2 -4
View File
@@ -896,10 +896,8 @@ fn derive_capabilities(tools: &[String]) -> AgentCapabilities {
"shell_exec" => {
caps.shell = vec!["*".to_string()];
}
"web_fetch" | "web_search" | "browser_navigate" => {
if caps.network.is_empty() {
caps.network = vec!["*".to_string()];
}
"web_fetch" | "web_search" | "browser_navigate" if caps.network.is_empty() => {
caps.network = vec!["*".to_string()];
}
"agent_send" | "agent_list" => {
if caps.agent_message.is_empty() {
@@ -0,0 +1,225 @@
//! Per-turn agent context loader for external `context.md` files.
//!
//! Some agents depend on a `context.md` file updated by external tools (e.g. a
//! cron job that writes live market data, or a script that refreshes project
//! state). Before issue #843 this file was read once when the session started
//! and then cached for the lifetime of the conversation, so external updates
//! never reached the LLM.
//!
//! The default behaviour is now a small disk read per turn when the prompt is
//! assembled. Agents that depend on the old behaviour can opt back in via the
//! `cache_context` flag on their manifest.
//!
//! This module intentionally does not participate in per-token streaming — it
//! is called once per agent turn, right before the system prompt is built.
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::{collections::HashMap, fs};
use tracing::{debug, warn};
/// Maximum size of `context.md` to inject into the prompt (32 KB).
///
/// Matches the cap used by [`crate::workspace_context`] and the kernel's
/// identity-file reader so a runaway file cannot blow up the prompt.
const MAX_CONTEXT_BYTES: u64 = 32_768;
/// Filename that agents use for per-turn refreshable context.
pub const CONTEXT_FILENAME: &str = "context.md";
/// In-memory cache of the last successful read for each workspace.
///
/// Used for two purposes:
/// 1. When `cache_context = true`, the first successful read is returned on
/// every subsequent call.
/// 2. When `cache_context = false` and a re-read fails on disk (e.g. the file
/// was temporarily replaced by an external writer), we fall back to the
/// previous content instead of dropping context mid-conversation.
fn cache() -> &'static Mutex<HashMap<PathBuf, String>> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, String>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Load the agent's `context.md` for this turn.
///
/// Returns the current on-disk content, or — if the read fails after a
/// previous success — the cached content with a warning. Returns `None` when
/// no context.md has ever been seen for this workspace.
///
/// When `cache_context` is true the first successful read is stored and
/// returned verbatim on every future call. Callers pass the flag straight from
/// `AgentManifest::cache_context`.
pub fn load_context_md(workspace: &Path, cache_context: bool) -> Option<String> {
let path = workspace.join(CONTEXT_FILENAME);
if cache_context {
if let Some(cached) = get_cached(&path) {
return Some(cached);
}
}
match read_capped(&path) {
Ok(Some(content)) => {
store_cached(&path, &content);
Some(content)
}
Ok(None) => {
// File is absent or empty — do not serve a stale cache for a
// deleted file unless the caller explicitly opted into caching.
if cache_context {
get_cached(&path)
} else {
None
}
}
Err(e) => {
if let Some(prev) = get_cached(&path) {
warn!(
path = %path.display(),
error = %e,
"Failed to re-read context.md; falling back to cached content"
);
Some(prev)
} else {
debug!(path = %path.display(), error = %e, "context.md unreadable and no cache");
None
}
}
}
}
fn get_cached(path: &Path) -> Option<String> {
cache()
.lock()
.ok()
.and_then(|guard| guard.get(path).cloned())
}
fn store_cached(path: &Path, content: &str) {
if let Ok(mut guard) = cache().lock() {
guard.insert(path.to_path_buf(), content.to_string());
}
}
/// Read the file, returning Ok(None) if it is missing or empty, and
/// Ok(Some(...)) if it has usable content. Oversized files are truncated to
/// [`MAX_CONTEXT_BYTES`] so prompt size remains bounded.
fn read_capped(path: &Path) -> std::io::Result<Option<String>> {
let meta = match fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if !meta.is_file() {
return Ok(None);
}
let content = fs::read_to_string(path)?;
if content.trim().is_empty() {
return Ok(None);
}
if meta.len() > MAX_CONTEXT_BYTES {
let truncated = crate::str_utils::safe_truncate_str(&content, MAX_CONTEXT_BYTES as usize);
return Ok(Some(truncated.to_string()));
}
Ok(Some(content))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn fresh_workspace(tag: &str) -> PathBuf {
// Unique temp dir per test to avoid cross-test cache pollution.
let dir = std::env::temp_dir().join(format!(
"openfang_ctx_{}_{}",
tag,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reread_picks_up_external_update() {
let ws = fresh_workspace("reread");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, "initial content A").unwrap();
let first = load_context_md(&ws, false).unwrap();
assert!(first.contains("initial content A"));
// External writer updates the file (simulates the cron case from #843).
{
let mut f = fs::File::create(&path).unwrap();
f.write_all(b"updated content B").unwrap();
}
let second = load_context_md(&ws, false).unwrap();
assert!(second.contains("updated content B"));
assert!(!second.contains("initial content A"));
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn cache_context_true_freezes_first_read() {
let ws = fresh_workspace("cache");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, "frozen A").unwrap();
let first = load_context_md(&ws, true).unwrap();
assert!(first.contains("frozen A"));
fs::write(&path, "never seen B").unwrap();
let second = load_context_md(&ws, true).unwrap();
assert_eq!(first, second);
assert!(!second.contains("never seen B"));
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn missing_file_returns_none() {
let ws = fresh_workspace("missing");
assert!(load_context_md(&ws, false).is_none());
assert!(load_context_md(&ws, true).is_none());
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn read_failure_falls_back_to_cache() {
let ws = fresh_workspace("fallback");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, "cached payload").unwrap();
let first = load_context_md(&ws, false).unwrap();
assert!(first.contains("cached payload"));
// Write bytes that are not valid UTF-8 so read_to_string returns an
// IO error. This simulates a transient read failure while the cron
// job is mid-rewrite.
{
let mut f = fs::File::create(&path).unwrap();
f.write_all(&[0xff, 0xfe, 0xfd, 0x80, 0x81]).unwrap();
}
let second = load_context_md(&ws, false);
assert_eq!(second.as_deref(), Some("cached payload"));
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn empty_file_treated_as_absent() {
let ws = fresh_workspace("empty");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, " \n\n ").unwrap();
assert!(load_context_md(&ws, false).is_none());
let _ = fs::remove_dir_all(&ws);
}
}
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -404,6 +404,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
conversation_text.push_str(&format!("[Image: {media_type}]\n\n"));
}
ContentBlock::Thinking { .. } => {}
ContentBlock::RedactedThinking { .. } => {}
ContentBlock::Unknown => {}
}
}
@@ -435,10 +436,9 @@ 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)
// 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();
@@ -458,6 +458,7 @@ async fn summarize_messages(
text: summarize_prompt,
provider_metadata: None,
}]),
..Default::default()
}],
tools: vec![],
max_tokens: config.max_summary_tokens,
@@ -576,6 +577,7 @@ async fn summarize_in_chunks(
text: merge_prompt,
provider_metadata: None,
}]),
..Default::default()
}],
tools: vec![],
max_tokens: config.max_summary_tokens,
@@ -913,6 +915,7 @@ mod tests {
input: serde_json::json!({"query": "test"}),
provider_metadata: None,
}]),
..Default::default()
};
messages[2] = Message {
role: Role::User,
@@ -922,6 +925,7 @@ mod tests {
content: "Search results here".to_string(),
is_error: false,
}]),
..Default::default()
};
let session = Session {
@@ -1252,6 +1256,7 @@ mod tests {
provider_metadata: None,
},
]),
..Default::default()
},
Message {
role: Role::User,
@@ -1261,6 +1266,7 @@ mod tests {
content: "Results found".to_string(),
is_error: false,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -1268,6 +1274,7 @@ mod tests {
media_type: "image/png".to_string(),
data: "base64data".to_string(),
}]),
..Default::default()
},
];
@@ -1402,6 +1409,7 @@ mod tests {
content: tool_content,
is_error: false,
}]),
..Default::default()
}];
let text = build_conversation_text(&messages, &config);
// The base64 blob should be stripped/replaced by session_repair
@@ -1422,6 +1430,7 @@ mod tests {
content: large_result,
is_error: false,
}]),
..Default::default()
}];
let text = build_conversation_text(&messages, &config);
// Should be capped at ~2000 chars (plus the "..." suffix)
@@ -1446,6 +1455,7 @@ mod tests {
content: short_result.to_string(),
is_error: false,
}]),
..Default::default()
}];
let text = build_conversation_text(&messages, &config);
assert!(text.contains(short_result));
@@ -1465,6 +1475,7 @@ mod tests {
input: serde_json::json!({}),
provider_metadata: None,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -1474,6 +1485,7 @@ mod tests {
content: "file contents".to_string(),
is_error: false,
}]),
..Default::default()
},
Message::assistant("Done reading."),
];
@@ -5,6 +5,7 @@
//! - Layer 2: Context guard that scans all tool results before LLM calls
//! and compacts oldest results when total exceeds 75% headroom.
use crate::str_utils::safe_truncate_str;
use openfang_types::message::{ContentBlock, Message, MessageContent};
use openfang_types::tool::ToolDefinition;
use tracing::debug;
@@ -217,11 +218,14 @@ fn truncate_to(content: &str, max_chars: usize) -> String {
.rfind('\n')
.map(|pos| search_start + pos)
.unwrap_or(keep);
// Use safe_truncate_str as an extra layer of safety
let safe_content = safe_truncate_str(content, break_point);
format!(
"{}\n\n[COMPACTED: {} → {} chars by context guard]",
&content[..break_point],
safe_content,
content.len(),
break_point
safe_content.len()
)
}
@@ -286,6 +290,7 @@ mod tests {
content: big_result.clone(),
is_error: false,
}]),
..Default::default()
},
Message {
role: openfang_types::message::Role::User,
@@ -295,6 +300,7 @@ mod tests {
content: big_result,
is_error: false,
}]),
..Default::default()
},
];
@@ -346,6 +352,7 @@ mod tests {
content: big_chinese,
is_error: false,
}]),
..Default::default()
}];
// Must not panic on multi-byte content
let compacted = apply_context_guard(&mut messages, &budget, &[]);

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