Compare commits

...
25 Commits
Author SHA1 Message Date
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
Nimit Bhardwaj 5e228336e4 fix(chat): support Shift+Enter for multi-line input and proper newline display 2026-05-08 23:12:29 +05:30
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
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 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
38 changed files with 3169 additions and 183 deletions
Generated
+16 -14
View File
@@ -3959,7 +3959,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"argon2",
"async-trait",
@@ -4001,7 +4001,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"aes",
"async-trait",
@@ -4040,7 +4040,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"clap",
"clap_complete",
@@ -4068,7 +4068,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"axum",
"open",
@@ -4094,7 +4094,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"aes-gcm",
"argon2",
@@ -4122,7 +4122,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"chrono",
"dashmap",
@@ -4140,7 +4140,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"chrono",
@@ -4179,7 +4179,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"chrono",
@@ -4199,7 +4199,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4218,7 +4218,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"anyhow",
"async-trait",
@@ -4254,11 +4254,13 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"chrono",
"ed25519-dalek",
"hex",
"openfang-types",
"rand 0.8.5",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -4277,7 +4279,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"bitflags 2.11.0",
@@ -4297,7 +4299,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"chrono",
@@ -9231,7 +9233,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.6.5"
version = "0.6.7"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.6.5"
version = "0.6.7"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
+2 -2
View File
@@ -19,8 +19,8 @@
<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.6.5-green?style=flat-square" alt="v0.6.5" />
<img src="https://img.shields.io/badge/tests-2,585%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/version-0.6.7-green?style=flat-square" alt="v0.6.7" />
<img src="https://img.shields.io/badge/tests-2,657%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>
+230
View File
@@ -692,6 +692,98 @@ pub async fn kill_agent(
}
}
/// DELETE /api/agents/{id}/uninstall — Permanently uninstall an agent.
///
/// Issue #1163: in addition to killing the agent (registry + memory + cron),
/// this also removes the on-disk `~/.openfang/agents/<name>/` directory so
/// the agent does not auto-respawn on the next daemon start.
pub async fn uninstall_agent(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let agent_id: AgentId = match id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid agent ID"})),
);
}
};
// Capture the agent name BEFORE killing — registry entry is gone after.
let agent_name = match state.kernel.registry.get(agent_id) {
Some(entry) => entry.name.clone(),
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
);
}
};
// Step 1: kill the agent (registry, memory, cron, triggers, caps).
if let Err(e) = state.kernel.kill_agent(agent_id) {
tracing::warn!("kill_agent failed during uninstall for {id}: {e}");
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found or already terminated"})),
);
}
// Step 2: remove ~/.openfang/agents/<name>/ so the agent does NOT
// auto-respawn from disk on the next daemon start.
let agents_dir = state.kernel.config.home_dir.join("agents");
let agent_dir = agents_dir.join(&agent_name);
let dir_removed = if agent_dir.is_dir() {
// Safety: only allow removal if the parent is exactly the agents root.
let parent_ok = agent_dir
.parent()
.map(|p| p == agents_dir.as_path())
.unwrap_or(false);
if !parent_ok {
tracing::warn!(
agent = %agent_name,
path = %agent_dir.display(),
"Refusing to remove agent dir outside agents root"
);
false
} else {
match std::fs::remove_dir_all(&agent_dir) {
Ok(()) => {
tracing::info!(
agent = %agent_name,
path = %agent_dir.display(),
"Removed agent directory on uninstall (#1163)"
);
true
}
Err(e) => {
tracing::warn!(
agent = %agent_name,
path = %agent_dir.display(),
"Failed to remove agent directory: {e}"
);
false
}
}
}
} else {
false
};
(
StatusCode::OK,
Json(serde_json::json!({
"status": "uninstalled",
"agent_id": id,
"name": agent_name,
"dir_removed": dir_removed,
})),
)
}
/// POST /api/agents/{id}/restart — Restart a crashed/stuck agent.
///
/// Cancels any active task, resets agent state to Running, and updates last_active.
@@ -7077,6 +7169,10 @@ pub async fn compact_session(
}
/// POST /api/agents/{id}/stop — Cancel an agent's current LLM run.
///
/// If the agent is owned by an active hand instance, the hand instance is
/// also deactivated. Otherwise the hand stays registered as `Active` and the
/// user cannot re-activate it via the wizard (issue #1164).
pub async fn stop_agent(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
@@ -7090,6 +7186,33 @@ pub async fn stop_agent(
)
}
};
// If this agent is the agent of an active hand instance, deactivate the
// hand entirely — which also kills the agent and cancels the run. This
// matches what users expect when they click Stop on a hand-owned agent.
if let Some(instance) = state.kernel.hand_registry.find_by_agent(agent_id) {
match state.kernel.deactivate_hand(instance.instance_id) {
Ok(()) => {
return (
StatusCode::OK,
Json(serde_json::json!({
"status": "ok",
"message": "Hand deactivated",
"hand_deactivated": true,
"hand_id": instance.hand_id,
"instance_id": instance.instance_id,
})),
);
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
}
}
match state.kernel.stop_agent_run(agent_id) {
Ok(true) => (
StatusCode::OK,
@@ -12583,3 +12706,110 @@ mod skill_config_tests {
assert_eq!(back, doc);
}
}
#[cfg(test)]
mod uninstall_agent_tests {
//! Issue #1163 — directory-removal portion of the uninstall flow.
//!
//! These tests exercise the same logic the route handler runs after
//! `kernel.kill_agent()`: locate `<home>/agents/<name>/`, verify it is
//! directly under the agents root, and remove it. Live end-to-end
//! coverage (real HTTP + kernel) belongs in `tests/api_integration_test.rs`.
use std::path::Path;
/// Mirror of the dir-removal logic in `uninstall_agent`. Kept in sync
/// with the route handler so the rules can be unit-tested without a
/// running kernel. Returns whether the directory was removed.
fn remove_agent_dir(home_dir: &Path, agent_name: &str) -> bool {
let agents_dir = home_dir.join("agents");
let agent_dir = agents_dir.join(agent_name);
if !agent_dir.is_dir() {
return false;
}
let parent_ok = agent_dir
.parent()
.map(|p| p == agents_dir.as_path())
.unwrap_or(false);
if !parent_ok {
return false;
}
std::fs::remove_dir_all(&agent_dir).is_ok()
}
#[test]
fn removes_agent_directory_under_agents_root() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let agents = home.join("agents");
std::fs::create_dir_all(agents.join("trash-agent")).unwrap();
std::fs::write(
agents.join("trash-agent").join("agent.toml"),
"name = \"trash-agent\"\n",
)
.unwrap();
assert!(agents.join("trash-agent").is_dir());
let removed = remove_agent_dir(&home, "trash-agent");
assert!(removed, "agent directory must be removed");
assert!(!agents.join("trash-agent").exists());
}
#[test]
fn returns_false_when_no_directory_exists() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
std::fs::create_dir_all(home.join("agents")).unwrap();
let removed = remove_agent_dir(&home, "ghost-agent");
assert!(!removed, "no dir => false, but uninstall still succeeds");
}
#[test]
fn does_not_touch_siblings() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let agents = home.join("agents");
std::fs::create_dir_all(agents.join("trash-agent")).unwrap();
std::fs::create_dir_all(agents.join("keep-me")).unwrap();
std::fs::write(
agents.join("trash-agent").join("agent.toml"),
"name = \"trash-agent\"\n",
)
.unwrap();
std::fs::write(
agents.join("keep-me").join("agent.toml"),
"name = \"keep-me\"\n",
)
.unwrap();
assert!(remove_agent_dir(&home, "trash-agent"));
assert!(!agents.join("trash-agent").exists());
assert!(
agents.join("keep-me").is_dir(),
"sibling agent dirs must not be touched by uninstall"
);
}
#[test]
fn rejects_path_traversal_attempt() {
// A name like "../escape" would join to a path whose parent is the
// agents root only if the file system resolves it that way — but
// `parent()` on a non-canonicalized Path returns the textual parent,
// which for `<home>/agents/../escape` is `<home>/agents/..`, not
// `<home>/agents`. The check rejects it.
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
std::fs::create_dir_all(home.join("agents")).unwrap();
// Create a sibling dir outside agents/ that an attacker might want
// to delete.
std::fs::create_dir_all(home.join("escape")).unwrap();
std::fs::write(home.join("escape").join("secret.toml"), "x = 1\n").unwrap();
let removed = remove_agent_dir(&home, "../escape");
assert!(!removed, "must reject path-traversal names");
assert!(
home.join("escape").is_dir(),
"sibling dir outside agents/ must NOT be deleted"
);
}
}
+4
View File
@@ -186,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),
+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",
+23 -8
View File
@@ -1025,15 +1025,30 @@ 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) {
+3 -1
View File
@@ -580,6 +580,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g x-show="!$store.app.focusMode"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g><g x-show="$store.app.focusMode"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></svg>
</button>
<button class="btn btn-danger btn-sm" @click="killAgent()">Stop</button>
<button class="btn btn-danger btn-sm" @click="uninstallAgent()" title="Stop and remove agent files from workspace">Uninstall</button>
</div>
</div>
@@ -739,7 +740,7 @@
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
</div>
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
@keydown.enter.prevent="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){$event.preventDefault();if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.escape="showSlashMenu = false; showModelPicker = false"
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
@@ -988,6 +989,7 @@
<button class="btn btn-ghost" @click="cloneAgent(detailAgent)">Clone</button>
<button class="btn btn-ghost" @click="clearHistory(detailAgent)">Clear History</button>
<button class="btn btn-danger" @click="killAgent(detailAgent)">Stop</button>
<button class="btn btn-danger" @click="uninstallAgent(detailAgent)" title="Stop and remove agent files from workspace">Uninstall</button>
</div>
</div>
+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) {
@@ -376,6 +376,29 @@ function agentsPage() {
});
},
// Issue #1163: uninstall an agent (kill + remove ~/.openfang/agents/<name>/).
uninstallAgent(agent) {
var self = this;
OpenFangToast.confirm(
'Uninstall Agent',
'Uninstall agent "' + agent.name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
async function() {
try {
var res = await OpenFangAPI.del('/api/agents/' + agent.id + '/uninstall');
var msg = 'Agent "' + agent.name + '" uninstalled';
if (res && res.dir_removed === false) {
msg += ' (no on-disk files found)';
}
OpenFangToast.success(msg);
self.showDetailModal = false;
await Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
}
}
);
},
killAllAgents() {
var list = this.filteredAgents;
if (!list.length) return;
@@ -143,6 +143,29 @@ function chatPage() {
// Fetch dynamic commands from server
this.fetchCommands();
// Observe DOM for new messages and render LaTeX
this._latexObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
var bubbles = node.querySelector ? node.querySelectorAll('.message-bubble') : [];
if (node.classList && node.classList.contains('message-bubble')) {
bubbles = [node];
}
bubbles.forEach(function(bubble) {
if (bubble.textContent && hasLatexDelimiters(bubble.textContent)) {
renderLatex(bubble);
}
});
}
});
});
});
this._latexObserver.observe(document.getElementById('messages') || document.body, {
childList: true,
subtree: true
});
// Ctrl+/ keyboard shortcut
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
@@ -175,6 +198,10 @@ function chatPage() {
if (store.pendingAgent) {
self.selectAgent(store.pendingAgent);
store.pendingAgent = null;
} else {
// Restore previously active agent after page refresh (#1179).
// The agent list may not be loaded yet, so resolve once it appears.
self._restoreActiveAgent();
}
// Watch for future pending agent selections (e.g., user clicks agent while on chat)
@@ -185,6 +212,13 @@ function chatPage() {
}
});
// Re-attempt restore once the agent list arrives from the server
this.$watch('$store.app.agents', function(agents) {
if (!self.currentAgent && agents && agents.length) {
self._restoreActiveAgent();
}
});
// Watch for slash commands + model autocomplete
this.$watch('inputText', function(val) {
var modelMatch = val.match(/^\/model\s+(.*)$/i);
@@ -528,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':
@@ -563,9 +598,27 @@ function chatPage() {
}
},
// Restore the previously-active agent (set in selectAgent) after a page
// refresh, so the WebSocket re-attaches to the same session and any
// in-flight tool output streams back into the chat (#1179).
_restoreActiveAgent: function() {
var storedId = null;
try { storedId = localStorage.getItem('of-active-agent'); } catch(e) { /* ignore */ }
if (!storedId) return;
var agents = (Alpine.store('app') && Alpine.store('app').agents) || [];
var match = null;
for (var i = 0; i < agents.length; i++) {
if (agents[i] && agents[i].id === storedId) { match = agents[i]; break; }
}
if (match) {
this.selectAgent(match);
}
},
selectAgent(agent) {
this.currentAgent = agent;
this.messages = [];
try { localStorage.setItem('of-active-agent', agent.id); } catch(e) { /* ignore */ }
this.connectWs(agent.id);
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
// Show welcome tips on first use
@@ -1150,6 +1203,7 @@ function chatPage() {
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
OpenFangToast.success(t('chat.agent_stopped') + ' "' + name + '"');
Alpine.store('app').refreshAgents();
} catch(e) {
@@ -1158,6 +1212,37 @@ function chatPage() {
});
},
// Permanently uninstall the agent: kill + remove ~/.openfang/agents/<name>/
// Issue #1163.
uninstallAgent: function() {
if (!this.currentAgent) return;
var self = this;
var name = this.currentAgent.name;
var agentId = this.currentAgent.id;
OpenFangToast.confirm(
'Uninstall Agent',
'Uninstall agent "' + name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
async function() {
try {
var res = await OpenFangAPI.del('/api/agents/' + agentId + '/uninstall');
OpenFangAPI.wsDisconnect();
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
var msg = 'Agent "' + name + '" uninstalled';
if (res && res.dir_removed === false) {
msg += ' (no on-disk files found)';
}
OpenFangToast.success(msg);
Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
}
}
);
},
_latexTimer: null,
scrollToBottom() {
var self = this;
+347 -62
View File
@@ -4,7 +4,7 @@
//! `BridgeManager` which owns running adapters and dispatches messages.
use crate::formatter;
use crate::router::AgentRouter;
use crate::router::{AgentRouter, BindingContext};
use crate::types::{
default_phase_emoji, AgentPhase, ChannelAdapter, ChannelContent, ChannelMessage, ChannelUser,
LifecycleReaction,
@@ -679,31 +679,33 @@ fn sender_user_id(message: &ChannelMessage) -> &str {
.unwrap_or(&message.sender.platform_id)
}
/// Extract the channel/conversation ID from a message, for bindings whose
/// `match_rule.channel_id` is set.
/// Build a `BindingContext` for routing the given inbound message.
///
/// On Discord and Slack, `sender.platform_id` already holds the channel/
/// conversation ID (per `discord.rs` and `slack.rs`, where the user ID lives
/// in metadata under `sender_user_id`). On other adapters where the platform
/// ID is the user, callers can opt-in by stashing the channel ID under the
/// `sender_channel_id` metadata key.
fn sender_channel_id(message: &ChannelMessage) -> Option<&str> {
if let Some(v) = message
.metadata
.get("sender_channel_id")
.and_then(|v| v.as_str())
{
return Some(v);
}
// On Discord/Slack, the metadata `sender_user_id` is set and differs from
// `sender.platform_id` — in that case, platform_id IS the channel ID.
let user_in_meta = message
.metadata
.get("sender_user_id")
.and_then(|v| v.as_str());
match user_in_meta {
Some(uid) if uid != message.sender.platform_id => Some(&message.sender.platform_id),
_ => None,
/// Populates `channel_id` so per-channel bindings (e.g. `channel_id = "<discord_channel>"`)
/// can route to dedicated agents. The channel ID source is delegated to
/// [`ChannelMessage::channel_id`] — the single source of truth shared with
/// config validation (see `CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL` in
/// `openfang-types::config`). `peer_id` uses the resolved user ID, not
/// `sender.platform_id`, so user-scoped bindings still match correctly on
/// Discord/Slack/etc. where `platform_id` holds the channel.
///
/// This replaces the earlier heuristic `sender_channel_id()` (which inferred
/// "platform_id is the channel" from "metadata has `sender_user_id`"). The
/// allowlist is explicit, the metadata-fallback path is documented, and
/// adapters can be added or removed in one place (`openfang-types::config`)
/// without touching this file.
fn binding_context_for(message: &ChannelMessage) -> BindingContext {
BindingContext {
channel: channel_type_str(&message.channel).to_string(),
account_id: None,
peer_id: sender_user_id(message).to_string(),
channel_id: message.channel_id(),
guild_id: message
.metadata
.get("guild_id")
.and_then(|v| v.as_str())
.map(String::from),
roles: Vec::new(),
}
}
@@ -858,6 +860,93 @@ async fn dispatch_message(
return;
}
// Multipart: flatten children into LLM content blocks. If any image
// succeeds, dispatch as multimodal; otherwise fall through to the text
// path (Multipart arm in the match below builds the combined descriptor).
if let ChannelContent::Multipart(parts) = &message.content {
let mut blocks: Vec<ContentBlock> = Vec::new();
for part in parts {
debug_assert!(
!matches!(part, ChannelContent::Multipart(_)),
"nested Multipart in ChannelContent — adapters should produce flat lists"
);
match part {
ChannelContent::Text(t) => blocks.push(ContentBlock::Text {
text: t.clone(),
provider_metadata: None,
}),
ChannelContent::Image { url, caption } => {
let mut img = download_image_to_blocks(url, caption.as_deref()).await;
blocks.append(&mut img);
}
ChannelContent::File { url, filename, .. } => {
blocks.push(ContentBlock::Text {
text: format!("[User sent a file ({filename}): {url}]"),
provider_metadata: None,
});
}
ChannelContent::Voice {
url,
duration_seconds,
} => {
blocks.push(ContentBlock::Text {
text: format!("[User sent a voice message ({duration_seconds}s): {url}]"),
provider_metadata: None,
});
}
ChannelContent::Location { lat, lon } => {
blocks.push(ContentBlock::Text {
text: format!("[User shared location: {lat}, {lon}]"),
provider_metadata: None,
});
}
ChannelContent::FileData { filename, .. } => {
blocks.push(ContentBlock::Text {
text: format!("[User sent a local file: {filename}]"),
provider_metadata: None,
});
}
// Commands aren't expected inside Multipart, but render as
// text rather than drop the message if one slips through.
ChannelContent::Command { name, args } => {
blocks.push(ContentBlock::Text {
text: format!("/{name} {}", args.join(" ")),
provider_metadata: None,
});
}
// Defensive: debug_assert above catches this in dev; ignore
// gracefully in release.
ChannelContent::Multipart(_) => {}
}
}
if blocks
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }))
{
let prefix_style = overrides
.as_ref()
.map(|o| o.prefix_agent_name)
.unwrap_or(PrefixStyle::Off);
dispatch_with_blocks(
blocks,
message,
handle,
router,
adapter,
adapter_arc,
ct_str,
thread_id,
output_format,
lifecycle_reactions,
prefix_style,
)
.await;
return;
}
// No image blocks — fall through to text path below.
}
// For images: download, base64 encode, and send as multimodal content blocks
if let ChannelContent::Image {
ref url,
@@ -909,6 +998,7 @@ async fn dispatch_message(
ChannelContent::File {
ref url,
ref filename,
..
} => {
format!("[User sent a file ({filename}): {url}]")
}
@@ -924,6 +1014,37 @@ async fn dispatch_message(
ChannelContent::FileData { ref filename, .. } => {
format!("[User sent a local file: {filename}]")
}
ChannelContent::Multipart(parts) => parts
.iter()
.map(|p| match p {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Image { url, caption } => match caption {
Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"),
None => format!("[User sent a photo: {url}]"),
},
ChannelContent::File { url, filename, .. } => {
format!("[User sent a file ({filename}): {url}]")
}
ChannelContent::Voice {
url,
duration_seconds,
} => format!("[User sent a voice message ({duration_seconds}s): {url}]"),
ChannelContent::Location { lat, lon } => {
format!("[User shared location: {lat}, {lon}]")
}
ChannelContent::FileData { filename, .. } => {
format!("[User sent a local file: {filename}]")
}
ChannelContent::Command { name, args } => {
format!("/{name} {}", args.join(" "))
}
// Nesting is rejected by adapters; emit empty so the join
// doesn't insert spurious separators.
ChannelContent::Multipart(_) => String::new(),
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n"),
};
// Check if it's a slash command embedded in text (e.g. "/agents")
@@ -1029,13 +1150,15 @@ async fn dispatch_message(
// Route to agent (standard path).
// Use sender_user_id() so user-keyed bindings (peer_id) match for adapters like
// Discord/Slack where sender.platform_id is the channel ID, not the user ID.
// Pass the channel/conversation ID separately so bindings with `channel_id`
// can match (e.g. "messages in Discord channel X → agent Y").
let agent_id = router.resolve_with_channel_id(
// Use resolve_with_context so channel_id-scoped (and guild_id-scoped)
// bindings can route per channel — see binding_context_for() for the
// single-source-of-truth allowlist.
let binding_ctx = binding_context_for(message);
let agent_id = router.resolve_with_context(
&message.channel,
sender_user_id(message),
message.sender.openfang_user.as_deref(),
sender_channel_id(message),
&binding_ctx,
);
let agent_id = match agent_id {
@@ -1381,6 +1504,10 @@ fn media_type_from_url(url: &str) -> String {
/// Download an image from a URL and build content blocks for multimodal LLM input.
///
/// Accepts both `http(s)://` URLs (fetched via reqwest) and `file://` URLs
/// (read from local disk — used by the channel inbox materialization path so
/// agents see a stable local path even after a Discord CDN URL has expired).
///
/// Returns a `Vec<ContentBlock>` containing an image block (base64-encoded) and
/// optionally a text block for the caption. If the download fails, returns a
/// text-only block describing the failure.
@@ -1390,38 +1517,79 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<Conte
// 5 MB limit to prevent memory abuse from oversized images
const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
let client = reqwest::Client::new();
let resp = match client.get(url).send().await {
Ok(r) => r,
Err(e) => {
warn!("Failed to download image from channel: {e}");
return vec![ContentBlock::Text {
text: format!("[Image download failed: {e}]"),
provider_metadata: None,
}];
}
};
// Branch on URL scheme: file:// reads from local disk, everything else
// goes through HTTP. We unify both paths into (bytes, header_type) before
// the size/magic-byte logic below.
let (bytes, header_type): (Vec<u8>, Option<String>) =
if let Some(path) = url.strip_prefix("file://") {
// file:// — local read. No content-type header to honor; magic-byte
// sniffing and URL extension fallback do all the work. We don't
// percent-decode: the inbox writer controls filenames and avoids
// characters that would need encoding.
match tokio::fs::read(path).await {
Ok(b) => (b, None),
Err(e) => {
warn!("Failed to read image from local path {path}: {e}");
return vec![ContentBlock::Text {
text: format!("[Image read failed: {e}]"),
provider_metadata: None,
}];
}
}
} else {
// Build the client with transparent decompression DISABLED. Discord's
// CDN edges occasionally advertise `content-encoding: gzip` (or br)
// on PNG/JPEG passthroughs while the body is the raw, uncompressed
// image bytes. With the default reqwest client (gzip/deflate/brotli
// features enabled at the workspace level), this causes the
// decompression layer to choke on the image header and reqwest
// returns "error decoding response body" only on `bytes().await`,
// not on `send()`. Forcing identity encoding sidesteps the whole
// class of CDN content-encoding-flapping bugs. We also set a UA
// (some CDNs 403 clients without one) and a 30s timeout aligned
// with the upstream 5 MB cap.
let client = reqwest::Client::builder()
.no_gzip()
.no_deflate()
.no_brotli()
.user_agent("openfang/0.1 (+https://openfang.ai)")
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let resp = match client.get(url).send().await {
Ok(r) => r,
Err(e) => {
warn!("Failed to download image from channel: {e}");
return vec![ContentBlock::Text {
text: format!("[Image download failed: {e}]"),
provider_metadata: None,
}];
}
};
// Detect media type from Content-Type header — but only trust it if it's
// actually an image/* type. Many APIs (Telegram, S3 pre-signed URLs) return
// `application/octet-stream` for all files, which breaks vision.
let header_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string())
.filter(|ct| ct.starts_with("image/"));
// Detect media type from Content-Type header — but only trust it if
// it's actually an image/* type. Many APIs (Telegram, S3 pre-signed
// URLs) return `application/octet-stream` for all files, which
// breaks vision.
let header_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string())
.filter(|ct| ct.starts_with("image/"));
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => {
warn!("Failed to read image bytes: {e}");
return vec![ContentBlock::Text {
text: format!("[Image read failed: {e}]"),
provider_metadata: None,
}];
}
};
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => {
warn!("Failed to read image bytes: {e}");
return vec![ContentBlock::Text {
text: format!("[Image read failed: {e}]"),
provider_metadata: None,
}];
}
};
(bytes.to_vec(), header_type)
};
// Three-tier media type detection:
// 1. Trusted Content-Type header (only if image/*)
@@ -1485,12 +1653,13 @@ async fn dispatch_with_blocks(
) {
// Route to agent (same logic as text path).
// Use sender_user_id() so user-keyed bindings match for Discord/Slack;
// pass channel_id so per-room bindings match too.
let agent_id = router.resolve_with_channel_id(
// resolve_with_context lets channel_id-scoped bindings match per room.
let binding_ctx = binding_context_for(message);
let agent_id = router.resolve_with_context(
&message.channel,
sender_user_id(message),
message.sender.openfang_user.as_deref(),
sender_channel_id(message),
&binding_ctx,
);
let agent_id = match agent_id {
@@ -2190,6 +2359,122 @@ mod tests {
assert_eq!(GroupPolicy::default(), GroupPolicy::MentionOnly);
}
// -- binding_context_for / ChannelMessage::channel_id() coverage --
//
// These tests pin the routing-time behavior so future adapter additions to
// CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL cannot silently regress the bridge.
fn make_msg_for_ctx(
channel: ChannelType,
platform_id: &str,
metadata: Vec<(&str, serde_json::Value)>,
) -> ChannelMessage {
let mut md = std::collections::HashMap::new();
for (k, v) in metadata {
md.insert(k.to_string(), v);
}
ChannelMessage {
channel,
platform_message_id: "msg-1".to_string(),
sender: crate::types::ChannelUser {
platform_id: platform_id.to_string(),
display_name: "Tester".to_string(),
openfang_user: None,
},
content: ChannelContent::Text("hi".to_string()),
target_agent: None,
timestamp: chrono::Utc::now(),
is_group: true,
thread_id: None,
metadata: md,
}
}
#[test]
fn test_binding_context_for_discord_uses_platform_id_as_channel() {
let msg = make_msg_for_ctx(ChannelType::Discord, "1234567890", vec![]);
let ctx = binding_context_for(&msg);
assert_eq!(ctx.channel, "discord");
assert_eq!(ctx.channel_id.as_deref(), Some("1234567890"));
}
#[test]
fn test_binding_context_for_telegram_uses_platform_id_as_channel() {
// Regression guard: Telegram is on the channel-ID allowlist.
let msg = make_msg_for_ctx(ChannelType::Telegram, "-100123", vec![]);
let ctx = binding_context_for(&msg);
assert_eq!(ctx.channel, "telegram");
assert_eq!(ctx.channel_id.as_deref(), Some("-100123"));
}
#[test]
fn test_binding_context_for_matrix_uses_room_id_from_platform_id() {
let msg = make_msg_for_ctx(ChannelType::Matrix, "!room:server.tld", vec![]);
let ctx = binding_context_for(&msg);
assert_eq!(ctx.channel_id.as_deref(), Some("!room:server.tld"));
}
#[test]
fn test_binding_context_for_custom_supported_adapter() {
// Custom("twitch") is on the allowlist.
let msg = make_msg_for_ctx(
ChannelType::Custom("twitch".to_string()),
"channel-foo",
vec![],
);
let ctx = binding_context_for(&msg);
assert_eq!(ctx.channel, "twitch");
assert_eq!(ctx.channel_id.as_deref(), Some("channel-foo"));
}
#[test]
fn test_binding_context_for_user_id_adapter_returns_none() {
// Reddit's platform_id is the post author, not a subreddit/conversation.
// The bridge must not surface that as `channel_id` (would silently match
// user-scoped bindings against a user ID).
let msg = make_msg_for_ctx(
ChannelType::Custom("reddit".to_string()),
"u/some-user",
vec![],
);
let ctx = binding_context_for(&msg);
assert_eq!(ctx.channel, "reddit");
assert!(ctx.channel_id.is_none());
// peer_id still falls through to platform_id (sender_user_id default).
assert_eq!(ctx.peer_id, "u/some-user");
}
#[test]
fn test_binding_context_for_metadata_fallback() {
// For non-allowlisted adapters, metadata["channel_id"] is the
// documented escape hatch — verify the bridge honors it.
let msg = make_msg_for_ctx(
ChannelType::Custom("reddit".to_string()),
"u/some-user",
vec![("channel_id", serde_json::json!("r/rust"))],
);
let ctx = binding_context_for(&msg);
assert_eq!(ctx.channel_id.as_deref(), Some("r/rust"));
}
#[test]
fn test_binding_context_for_metadata_guild_id() {
let msg = make_msg_for_ctx(
ChannelType::Discord,
"1234567890",
vec![("guild_id", serde_json::json!("99999"))],
);
let ctx = binding_context_for(&msg);
assert_eq!(ctx.guild_id.as_deref(), Some("99999"));
}
#[test]
fn test_channel_message_channel_id_email_returns_none() {
// Email's platform_id is the sender address — not a channel.
let msg = make_msg_for_ctx(ChannelType::Email, "alice@example.com", vec![]);
assert!(msg.channel_id().is_none());
}
#[test]
fn test_channel_type_str() {
assert_eq!(channel_type_str(&ChannelType::Telegram), "telegram");
+332 -6
View File
@@ -538,6 +538,77 @@ impl ChannelAdapter for DiscordAdapter {
}
}
/// 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,
@@ -546,6 +617,11 @@ async fn parse_discord_message(
allowed_users: &[String],
ignore_bots: bool,
) -> 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,10 +653,6 @@ 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");
let username = author["username"].as_str().unwrap_or("Unknown");
@@ -597,7 +669,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 +684,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)
@@ -1032,4 +1148,214 @@ mod tests {
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)
.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)
.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)
.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)
.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)
.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)
.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)
.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)
.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).await;
assert!(msg.is_none());
}
}
+32 -9
View File
@@ -498,7 +498,7 @@ impl TelegramAdapter {
self.api_send_photo(chat_id, &url, caption.as_deref(), thread_id)
.await?;
}
ChannelContent::File { url, filename } => {
ChannelContent::File { url, filename, .. } => {
self.api_send_document(chat_id, &url, &filename, thread_id)
.await?;
}
@@ -521,6 +521,17 @@ impl TelegramAdapter {
self.api_send_message(chat_id, text.trim(), thread_id)
.await?;
}
ChannelContent::Multipart(parts) => {
// Send each child as its own Telegram message. Nested
// Multipart is rejected by adapters; flatten defensively.
for part in parts {
if let ChannelContent::Multipart(_) = part {
debug_assert!(false, "nested Multipart in send_to_user");
continue;
}
Box::pin(self.send_content(user, part, thread_id)).await?;
}
}
}
Ok(())
}
@@ -934,7 +945,12 @@ async fn parse_telegram_update(
.unwrap_or("document")
.to_string();
match telegram_get_file_url(token, client, file_id, api_base_url).await {
Some(url) => ChannelContent::File { url, filename },
Some(url) => ChannelContent::File {
url,
filename,
mime: None,
size: None,
},
None => ChannelContent::Text(format!("[Document received: {filename}]")),
}
} else if message.get("voice").is_some() {
@@ -2138,10 +2154,7 @@ mod tests {
body,
)
} else {
(
StatusCode::OK,
r#"{"ok":true,"result":true}"#.to_string(),
)
(StatusCode::OK, r#"{"ok":true,"result":true}"#.to_string())
}
}
}));
@@ -2218,7 +2231,10 @@ mod tests {
// Two-chunk message; first POST fails. Nothing delivered → Err.
let big = "a".repeat(5000); // > 4096 → split into two chunks
let stub = StubServer::new(vec![
(500, r#"{"ok":false,"error_code":500,"description":"server"}"#),
(
500,
r#"{"ok":false,"error_code":500,"description":"server"}"#,
),
(200, r#"{"ok":true,"result":{}}"#),
]);
let base = spawn_stub_server(stub.clone()).await;
@@ -2246,7 +2262,10 @@ mod tests {
let big = "a".repeat(5000);
let stub = StubServer::new(vec![
(200, r#"{"ok":true,"result":{}}"#),
(400, r#"{"ok":false,"error_code":400,"description":"some err"}"#),
(
400,
r#"{"ok":false,"error_code":400,"description":"some err"}"#,
),
]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
@@ -2257,7 +2276,11 @@ mod tests {
result.is_ok(),
"partial delivery must return Ok (best-effort), got {result:?}"
);
assert_eq!(stub.hit_count(), 2, "both chunks should have been attempted");
assert_eq!(
stub.hit_count(),
2,
"both chunks should have been attempted"
);
}
// -----------------------------------------------------------------------
+98
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")]
@@ -365,6 +435,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,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenFang",
"version": "0.6.5",
"version": "0.6.7",
"identifier": "ai.openfang.desktop",
"build": {},
"app": {
+245 -1
View File
@@ -1056,7 +1056,13 @@ impl OpenFangKernel {
// Initialize media understanding engine
let media_engine =
openfang_runtime::media_understanding::MediaEngine::new(config.media.clone());
let tts_engine = openfang_runtime::tts::TtsEngine::new(config.tts.clone());
// Closes #1051: thread MediaConfig URL overrides into the TTS engine
// so local OpenAI/ElevenLabs-compatible services can be targeted.
let tts_engine = openfang_runtime::tts::TtsEngine::new(config.tts.clone())
.with_base_urls(
config.media.tts_openai_base_url.clone(),
config.media.tts_elevenlabs_base_url.clone(),
);
let mut pairing = crate::pairing::PairingManager::new(config.pairing.clone());
// Load paired devices from database and set up persistence callback
@@ -1394,6 +1400,91 @@ impl OpenFangKernel {
}
}
// Issue #1140: auto-spawn agents from `~/.openfang/agents/<name>/agent.toml`
// that are present on disk but not yet in the registry. Without this,
// user-placed agent dirs never appear in `GET /api/agents` (and thus
// the chat tab's dropdown) until they are explicitly spawned via API
// or CLI. We scan the agents directory and call `spawn_agent` for any
// valid manifest whose name is not already registered (idempotent).
{
let agents_dir = kernel.config.home_dir.join("agents");
if agents_dir.is_dir() {
let mut auto_spawned = 0usize;
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
for entry in entries.flatten() {
let dir_path = entry.path();
if !dir_path.is_dir() {
continue;
}
let toml_path = dir_path.join("agent.toml");
if !toml_path.exists() {
continue;
}
let dir_name = match dir_path.file_name() {
Some(n) => n.to_string_lossy().to_string(),
None => continue,
};
// Skip if an agent with this name already exists in the
// registry (was restored from DB or already spawned).
if kernel.registry.find_by_name(&dir_name).is_some() {
continue;
}
let toml_str = match std::fs::read_to_string(&toml_path) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
agent = %dir_name,
path = %toml_path.display(),
"Failed to read agent.toml: {e}"
);
continue;
}
};
let mut manifest: openfang_types::agent::AgentManifest =
match toml::from_str(&toml_str) {
Ok(m) => m,
Err(e) => {
tracing::warn!(
agent = %dir_name,
path = %toml_path.display(),
"Invalid agent.toml, skipping auto-spawn: {e}"
);
continue;
}
};
// Prefer the directory name as the canonical agent name
// so the dashboard and CLI stay consistent with the
// on-disk layout, even if the manifest's `name` field
// disagrees.
if manifest.name.is_empty() {
manifest.name = dir_name.clone();
}
match kernel.spawn_agent(manifest) {
Ok(id) => {
auto_spawned += 1;
info!(
agent = %dir_name,
id = %id,
"Auto-spawned agent from ~/.openfang/agents"
);
}
Err(e) => {
tracing::warn!(
agent = %dir_name,
"Failed to auto-spawn agent from disk: {e}"
);
}
}
}
}
if auto_spawned > 0 {
info!(
"Auto-spawned {auto_spawned} agent(s) from ~/.openfang/agents"
);
}
}
}
// If no agents exist (fresh install), spawn a default assistant
if kernel.registry.list().is_empty() {
info!("No agents found — spawning default assistant");
@@ -7381,6 +7472,8 @@ impl KernelHandle for OpenFangKernel {
"file" => openfang_channels::types::ChannelContent::File {
url: media_url.to_string(),
filename: filename.unwrap_or("file").to_string(),
mime: None,
size: None,
},
_ => {
return Err(format!(
@@ -8038,6 +8131,69 @@ mod tests {
kernel.shutdown();
}
// ----------------------------------------------------------------------
// Issue #1164: Agent Stop on a hand-owned agent must also deactivate the
// hand instance, otherwise the hand stays Active and the user cannot
// re-activate it (wizard fails with 400 "Hand already active").
// ----------------------------------------------------------------------
#[test]
fn test_hand_owned_agent_stop_clears_hand_for_reactivation() {
let tmp = tempfile::tempdir().unwrap();
let home_dir = tmp.path().join("openfang-kernel-hand-stop-test");
std::fs::create_dir_all(&home_dir).unwrap();
let config = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
// Activate a hand and grab its agent id (mirrors what the wizard does).
let instance = kernel
.activate_hand("lead", HashMap::new(), None)
.expect("lead hand should activate");
let agent_id = instance.agent_id.expect("lead hand agent id");
let first_instance_id = instance.instance_id;
// Sanity: hand is Active and re-activation is rejected.
assert!(kernel
.activate_hand("lead", HashMap::new(), None)
.is_err());
// Simulate what POST /api/agents/{id}/stop now does for a hand-owned
// agent: look up the instance and deactivate the hand (which also
// kills the agent and cancels any running task).
let owning = kernel
.hand_registry
.find_by_agent(agent_id)
.expect("active hand owning the agent");
assert_eq!(owning.instance_id, first_instance_id);
kernel
.deactivate_hand(owning.instance_id)
.expect("deactivate via stop path");
// The hand instance must be gone now — re-activation must succeed.
assert!(kernel.hand_registry.find_by_agent(agent_id).is_none());
let active: Vec<_> = kernel
.hand_registry
.list_instances()
.into_iter()
.filter(|i| i.hand_id == "lead")
.collect();
assert!(
active.is_empty(),
"no lead instances should remain after stop",
);
let second = kernel
.activate_hand("lead", HashMap::new(), None)
.expect("hand must be re-activatable after stop");
assert_ne!(second.instance_id, first_instance_id);
kernel.shutdown();
}
// ----------------------------------------------------------------------
// Issue #890: activate_agent — wake up inactive agents
// ----------------------------------------------------------------------
@@ -8634,4 +8790,92 @@ mod tests {
kernel.shutdown();
}
// ----------------------------------------------------------------------
// Issue #1140: agents placed at ~/.openfang/agents/<name>/agent.toml
// must auto-spawn on boot so they appear in the chat tab.
// ----------------------------------------------------------------------
#[test]
fn test_1140_auto_spawn_agents_from_disk() {
let tmp = tempfile::tempdir().unwrap();
let home_dir = tmp.path().join("openfang-1140");
let agents_dir = home_dir.join("agents");
std::fs::create_dir_all(agents_dir.join("my-custom-agent")).unwrap();
// Write a minimal valid agent.toml for a user-placed agent.
let manifest_toml = r#"
name = "my-custom-agent"
description = "A user-installed agent placed in ~/.openfang/agents"
[model]
provider = "default"
model = "default"
system_prompt = "You are a test agent."
"#;
std::fs::write(
agents_dir.join("my-custom-agent").join("agent.toml"),
manifest_toml,
)
.unwrap();
// Also drop an invalid dir (no agent.toml) to make sure scan skips it.
std::fs::create_dir_all(agents_dir.join("not-an-agent")).unwrap();
// And an unparseable agent.toml — must not abort the scan.
std::fs::create_dir_all(agents_dir.join("bad-agent")).unwrap();
std::fs::write(
agents_dir.join("bad-agent").join("agent.toml"),
"this is = not valid = toml",
)
.unwrap();
let config = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
// The disk-placed agent must be in the registry and visible via list().
let entry = kernel
.registry
.find_by_name("my-custom-agent")
.expect("my-custom-agent must be auto-spawned from ~/.openfang/agents");
assert_eq!(entry.name, "my-custom-agent");
// GET /api/agents pulls from kernel.registry.list(); confirm the agent
// is in that list so the chat tab can render it.
let listed = kernel.registry.list();
assert!(
listed.iter().any(|e| e.name == "my-custom-agent"),
"kernel.registry.list() must include the disk-loaded agent"
);
// The invalid manifest must not have produced an agent entry.
assert!(
kernel.registry.find_by_name("bad-agent").is_none(),
"agents with invalid TOML must be skipped, not crash boot"
);
// Reboot the kernel against the same home dir: must NOT double-spawn,
// because the agent is now persisted in the DB. find_by_name handles
// uniqueness, but we also assert the count is stable.
let count_before = kernel.registry.list().len();
kernel.shutdown();
let config2 = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
..KernelConfig::default()
};
let kernel2 = OpenFangKernel::boot_with_config(config2).expect("kernel re-boots");
let count_after = kernel2.registry.list().len();
assert_eq!(
count_before, count_after,
"auto-spawn must be idempotent across reboots"
);
assert!(kernel2.registry.find_by_name("my-custom-agent").is_some());
kernel2.shutdown();
}
}
+1 -1
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)
@@ -11,7 +11,7 @@
use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent};
use async_trait::async_trait;
use dashmap::DashMap;
use openfang_types::message::{ContentBlock, Role, StopReason, TokenUsage};
use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage};
use serde::Deserialize;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncReadExt};
@@ -130,6 +130,14 @@ impl ClaudeCodeDriver {
}
/// Build a text prompt from the completion request messages.
///
/// The Claude Code CLI is text-only (`-p <prompt>`), so non-text content
/// blocks (images, etc.) cannot be sent natively. Rather than dropping
/// them silently — which causes the model to hallucinate about content
/// it can't see — we render each non-text block as a synthetic
/// `[attachment: ...]` marker. The model still can't *view* the
/// attachment, but it knows the attachment exists and can acknowledge
/// it coherently instead of confabulating.
fn build_prompt(request: &CompletionRequest) -> String {
let mut parts = Vec::new();
@@ -139,15 +147,53 @@ impl ClaudeCodeDriver {
Role::Assistant => "Assistant",
Role::System => "System",
};
let text = msg.content.text_content();
if !text.is_empty() {
parts.push(format!("[{role_label}]\n{text}"));
let rendered = Self::render_content(&msg.content);
if !rendered.is_empty() {
parts.push(format!("[{role_label}]\n{rendered}"));
}
}
parts.join("\n\n")
}
/// Render message content for the text-only CLI prompt.
///
/// Text blocks pass through verbatim. Image blocks are rendered as
/// `[attachment: <media_type> image, ~N KB — not viewable on this
/// provider]` so the model receives a positive signal that an
/// attachment arrived. ToolUse/ToolResult/Thinking are omitted —
/// the CLI manages its own tool loop.
fn render_content(content: &MessageContent) -> String {
match content {
MessageContent::Text(s) => s.clone(),
MessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text, .. } => {
if text.is_empty() {
None
} else {
Some(text.clone())
}
}
ContentBlock::Image { media_type, data } => {
// base64 → ~3/4 the length in decoded bytes.
let approx_kb = (data.len().saturating_mul(3) / 4) / 1024;
Some(format!(
"[attachment: {media_type} image, ~{approx_kb} KB — not viewable on this provider]"
))
}
ContentBlock::ToolUse { .. }
| ContentBlock::ToolResult { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. }
| ContentBlock::Unknown => None,
})
.collect::<Vec<_>>()
.join("\n"),
}
}
/// Map a model ID like "claude-code/opus" to CLI --model flag value.
fn model_flag(model: &str) -> Option<String> {
let stripped = model.strip_prefix("claude-code/").unwrap_or(model);
@@ -727,6 +773,79 @@ mod tests {
assert!(prompt.contains("Hello"));
}
#[test]
fn test_build_prompt_renders_image_attachment_marker() {
use openfang_types::message::{ContentBlock, Message, MessageContent};
// ~12 KB of base64 — decoded ~9 KB.
let fake_b64 = "A".repeat(12 * 1024);
let request = CompletionRequest {
model: "claude-code/sonnet".to_string(),
messages: vec![Message {
role: Role::User,
content: MessageContent::Blocks(vec![
ContentBlock::Text {
text: "what's in this?".to_string(),
provider_metadata: None,
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: fake_b64,
},
]),
..Default::default()
}],
tools: vec![],
max_tokens: 1024,
temperature: 0.7,
system: None,
thinking: None,
};
let prompt = ClaudeCodeDriver::build_prompt(&request);
assert!(prompt.contains("what's in this?"), "text preserved");
assert!(
prompt.contains("[attachment: image/png image"),
"image rendered as synthetic attachment marker, got: {prompt}"
);
assert!(
prompt.contains("not viewable on this provider"),
"marker explains the limitation, got: {prompt}"
);
}
#[test]
fn test_build_prompt_image_only_still_emits_marker() {
use openfang_types::message::{ContentBlock, Message, MessageContent};
let request = CompletionRequest {
model: "claude-code/sonnet".to_string(),
messages: vec![Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::Image {
media_type: "image/jpeg".to_string(),
data: "Zm9v".to_string(),
}]),
..Default::default()
}],
tools: vec![],
max_tokens: 1024,
temperature: 0.7,
system: None,
thinking: None,
};
let prompt = ClaudeCodeDriver::build_prompt(&request);
assert!(
prompt.contains("[User]"),
"user role label emitted even with image-only content, got: {prompt}"
);
assert!(
prompt.contains("[attachment: image/jpeg image"),
"bare image renders marker, got: {prompt}"
);
}
#[test]
fn test_model_flag_mapping() {
assert_eq!(
+217 -51
View File
@@ -190,9 +190,14 @@ struct OaiMessage {
tool_calls: Option<Vec<OaiToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
/// Moonshot Kimi: sent as empty string on assistant messages with tool_calls when using Kimi (thinking is disabled for multi-turn compatibility).
/// Legacy reasoning field. Pre-vLLM 0.19, DeepSeek, Moonshot/Kimi (empty string when thinking is disabled for tool_calls multi-turn).
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
/// New reasoning field per OpenAI GPT-OSS Responses-API convention.
/// vLLM 0.19+ (PR #33402) renamed `reasoning_content` to `reasoning`.
/// Issue #1157: emit both for backward compat across servers.
#[serde(skip_serializing_if = "Option::is_none")]
reasoning: Option<String>,
}
/// Content can be a plain string or an array of content parts (for images).
@@ -263,8 +268,23 @@ struct OaiResponseMessage {
content: Option<String>,
tool_calls: Option<Vec<OaiToolCall>>,
/// Reasoning/thinking content returned by some models (DeepSeek-R1, Qwen3, etc.)
/// via LM Studio, Ollama, and other local inference servers.
/// via LM Studio, Ollama, and pre-0.19 vLLM.
reasoning_content: Option<String>,
/// New reasoning field per OpenAI GPT-OSS Responses-API convention.
/// vLLM 0.19+ (PR #33402) emits this name instead of `reasoning_content`.
/// Issue #1157.
reasoning: Option<String>,
}
impl OaiResponseMessage {
/// Return whichever reasoning field the server populated.
/// vLLM ≥ 0.19 → `reasoning`. Older servers / DeepSeek / Qwen → `reasoning_content`.
fn reasoning_text(&self) -> Option<&str> {
self.reasoning
.as_deref()
.filter(|s| !s.is_empty())
.or_else(|| self.reasoning_content.as_deref().filter(|s| !s.is_empty()))
}
}
#[derive(Debug, Deserialize)]
@@ -292,16 +312,16 @@ fn strip_trailing_empty_assistant(messages: &mut Vec<OaiMessage>) {
/// Assemble an outbound assistant `OaiMessage` from `ContentBlock`s, replaying
/// any `Thinking` blocks in the format the upstream model originally emitted.
///
/// This is the fix for issue #1098 thinking-model state preservation.
/// This is the fix for issue #1098 — thinking-model state preservation.
/// Without this, `<think>...</think>` and `reasoning_content` are stripped on
/// the next turn so the model loses its prior reasoning trace and re-derives
/// the answer (degrading quality). We honour `provider_metadata.format`:
///
/// - `"reasoning_content"` emitted on the OpenAI `reasoning_content` field
/// - `"reasoning_content"` → emitted on the OpenAI `reasoning_content` field
/// (DeepSeek-R1, Qwen3, MiniMax M2 via LM Studio/Ollama)
/// - `"inline_think"` wrapped in `<think>...</think>` and prepended to
/// - `"inline_think"` → wrapped in `<think>...</think>` and prepended to
/// the visible content (MiniMax M2.5, Llama-3.3-think variants)
/// - missing/other fall back to the legacy Moonshot/Kimi behaviour
/// - missing/other → fall back to the legacy Moonshot/Kimi behaviour
/// (only emit `reasoning_content` when `needs_reasoning_content()` is true)
fn assemble_assistant_message(
blocks: &[ContentBlock],
@@ -360,7 +380,7 @@ fn assemble_assistant_message(
}
}
_ => {
// Unknown format preserve as inline_think since it's
// Unknown format — preserve as inline_think since it's
// safe (visible to the model as ordinary text). The
// legacy Moonshot path overrides this below.
let entry = format!("<think>{thinking}</think>");
@@ -387,14 +407,22 @@ fn assemble_assistant_message(
let has_tool_calls = !tool_calls.is_empty();
let needs_reasoning = driver.needs_reasoning_content(model);
// Final reasoning_content field: the per-block format hint wins; otherwise
// Final reasoning fields: the per-block format hint wins; otherwise
// fall back to legacy Moonshot/Kimi behaviour (empty string when needed).
let reasoning_content = if reasoning_field.is_some() {
reasoning_field
//
// Issue #1157: vLLM ≥ 0.19 renamed `reasoning_content` to `reasoning`.
// Emit BOTH fields so the persisted thinking trace reaches the model
// regardless of which server version we're talking to. Old servers
// ignore `reasoning`; new vLLM ignores `reasoning_content` (and would
// otherwise silently strip our thinking, see PR vllm#33402).
let (reasoning_content, reasoning) = if let Some(text) = reasoning_field {
(Some(text.clone()), Some(text))
} else if needs_reasoning {
Some(String::new())
// Moonshot/Kimi legacy contract: empty `reasoning_content` to disable
// thinking on tool-call multi-turn. The `reasoning` field stays unset.
(Some(String::new()), None)
} else {
None
(None, None)
};
OaiMessage {
@@ -415,6 +443,7 @@ fn assemble_assistant_message(
},
tool_call_id: None,
reasoning_content,
reasoning,
}
}
@@ -431,7 +460,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
reasoning: None,
});
}
// Convert messages
@@ -444,7 +474,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
reasoning: None,
});
}
(Role::User, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -453,7 +484,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
reasoning: None,
});
}
(Role::Assistant, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -462,7 +494,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
reasoning: None,
});
}
(Role::User, MessageContent::Blocks(blocks)) => {
// Handle tool results and images in user messages
@@ -486,7 +519,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: Some(tool_use_id.clone()),
reasoning_content: None,
});
reasoning: None,
});
}
ContentBlock::Text { text, .. } => {
parts.push(OaiContentPart::Text { text: text.clone() });
@@ -509,7 +543,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
reasoning: None,
});
}
}
(Role::Assistant, MessageContent::Blocks(blocks)) => {
@@ -676,7 +711,7 @@ impl LlmDriver for OpenAIDriver {
continue;
}
// Model doesn't support function calling retry without tools
// Model doesn't support function calling — retry without tools
// (e.g. GLM-5 on DashScope returns 500 "internal error" when tools are sent)
let body_lower = body.to_lowercase();
if !oai_request.tools.is_empty()
@@ -720,19 +755,19 @@ impl LlmDriver for OpenAIDriver {
let mut content = Vec::new();
let mut tool_calls = Vec::new();
// Capture reasoning_content from models that use a separate field
// (DeepSeek-R1, Qwen3, etc. via LM Studio/Ollama)
if let Some(ref reasoning) = choice.message.reasoning_content {
// Capture reasoning text from models that use a separate field.
// Issue #1098 (legacy `reasoning_content`) + #1157 (vLLM ≥ 0.19
// renamed it to `reasoning`). Accept either.
if let Some(reasoning) = choice.message.reasoning_text() {
if !reasoning.is_empty() {
debug!(
len = reasoning.len(),
"Captured reasoning_content from response"
);
debug!(len = reasoning.len(), "Captured reasoning from response");
// Mark the format so the outbound path knows to re-emit
// this as a `reasoning_content` field rather than as
// inline `<think>` tags. Issue #1098.
// this on the reasoning field rather than as inline
// `<think>` tags. The outbound assembler writes BOTH
// `reasoning` and `reasoning_content` for cross-server
// compat.
content.push(ContentBlock::Thinking {
thinking: reasoning.clone(),
thinking: reasoning.to_string(),
signature: None,
provider_metadata: Some(serde_json::json!({
"format": "reasoning_content"
@@ -741,14 +776,17 @@ impl LlmDriver for OpenAIDriver {
}
}
let already_has_reasoning = choice.message.reasoning_text().is_some();
if let Some(text) = choice.message.content {
if !text.is_empty() {
// Extract <think>...</think> blocks that some local models
// embed directly in the content field.
let (cleaned, thinking) = extract_think_tags(&text);
if let Some(think_text) = thinking {
// Only add if we didn't already get reasoning_content
if choice.message.reasoning_content.is_none() {
// Only add if we didn't already get a reasoning field
// (either legacy `reasoning_content` or new vLLM 0.19+
// `reasoning`). Issue #1157.
if !already_has_reasoning {
// Mark the format so we re-emit as inline `<think>`
// tags on the next turn (MiniMax/M2.5 style).
content.push(ContentBlock::Thinking {
@@ -843,7 +881,7 @@ impl LlmDriver for OpenAIDriver {
// this as a "silent failure" and loop unnecessarily.
if !content.is_empty() && usage.input_tokens == 0 && usage.output_tokens == 0 {
debug!(
"Response has content but no usage stats setting synthetic output_tokens=1"
"Response has content but no usage stats — setting synthetic output_tokens=1"
);
usage.output_tokens = 1;
}
@@ -877,7 +915,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
reasoning: None,
});
}
for msg in &request.messages {
@@ -889,7 +928,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
reasoning: None,
});
}
(Role::User, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -898,7 +938,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
reasoning: None,
});
}
(Role::Assistant, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -907,7 +948,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
reasoning: None,
});
}
(Role::User, MessageContent::Blocks(blocks)) => {
for block in blocks {
@@ -927,7 +969,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: Some(tool_use_id.clone()),
reasoning_content: None,
});
reasoning: None,
});
}
}
}
@@ -1091,7 +1134,7 @@ impl LlmDriver for OpenAIDriver {
continue;
}
// Provider doesn't support stream_options retry without it
// Provider doesn't support stream_options — retry without it
if status == 400
&& oai_request.stream_options.is_some()
&& attempt < max_retries
@@ -1104,7 +1147,7 @@ impl LlmDriver for OpenAIDriver {
continue;
}
// Model doesn't support function calling retry without tools
// Model doesn't support function calling — retry without tools
let body_lower = body.to_lowercase();
if !oai_request.tools.is_empty()
&& attempt < max_retries
@@ -1193,7 +1236,7 @@ impl LlmDriver for OpenAIDriver {
for choice in choices {
let delta = &choice["delta"];
// Text content delta route through think filter to
// Text content delta — route through think filter to
// strip <think>...</think> tags before they reach the client.
if let Some(text) = delta["content"].as_str() {
if !text.is_empty() {
@@ -1309,7 +1352,7 @@ impl LlmDriver for OpenAIDriver {
sse_lines = sse_line_count,
finish = ?finish_reason,
buffer_remaining = buffer.len(),
"SSE stream returned empty: 0 content, 0 tokens likely a silently failed request"
"SSE stream returned empty: 0 content, 0 tokens — likely a silently failed request"
);
} else {
debug!(
@@ -1449,7 +1492,7 @@ impl LlmDriver for OpenAIDriver {
// non-zero output_tokens so the agent loop doesn't misclassify
// this as a "silent failure" and loop unnecessarily.
if !content.is_empty() && usage.input_tokens == 0 && usage.output_tokens == 0 {
debug!("Stream has content but no usage stats setting synthetic output_tokens=1");
debug!("Stream has content but no usage stats — setting synthetic output_tokens=1");
usage.output_tokens = 1;
}
@@ -1502,7 +1545,7 @@ fn extract_think_tags(text: &str) -> (String, Option<String>) {
break;
}
} else {
// Unclosed <think> tag treat everything after as thinking
// Unclosed <think> tag — treat everything after as thinking
let thought = cleaned[start + "<think>".len()..].trim().to_string();
if !thought.is_empty() {
thinking_parts.push(thought);
@@ -1609,7 +1652,7 @@ fn parse_groq_failed_tool_call(body: &str) -> Option<CompletionResponse> {
let args = &call_content[brace_pos..];
(name, args)
} else {
// No args just a tool name
// No args — just a tool name
(call_content.trim(), "{}")
};
@@ -1625,7 +1668,7 @@ fn parse_groq_failed_tool_call(body: &str) -> Option<CompletionResponse> {
}
if tool_calls.is_empty() {
// No tool calls found the model generated plain text but Groq rejected it.
// No tool calls found — the model generated plain text but Groq rejected it.
// Return it as a normal text response instead of failing.
if !failed.trim().is_empty() {
warn!("Recovering plain text from Groq failed_generation (no tool calls)");
@@ -1870,9 +1913,132 @@ mod tests {
let msg: OaiResponseMessage = serde_json::from_str(json).unwrap();
assert!(msg.content.is_none());
assert!(msg.reasoning_content.is_none());
assert!(msg.reasoning.is_none());
}
// ── Azure OpenAI tests ──────────────────────────────────────────
// ── Issue #1157: vLLM ≥ 0.19 reasoning field rename ─────────────────
/// vLLM 0.19+ (PR #33402) returns `reasoning` instead of
/// `reasoning_content`. We must accept the new name on ingress.
#[test]
fn test_oai_response_message_with_vllm_reasoning_field() {
let json =
r#"{"content": "Answer.", "reasoning": "I weighed A vs B.", "tool_calls": null}"#;
let msg: OaiResponseMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.content.as_deref(), Some("Answer."));
assert!(msg.reasoning_content.is_none());
assert_eq!(msg.reasoning.as_deref(), Some("I weighed A vs B."));
// reasoning_text() must surface the new field transparently.
assert_eq!(msg.reasoning_text(), Some("I weighed A vs B."));
}
/// If a server sends both fields (during the transition), prefer the
/// new `reasoning` name since that's what vLLM 0.19+ writes natively.
#[test]
fn test_reasoning_text_prefers_new_field() {
let json = r#"{"content": null, "reasoning": "new", "reasoning_content": "old"}"#;
let msg: OaiResponseMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.reasoning_text(), Some("new"));
}
/// If only the legacy field is set (older vLLM, DeepSeek, Ollama),
/// `reasoning_text()` must still return it.
#[test]
fn test_reasoning_text_falls_back_to_legacy_field() {
let json = r#"{"content": null, "reasoning_content": "legacy thinking"}"#;
let msg: OaiResponseMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.reasoning_text(), Some("legacy thinking"));
}
/// Outbound assembler must emit BOTH `reasoning` and `reasoning_content`
/// when a `Thinking` block carries the `reasoning_content` format hint,
/// so the persisted thinking reaches the model regardless of whether
/// the upstream server is pre- or post-vLLM 0.19.
#[test]
fn test_assemble_emits_both_reasoning_fields_for_vllm_compat() {
let driver = OpenAIDriver::new(
"test".to_string(),
"http://localhost:8000/v1".to_string(),
);
let blocks = vec![
ContentBlock::Thinking {
thinking: "MARKER-vllm-019".to_string(),
signature: None,
provider_metadata: Some(serde_json::json!({"format": "reasoning_content"})),
},
ContentBlock::Text {
text: "final".to_string(),
provider_metadata: None,
},
];
let msg = assemble_assistant_message(&blocks, "minimax-m2", &driver);
assert_eq!(
msg.reasoning_content.as_deref(),
Some("MARKER-vllm-019"),
"legacy reasoning_content field required for pre-0.19 vLLM and DeepSeek"
);
assert_eq!(
msg.reasoning.as_deref(),
Some("MARKER-vllm-019"),
"new reasoning field required for vLLM ≥ 0.19 (PR #33402)"
);
// Serialize and confirm the wire shape has both keys at top level.
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["reasoning_content"], "MARKER-vllm-019");
assert_eq!(json["reasoning"], "MARKER-vllm-019");
}
/// Non-reasoning models (gpt-4o, claude, …) must NOT carry either
/// reasoning field on the wire. Regression guard for the dual-emit
/// change in #1157.
#[test]
fn test_assemble_no_reasoning_fields_for_plain_model() {
let driver =
OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
let blocks = vec![ContentBlock::Text {
text: "hi".to_string(),
provider_metadata: None,
}];
let msg = assemble_assistant_message(&blocks, "gpt-4o", &driver);
assert!(msg.reasoning_content.is_none());
assert!(msg.reasoning.is_none());
let json = serde_json::to_value(&msg).unwrap();
assert!(json.get("reasoning").is_none());
assert!(json.get("reasoning_content").is_none());
}
/// Moonshot/Kimi legacy contract: emit empty `reasoning_content` to
/// disable thinking on tool-call multi-turn. Issue #1157 must not
/// regress this — `reasoning` stays absent because Moonshot doesn't
/// understand the new name.
#[test]
fn test_assemble_moonshot_keeps_legacy_field_only() {
let driver = OpenAIDriver::new(
"test".to_string(),
"https://api.moonshot.cn/v1".to_string(),
);
let blocks = vec![
ContentBlock::ToolUse {
id: "call_1".to_string(),
name: "search".to_string(),
input: serde_json::json!({"q": "x"}),
provider_metadata: None,
},
];
let msg = assemble_assistant_message(&blocks, "kimi-k2", &driver);
assert_eq!(
msg.reasoning_content.as_deref(),
Some(""),
"Moonshot Kimi requires empty reasoning_content on tool_calls turns"
);
assert!(
msg.reasoning.is_none(),
"Moonshot does not understand the new vLLM `reasoning` field"
);
}
// ── Azure OpenAI tests ──────────────────────────────────────────
#[test]
fn test_azure_driver_creation() {
@@ -1948,7 +2114,7 @@ mod tests {
assert_eq!(url, "https://api.moonshot.ai/v1/chat/completions");
}
// ── issue #1098: thinking-block round-trip ────────────────────────
// ── issue #1098: thinking-block round-trip ────────────────────────
/// Inline `<think>` blocks captured on ingress must be re-emitted in
/// historical assistant turns so MiniMax-style models retain reasoning
@@ -2017,7 +2183,7 @@ mod tests {
}
/// Without thinking blocks, the outbound message should be a plain
/// assistant message preserve the legacy shape.
/// assistant message — preserve the legacy shape.
#[test]
fn test_assemble_assistant_no_thinking_is_plain() {
let driver =
@@ -2042,14 +2208,14 @@ mod tests {
// Step 1: parse server response shape.
let json = serde_json::json!({
"content": "Final answer.",
"reasoning_content": "I considered options A, B, and C",
"reasoning_content": "I considered options A, B, and C…",
"tool_calls": null
});
let server_msg: OaiResponseMessage = serde_json::from_value(json).unwrap();
assert_eq!(server_msg.content.as_deref(), Some("Final answer."));
assert_eq!(
server_msg.reasoning_content.as_deref(),
Some("I considered options A, B, and C")
Some("I considered options A, B, and C…")
);
// Step 2: simulate the driver building blocks (mirrors the live
@@ -2076,7 +2242,7 @@ mod tests {
// The reasoning_content field must round-trip verbatim.
assert_eq!(
outbound.reasoning_content.as_deref(),
Some("I considered options A, B, and C"),
Some("I considered options A, B, and C…"),
"issue #1098 regression: reasoning was stripped on resubmission"
);
}
+52 -2
View File
@@ -7,7 +7,15 @@ use tracing::warn;
/// Generate images via OpenAI's image generation API.
///
/// Requires OPENAI_API_KEY to be set.
pub async fn generate_image(request: &ImageGenRequest) -> Result<ImageGenResult, String> {
///
/// `base_url_override` (sourced from `MediaConfig.image_gen_base_url`) lets
/// callers redirect the request to a local OpenAI-compatible image service
/// (e.g. Lemonade/Flux, LM Studio). When `None`, the hardcoded
/// `https://api.openai.com/v1/images/generations` endpoint is used. Closes #1051.
pub async fn generate_image(
request: &ImageGenRequest,
base_url_override: Option<&str>,
) -> Result<ImageGenResult, String> {
// Validate request
request.validate()?;
@@ -30,9 +38,19 @@ pub async fn generate_image(request: &ImageGenRequest) -> Result<ImageGenResult,
body["quality"] = serde_json::json!(request.quality);
}
// `image_gen_base_url` (config.media.image_gen_base_url) overrides the
// hardcoded provider URL when set, allowing the same OpenAI-compat JSON
// wire format to be sent to a local image generation service
// (Lemonade/Flux, LM Studio, etc.) instead of the cloud provider. The
// Authorization header is still built from `OPENAI_API_KEY`; local
// services typically accept any non-empty bearer token. Closes #1051.
let url = base_url_override
.map(|base| format!("{}/v1/images/generations", base.trim_end_matches('/')))
.unwrap_or_else(|| "https://api.openai.com/v1/images/generations".to_string());
let client = reqwest::Client::new();
let response = client
.post("https://api.openai.com/v1/images/generations")
.post(&url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&body)
@@ -201,6 +219,38 @@ mod tests {
}
}
/// Closes #1051: when `image_gen_base_url` is set, the URL building
/// logic must use the override (with `/v1/images/generations` appended)
/// and strip any trailing slash from the user-supplied base. When unset,
/// the hardcoded provider URL is used.
#[test]
fn test_image_gen_base_url_override_logic() {
// Helper mirroring the URL construction in `generate_image`.
fn build(base: Option<&str>) -> String {
base.map(|b| format!("{}/v1/images/generations", b.trim_end_matches('/')))
.unwrap_or_else(|| "https://api.openai.com/v1/images/generations".to_string())
}
// Default: hardcoded URL preserved (backward compatibility).
assert_eq!(build(None), "https://api.openai.com/v1/images/generations");
// Override applied.
assert_eq!(
build(Some("http://127.0.0.1:7000")),
"http://127.0.0.1:7000/v1/images/generations"
);
// Trailing slash on the user-supplied base is stripped.
assert_eq!(
build(Some("http://127.0.0.1:7000/")),
"http://127.0.0.1:7000/v1/images/generations"
);
assert_eq!(
build(Some("https://images.example.com/")),
"https://images.example.com/v1/images/generations"
);
}
#[test]
fn test_save_images_creates_dir() {
let dir = tempfile::tempdir().unwrap();
@@ -24,6 +24,13 @@ impl MediaEngine {
}
}
/// Read-only access to the media configuration. Used by callers that
/// need the URL overrides (e.g. image_gen_base_url for #1051) without
/// taking ownership of the engine.
pub fn config(&self) -> &MediaConfig {
&self.config
}
/// Describe an image using a vision-capable LLM.
/// Auto-cascade: Anthropic -> OpenAI -> Gemini (based on API key availability).
pub async fn describe_image(
@@ -35,6 +35,12 @@ pub const SAFE_ENV_VARS_WINDOWS: &[&str] = &[
/// - On Windows, the Windows-specific safe variables (`SAFE_ENV_VARS_WINDOWS`)
/// - Any additional variables the caller explicitly allows via `allowed_env_vars`
///
/// `allowed_env_vars` accepts either explicit variable names or the special
/// wildcard entry `"*"`, which forwards every variable present in the parent
/// process. Use the wildcard only when the operator has explicitly opted in
/// (e.g. `exec_policy.shell_env_passthrough = ["*"]`) — it will leak any
/// secret the parent holds into the child.
///
/// Variables that are not set in the current process environment are silently
/// skipped (rather than being set to empty strings).
pub fn sandbox_command(cmd: &mut tokio::process::Command, allowed_env_vars: &[String]) {
@@ -55,6 +61,14 @@ pub fn sandbox_command(cmd: &mut tokio::process::Command, allowed_env_vars: &[St
}
}
// Wildcard: forward every var from the parent process.
if allowed_env_vars.iter().any(|v| v == "*") {
for (key, val) in std::env::vars() {
cmd.env(key, val);
}
return;
}
// Re-add caller-specified allowed vars.
for var in allowed_env_vars {
if let Ok(val) = std::env::var(var) {
@@ -63,6 +77,22 @@ pub fn sandbox_command(cmd: &mut tokio::process::Command, allowed_env_vars: &[St
}
}
/// Merge two env-passthrough lists (hand-granted + exec-policy-granted),
/// deduplicating entries. If either contains `"*"`, the result is just `["*"]`
/// (wildcard subsumes anything else).
pub fn merge_env_passthrough(a: &[String], b: &[String]) -> Vec<String> {
if a.iter().any(|v| v == "*") || b.iter().any(|v| v == "*") {
return vec!["*".to_string()];
}
let mut out: Vec<String> = Vec::with_capacity(a.len() + b.len());
for v in a.iter().chain(b.iter()) {
if !out.iter().any(|existing| existing == v) {
out.push(v.clone());
}
}
out
}
/// Validates that an executable path does not contain directory traversal
/// components (`..`).
///
@@ -711,6 +741,40 @@ pub async fn wait_or_kill_with_idle(
mod tests {
use super::*;
// ── Env passthrough merge (issue #1169) ────────────────────────────
#[test]
fn test_merge_env_passthrough_empty() {
let merged = merge_env_passthrough(&[], &[]);
assert!(merged.is_empty());
}
#[test]
fn test_merge_env_passthrough_dedup() {
let a = vec!["TZ".to_string(), "HOME".to_string()];
let b = vec!["TZ".to_string(), "PATH".to_string()];
let merged = merge_env_passthrough(&a, &b);
assert_eq!(merged, vec!["TZ", "HOME", "PATH"]);
}
#[test]
fn test_merge_env_passthrough_wildcard_a() {
let merged = merge_env_passthrough(&["*".to_string()], &["TZ".to_string()]);
assert_eq!(merged, vec!["*"]);
}
#[test]
fn test_merge_env_passthrough_wildcard_b() {
let merged = merge_env_passthrough(&["TZ".to_string()], &["*".to_string()]);
assert_eq!(merged, vec!["*"]);
}
#[test]
fn test_exec_policy_default_has_empty_passthrough() {
let policy = openfang_types::config::ExecPolicy::default();
assert!(policy.shell_env_passthrough.is_empty());
}
#[test]
fn test_validate_path() {
// Clean paths should be accepted.
+206 -3
View File
@@ -205,6 +205,7 @@ pub async fn execute_tool(
"file_read" => tool_file_read(input, workspace_root).await,
"file_write" => tool_file_write(input, workspace_root).await,
"file_list" => tool_file_list(input, workspace_root).await,
"create_directory" => tool_create_directory(input, workspace_root).await,
"apply_patch" => tool_apply_patch(input, workspace_root).await,
// Web tools (upgraded: multi-provider search, SSRF-protected fetch)
@@ -331,7 +332,7 @@ pub async fn execute_tool(
"media_transcribe" => tool_media_transcribe(input, media_engine).await,
// Image generation tool
"image_generate" => tool_image_generate(input, workspace_root).await,
"image_generate" => tool_image_generate(input, workspace_root, media_engine).await,
// TTS/STT tools
"text_to_speech" => tool_text_to_speech(input, tts_engine, workspace_root).await,
@@ -595,6 +596,17 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"required": ["path"]
}),
},
ToolDefinition {
name: "create_directory".to_string(),
description: "Create a directory (and any missing parent directories) at the given path. Paths are relative to the agent workspace. Idempotent: succeeds if the directory already exists.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The directory path to create" }
},
"required": ["path"]
}),
},
ToolDefinition {
name: "apply_patch".to_string(),
description: "Apply a multi-hunk diff patch to add, update, move, or delete files. Use this for targeted edits instead of full file overwrites.".to_string(),
@@ -1358,6 +1370,82 @@ async fn tool_file_write(
))
}
/// Resolve a directory path for creation. Unlike `resolve_file_path`, this walks
/// up the path to find the nearest existing ancestor, canonicalizes that, and
/// re-appends the missing segments. This lets `create_directory` accept nested
/// paths like `a/b/c/d` even when none of `a`, `b`, `c` exist yet.
fn resolve_directory_path_for_create(
raw_path: &str,
workspace_root: Option<&Path>,
) -> Result<PathBuf, String> {
// Reject `..` components regardless of workspace.
let _ = validate_path(raw_path)?;
let Some(root) = workspace_root else {
return Ok(PathBuf::from(raw_path));
};
let path = Path::new(raw_path);
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let canon_root = root
.canonicalize()
.map_err(|e| format!("Failed to resolve workspace root: {e}"))?;
// Walk up to find the nearest existing ancestor, canonicalize it, then
// re-append the missing tail.
let mut existing: PathBuf = candidate.clone();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
while !existing.exists() {
let parent = match existing.parent() {
Some(p) => p.to_path_buf(),
None => return Err("Invalid path: no existing ancestor".to_string()),
};
let name = match existing.file_name() {
Some(n) => n.to_os_string(),
None => return Err("Invalid path: no filename component".to_string()),
};
tail.push(name);
existing = parent;
}
let canon_existing = existing
.canonicalize()
.map_err(|e| format!("Failed to resolve ancestor directory: {e}"))?;
let mut resolved = canon_existing;
for segment in tail.into_iter().rev() {
resolved.push(segment);
}
if !resolved.starts_with(&canon_root) {
return Err(format!(
"Access denied: path '{raw_path}' resolves outside workspace"
));
}
Ok(resolved)
}
async fn tool_create_directory(
input: &serde_json::Value,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
if raw_path.is_empty() {
return Err("'path' parameter is empty".to_string());
}
let resolved = resolve_directory_path_for_create(raw_path, workspace_root)?;
tokio::fs::create_dir_all(&resolved)
.await
.map_err(|e| format!("Failed to create directory: {e}"))?;
Ok(format!("Created directory {}", resolved.display()))
}
async fn tool_file_list(
input: &serde_json::Value,
workspace_root: Option<&Path>,
@@ -1579,7 +1667,18 @@ async fn tool_shell_exec(
// SECURITY: Isolate environment to prevent credential leakage.
// Hand settings may grant access to specific provider API keys.
crate::subprocess_sandbox::sandbox_command(&mut cmd, allowed_env);
//
// Operators can also forward additional vars via
// `exec_policy.shell_env_passthrough` (issue #1169). This is the path
// Docker users hit: their container env (TZ, GOG_*, etc.) is present
// in PID 1 but `env_clear()` strips it. Listing names (or `"*"`) here
// re-adds them to the child.
let policy_env_passthrough: &[String] = exec_policy
.map(|p| p.shell_env_passthrough.as_slice())
.unwrap_or(&[]);
let merged_env =
crate::subprocess_sandbox::merge_env_passthrough(allowed_env, policy_env_passthrough);
crate::subprocess_sandbox::sandbox_command(&mut cmd, &merged_env);
// Ensure UTF-8 output on Windows
#[cfg(windows)]
@@ -2947,6 +3046,7 @@ async fn tool_media_transcribe(
async fn tool_image_generate(
input: &serde_json::Value,
workspace_root: Option<&Path>,
media_engine: Option<&crate::media_understanding::MediaEngine>,
) -> Result<String, String> {
let prompt = input["prompt"]
.as_str()
@@ -2976,7 +3076,11 @@ async fn tool_image_generate(
count,
};
let result = crate::image_gen::generate_image(&request).await?;
// Closes #1051: route to a local OpenAI-compatible image generation
// service when `media.image_gen_base_url` is set.
let base_url_override = media_engine
.and_then(|e| e.config().image_gen_base_url.as_deref());
let result = crate::image_gen::generate_image(&request, base_url_override).await?;
// Save images to workspace if available
let saved_paths = if let Some(workspace) = workspace_root {
@@ -3448,6 +3552,9 @@ mod tests {
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
// Original 12
assert!(names.contains(&"file_read"));
assert!(names.contains(&"file_write"));
assert!(names.contains(&"file_list"));
assert!(names.contains(&"create_directory"));
assert!(names.contains(&"shell_exec"));
assert!(names.contains(&"agent_send"));
assert!(names.contains(&"agent_spawn"));
@@ -3619,6 +3726,102 @@ mod tests {
assert!(result.content.contains("traversal"));
}
#[tokio::test]
async fn test_create_directory_path_traversal_blocked() {
let result = execute_tool(
"test-id",
"create_directory",
&serde_json::json!({"path": "../../etc/evil"}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("traversal"));
}
#[tokio::test]
async fn test_create_directory_creates_nested() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let result = tool_create_directory(
&serde_json::json!({"path": "a/b/c"}),
Some(root),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
let expected = root.join("a").join("b").join("c");
assert!(expected.is_dir(), "Expected directory to exist: {}", expected.display());
}
#[tokio::test]
async fn test_create_directory_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
// First create
let r1 = tool_create_directory(
&serde_json::json!({"path": "data/logs"}),
Some(root),
)
.await;
assert!(r1.is_ok());
// Second create on existing dir should also succeed
let r2 = tool_create_directory(
&serde_json::json!({"path": "data/logs"}),
Some(root),
)
.await;
assert!(r2.is_ok(), "Expected idempotent success, got: {:?}", r2);
}
#[tokio::test]
async fn test_create_directory_missing_path_param() {
let result = tool_create_directory(&serde_json::json!({}), None).await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("Missing 'path'"), "got: {msg}");
}
#[tokio::test]
async fn test_create_directory_dispatch_via_execute_tool() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let result = execute_tool(
"test-id",
"create_directory",
&serde_json::json!({"path": "nested/folder"}),
None, // kernel
None, // allowed_tools
None, // caller_agent_id
None, // skill_registry
None, // mcp_connections
None, // web_ctx
None, // browser_ctx
None, // allowed_env_vars
Some(root.as_path()), // workspace_root
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(!result.is_error, "Expected success, got: {}", result.content);
assert!(root.join("nested").join("folder").is_dir());
}
#[tokio::test]
async fn test_file_list_path_traversal_blocked() {
let result = execute_tool(
+136 -3
View File
@@ -19,11 +19,38 @@ pub struct TtsResult {
/// Text-to-speech engine.
pub struct TtsEngine {
config: TtsConfig,
/// Optional override for OpenAI TTS base URL. When set, the engine POSTs
/// to `<openai_base_url>/v1/audio/speech` instead of the hardcoded
/// `https://api.openai.com/v1/audio/speech`. Sourced from
/// `MediaConfig.tts_openai_base_url`. Closes #1051.
openai_base_url: Option<String>,
/// Optional override for ElevenLabs TTS base URL. When set, the engine
/// POSTs to `<elevenlabs_base_url>/v1/text-to-speech/{voice_id}` instead
/// of the hardcoded `https://api.elevenlabs.io/...`. Sourced from
/// `MediaConfig.tts_elevenlabs_base_url`. Closes #1051.
elevenlabs_base_url: Option<String>,
}
impl TtsEngine {
pub fn new(config: TtsConfig) -> Self {
Self { config }
Self {
config,
openai_base_url: None,
elevenlabs_base_url: None,
}
}
/// Attach optional base-URL overrides from `MediaConfig`. Use this to
/// route TTS calls at a local OpenAI-compatible service (e.g.
/// Lemonade/Kokoro, LM Studio) or an ElevenLabs proxy. Closes #1051.
pub fn with_base_urls(
mut self,
openai_base_url: Option<String>,
elevenlabs_base_url: Option<String>,
) -> Self {
self.openai_base_url = openai_base_url;
self.elevenlabs_base_url = elevenlabs_base_url;
self
}
/// Detect which TTS provider is available based on environment variables.
@@ -100,9 +127,21 @@ impl TtsEngine {
"speed": self.config.openai.speed,
});
// `tts_openai_base_url` (config.media.tts_openai_base_url) overrides
// the hardcoded provider URL when set, allowing the same OpenAI-compat
// JSON wire format to be sent to a local TTS service (Lemonade/Kokoro,
// LM Studio, etc.) instead of the cloud provider. The Authorization
// header is still built from `OPENAI_API_KEY`; local services typically
// accept any non-empty bearer token. Closes #1051.
let url = self
.openai_base_url
.as_deref()
.map(|base| format!("{}/v1/audio/speech", base.trim_end_matches('/')))
.unwrap_or_else(|| "https://api.openai.com/v1/audio/speech".to_string());
let client = reqwest::Client::new();
let response = client
.post("https://api.openai.com/v1/audio/speech")
.post(&url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&body)
@@ -161,7 +200,17 @@ impl TtsEngine {
std::env::var("ELEVENLABS_API_KEY").map_err(|_| "ELEVENLABS_API_KEY not set")?;
let voice_id = voice_override.unwrap_or(&self.config.elevenlabs.voice_id);
let url = format!("https://api.elevenlabs.io/v1/text-to-speech/{}", voice_id);
// `tts_elevenlabs_base_url` (config.media.tts_elevenlabs_base_url)
// overrides the hardcoded provider URL when set, allowing the same
// ElevenLabs JSON wire format to be routed through a proxy or
// self-hosted ElevenLabs-compatible gateway. The `xi-api-key` header
// still comes from `ELEVENLABS_API_KEY`. Closes #1051.
let base = self
.elevenlabs_base_url
.as_deref()
.map(|b| b.trim_end_matches('/').to_string())
.unwrap_or_else(|| "https://api.elevenlabs.io".to_string());
let url = format!("{}/v1/text-to-speech/{}", base, voice_id);
let body = serde_json::json!({
"text": text,
@@ -306,4 +355,88 @@ mod tests {
fn test_max_audio_constant() {
assert_eq!(MAX_AUDIO_RESPONSE_BYTES, 10 * 1024 * 1024);
}
#[test]
fn test_with_base_urls_sets_overrides() {
let engine = TtsEngine::new(default_config()).with_base_urls(
Some("http://127.0.0.1:8000".to_string()),
Some("http://127.0.0.1:9000".to_string()),
);
assert_eq!(
engine.openai_base_url.as_deref(),
Some("http://127.0.0.1:8000")
);
assert_eq!(
engine.elevenlabs_base_url.as_deref(),
Some("http://127.0.0.1:9000")
);
}
/// Closes #1051: when the OpenAI TTS base URL is overridden, the URL
/// building logic must append `/v1/audio/speech` and strip any trailing
/// slash. When unset, the hardcoded provider URL is used.
#[test]
fn test_tts_openai_base_url_override_logic() {
// Helper mirroring the URL construction in `synthesize_openai`.
fn build(base: Option<&str>) -> String {
base.map(|b| format!("{}/v1/audio/speech", b.trim_end_matches('/')))
.unwrap_or_else(|| "https://api.openai.com/v1/audio/speech".to_string())
}
// Default: hardcoded URL preserved (backward compatibility).
assert_eq!(build(None), "https://api.openai.com/v1/audio/speech");
// Override applied.
assert_eq!(
build(Some("http://127.0.0.1:8000")),
"http://127.0.0.1:8000/v1/audio/speech"
);
// Trailing slash on the user-supplied base is stripped.
assert_eq!(
build(Some("http://127.0.0.1:8000/")),
"http://127.0.0.1:8000/v1/audio/speech"
);
assert_eq!(
build(Some("https://tts.example.com/")),
"https://tts.example.com/v1/audio/speech"
);
}
/// Closes #1051: when the ElevenLabs TTS base URL is overridden, the URL
/// building logic must append `/v1/text-to-speech/{voice_id}` and strip
/// any trailing slash. When unset, the hardcoded provider URL is used.
#[test]
fn test_tts_elevenlabs_base_url_override_logic() {
fn build(base: Option<&str>, voice_id: &str) -> String {
let b = base
.map(|b| b.trim_end_matches('/').to_string())
.unwrap_or_else(|| "https://api.elevenlabs.io".to_string());
format!("{}/v1/text-to-speech/{}", b, voice_id)
}
let voice = "21m00Tcm4TlvDq8ikWAM";
// Default: hardcoded URL preserved.
assert_eq!(
build(None, voice),
format!("https://api.elevenlabs.io/v1/text-to-speech/{voice}")
);
// Override applied.
assert_eq!(
build(Some("http://127.0.0.1:9000"), voice),
format!("http://127.0.0.1:9000/v1/text-to-speech/{voice}")
);
// Trailing slash stripped.
assert_eq!(
build(Some("http://127.0.0.1:9000/"), voice),
format!("http://127.0.0.1:9000/v1/text-to-speech/{voice}")
);
assert_eq!(
build(Some("https://eleven.example.com/"), voice),
format!("https://eleven.example.com/v1/text-to-speech/{voice}")
);
}
}
+2
View File
@@ -25,3 +25,5 @@ zip = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
tokio-test = { workspace = true }
ed25519-dalek = { workspace = true }
rand = { workspace = true }
+30 -1
View File
@@ -11,6 +11,7 @@
//! - Download: `GET /api/v1/download?slug=...`
//! - File: `GET /api/v1/skills/{slug}/file?path=SKILL.md`
use crate::installer::{enforce_require_signed, InstallOptions};
use crate::openclaw_compat;
use crate::verify::{SkillVerifier, SkillWarning, WarningSeverity};
use crate::SkillError;
@@ -491,6 +492,25 @@ impl ClawHubClient {
/// Install a skill from ClawHub into the target directory.
///
/// Convenience wrapper around [`Self::install_with_options`] using the
/// default (permissive) options. Existing callers behave exactly as
/// before.
pub async fn install(
&self,
slug: &str,
target_dir: &Path,
) -> Result<ClawHubInstallResult, SkillError> {
self.install_with_options(slug, target_dir, &InstallOptions::default())
.await
}
/// Install a skill from ClawHub with explicit enforcement options.
///
/// When `opts.require_signed` is true, the skill must ship with a valid
/// Ed25519 `SignedManifest` envelope (see [`crate::installer`] for the
/// well-known filenames). Skills failing the gate are removed from disk
/// and a `SkillError::SecurityBlocked` is returned.
///
/// Security pipeline:
/// 1. Download skill zip and compute SHA256
/// 2. Detect format (SKILL.md vs package.json)
@@ -499,10 +519,12 @@ impl ClawHubClient {
/// 5. If prompt-only: run prompt injection scan
/// 6. Check binary dependencies
/// 7. Write skill.toml with `verified: false`
pub async fn install(
/// 8. Enforce `require_signed` if requested.
pub async fn install_with_options(
&self,
slug: &str,
target_dir: &Path,
opts: &InstallOptions,
) -> Result<ClawHubInstallResult, SkillError> {
// Use /api/v1/download?slug=... endpoint
let url = format!("{}/download?slug={}", self.base_url, urlencoded(slug));
@@ -637,6 +659,12 @@ impl ClawHubClient {
// Step 7: Write skill.toml
openclaw_compat::write_openfang_manifest(&skill_dir, &manifest)?;
// Step 8: Enforce --require-signed gate, if requested.
if let Err(e) = enforce_require_signed(&skill_dir, opts) {
let _ = std::fs::remove_dir_all(&skill_dir);
return Err(e);
}
let result = ClawHubInstallResult {
skill_name: manifest.skill.name.clone(),
version: manifest.skill.version.clone(),
@@ -650,6 +678,7 @@ impl ClawHubClient {
slug,
skill_name = %result.skill_name,
warnings = result.warnings.len(),
require_signed = opts.require_signed,
"Installed skill from ClawHub"
);
+299
View File
@@ -0,0 +1,299 @@
//! Skill install enforcement options.
//!
//! Wraps the per-source install clients (FangHub `marketplace`, ClawHub) with
//! optional supply-chain gates. The flagship gate is `require_signed`: when
//! true, an Ed25519 `SignedManifest` envelope must sit alongside the skill
//! payload and verify cleanly before the install is considered complete.
//!
//! The signature envelope is a JSON serialisation of
//! [`openfang_types::manifest_signing::SignedManifest`]. The installer looks
//! for it at one of these well-known names inside the freshly written skill
//! directory:
//!
//! - `signature.json`
//! - `skill.toml.sig.json`
//! - `SKILL.md.sig.json`
//!
//! On a `require_signed` failure the skill directory is removed and a
//! `SkillError::SecurityBlocked` is returned, matching the existing
//! prompt-injection-blocked path in `clawhub.rs`.
use crate::SkillError;
use openfang_types::manifest_signing::SignedManifest;
use std::path::Path;
/// Options controlling enforcement during skill install.
///
/// Defaults are permissive — `require_signed` is `false` so existing
/// callers (`Installer::install`, `Installer::install` on the marketplace)
/// behave exactly as before.
#[derive(Debug, Clone, Default)]
pub struct InstallOptions {
/// When true, reject any skill that does not ship with a valid Ed25519
/// `SignedManifest` envelope. The `--require-signed` CLI flag maps here.
pub require_signed: bool,
/// Optional allow-list of acceptable signer public keys (hex-encoded,
/// 32 bytes / 64 hex chars). When non-empty, the envelope's
/// `signer_public_key` must match one of these entries in addition to
/// passing cryptographic verification. Empty = any valid signature
/// accepted (TOFU mode).
pub allowed_signer_keys: Vec<String>,
}
impl InstallOptions {
/// Convenience: `require_signed = true`, no key pinning.
pub fn require_signed() -> Self {
Self {
require_signed: true,
allowed_signer_keys: Vec::new(),
}
}
/// Convenience: `require_signed = true` with a pinned signer key.
pub fn require_signed_by(pubkey_hex: impl Into<String>) -> Self {
Self {
require_signed: true,
allowed_signer_keys: vec![pubkey_hex.into()],
}
}
}
/// Well-known filenames the installer searches for a detached signature
/// envelope, in priority order.
const SIGNATURE_CANDIDATES: &[&str] = &[
"signature.json",
"skill.toml.sig.json",
"SKILL.md.sig.json",
];
/// Locate a `SignedManifest` envelope inside `skill_dir`, if any.
///
/// Returns the parsed envelope on the first candidate that exists and parses
/// successfully. Files that exist but fail to parse return an error — a
/// malformed envelope is a stronger signal than an absent one.
pub fn load_signature(skill_dir: &Path) -> Result<Option<SignedManifest>, SkillError> {
for name in SIGNATURE_CANDIDATES {
let path = skill_dir.join(name);
if !path.exists() {
continue;
}
let raw = std::fs::read_to_string(&path)?;
let envelope: SignedManifest = serde_json::from_str(&raw).map_err(|e| {
SkillError::InvalidManifest(format!(
"Signature envelope at {} is not valid JSON: {e}",
path.display()
))
})?;
return Ok(Some(envelope));
}
Ok(None)
}
/// Enforce `require_signed` against a freshly installed skill directory.
///
/// Returns `Ok(())` when:
/// - `opts.require_signed` is false (no enforcement); or
/// - a `SignedManifest` envelope is found, `verify()` passes, and (when
/// `allowed_signer_keys` is non-empty) the signer key is allow-listed.
///
/// Returns `SkillError::SecurityBlocked` when enforcement is on and the
/// skill fails any of those checks. On failure the caller is expected to
/// remove `skill_dir` to keep the skills directory clean.
pub fn enforce_require_signed(
skill_dir: &Path,
opts: &InstallOptions,
) -> Result<(), SkillError> {
if !opts.require_signed {
return Ok(());
}
let envelope = match load_signature(skill_dir)? {
Some(e) => e,
None => {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: no signature envelope found in {} \
(looked for signature.json / skill.toml.sig.json / SKILL.md.sig.json)",
skill_dir.display()
)))
}
};
if let Err(e) = envelope.verify() {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: signature verification failed: {e}"
)));
}
if !opts.allowed_signer_keys.is_empty() {
let actual = hex::encode(&envelope.signer_public_key);
let actual_lower = actual.to_lowercase();
let matched = opts
.allowed_signer_keys
.iter()
.any(|k| k.trim().to_lowercase() == actual_lower);
if !matched {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: signer key {actual} not in allow-list \
(signer_id = {:?})",
envelope.signer_id
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use tempfile::TempDir;
fn write_skill_toml(dir: &Path) -> String {
let toml = r#"
[skill]
name = "signed-skill"
version = "0.1.0"
description = "A signed skill"
[runtime]
type = "python"
entry = "main.py"
"#;
std::fs::write(dir.join("skill.toml"), toml).unwrap();
toml.to_string()
}
fn write_signature(dir: &Path, envelope: &SignedManifest, name: &str) {
let json = serde_json::to_string_pretty(envelope).unwrap();
std::fs::write(dir.join(name), json).unwrap();
}
#[test]
fn require_signed_off_passes_unsigned() {
let dir = TempDir::new().unwrap();
write_skill_toml(dir.path());
let opts = InstallOptions::default();
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
}
#[test]
fn require_signed_on_rejects_missing_signature() {
let dir = TempDir::new().unwrap();
write_skill_toml(dir.path());
let opts = InstallOptions::require_signed();
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
match err {
SkillError::SecurityBlocked(msg) => {
assert!(msg.contains("no signature envelope"), "got: {msg}");
}
other => panic!("expected SecurityBlocked, got {other:?}"),
}
}
#[test]
fn require_signed_on_accepts_valid_signature() {
let dir = TempDir::new().unwrap();
let toml = write_skill_toml(dir.path());
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(toml, &signing_key, "test-signer");
write_signature(dir.path(), &envelope, "signature.json");
let opts = InstallOptions::require_signed();
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
}
#[test]
fn require_signed_on_rejects_tampered_envelope() {
let dir = TempDir::new().unwrap();
let toml = write_skill_toml(dir.path());
let signing_key = SigningKey::generate(&mut OsRng);
let mut envelope = SignedManifest::sign(toml, &signing_key, "test-signer");
// Tamper with the manifest body — content_hash will no longer match.
envelope.manifest.push_str("\n# evil append\n");
write_signature(dir.path(), &envelope, "signature.json");
let opts = InstallOptions::require_signed();
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
match err {
SkillError::SecurityBlocked(msg) => {
assert!(
msg.contains("signature verification failed")
|| msg.contains("content hash mismatch"),
"got: {msg}"
);
}
other => panic!("expected SecurityBlocked, got {other:?}"),
}
}
#[test]
fn require_signed_rejects_malformed_envelope() {
let dir = TempDir::new().unwrap();
write_skill_toml(dir.path());
std::fs::write(dir.path().join("signature.json"), "{not valid json").unwrap();
let opts = InstallOptions::require_signed();
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
match err {
SkillError::InvalidManifest(msg) => {
assert!(msg.contains("Signature envelope"), "got: {msg}");
}
other => panic!("expected InvalidManifest, got {other:?}"),
}
}
#[test]
fn require_signed_with_allowed_keys_accepts_listed_key() {
let dir = TempDir::new().unwrap();
let toml = write_skill_toml(dir.path());
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(toml, &signing_key, "test-signer");
let pk_hex = hex::encode(&envelope.signer_public_key);
write_signature(dir.path(), &envelope, "signature.json");
let opts = InstallOptions::require_signed_by(pk_hex);
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
}
#[test]
fn require_signed_with_allowed_keys_rejects_unlisted_key() {
let dir = TempDir::new().unwrap();
let toml = write_skill_toml(dir.path());
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(toml, &signing_key, "evil-signer");
write_signature(dir.path(), &envelope, "signature.json");
// Allow only a different key.
let other_key = SigningKey::generate(&mut OsRng);
let other_hex = hex::encode(other_key.verifying_key().to_bytes());
let opts = InstallOptions::require_signed_by(other_hex);
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
match err {
SkillError::SecurityBlocked(msg) => {
assert!(msg.contains("not in allow-list"), "got: {msg}");
}
other => panic!("expected SecurityBlocked, got {other:?}"),
}
}
#[test]
fn load_signature_returns_none_when_absent() {
let dir = TempDir::new().unwrap();
write_skill_toml(dir.path());
assert!(load_signature(dir.path()).unwrap().is_none());
}
#[test]
fn load_signature_finds_alternate_filename() {
let dir = TempDir::new().unwrap();
let toml = write_skill_toml(dir.path());
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(toml, &signing_key, "alt-name-signer");
write_signature(dir.path(), &envelope, "skill.toml.sig.json");
let loaded = load_signature(dir.path()).unwrap().unwrap();
assert_eq!(loaded.signer_id, "alt-name-signer");
}
}
+1
View File
@@ -10,6 +10,7 @@
pub mod bundled;
pub mod clawhub;
pub mod config_injection;
pub mod installer;
pub mod loader;
pub mod marketplace;
pub mod openclaw_compat;
+32 -2
View File
@@ -3,6 +3,7 @@
//! For Phase 1, uses GitHub releases as the registry backend.
//! Each skill is a GitHub repo with releases containing the skill bundle.
use crate::installer::{enforce_require_signed, InstallOptions};
use crate::SkillError;
use std::path::Path;
use tracing::info;
@@ -90,8 +91,28 @@ impl MarketplaceClient {
/// Install a skill from a GitHub repo by name.
///
/// Downloads the latest release tarball and extracts it to the target directory.
/// Convenience wrapper around [`Self::install_with_options`] using the
/// default (permissive) options. Existing callers behave exactly as
/// before.
pub async fn install(&self, skill_name: &str, target_dir: &Path) -> Result<String, SkillError> {
self.install_with_options(skill_name, target_dir, &InstallOptions::default())
.await
}
/// Install a skill from a GitHub repo with explicit enforcement options.
///
/// When `opts.require_signed` is true, the installed bundle must contain
/// a valid Ed25519 `SignedManifest` envelope. Skills failing the gate
/// are removed from disk and a `SkillError::SecurityBlocked` is
/// returned.
///
/// Downloads the latest release tarball and extracts it to the target directory.
pub async fn install_with_options(
&self,
skill_name: &str,
target_dir: &Path,
opts: &InstallOptions,
) -> Result<String, SkillError> {
let repo = format!("{}/{}", self.config.github_org, skill_name);
let url = format!(
"{}/repos/{}/releases/latest",
@@ -163,7 +184,16 @@ impl MarketplaceClient {
serde_json::to_string_pretty(&meta).unwrap_or_default(),
)?;
info!("Installed skill: {skill_name} {version}");
// Enforce --require-signed gate, if requested.
if let Err(e) = enforce_require_signed(&skill_dir, opts) {
let _ = std::fs::remove_dir_all(&skill_dir);
return Err(e);
}
info!(
"Installed skill: {skill_name} {version} (require_signed={})",
opts.require_signed
);
Ok(version)
}
}
+277 -1
View File
@@ -733,7 +733,12 @@ impl Default for VaultConfig {
}
/// Agent binding — routes specific channel/account/peer patterns to agents.
///
/// `deny_unknown_fields` so typos at the binding level (e.g. `match_rule` →
/// `match_rules`) fail loudly at config-load instead of silently leaving the
/// rule defaulted to "match everything".
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentBinding {
/// Target agent name or ID.
pub agent: String,
@@ -741,6 +746,50 @@ pub struct AgentBinding {
pub match_rule: BindingMatchRule,
}
/// Lowercased channel-name strings whose adapters place a channel/conversation/
/// room/space/chat ID directly in `ChannelMessage::sender.platform_id` (these
/// adapters overload that field because it doubles as the send target).
///
/// Single source of truth shared between:
/// - Config validation (warn the user when their `channel_id` binding targets
/// an adapter that doesn't populate `ctx.channel_id`).
/// - `ChannelMessage::channel_id()` in `openfang-channels::types` (routing-time
/// accessor that reads from this list to decide where to source the ID).
///
/// Adapters not listed fall back to `metadata["channel_id"]` if present, then
/// `None`. Hybrid adapters whose `platform_id` flips between channel and user
/// based on `is_group` (IRC, Zulip) are intentionally excluded — a single
/// channel-scoped binding would silently match DMs.
///
/// Compared against the lowercased `channel` string from `BindingMatchRule`
/// or from `channel_type_str()` at routing time.
pub const CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL: &[&str] = &[
"discord",
"slack",
"telegram",
"matrix",
"mattermost",
"teams",
"webex",
"rocketchat",
"nextcloud",
"pumble",
"revolt",
"guilded",
"feishu",
// Feishu Intl region emits `Custom("lark")` from `feishu.rs` (region.as_str()
// returns "lark" when configured for international). Listed alongside
// "feishu" so both regional spellings resolve identically at routing and
// validation time.
"lark",
"keybase",
"google_chat",
"line",
"twist",
"flock",
"twitch",
];
/// Match rule for agent bindings. All specified (non-None) fields must match.
///
/// `#[serde(deny_unknown_fields)]` is intentional: a typo like `channnel_id` or
@@ -755,7 +804,18 @@ pub struct BindingMatchRule {
/// Specific account/bot ID within the channel.
#[serde(default)]
pub account_id: Option<String>,
/// Peer/user ID for DM routing.
/// Peer/user ID. Matches `BindingContext::peer_id`, which the bridge
/// populates from `ChannelMessage::sender_user_id()` — i.e. the platform's
/// *user* identity (Discord user ID, Slack user ID, etc.).
///
/// Note: the legacy `AgentRouter::resolve()` entry point (kept for tests
/// and any caller without bridge context) builds a synthetic context
/// where `peer_id` is filled from the raw `platform_user_id` argument. On
/// adapters that overload `platform_id` as the channel ID (Discord, Slack,
/// …), passing those callers a channel-scoped value will match here. New
/// code should route through `resolve_with_context` and a bridge-built
/// `BindingContext`. For platform-native channel/conversation matching,
/// use `channel_id` instead.
#[serde(default)]
pub peer_id: Option<String>,
/// Guild/server ID (Discord/Slack).
@@ -766,6 +826,8 @@ pub struct BindingMatchRule {
/// ID (`C…`/`D…`/`G…`); on Telegram it is the chat ID; on IRC it is the
/// channel name. Bridges populate this from the message's channel/conversation
/// identifier so bindings can route by room independent of which user posted.
/// Pair with `channel` to disambiguate across platforms (channel IDs are
/// not portable).
#[serde(default)]
pub channel_id: Option<String>,
/// Role-based routing (user must have at least one).
@@ -907,6 +969,25 @@ pub struct ExecPolicy {
/// produce no stdout/stderr output for this duration. Default: 30.
#[serde(default = "default_no_output_timeout")]
pub no_output_timeout_secs: u64,
/// Environment variables to forward from the OpenFang process into
/// `shell_exec` subprocesses.
///
/// By default, subprocesses run with `env_clear()` and only receive a
/// minimal safe set (PATH, HOME, TMPDIR, LANG, TERM, etc. — see
/// `subprocess_sandbox::SAFE_ENV_VARS`). Anything else — including
/// user-defined variables present in the container/host environment —
/// is stripped. This list lets operators explicitly re-add specific
/// variables to the subprocess environment.
///
/// Each entry is an env var name. A single entry of `"*"` forwards
/// every variable present in the parent process. Use with care — `*`
/// will leak API keys and other secrets into child processes.
///
/// Aliases `env_passthrough` and `env_allowlist` are accepted for
/// backwards compatibility with users who configured these names
/// before the field existed (issue #1169).
#[serde(default, alias = "env_passthrough", alias = "env_allowlist")]
pub shell_env_passthrough: Vec<String>,
}
fn default_no_output_timeout() -> u64 {
@@ -928,6 +1009,7 @@ impl Default for ExecPolicy {
timeout_secs: 30,
max_output_bytes: 100 * 1024,
no_output_timeout_secs: default_no_output_timeout(),
shell_env_passthrough: Vec::new(),
}
}
}
@@ -1050,6 +1132,14 @@ impl Default for ThinkingConfig {
}
/// Top-level kernel configuration.
///
/// `deny_unknown_fields` is intentionally *not* applied here. Strict-field
/// validation is scoped to bindings (`AgentBinding` + `BindingMatchRule`),
/// where silent no-op routing is the failure mode worth catching loudly.
/// Adding it to the top-level kernel struct would also reject forward-compat
/// keys, downstream-fork-only keys, and "I'm trying out a future field early"
/// workflows — a wider behavior change than this PR's mandate. If we want it
/// later, it ships as its own decision.
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct KernelConfig {
@@ -3723,6 +3813,42 @@ impl KernelConfig {
SearchProvider::DuckDuckGo | SearchProvider::Auto => {}
}
// --- Binding validation (channel_id) ---
// Use the shared allowlist (CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL) so this
// validation cannot drift from the routing-time accessor in
// ChannelMessage::channel_id(). Adapters not on the list may still
// populate ctx.channel_id via metadata["channel_id"], but we cannot
// detect that statically — so we warn conservatively and document the
// metadata escape hatch in docs/channel-adapters.md.
for (idx, binding) in self.bindings.iter().enumerate() {
let rule = &binding.match_rule;
if let Some(ref cid) = rule.channel_id {
match rule.channel.as_deref() {
None => {
warnings.push(format!(
"Binding #{} (agent='{}') sets channel_id='{}' without channel; \
channel IDs are not portable across platforms. Pair with channel = \"discord\" (or similar).",
idx, binding.agent, cid
));
}
Some(ch) => {
let ch_lower = ch.to_lowercase();
if !CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL
.iter()
.any(|p| *p == ch_lower)
{
warnings.push(format!(
"Binding #{} (agent='{}') sets channel_id='{}' for channel='{}', \
but the {} adapter does not populate ctx.channel_id from sender.platform_id; \
this binding will only match if the adapter writes channel_id into message metadata.",
idx, binding.agent, cid, ch, ch
));
}
}
}
}
}
// --- Production bounds validation ---
// Clamp dangerous zero/extreme values to safe defaults instead of crashing.
warnings
@@ -3782,6 +3908,106 @@ mod tests {
assert!(toml_str.contains("log_level"));
}
#[test]
fn test_binding_match_rule_deny_unknown_fields_rejects_typo() {
// Typo (`channnel_id` with three n's) should fail to parse rather than
// silently producing a no-op rule that matches every message.
let toml_input = r#"
channel = "discord"
channnel_id = "12345"
"#;
let result: Result<BindingMatchRule, _> = toml::from_str(toml_input);
assert!(
result.is_err(),
"expected deny_unknown_fields to reject typo'd field, got: {:?}",
result
);
}
#[test]
fn test_agent_binding_deny_unknown_fields() {
// A typo at the AgentBinding level (`match_rules` plural instead of
// `match_rule`) must fail config load — silent default would make the
// binding a wildcard.
let toml_str = r#"
agent = "x"
[match_rules]
channel = "discord"
"#;
let result: Result<AgentBinding, _> = toml::from_str(toml_str);
assert!(
result.is_err(),
"expected deny_unknown_fields rejection, got Ok"
);
}
#[test]
fn test_validate_warns_channel_id_without_channel() {
let mut config = KernelConfig::default();
config.bindings.push(AgentBinding {
agent: "ghost".to_string(),
match_rule: BindingMatchRule {
channel_id: Some("99999".to_string()),
..Default::default()
},
});
let warnings = config.validate();
assert!(
warnings
.iter()
.any(|w| w.contains("channel_id") && w.contains("without channel")),
"expected channel_id-without-channel warning, got: {:?}",
warnings
);
}
#[test]
fn test_validate_warns_channel_id_for_unsupported_adapter() {
// Reddit's `platform_id` is the post author, not a subreddit; no
// per-conversation channel_id, so the binding can never match.
let mut config = KernelConfig::default();
config.bindings.push(AgentBinding {
agent: "ghost".to_string(),
match_rule: BindingMatchRule {
channel: Some("reddit".to_string()),
channel_id: Some("r/something".to_string()),
..Default::default()
},
});
let warnings = config.validate();
assert!(
warnings
.iter()
.any(|w| w.contains("reddit") && w.contains("does not populate")),
"expected unsupported-adapter warning, got: {:?}",
warnings
);
}
#[test]
fn test_validate_no_warning_for_supported_adapters() {
// Discord, Slack, Telegram all overload platform_id as the channel ID —
// these must not warn.
for ch in ["discord", "slack", "telegram"] {
let mut config = KernelConfig::default();
config.bindings.push(AgentBinding {
agent: "ok".to_string(),
match_rule: BindingMatchRule {
channel: Some(ch.to_string()),
channel_id: Some("X".to_string()),
..Default::default()
},
});
let warnings = config.validate();
assert!(
!warnings.iter().any(|w| w.contains("does not populate")),
"did not expect channel_id-coverage warning for {}, got: {:?}",
ch,
warnings
);
}
}
#[test]
fn test_discord_config_defaults() {
let dc = DiscordConfig::default();
@@ -4394,4 +4620,54 @@ mod tests {
let config: KernelConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.heartbeat.default_timeout_secs, 300);
}
// ── Issue #1169: shell_env_passthrough on ExecPolicy ──────────────
#[test]
fn test_exec_policy_passthrough_default_empty() {
let policy = ExecPolicy::default();
assert!(policy.shell_env_passthrough.is_empty());
}
#[test]
fn test_exec_policy_passthrough_deserializes() {
let toml_str = r#"
mode = "full"
shell_env_passthrough = ["TZ", "GOG_ACCOUNT"]
"#;
let policy: ExecPolicy = toml::from_str(toml_str).unwrap();
assert_eq!(policy.shell_env_passthrough, vec!["TZ", "GOG_ACCOUNT"]);
}
#[test]
fn test_exec_policy_passthrough_alias_env_passthrough() {
// Backwards-compat alias from the issue body (#1169).
let toml_str = r#"
mode = "full"
env_passthrough = ["TZ", "GOG_ACCOUNT"]
"#;
let policy: ExecPolicy = toml::from_str(toml_str).unwrap();
assert_eq!(policy.shell_env_passthrough, vec!["TZ", "GOG_ACCOUNT"]);
}
#[test]
fn test_exec_policy_passthrough_alias_env_allowlist() {
// Backwards-compat alias from the issue body (#1169).
let toml_str = r#"
mode = "full"
env_allowlist = ["TZ", "HOME"]
"#;
let policy: ExecPolicy = toml::from_str(toml_str).unwrap();
assert_eq!(policy.shell_env_passthrough, vec!["TZ", "HOME"]);
}
#[test]
fn test_exec_policy_passthrough_wildcard() {
let toml_str = r#"
mode = "full"
shell_env_passthrough = ["*"]
"#;
let policy: ExecPolicy = toml::from_str(toml_str).unwrap();
assert_eq!(policy.shell_env_passthrough, vec!["*"]);
}
}
+105
View File
@@ -96,6 +96,44 @@ pub struct MediaConfig {
/// # works for most local OpenAI-compat servers).
/// ```
pub audio_base_url: Option<String>,
/// Optional override for the OpenAI TTS endpoint base URL.
///
/// When set, replaces `https://api.openai.com` with
/// `<tts_openai_base_url>/v1/audio/speech`. Use this to point at a
/// local OpenAI-compatible TTS service (Lemonade/Kokoro, LM Studio,
/// etc.) while keeping the same JSON wire format. The Authorization
/// header is still built from `OPENAI_API_KEY` (local services
/// usually accept any non-empty bearer token).
///
/// Closes <https://github.com/RightNow-AI/openfang/issues/1051>.
#[serde(default)]
pub tts_openai_base_url: Option<String>,
/// Optional override for the ElevenLabs TTS endpoint base URL.
///
/// When set, replaces `https://api.elevenlabs.io` with
/// `<tts_elevenlabs_base_url>/v1/text-to-speech/{voice_id}`. Use this
/// to route through a proxy or self-hosted ElevenLabs-compatible
/// gateway. The `xi-api-key` header still comes from
/// `ELEVENLABS_API_KEY`.
///
/// Closes <https://github.com/RightNow-AI/openfang/issues/1051>.
#[serde(default)]
pub tts_elevenlabs_base_url: Option<String>,
/// Optional override for the OpenAI image generation endpoint base URL.
///
/// When set, replaces `https://api.openai.com` with
/// `<image_gen_base_url>/v1/images/generations`. Use this to point at
/// a local OpenAI-compatible image generation service
/// (Lemonade/Flux, LM Studio, etc.) while keeping the same JSON wire
/// format. The Authorization header is still built from
/// `OPENAI_API_KEY`.
///
/// Closes <https://github.com/RightNow-AI/openfang/issues/1051>.
#[serde(default)]
pub image_gen_base_url: Option<String>,
}
impl Default for MediaConfig {
@@ -108,6 +146,9 @@ impl Default for MediaConfig {
image_provider: None,
audio_provider: None,
audio_base_url: None,
tts_openai_base_url: None,
tts_elevenlabs_base_url: None,
image_gen_base_url: None,
}
}
}
@@ -382,6 +423,70 @@ mod tests {
assert_eq!(config.max_concurrency, 2);
assert!(config.image_provider.is_none());
assert!(config.audio_base_url.is_none());
assert!(config.tts_openai_base_url.is_none());
assert!(config.tts_elevenlabs_base_url.is_none());
assert!(config.image_gen_base_url.is_none());
}
#[test]
fn test_media_config_tts_openai_base_url_serde_roundtrip() {
let config = MediaConfig {
tts_openai_base_url: Some("http://127.0.0.1:8000".to_string()),
..MediaConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: MediaConfig = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed.tts_openai_base_url.as_deref(),
Some("http://127.0.0.1:8000")
);
}
#[test]
fn test_media_config_tts_elevenlabs_base_url_serde_roundtrip() {
let config = MediaConfig {
tts_elevenlabs_base_url: Some("http://127.0.0.1:9000".to_string()),
..MediaConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: MediaConfig = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed.tts_elevenlabs_base_url.as_deref(),
Some("http://127.0.0.1:9000")
);
}
#[test]
fn test_media_config_image_gen_base_url_serde_roundtrip() {
let config = MediaConfig {
image_gen_base_url: Some("http://127.0.0.1:7000".to_string()),
..MediaConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let parsed: MediaConfig = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed.image_gen_base_url.as_deref(),
Some("http://127.0.0.1:7000")
);
}
#[test]
fn test_media_config_backward_compat_no_tts_or_image_overrides() {
// Old TOML/JSON without the three new URL override fields must still
// parse with None, thanks to #[serde(default)] on the struct.
let legacy_json = r#"{
"image_description": true,
"audio_transcription": true,
"video_description": false,
"max_concurrency": 2,
"image_provider": null,
"audio_provider": "openai",
"audio_base_url": null
}"#;
let parsed: MediaConfig = serde_json::from_str(legacy_json).unwrap();
assert!(parsed.tts_openai_base_url.is_none());
assert!(parsed.tts_elevenlabs_base_url.is_none());
assert!(parsed.image_gen_base_url.is_none());
}
#[test]
+13
View File
@@ -15,6 +15,9 @@ pub fn map_tool_name(openclaw_name: &str) -> Option<&'static str> {
"Edit" | "edit" => Some("file_write"),
"Glob" | "glob" | "list_files" => Some("file_list"),
"Grep" | "grep" => Some("file_list"),
"Mkdir" | "mkdir" | "make_directory" | "makeDirectory" | "make_dir" | "makedir"
| "create_dir" | "createDir" | "createDirectory" | "new_directory" | "new_folder"
| "create_folder" | "createFolder" => Some("create_directory"),
"Bash" | "bash" | "exec" | "execute_command" => Some("shell_exec"),
"WebSearch" | "web_search" => Some("web_search"),
"WebFetch" | "fetch_url" | "web_fetch" => Some("web_fetch"),
@@ -56,6 +59,7 @@ pub fn is_known_openfang_tool(name: &str) -> bool {
"file_read"
| "file_write"
| "file_list"
| "create_directory"
| "shell_exec"
| "web_search"
| "web_fetch"
@@ -147,6 +151,14 @@ mod tests {
assert_eq!(map_tool_name("execute"), Some("shell_exec"));
assert_eq!(map_tool_name("shell"), Some("shell_exec"));
// create_directory aliases
assert_eq!(map_tool_name("Mkdir"), Some("create_directory"));
assert_eq!(map_tool_name("mkdir"), Some("create_directory"));
assert_eq!(map_tool_name("make_directory"), Some("create_directory"));
assert_eq!(map_tool_name("create_dir"), Some("create_directory"));
assert_eq!(map_tool_name("createDirectory"), Some("create_directory"));
assert_eq!(map_tool_name("create_folder"), Some("create_directory"));
// Unknown
assert_eq!(map_tool_name("unknown_tool"), None);
assert_eq!(map_tool_name(""), None);
@@ -180,6 +192,7 @@ mod tests {
"file_read",
"file_write",
"file_list",
"create_directory",
"shell_exec",
"web_search",
"web_fetch",
+5
View File
@@ -11,6 +11,11 @@ services:
- "4200:4200"
volumes:
- openfang-data:/data
# Uncomment to reach host services (Ollama, whisper.cpp, local Postgres)
# from inside the container. Required on Linux and colima. See
# docs/troubleshooting.md#connecting-to-host-services-from-docker.
# extra_hosts:
# - "host.docker.internal:host-gateway"
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
+57 -4
View File
@@ -622,10 +622,63 @@ Features:
The `AgentRouter` determines which agent receives an incoming message. The routing logic is:
1. **Per-channel default**: Each channel config has a `default_agent` field. Messages from that channel go to that agent.
2. **User-agent binding**: If a user has previously been associated with a specific agent (via commands or configuration), messages from that user route to that agent.
3. **Command prefix**: Users can switch agents by sending a command like `/agent coder` in the chat. Subsequent messages will be routed to the "coder" agent.
4. **Fallback**: If no routing applies, messages go to the first available agent.
1. **Bindings** (most specific first). Declarative `[[bindings]]` rules in `config.toml` map message attributes (channel, channel_id, peer_id, guild_id, account_id, roles) to agents. The router scores each rule by specificity and picks the highest-scoring match.
2. **Per-channel default**: Each channel config has a `default_agent` field. Messages from that channel go to that agent.
3. **User-agent binding**: If a user has previously been associated with a specific agent (via commands or configuration), messages from that user route to that agent.
4. **Command prefix**: Users can switch agents by sending a command like `/agent coder` in the chat. Subsequent messages will be routed to the "coder" agent.
5. **Fallback**: If no routing applies, messages go to the first available agent.
### Bindings
A binding has an `agent` (the target) and a `match_rule` (the criteria). All non-empty fields in the rule must match.
```toml
# Route a specific Discord channel to a dedicated agent.
[[bindings]]
agent = "researcher-medical"
match_rule = { channel = "discord", channel_id = "1234567890" }
[[bindings]]
agent = "researcher-business"
match_rule = { channel = "discord", channel_id = "9876543210" }
# Catch-all for the same user on any other channel.
[[bindings]]
agent = "assistant"
match_rule = { channel = "discord", peer_id = "user_discord_id" }
```
**`peer_id` vs `channel_id`** — these are easy to confuse and the difference matters:
- `peer_id` matches the **user** (Discord user ID, Slack user ID, etc.).
- `channel_id` matches the **channel/conversation** (Discord text channel, Slack conversation, Telegram chat).
Use `peer_id` for "messages from this person." Use `channel_id` for "messages in this room."
**Specificity scores** (higher wins):
| Field | Score |
| ------------ | ----- |
| `peer_id` | 8 |
| `channel_id` | 8 |
| `guild_id` | 4 |
| `roles` | 2 |
| `account_id` | 2 |
| `channel` | 1 |
A binding's score is the sum of its set fields. `peer_id` and `channel_id` are equally specific, so a rule with both (16) beats either alone (8). Ties are broken by declaration order in the config.
**Adapter coverage for `channel_id`** — the following adapters populate `ctx.channel_id` directly from `sender.platform_id` (their "user" field is overloaded as a channel/conversation/room/space ID because that field doubles as the send target):
`discord`, `slack`, `telegram`, `matrix`, `mattermost`, `teams`, `webex`, `rocketchat`, `nextcloud`, `pumble`, `revolt`, `guilded`, `feishu`, `lark`, `keybase`, `google_chat`, `line`, `twist`, `flock`, `twitch`.
(Feishu Intl region emits `Custom("lark")` rather than `Custom("feishu")`; both spellings are recognized.)
Adapters not on this list (Reddit, Bluesky, Mastodon, Signal, Email, ntfy, Discourse, etc.) carry a *user* ID in `platform_id` and have no per-conversation concept, or use a hybrid scheme (IRC, Zulip flip between channel and user based on `is_group`). Bindings targeting `channel_id` on those platforms will only match if the adapter writes a `channel_id` key into message metadata.
The kernel emits a startup warning when a binding sets `channel_id` for a non-supporting adapter, so misconfigurations surface early instead of silently routing nowhere. The single source of truth for this list is `CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL` in `openfang-types::config`, consumed by both routing (`ChannelMessage::channel_id()`) and config validation.
**Strict parsing** — `AgentBinding` and `BindingMatchRule` use `#[serde(deny_unknown_fields)]`. Typos at the binding level (e.g. `match_rules` for `match_rule`, `channnel_id` for `channel_id`) fail config load with a clear error rather than parsing into a no-op rule that silently matches every message. Existing configs that work today are unaffected; only configs with stray/misspelled fields inside a `[[bindings]]` block need a fix. The top-level `KernelConfig` deliberately stays permissive so unrecognized top-level keys (forward-compat, downstream forks) don't break startup.
---
+17
View File
@@ -82,6 +82,23 @@ cd openfang
docker compose up -d
```
**Reaching host services from the container.** If you run a local LLM
(Ollama, whisper.cpp, vLLM) on the host and want the agent to call it, add
the host-gateway bridge. Required on Linux and colima:
```bash
docker run -d \
--add-host=host.docker.internal:host-gateway \
-e OLLAMA_HOST=http://host.docker.internal:11434 \
-p 4200:4200 \
ghcr.io/rightnow-ai/openfang:latest
```
For Compose, add `extra_hosts: ["host.docker.internal:host-gateway"]` to the
service. See [Troubleshooting → Connecting to host services from Docker](troubleshooting.md#connecting-to-host-services-from-docker)
and the [curl-equipped overlay image](troubleshooting.md#curl-equipped-reference-image)
if you need in-container `curl` for healthchecks.
### Verify Installation
```bash
+76
View File
@@ -110,6 +110,75 @@ rm ~/.config/fish/conf.d/openfang.fish
- Port already in use: change the port mapping `-p 3001:4200`
- Permission denied on volume mount: check directory permissions
### Connecting to host services from Docker
If you run OpenFang inside Docker and need to reach a service running on the
host (Ollama on `127.0.0.1:11434`, whisper.cpp on `127.0.0.1:8090`, a local
Postgres, etc.), `localhost` inside the container points at the container
itself, not the host. You must opt in to the host bridge.
On Docker Desktop (macOS/Windows) `host.docker.internal` resolves
automatically. On Linux and on colima (macOS) it does not, and you must add
the flag explicitly:
```bash
docker run --rm \
--add-host=host.docker.internal:host-gateway \
-e OLLAMA_HOST=http://host.docker.internal:11434 \
-p 4200:4200 \
ghcr.io/rightnow-ai/openfang:latest
```
Verify the bridge works:
```bash
docker exec <container> getent hosts host.docker.internal
# 192.168.x.x host.docker.internal
```
For Docker Compose use `extra_hosts:`:
```yaml
services:
openfang:
image: ghcr.io/rightnow-ai/openfang:latest
ports:
- "4200:4200"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- OLLAMA_HOST=http://host.docker.internal:11434
```
Without this flag on Linux/colima, calls to host services fail silently with
connection refused or DNS lookup errors.
### Curl-equipped reference image
The default `ghcr.io/rightnow-ai/openfang` image does not ship `curl`, so
`docker exec openfang curl ...` returns `exec: curl: not found`. If you need
in-container probes for healthchecks or egress verification, build a thin
overlay image:
```dockerfile
# Dockerfile.curl
FROM ghcr.io/rightnow-ai/openfang:latest
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
```
Build and run:
```bash
docker build -f Dockerfile.curl -t openfang-curl:latest .
docker run --rm openfang-curl:latest curl -s https://example.com
```
Use this variant when you need `HEALTHCHECK` directives or in-container
diagnostics. The base image stays slim by default.
---
## Configuration Issues
@@ -596,6 +665,13 @@ docker run -d --name openfang \
ghcr.io/rightnow-ai/openfang:latest
```
To reach a host LLM (Ollama, vLLM, whisper.cpp) from inside the container,
add `--add-host=host.docker.internal:host-gateway`. See
[Connecting to host services from Docker](#connecting-to-host-services-from-docker).
The default image does not ship `curl`; build the
[curl-equipped overlay](#curl-equipped-reference-image) if you need
in-container healthchecks.
### How do I protect the dashboard with a password?
OpenFang has built-in dashboard authentication. Enable it in `~/.openfang/config.toml`: