Compare commits

...
27 Commits
Author SHA1 Message Date
jaberjaber23 4aa1508f54 clippy lint 2026-05-12 20:43:56 +03:00
jaberjaber23 5447bf7f1d toctou staging 2026-05-12 17:11:36 +03:00
jaberjaber23 b77ebfb897 thread routing 2026-05-12 17:09:09 +03:00
jaberjaber23 2323cd5e67 audit hands 2026-05-12 17:06:43 +03:00
jaberjaber23 df29e5e8b9 audit endpoint 2026-05-12 17:05:42 +03:00
jaberjaber23 8b411c21c4 codex hardening 2026-05-12 16:38:50 +03:00
jaberjaber23 b00af5eddd add requesty 2026-05-12 16:35:22 +03:00
jaberjaber23 36177425c4 skill tools 2026-05-12 16:34:49 +03:00
jaberjaber23 6bed6c04ff workspace split 2026-05-12 16:33:49 +03:00
jaberjaber23 4c496be02f integration fixes 2026-05-12 16:13:08 +03:00
jaberjaber23 7f7b071528 provider refs 2026-05-12 15:53:15 +03:00
jaberjaber23 2d1fb8171c matrix refresh 2026-05-12 15:50:44 +03:00
Jaber Jaber e683acc565 Merge pull request #1045 from dongtran16092006/fix-mcp-system-prompt
fix: system prompt and identity handling, and config form hydration
2026-05-12 15:49:20 +03:00
jaberjaber23 5396889ff1 bedrock redacted 2026-05-12 15:48:54 +03:00
jaberjaber23 c9e8d31571 ws auth align 2026-05-12 15:47:38 +03:00
Jaber Jaber d8ad91572e Merge pull request #1054 from Hypn0sis/feat/discord-smart-thread
feat(discord): smart auto-thread mode (true/false/smart)
2026-05-12 15:47:05 +03:00
jaberjaber23 c27bfebd17 bump v0.6.7 2026-05-12 15:44:29 +03:00
jaberjaber23 f05ba5e42f tts image urls 2026-05-12 15:38:31 +03:00
jaberjaber23 d9e72abb4b uninstall agent 2026-05-12 15:37:44 +03:00
jaberjaber23 505a8e8080 hand stop 2026-05-12 15:37:02 +03:00
jaberjaber23 5cc865e6e6 shell env 2026-05-12 15:34:39 +03:00
jaberjaber23 6a1ce40d86 require signed 2026-05-12 15:34:01 +03:00
jaberjaber23 fbb7936234 docker docs 2026-05-12 15:30:40 +03:00
jaberjaber23 569e76c79a ws reconnect 2026-05-12 15:29:56 +03:00
dongtran16092006 93b57bdd52 fix: system prompt and identity handling, and config form hydration #1045 2026-04-20 18:10:16 +07:00
Matteo De Agazio 0227ff1790 fix(discord): cap dedup set on thread delete, add #[cfg(test)] to test mod
- Replace retain(|_| true) no-op with a size-capped clear: when
  threaded_message_ids exceeds MAX_DEDUP_MSG_IDS (2000) it is cleared.
  MESSAGE_UPDATE embed events arrive within seconds so old entries are
  always safe to discard; prevents unbounded growth on busy servers.
- Add #[cfg(test)] to mod tests so empty_threads() helper is only
  compiled in test mode — removes the need for #[allow(dead_code)].
2026-04-20 09:31:09 +02:00
Matteo De Agazio 525d7d844a feat(discord): smart auto-thread mode (true/false/smart) 2026-04-20 09:31:09 +02:00
46 changed files with 4597 additions and 286 deletions
Generated
+19 -14
View File
@@ -3959,7 +3959,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"argon2",
"async-trait",
@@ -4001,7 +4001,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"aes",
"async-trait",
@@ -4040,7 +4040,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"clap",
"clap_complete",
@@ -4068,7 +4068,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"axum",
"open",
@@ -4094,7 +4094,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"aes-gcm",
"argon2",
@@ -4122,14 +4122,16 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"chrono",
"dashmap",
"dirs 6.0.0",
"hex",
"openfang-types",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.18",
"tokio-test",
@@ -4140,7 +4142,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"async-trait",
"chrono",
@@ -4165,6 +4167,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2",
"subtle",
"tempfile",
"thiserror 2.0.18",
@@ -4179,7 +4182,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"async-trait",
"chrono",
@@ -4199,7 +4202,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4218,7 +4221,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"anyhow",
"async-trait",
@@ -4254,11 +4257,13 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"chrono",
"ed25519-dalek",
"hex",
"openfang-types",
"rand 0.8.5",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -4277,7 +4282,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"async-trait",
"bitflags 2.11.0",
@@ -4297,7 +4302,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.6.6"
version = "0.6.8"
dependencies = [
"async-trait",
"chrono",
@@ -9231,7 +9236,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.6.6"
version = "0.6.8"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.6.6"
version = "0.6.8"
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.6-green?style=flat-square" alt="v0.6.6" />
<img src="https://img.shields.io/badge/tests-2,625%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/version-0.6.8-green?style=flat-square" alt="v0.6.8" />
<img src="https://img.shields.io/badge/tests-2,669%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>
+11 -2
View File
@@ -1166,11 +1166,12 @@ pub async fn start_channel_bridge_with_config(
if let Some(ref tg_config) = config.telegram {
if let Some(token) = read_token(&tg_config.bot_token_env, "Telegram") {
let poll_interval = Duration::from_secs(tg_config.poll_interval_secs);
let adapter = Arc::new(TelegramAdapter::new(
let adapter = Arc::new(TelegramAdapter::with_thread_routes(
token,
tg_config.allowed_users.clone(),
poll_interval,
tg_config.api_url.clone(),
tg_config.thread_routes.clone(),
));
adapters.push((adapter, tg_config.default_agent.clone()));
}
@@ -1185,6 +1186,7 @@ pub async fn start_channel_bridge_with_config(
dc_config.allowed_users.clone(),
dc_config.ignore_bots,
dc_config.intents,
dc_config.auto_thread.clone(),
));
adapters.push((adapter, dc_config.default_agent.clone()));
}
@@ -1249,10 +1251,17 @@ pub async fn start_channel_bridge_with_config(
// Matrix
if let Some(ref mx_config) = config.matrix {
if let Some(token) = read_token(&mx_config.access_token_env, "Matrix") {
let adapter = Arc::new(MatrixAdapter::new(
// MSC2918 refresh-token support: optional env var, when present the
// adapter auto-recovers from M_UNKNOWN_TOKEN 401s.
let refresh = mx_config
.refresh_token_env
.as_deref()
.and_then(|env| read_token(env, "Matrix refresh"));
let adapter = Arc::new(MatrixAdapter::with_refresh_token(
mx_config.homeserver_url.clone(),
mx_config.user_id.clone(),
token,
refresh,
mx_config.allowed_rooms.clone(),
mx_config.auto_accept_invites,
));
+403 -23
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.
@@ -1417,11 +1509,15 @@ pub async fn get_agent(
"network": entry.manifest.capabilities.network,
},
"description": entry.manifest.description,
"system_prompt": entry.manifest.model.system_prompt,
"tags": entry.manifest.tags,
"identity": {
"emoji": entry.identity.emoji,
"avatar_url": entry.identity.avatar_url,
"color": entry.identity.color,
"archetype": entry.identity.archetype,
"vibe": entry.identity.vibe,
"greeting_style": entry.identity.greeting_style,
},
"skills": entry.manifest.skills,
"skills_mode": if entry.manifest.skills.is_empty() { "all" } else { "allowlist" },
@@ -3607,7 +3703,14 @@ pub async fn install_skill(
let config = openfang_skills::marketplace::MarketplaceConfig::default();
let client = openfang_skills::marketplace::MarketplaceClient::new(config);
match client.install(&req.name, &skills_dir).await {
let opts = openfang_skills::installer::InstallOptions {
require_signed: req.require_signed,
allowed_signer_keys: req.allowed_signer_keys.clone(),
};
match client
.install_with_options(&req.name, &skills_dir, &opts)
.await
{
Ok(version) => {
// Hot-reload so agents see the new skill immediately
state.kernel.reload_skills();
@@ -3664,6 +3767,109 @@ pub async fn reload_skills(State(state): State<Arc<AppState>>) -> impl IntoRespo
Json(serde_json::json!({"status": "reloaded"}))
}
/// POST /api/audit/append — Append an entry to the Merkle hash chain audit
/// trail on behalf of an external (instance-side) wrapper (issue #1174).
///
/// RBAC: gated by the same bearer-token middleware as POST /api/skills/install
/// (see `middleware::auth_middleware`). When `api_key` is configured every
/// caller must present `Authorization: Bearer <key>` — wrappers running in the
/// same trust boundary as the daemon are expected to share that key.
pub async fn audit_append(
State(state): State<Arc<AppState>>,
Json(req): Json<AuditAppendRequest>,
) -> impl IntoResponse {
use openfang_runtime::audit::AuditAction;
// SECURITY: bound input sizes so a wrapper cannot wedge the chain with
// unbounded strings. The audit table stores TEXT columns and the chain
// hash is computed over the same bytes — keep it sane.
const MAX_FIELD: usize = 16 * 1024;
if req.event_type.len() > MAX_FIELD
|| req.agent_id.len() > MAX_FIELD
|| req.detail.len() > MAX_FIELD
|| req
.outcome
.as_ref()
.map(|s| s.len() > MAX_FIELD)
.unwrap_or(false)
|| req
.signing_context
.as_ref()
.map(|s| s.len() > MAX_FIELD)
.unwrap_or(false)
{
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "field exceeds 16KB limit"})),
);
}
// Map operator-supplied event_type → AuditAction (case-insensitive).
let action = match req.event_type.trim().to_ascii_lowercase().as_str() {
"toolinvoke" | "tool_invoke" | "tool" => AuditAction::ToolInvoke,
"capabilitycheck" | "capability_check" | "capability" => AuditAction::CapabilityCheck,
"agentspawn" | "agent_spawn" | "spawn" => AuditAction::AgentSpawn,
"agentkill" | "agent_kill" | "kill" => AuditAction::AgentKill,
"agentmessage" | "agent_message" | "message" => AuditAction::AgentMessage,
"memoryaccess" | "memory_access" | "memory" => AuditAction::MemoryAccess,
"fileaccess" | "file_access" | "file" => AuditAction::FileAccess,
"networkaccess" | "network_access" | "network" => AuditAction::NetworkAccess,
"shellexec" | "shell_exec" | "shell" => AuditAction::ShellExec,
"authattempt" | "auth_attempt" | "auth" => AuditAction::AuthAttempt,
"wireconnect" | "wire_connect" | "wire" => AuditAction::WireConnect,
"configchange" | "config_change" | "config" => AuditAction::ConfigChange,
other => {
tracing::warn!(
"audit_append: unknown event_type {other:?}, falling back to ToolInvoke"
);
AuditAction::ToolInvoke
}
};
// Compose a detail string that preserves the operator's free-form detail
// plus optional signing context and structured payload, so wrappers can
// attach context without changing the on-chain schema.
let mut detail = req.detail.clone();
if let Some(ctx) = req.signing_context.as_ref().filter(|s| !s.is_empty()) {
if !detail.is_empty() {
detail.push_str(" | ");
}
detail.push_str("signer=");
detail.push_str(ctx);
}
if let Some(payload) = req.payload.as_ref() {
let serialised = serde_json::to_string(payload)
.unwrap_or_else(|_| String::from("<unserialisable payload>"));
// Cap payload contribution so a huge JSON blob cannot blow the entry.
let truncated: String = serialised.chars().take(8 * 1024).collect();
if !detail.is_empty() {
detail.push_str(" | ");
}
detail.push_str("payload=");
detail.push_str(&truncated);
}
let agent_id = if req.agent_id.trim().is_empty() {
"external-wrapper".to_string()
} else {
req.agent_id.clone()
};
let outcome = req.outcome.clone().unwrap_or_else(|| "ok".to_string());
let hash = state.kernel.audit_log.record(agent_id, action, detail, outcome);
let seq = state.kernel.audit_log.len().saturating_sub(1) as u64;
(
StatusCode::OK,
Json(serde_json::json!({
"status": "appended",
"seq": seq,
"hash": hash,
"tip": state.kernel.audit_log.tip_hash(),
})),
)
}
/// GET /api/marketplace/search — Search the FangHub marketplace.
pub async fn marketplace_search(
Query(params): Query<HashMap<String, String>>,
@@ -7077,6 +7283,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 +7300,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,
@@ -9846,8 +10083,10 @@ pub async fn clone_agent(
// new_name always wins over any name field in overrides.
cloned_manifest.name = new_name.clone();
// Let the kernel assign a fresh workspace path — never share with the template.
// Let the kernel assign fresh workspace and state directory paths.
// Never share private state with the template (see issue #868, #1097).
cloned_manifest.workspace = None;
cloned_manifest.state_dir = None;
// Spawn the cloned agent.
let new_id = match state.kernel.spawn_agent(cloned_manifest) {
@@ -9860,25 +10099,42 @@ pub async fn clone_agent(
}
};
// Copy non-memory workspace files from source to destination.
// MEMORY.md and HEARTBEAT.md are intentionally skipped — the cloned agent
// must start with independent memory (issue #868).
// Copy non-memory identity files from source state_dir to destination
// state_dir. MEMORY.md and HEARTBEAT.md are intentionally skipped — the
// cloned agent must start with independent memory (issue #868). Identity
// files live in state_dir per #1097; fall back to legacy workspace for
// older agents.
let new_entry = state.kernel.registry.get(new_id);
let src_state = source
.manifest
.state_dir
.as_ref()
.or(source.manifest.workspace.as_ref());
let dst_state = new_entry.as_ref().and_then(|e| {
e.manifest
.state_dir
.as_ref()
.or(e.manifest.workspace.as_ref())
});
if let (Some(src_ws), Some(dst_ws)) = (src_state, dst_state) {
if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) {
for &fname in KNOWN_IDENTITY_FILES {
if MEMORY_FILES.contains(&fname) {
continue;
}
let src_file = src_can.join(fname);
let dst_file = dst_can.join(fname);
if src_file.exists() {
let _ = std::fs::copy(&src_file, &dst_file);
}
}
}
}
// Copy the user-facing `skills/` subdirectory so curated skills travel
// with the template. These live in the workspace, not the state dir.
if let (Some(ref src_ws), Some(ref new_entry)) = (&source.manifest.workspace, &new_entry) {
if let Some(ref dst_ws) = new_entry.manifest.workspace {
if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) {
for &fname in KNOWN_IDENTITY_FILES {
if MEMORY_FILES.contains(&fname) {
continue;
}
let src_file = src_can.join(fname);
let dst_file = dst_can.join(fname);
if src_file.exists() {
let _ = std::fs::copy(&src_file, &dst_file);
}
}
// Copy the `skills/` subdirectory if present so curated skills
// travel with the template.
let src_skills = src_can.join("skills");
let dst_skills = dst_can.join("skills");
if src_skills.is_dir() {
@@ -9981,8 +10237,11 @@ pub async fn list_agent_files(
}
};
let workspace = match entry.manifest.workspace {
Some(ref ws) => ws.clone(),
// Identity files live in the agent's private state directory (see #1097).
// Fall back to the legacy workspace location for agents created before the
// split so existing on-disk files remain reachable.
let workspace = match entry.manifest.state_dir.as_ref().or(entry.manifest.workspace.as_ref()) {
Some(ws) => ws.clone(),
None => {
return (
StatusCode::NOT_FOUND,
@@ -10043,8 +10302,15 @@ pub async fn get_agent_file(
}
};
let workspace = match entry.manifest.workspace {
Some(ref ws) => ws.clone(),
// Identity files live in the agent's private state directory (see #1097).
// Fall back to legacy workspace for agents created before the split.
let workspace = match entry
.manifest
.state_dir
.as_ref()
.or(entry.manifest.workspace.as_ref())
{
Some(ws) => ws.clone(),
None => {
return (
StatusCode::NOT_FOUND,
@@ -10150,8 +10416,15 @@ pub async fn set_agent_file(
}
};
let workspace = match entry.manifest.workspace {
Some(ref ws) => ws.clone(),
// Identity files live in the agent's private state directory (see #1097).
// Fall back to legacy workspace for agents created before the split.
let workspace = match entry
.manifest
.state_dir
.as_ref()
.or(entry.manifest.workspace.as_ref())
{
Some(ws) => ws.clone(),
None => {
return (
StatusCode::NOT_FOUND,
@@ -12583,3 +12856,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"
);
}
}
+9
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),
@@ -390,6 +394,11 @@ pub async fn build_router(
"/api/skills/reload",
axum::routing::post(routes::reload_skills),
)
// Audit trail (issue #1174 — instance-side wrapper integration)
.route(
"/api/audit/append",
axum::routing::post(routes::audit_append),
)
.route(
"/api/skills/{id}/config",
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
+103
View File
@@ -65,6 +65,15 @@ pub struct MessageResponse {
#[derive(Debug, Deserialize)]
pub struct SkillInstallRequest {
pub name: String,
/// When true, reject the install unless the bundle ships a valid
/// Ed25519 SignedManifest envelope bound to the on-disk manifest.
/// Maps to `InstallOptions::require_signed` (issue #1170).
#[serde(default)]
pub require_signed: bool,
/// Optional hex-encoded allow-list of acceptable signer public keys.
/// Empty = TOFU (any valid signature accepted).
#[serde(default)]
pub allowed_signer_keys: Vec<String>,
}
/// Request to uninstall a skill.
@@ -115,3 +124,97 @@ pub struct CommandsQuery {
#[serde(default)]
pub surface: Option<String>,
}
/// Request body for `POST /api/audit/append` (issue #1174).
///
/// Lets external (instance-side) wrappers append entries to the Merkle hash
/// chain audit log. The handler maps `event_type` to an `AuditAction` and
/// records the entry through `kernel.audit_log`.
#[derive(Debug, Deserialize)]
pub struct AuditAppendRequest {
/// Operator-supplied event category. Case-insensitive, matched against the
/// `AuditAction` enum variants (e.g. `tool_invoke`, `ConfigChange`,
/// `agent_message`). Unknown values fall back to `ToolInvoke`.
pub event_type: String,
/// Agent or wrapper identifier responsible for the event. When empty,
/// recorded as `"external-wrapper"`.
#[serde(default)]
pub agent_id: String,
/// Free-form detail string (e.g. tool name, URL, file path).
#[serde(default)]
pub detail: String,
/// Optional arbitrary payload. When present it is serialised to JSON and
/// appended onto the entry's detail so the wrapper retains structured
/// context without changing the on-chain schema.
#[serde(default)]
pub payload: Option<serde_json::Value>,
/// Optional outcome string (`"ok"`, `"denied"`, or an error). Defaults to
/// `"ok"` when omitted.
#[serde(default)]
pub outcome: Option<String>,
/// Optional operator-supplied signing context (e.g. wrapper identity, key
/// fingerprint). Mixed into the detail when present so the chain captures
/// who attested to the event.
#[serde(default)]
pub signing_context: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_install_request_defaults_back_compat() {
// Existing callers send `{"name": "..."}` only. New optional fields
// must default cleanly (issue #1170).
let req: SkillInstallRequest =
serde_json::from_str(r#"{"name":"github-helper"}"#).unwrap();
assert_eq!(req.name, "github-helper");
assert!(!req.require_signed);
assert!(req.allowed_signer_keys.is_empty());
}
#[test]
fn skill_install_request_parses_require_signed() {
let req: SkillInstallRequest = serde_json::from_str(
r#"{"name":"x","require_signed":true,"allowed_signer_keys":["abc123"]}"#,
)
.unwrap();
assert!(req.require_signed);
assert_eq!(req.allowed_signer_keys, vec!["abc123".to_string()]);
}
#[test]
fn audit_append_request_required_only() {
// Only `event_type` is required; everything else must default.
let req: AuditAppendRequest =
serde_json::from_str(r#"{"event_type":"ToolInvoke"}"#).unwrap();
assert_eq!(req.event_type, "ToolInvoke");
assert!(req.agent_id.is_empty());
assert!(req.detail.is_empty());
assert!(req.payload.is_none());
assert!(req.outcome.is_none());
assert!(req.signing_context.is_none());
}
#[test]
fn audit_append_request_full_payload() {
let body = r#"{
"event_type": "config_change",
"agent_id": "wrapper-1",
"detail": "rotated key",
"payload": {"key_id": "k-42", "ts": 1700000000},
"outcome": "ok",
"signing_context": "ed25519:deadbeef"
}"#;
let req: AuditAppendRequest = serde_json::from_str(body).unwrap();
assert_eq!(req.event_type, "config_change");
assert_eq!(req.agent_id, "wrapper-1");
assert_eq!(req.detail, "rotated key");
assert_eq!(req.outcome.as_deref(), Some("ok"));
assert_eq!(req.signing_context.as_deref(), Some("ed25519:deadbeef"));
let payload = req.payload.expect("payload present");
assert_eq!(payload["key_id"], "k-42");
assert_eq!(payload["ts"], 1_700_000_000);
}
}
+122 -16
View File
@@ -223,18 +223,31 @@ pub(crate) struct WsAuthCtx<'a> {
pub(crate) fn check_ws_auth(ctx: &WsAuthCtx<'_>) -> Result<(), axum::http::StatusCode> {
use axum::http::StatusCode;
// No api_key configured: only allow loopback or explicit opt-in.
// No api_key configured: behavior depends on whether dashboard auth is on.
//
// Issue #1189: previously this path allowed any loopback request through
// when api_key was empty, EVEN IF dashboard auth was enabled. That diverged
// from the HTTP middleware (which only opens the loopback no-auth path
// when api_key is empty AND auth.enabled is false). A local attacker with
// loopback access could chat with agents over WS even when the operator
// had configured dashboard credentials. Now mirror HTTP exactly.
if ctx.api_key.is_empty() {
// A session cookie can still rescue non-loopback requests when
// dashboard auth is enabled.
if ctx.auth_enabled && !ctx.session_secret.is_empty() {
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
if crate::session_auth::verify_session_token(&token, ctx.session_secret).is_some()
{
return Ok(());
// When dashboard auth is configured, require a valid session cookie
// regardless of bind address. Loopback no longer bypasses login.
if ctx.auth_enabled {
if !ctx.session_secret.is_empty() {
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
if crate::session_auth::verify_session_token(&token, ctx.session_secret)
.is_some()
{
return Ok(());
}
}
}
return Err(StatusCode::UNAUTHORIZED);
}
// No api_key AND dashboard auth disabled: keep the dev convenience
// path (loopback or explicit OPENFANG_ALLOW_NO_AUTH=1).
if ctx.is_loopback || ctx.allow_no_auth {
return Ok(());
}
@@ -1025,15 +1038,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) {
@@ -1918,4 +1946,82 @@ mod tests {
};
assert!(check_ws_auth(&ctx).is_ok());
}
// -----------------------------------------------------------------------
// Issue #1189: WS auth must mirror HTTP middleware. When dashboard auth
// is enabled, loopback + empty api_key + no cookie must NOT bypass.
// -----------------------------------------------------------------------
#[test]
fn ws_auth_dashboard_on_loopback_empty_key_no_cookie_rejected() {
// Issue #1189 regression guard: previously this returned Ok(()) because
// the empty-api_key branch allowed any loopback request through, even
// when dashboard credentials were configured. HTTP middleware rejects
// this path; WS must too.
let secret = "password-hash-style-secret";
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: true,
session_secret: secret,
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED,
"loopback must not bypass dashboard auth when api_key is empty"
);
}
#[test]
fn ws_auth_dashboard_on_loopback_valid_cookie_accepted() {
// With dashboard auth on, a valid session cookie is the supported
// credential and must upgrade successfully from loopback too.
let secret = "password-hash-style-secret";
let token = crate::session_auth::create_session_token("admin", secret, 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"cookie",
format!("openfang_session={token}").parse().unwrap(),
);
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: true,
session_secret: secret,
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(
check_ws_auth(&ctx).is_ok(),
"valid session cookie should authorize loopback WS upgrade"
);
}
#[test]
fn ws_auth_dashboard_off_loopback_empty_key_accepted() {
// Preserve the development convenience path: when dashboard auth is
// NOT configured AND api_key is empty, loopback still upgrades.
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: false,
session_secret: "",
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(
check_ws_auth(&ctx).is_ok(),
"loopback dev path must work when dashboard auth is disabled"
);
}
}
@@ -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>
@@ -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>
+46 -14
View File
@@ -337,29 +337,38 @@ function agentsPage() {
OpenFangAPI.wsDisconnect();
},
buildConfigForm(agent) {
var identity = (agent && agent.identity) || {};
return {
name: (agent && agent.name) || '',
system_prompt: (agent && agent.system_prompt) || '',
emoji: identity.emoji || '',
color: identity.color || '#FF5C00',
archetype: identity.archetype || '',
vibe: identity.vibe || ''
};
},
async showDetail(agent) {
this.detailAgent = agent;
this.detailAgent._fallbacks = [];
this.detailTab = 'info';
this.agentFiles = [];
this.editingFile = null;
this.fileContent = '';
this.editingFallback = false;
this.newFallbackValue = '';
this.configForm = {
name: agent.name || '',
system_prompt: agent.system_prompt || '',
emoji: (agent.identity && agent.identity.emoji) || '',
color: (agent.identity && agent.identity.color) || '#FF5C00',
archetype: (agent.identity && agent.identity.archetype) || '',
vibe: (agent.identity && agent.identity.vibe) || ''
};
this.showDetailModal = true;
// Fetch full agent detail to get fallback_models
// Load the full detail payload before opening the modal so editable
// fields such as system_prompt and identity metadata are hydrated.
var detail = agent;
try {
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
this.detailAgent._fallbacks = full.fallback_models || [];
} catch(e) { /* ignore */ }
detail = Object.assign({}, agent, full, {
identity: Object.assign({}, (agent && agent.identity) || {}, (full && full.identity) || {})
});
} catch(e) { /* fall back to list payload */ }
this.detailAgent = detail;
this.detailAgent._fallbacks = detail.fallback_models || [];
this.configForm = this.buildConfigForm(detail);
this.showDetailModal = true;
},
killAgent(agent) {
@@ -376,6 +385,29 @@ function agentsPage() {
});
},
// Issue #1163: uninstall an agent (kill + remove ~/.openfang/agents/<name>/).
uninstallAgent(agent) {
var self = this;
OpenFangToast.confirm(
'Uninstall Agent',
'Uninstall agent "' + agent.name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
async function() {
try {
var res = await OpenFangAPI.del('/api/agents/' + agent.id + '/uninstall');
var msg = 'Agent "' + agent.name + '" uninstalled';
if (res && res.dir_removed === false) {
msg += ' (no on-disk files found)';
}
OpenFangToast.success(msg);
self.showDetailModal = false;
await Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
}
}
);
},
killAllAgents() {
var list = this.filteredAgents;
if (!list.length) return;
@@ -198,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)
@@ -208,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);
@@ -551,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':
@@ -586,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
@@ -1173,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) {
@@ -1181,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;
+67 -9
View File
@@ -773,12 +773,20 @@ async fn dispatch_message(
.as_ref()
.map(|o| o.lifecycle_reactions)
.unwrap_or(true);
let thread_id = if threading_enabled {
message.thread_id.as_deref()
// --- Auto-thread: decide intent now, but create AFTER all policy guards ---
let auto_thread_name = if !threading_enabled && message.thread_id.is_none() {
adapter.should_auto_thread(message).await
} else {
None
};
// thread_id is resolved later, after all guards pass.
// Always propagate an existing thread_id (message arrived inside a thread),
// regardless of threading_enabled — that flag controls explicit threading config,
// not auto-detected thread context.
let mut effective_thread_id: Option<String> = message.thread_id.clone();
// --- DM/Group policy check ---
if let Some(ref ov) = overrides {
if message.is_group {
@@ -839,12 +847,42 @@ async fn dispatch_message(
if let Err(msg) =
rate_limiter.check(ct_str, sender_user_id(message), ov.rate_limit_per_user)
{
send_response(adapter, &message.sender, msg, thread_id, output_format).await;
// Rate-limit rejection: don't create a thread, use existing thread if any
send_response(
adapter,
&message.sender,
msg,
message.thread_id.as_deref(),
output_format,
)
.await;
return;
}
}
}
// --- Create auto-thread NOW (after all policy guards have passed) ---
if let Some(ref thread_name) = auto_thread_name {
match adapter
.create_thread(&message.sender, &message.platform_message_id, thread_name)
.await
{
Ok(new_thread_id) => {
info!(
"Created auto-thread {} for message {}",
thread_name, message.platform_message_id
);
effective_thread_id = Some(new_thread_id);
}
Err(e) => {
warn!("Failed to create auto-thread: {}", e);
}
}
}
// Resolve final thread_id reference used by all downstream send_response calls
let thread_id = effective_thread_id.as_deref();
// Handle commands first (early return)
if let ChannelContent::Command { ref name, ref args } = message.content {
let result = handle_command(
@@ -1153,13 +1191,33 @@ async fn dispatch_message(
// 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.
//
// Issue #780: when the adapter stamped a per-thread target agent in
// metadata (e.g. Telegram forum-topic routing via `thread_routes`), prefer
// it over the standard router so operators can scope topics to specific
// agents from config.toml.
let target_agent_name = message
.metadata
.get("target_agent_name")
.and_then(|v| v.as_str());
let routed_by_name = if let Some(name) = target_agent_name {
match handle.find_agent_by_name(name).await {
Ok(Some(id)) => Some(id),
_ => None,
}
} else {
None
};
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(),
&binding_ctx,
);
let agent_id = routed_by_name.or_else(|| {
router.resolve_with_context(
&message.channel,
sender_user_id(message),
message.sender.openfang_user.as_deref(),
&binding_ctx,
)
});
let agent_id = match agent_id {
Some(id) => id,
+303 -29
View File
@@ -8,7 +8,7 @@ use crate::types::{
};
use async_trait::async_trait;
use futures::{SinkExt, Stream, StreamExt};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -22,6 +22,10 @@ const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
const MAX_BACKOFF: Duration = Duration::from_secs(60);
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const DISCORD_MSG_LIMIT: usize = 2000;
/// Maximum number of seen message IDs kept in the dedup set.
/// MESSAGE_UPDATE (embed resolution) events arrive within seconds of the
/// original CREATE; entries older than this cap are safe to discard.
const MAX_DEDUP_MSG_IDS: usize = 2_000;
/// Discord Gateway opcodes.
mod opcode {
@@ -56,6 +60,8 @@ pub struct DiscordAdapter {
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
/// Auto-thread behavior: "true", "false", or "smart"
auto_thread: String,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Bot's own user ID (populated after READY event).
@@ -64,6 +70,13 @@ pub struct DiscordAdapter {
session_id: Arc<RwLock<Option<String>>>,
/// Resume gateway URL.
resume_gateway_url: Arc<RwLock<Option<String>>>,
/// Thread channel IDs created by this bot (thread_id → parent_channel_id).
/// Used to detect when incoming messages are inside a bot-created thread.
created_thread_ids: Arc<RwLock<HashMap<String, String>>>,
/// Message IDs seen via MESSAGE_CREATE (used to drop duplicate MESSAGE_UPDATE events).
/// Populated immediately when MESSAGE_CREATE is forwarded — before bridge processing —
/// to eliminate the race window where MESSAGE_UPDATE arrives before thread creation completes.
threaded_message_ids: Arc<RwLock<HashSet<String>>>,
}
impl DiscordAdapter {
@@ -73,6 +86,7 @@ impl DiscordAdapter {
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
auto_thread: String,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
@@ -82,11 +96,14 @@ impl DiscordAdapter {
allowed_users,
ignore_bots,
intents,
auto_thread,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
bot_user_id: Arc::new(RwLock::new(None)),
session_id: Arc::new(RwLock::new(None)),
resume_gateway_url: Arc::new(RwLock::new(None)),
created_thread_ids: Arc::new(RwLock::new(HashMap::new())),
threaded_message_ids: Arc::new(RwLock::new(HashSet::new())),
}
}
@@ -147,6 +164,79 @@ impl DiscordAdapter {
.await?;
Ok(())
}
/// Create a thread from a message in a Discord channel.
async fn api_create_thread(
&self,
channel_id: &str,
message_id: &str,
name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let url = format!(
"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/threads",
channel_id = channel_id,
message_id = message_id
);
let body = serde_json::json!({
"name": name,
"auto_archive_duration": 1440 // 24 hours
});
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bot {}", self.token.as_str()))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
return Err(format!("Discord createThread failed: {}", body_text).into());
}
let response: serde_json::Value = resp.json().await?;
let thread_id = response["id"].as_str().unwrap_or("").to_string();
// Track thread_id → parent channel_id so we can recognise messages
// that arrive inside this thread.
if !thread_id.is_empty() {
self.created_thread_ids
.write()
.await
.insert(thread_id.clone(), channel_id.to_string());
}
Ok(thread_id)
}
/// Send a message to an existing thread.
/// Discord threads are channels — post directly to channels/{thread_id}/messages.
async fn api_send_thread_message(
&self,
_channel_id: &str,
thread_id: &str,
text: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!("{DISCORD_API_BASE}/channels/{thread_id}/messages");
let chunks = split_message(text, DISCORD_MSG_LIMIT);
for chunk in chunks {
let body = serde_json::json!({ "content": chunk });
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bot {}", self.token.as_str()))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Discord sendThreadMessage failed: {body_text}");
}
}
Ok(())
}
}
#[async_trait]
@@ -159,6 +249,33 @@ impl ChannelAdapter for DiscordAdapter {
ChannelType::Discord
}
async fn should_auto_thread(&self, message: &ChannelMessage) -> Option<String> {
// Only auto-thread in group channels (servers), not DMs
if !message.is_group {
return None;
}
// Check auto_thread mode
match self.auto_thread.as_str() {
"true" => Some(thread_name_from_message(message)),
"false" => None,
"smart" => {
// Only create thread if bot was @mentioned
let was_mentioned = message
.metadata
.get("was_mentioned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if was_mentioned {
Some(thread_name_from_message(message))
} else {
None
}
}
_ => None,
}
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
@@ -176,6 +293,8 @@ impl ChannelAdapter for DiscordAdapter {
let bot_user_id = self.bot_user_id.clone();
let session_id_store = self.session_id.clone();
let resume_url_store = self.resume_gateway_url.clone();
let created_thread_ids = self.created_thread_ids.clone();
let threaded_message_ids = self.threaded_message_ids.clone();
let mut shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
@@ -414,19 +533,66 @@ impl ChannelAdapter for DiscordAdapter {
&allowed_guilds,
&allowed_users,
ignore_bots,
&created_thread_ids,
)
.await
{
// MESSAGE_UPDATE must be suppressed if we already
// forwarded a MESSAGE_CREATE for this message ID.
// The check uses `seen_message_ids` (tracked below)
// which is populated the moment MESSAGE_CREATE is
// forwarded — before the bridge even processes it.
// This closes the race window where MESSAGE_UPDATE
// arrives before adapter.create_thread() completes.
if event_name == "MESSAGE_UPDATE"
&& threaded_message_ids
.read()
.await
.contains(&msg.platform_message_id)
{
debug!(
"Discord MESSAGE_UPDATE skipped (already seen {})",
msg.platform_message_id
);
continue;
}
debug!(
"Discord {event_name} from {}: {:?}",
msg.sender.display_name, msg.content
);
// Mark this message as seen immediately so any
// concurrent or subsequent MESSAGE_UPDATE is dropped.
if event_name == "MESSAGE_CREATE" {
threaded_message_ids
.write()
.await
.insert(msg.platform_message_id.clone());
}
if tx.send(msg).await.is_err() {
return;
}
}
}
"THREAD_DELETE" | "CHANNEL_DELETE" => {
// Clean up tracking when a thread is deleted so the
// next message in the parent channel is treated fresh.
if let Some(tid) = d["id"].as_str() {
created_thread_ids.write().await.remove(tid);
// Prune the dedup set to prevent unbounded growth.
// Entries older than MAX_DEDUP_MSG_IDS are safe to
// discard — embed UPDATE events arrive within seconds.
let mut ids = threaded_message_ids.write().await;
if ids.len() > MAX_DEDUP_MSG_IDS {
ids.clear();
}
debug!("Discord thread/channel deleted: {tid}");
}
}
"RESUMED" => {
info!("Discord session resumed successfully");
}
@@ -532,6 +698,46 @@ impl ChannelAdapter for DiscordAdapter {
self.api_send_typing(&user.platform_id).await
}
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_thread_message(channel_id, thread_id, &text)
.await?;
}
_ => {
self.api_send_thread_message(channel_id, thread_id, "(Unsupported content type)")
.await?;
}
}
Ok(())
}
async fn create_thread(
&self,
user: &ChannelUser,
message_id: &str,
thread_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
let thread_id = self
.api_create_thread(channel_id, message_id, thread_name)
.await?;
// Also ensure the message_id is marked as seen (belt-and-suspenders:
// the gateway loop already inserts on MESSAGE_CREATE, but keep this
// in case create_thread is ever called from another path).
self.threaded_message_ids
.write()
.await
.insert(message_id.to_string());
Ok(thread_id)
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
@@ -616,6 +822,7 @@ async fn parse_discord_message(
allowed_guilds: &[String],
allowed_users: &[String],
ignore_bots: bool,
created_thread_ids: &Arc<RwLock<HashMap<String, String>>>,
) -> Option<ChannelMessage> {
// Diagnostic: dump the raw Discord payload so we can ground attachment
// parsing in real JSON. Gated by RUST_LOG; silent at default `info` level.
@@ -655,6 +862,20 @@ async fn parse_discord_message(
let content_text = d["content"].as_str().unwrap_or("");
let channel_id = d["channel_id"].as_str()?;
let message_id = d["id"].as_str().unwrap_or("0");
// Detect if this message is inside a bot-created thread.
// In Discord, a thread is its own channel — channel_id will be the thread's ID.
// If so, use the parent channel as platform_id and set thread_id so that:
// (a) auto-thread logic is skipped (message.thread_id.is_some())
// (b) responses are sent back into the same thread
let (effective_channel_id, parsed_thread_id) = {
let threads = created_thread_ids.read().await;
if let Some(parent_channel_id) = threads.get(channel_id) {
(parent_channel_id.clone(), Some(channel_id.to_string()))
} else {
(channel_id.to_string(), None)
}
};
let username = author["username"].as_str().unwrap_or("Unknown");
let discriminator = author["discriminator"].as_str().unwrap_or("0000");
let display_name = if discriminator == "0" {
@@ -760,7 +981,7 @@ async fn parse_discord_message(
channel: ChannelType::Discord,
platform_message_id: message_id.to_string(),
sender: ChannelUser {
platform_id: channel_id.to_string(),
platform_id: effective_channel_id,
display_name,
openfang_user: None,
},
@@ -768,15 +989,50 @@ async fn parse_discord_message(
target_agent: None,
timestamp,
is_group,
thread_id: None,
thread_id: parsed_thread_id,
metadata,
})
}
/// Build a Discord thread name from the message content.
/// Strips @mention prefixes (`<@...>`), trims whitespace, and truncates to
/// Discord's 100-character thread name limit. Falls back to the sender's
/// display name if the message has no usable text (e.g. image-only).
fn thread_name_from_message(message: &ChannelMessage) -> String {
let raw = match &message.content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Image { caption, .. } => caption.clone().unwrap_or_default(),
_ => String::new(),
};
// Strip leading Discord mention tokens (<@id> / <@!id>)
let stripped = regex_lite::Regex::new(r"^(<@!?\d+>\s*)+")
.map(|re| re.replace(&raw, "").into_owned())
.unwrap_or(raw);
let trimmed = stripped.trim().to_string();
if trimmed.is_empty() {
return message.sender.display_name.clone();
}
// Truncate to Discord's 100-char limit
if trimmed.chars().count() <= 100 {
trimmed
} else {
trimmed.chars().take(97).collect::<String>() + ""
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Convenience helper: empty thread-tracking map for tests that don't exercise threading.
fn empty_threads() -> Arc<RwLock<HashMap<String, String>>> {
Arc::new(RwLock::new(HashMap::new()))
}
#[tokio::test]
async fn test_parse_discord_message_basic() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
@@ -793,7 +1049,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
@@ -817,7 +1073,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -837,7 +1093,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -858,7 +1114,7 @@ mod tests {
});
// With ignore_bots=false, other bots' messages should be allowed
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
assert!(msg.is_some());
let msg = msg.unwrap();
assert_eq!(msg.sender.display_name, "somebot");
@@ -882,7 +1138,7 @@ mod tests {
});
// Even with ignore_bots=false, the bot's own messages must still be filtered
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -903,12 +1159,20 @@ mod tests {
});
// Not in allowed guilds
let msg =
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
let msg = parse_discord_message(
&d,
&bot_id,
&["111".into(), "222".into()],
&[],
true,
&empty_threads(),
)
.await;
assert!(msg.is_none());
// In allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
let msg =
parse_discord_message(&d, &bot_id, &["999".into()], &[], true, &empty_threads()).await;
assert!(msg.is_some());
}
@@ -927,7 +1191,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match &msg.content {
@@ -954,7 +1218,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -973,7 +1237,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
@@ -997,7 +1261,7 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
@@ -1028,16 +1292,25 @@ mod tests {
&[],
&["user111".into(), "user222".into()],
true,
&empty_threads(),
)
.await;
assert!(msg.is_none());
// In allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
let msg = parse_discord_message(
&d,
&bot_id,
&[],
&["user999".into()],
true,
&empty_threads(),
)
.await;
assert!(msg.is_some());
// Empty allowed_users = allow all
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_some());
}
@@ -1060,7 +1333,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(msg.is_group);
@@ -1083,7 +1356,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(msg2.is_group);
@@ -1105,7 +1378,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(!msg.is_group);
@@ -1144,6 +1417,7 @@ mod tests {
vec![],
true,
37376,
"true".to_string(),
);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
@@ -1183,7 +1457,7 @@ mod tests {
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)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1206,7 +1480,7 @@ mod tests {
"look at this",
vec![att("photo.jpg", Some("image/jpeg"), 50_000)],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1238,7 +1512,7 @@ mod tests {
att("b.png", Some("image/png"), 20_000),
],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1262,7 +1536,7 @@ mod tests {
att("b.png", Some("image/png"), 20_000),
],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1281,7 +1555,7 @@ mod tests {
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)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1301,7 +1575,7 @@ mod tests {
"",
vec![att("huge.png", Some("image/png"), 6 * 1024 * 1024)],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1326,7 +1600,7 @@ mod tests {
"see attached",
vec![att("doc.pdf", Some("application/pdf"), 200_000)],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1345,7 +1619,7 @@ mod tests {
// 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)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(matches!(msg.content, ChannelContent::Image { .. }));
@@ -1355,7 +1629,7 @@ mod tests {
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;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
}
+309 -36
View File
@@ -18,14 +18,20 @@ use zeroize::Zeroizing;
const SYNC_TIMEOUT_MS: u64 = 30000;
const MAX_MESSAGE_LEN: usize = 4096;
/// Shared access + refresh token pair. Tokens are zeroized on drop and rotated
/// in place when MSC2918 refresh succeeds.
type TokenPair = Arc<RwLock<(Zeroizing<String>, Option<Zeroizing<String>>)>>;
/// Matrix channel adapter using the Client-Server API.
pub struct MatrixAdapter {
/// Matrix homeserver URL (e.g., `"https://matrix.org"`).
homeserver_url: String,
/// Bot's user ID (e.g., "@openfang:matrix.org").
user_id: String,
/// SECURITY: Access token is zeroized on drop.
access_token: Zeroizing<String>,
/// SECURITY: Access + refresh tokens are zeroized on drop. Stored behind
/// an RwLock so the sync loop and send paths see rotated tokens after a
/// MSC2918 /refresh call (matrix.org/MAS rotates both tokens every refresh).
tokens: TokenPair,
/// HTTP client.
client: reqwest::Client,
/// Allowed room IDs (empty = all joined rooms).
@@ -40,19 +46,47 @@ pub struct MatrixAdapter {
}
impl MatrixAdapter {
/// Create a new Matrix adapter.
/// Create a new Matrix adapter without a refresh token.
pub fn new(
homeserver_url: String,
user_id: String,
access_token: String,
allowed_rooms: Vec<String>,
auto_accept_invites: bool,
) -> Self {
Self::with_refresh_token(
homeserver_url,
user_id,
access_token,
None,
allowed_rooms,
auto_accept_invites,
)
}
/// Create a new Matrix adapter with an optional refresh token (MSC2918).
///
/// When `refresh_token` is `Some`, the adapter will automatically call
/// `POST /_matrix/client/v3/refresh` on `401 M_UNKNOWN_TOKEN` responses
/// and retry the failed request once. Both tokens rotate on each refresh
/// under Matrix Authentication Service (MAS).
pub fn with_refresh_token(
homeserver_url: String,
user_id: String,
access_token: String,
refresh_token: Option<String>,
allowed_rooms: Vec<String>,
auto_accept_invites: bool,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let tokens: TokenPair = Arc::new(RwLock::new((
Zeroizing::new(access_token),
refresh_token.map(Zeroizing::new),
)));
Self {
homeserver_url,
user_id,
access_token: Zeroizing::new(access_token),
tokens,
client: reqwest::Client::new(),
allowed_rooms,
shutdown_tx: Arc::new(shutdown_tx),
@@ -62,6 +96,11 @@ impl MatrixAdapter {
}
}
/// Read the current access token (cloned).
async fn current_access_token(&self) -> String {
self.tokens.read().await.0.as_str().to_string()
}
/// Send a text message to a Matrix room.
async fn api_send_message(
&self,
@@ -81,18 +120,47 @@ impl MatrixAdapter {
"body": chunk,
});
let resp = self
.client
.put(&url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
let mut attempt = 0;
loop {
attempt += 1;
let token = self.current_access_token().await;
let resp = self
.client
.put(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await?;
if resp.status().is_success() {
break;
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("Matrix API error {status}: {body}").into());
let body_text = resp.text().await.unwrap_or_default();
// Try a single refresh+retry on M_UNKNOWN_TOKEN (MSC2918).
if attempt == 1
&& status == reqwest::StatusCode::UNAUTHORIZED
&& is_unknown_token_body(&body_text)
{
match try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens)
.await
{
Ok(()) => {
info!("Matrix: access token refreshed via MSC2918, retrying send");
continue;
}
Err(e) => {
return Err(format!(
"Matrix API error {status}: {body_text} (refresh failed: {e})"
)
.into());
}
}
}
return Err(format!("Matrix API error {status}: {body_text}").into());
}
}
@@ -103,21 +171,32 @@ impl MatrixAdapter {
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver_url);
let resp = self
.client
.get(&url)
.bearer_auth(&*self.access_token)
.send()
.await?;
let mut attempt = 0;
loop {
attempt += 1;
let token = self.current_access_token().await;
let resp = self.client.get(&url).bearer_auth(&token).send().await?;
if !resp.status().is_success() {
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
return Ok(user_id);
}
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
if attempt == 1
&& status == reqwest::StatusCode::UNAUTHORIZED
&& is_unknown_token_body(&body_text)
&& try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens)
.await
.is_ok()
{
info!("Matrix: access token refreshed via MSC2918, retrying /whoami");
continue;
}
return Err("Matrix authentication failed".into());
}
let body: serde_json::Value = resp.json().await?;
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
Ok(user_id)
}
#[cfg(test)]
@@ -126,6 +205,87 @@ impl MatrixAdapter {
}
}
/// Detect `M_UNKNOWN_TOKEN` errors in a Matrix response body.
///
/// Matrix returns 401 for multiple reasons; we only want to refresh on
/// `M_UNKNOWN_TOKEN` (the access token expired or was revoked). See
/// <https://spec.matrix.org/latest/client-server-api/#soft-logout>.
fn is_unknown_token_body(body: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("errcode").and_then(|c| c.as_str()).map(String::from))
.map(|c| c == "M_UNKNOWN_TOKEN")
.unwrap_or(false)
}
/// Whether a Matrix 401 body indicates a hard logout (operator must re-login).
///
/// `soft_logout: true` (or absent — default per spec) means the device is still
/// known to the server and a refresh-token grant is valid. `soft_logout: false`
/// means the device was invalidated and the operator must perform a new
/// `m.login.password` flow.
fn is_hard_logout(body: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("soft_logout").and_then(|s| s.as_bool()))
.map(|soft| !soft)
.unwrap_or(false)
}
/// Call `POST /_matrix/client/v3/refresh` (MSC2918) and rotate the stored tokens.
///
/// On success, replaces the access token and (if the server returned one) the
/// refresh token. MAS (matrix.org since 2025-04-07) rotates the refresh token
/// on every call, so callers must use the new value next time.
async fn try_refresh_tokens(
client: &reqwest::Client,
homeserver: &str,
tokens: &TokenPair,
) -> Result<(), String> {
let refresh_token = {
let guard = tokens.read().await;
match guard.1.as_ref() {
Some(rt) => rt.as_str().to_string(),
None => return Err("no refresh token configured".to_string()),
}
};
let url = format!("{homeserver}/_matrix/client/v3/refresh");
let resp = client
.post(&url)
.json(&serde_json::json!({ "refresh_token": refresh_token }))
.send()
.await
.map_err(|e| format!("refresh request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("refresh returned {status}: {body}"));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("refresh response parse error: {e}"))?;
let new_access = body
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| "refresh response missing access_token".to_string())?;
let new_refresh = body
.get("refresh_token")
.and_then(|v| v.as_str())
.map(String::from);
let mut guard = tokens.write().await;
guard.0 = Zeroizing::new(new_access.to_string());
if let Some(rt) = new_refresh {
guard.1 = Some(Zeroizing::new(rt));
}
Ok(())
}
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
async fn accept_invite(
client: &reqwest::Client,
@@ -218,7 +378,7 @@ impl ChannelAdapter for MatrixAdapter {
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let homeserver = self.homeserver_url.clone();
let access_token = self.access_token.clone();
let tokens = Arc::clone(&self.tokens);
// Use the validated user ID from /whoami instead of the config value.
// Matrix server delegation or casing differences can cause self.user_id
// to not match the sender field in timeline events, making the bot
@@ -232,9 +392,10 @@ impl ChannelAdapter for MatrixAdapter {
// FIX #4: Do an initial sync to get the since token, skipping old messages.
if since_token.read().await.is_none() {
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
let token = self.current_access_token().await;
if let Some(next) = initial_sync(&client, &homeserver, &token).await {
info!("Matrix: initial sync complete, skipping old messages");
*since_token.write().await = Some(token);
*since_token.write().await = Some(next);
}
}
@@ -257,12 +418,13 @@ impl ChannelAdapter for MatrixAdapter {
url.push_str(&format!("&since={token}"));
}
let current_token = tokens.read().await.0.as_str().to_string();
let resp = tokio::select! {
_ = shutdown_rx.changed() => {
info!("Matrix adapter shutting down");
break;
}
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
result = client.get(&url).bearer_auth(&current_token).send() => {
match result {
Ok(r) => r,
Err(e) => {
@@ -276,7 +438,38 @@ impl ChannelAdapter for MatrixAdapter {
};
if !resp.status().is_success() {
warn!("Matrix sync returned {}", resp.status());
let status = resp.status();
// MSC2918: on 401 M_UNKNOWN_TOKEN with a refresh token configured,
// try refreshing once and loop again immediately. Hard logout
// (soft_logout:false) is unrecoverable here — the operator must
// perform a fresh m.login.password.
if status == reqwest::StatusCode::UNAUTHORIZED {
let body_text = resp.text().await.unwrap_or_default();
if is_unknown_token_body(&body_text) {
if is_hard_logout(&body_text) {
warn!(
"Matrix: hard logout (soft_logout=false), operator must re-login"
);
} else {
match try_refresh_tokens(&client, &homeserver, &tokens).await {
Ok(()) => {
info!(
"Matrix: access token refreshed via MSC2918, resuming /sync"
);
backoff = Duration::from_secs(1);
continue;
}
Err(e) => {
warn!("Matrix: token refresh failed: {e}");
}
}
}
} else {
warn!("Matrix sync returned {status}: {body_text}");
}
} else {
warn!("Matrix sync returned {status}");
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(60));
continue;
@@ -309,8 +502,8 @@ impl ChannelAdapter for MatrixAdapter {
);
continue;
}
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
.await;
let tok = tokens.read().await.0.as_str().to_string();
accept_invite(&client, &homeserver, &tok, room_id).await;
}
}
}
@@ -380,10 +573,11 @@ impl ChannelAdapter for MatrixAdapter {
}
// FIX #3: Determine if room is a DM (2 members) or group.
let tok_for_count = tokens.read().await.0.as_str().to_string();
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
&tok_for_count,
room_id,
)
.await
@@ -409,10 +603,11 @@ impl ChannelAdapter for MatrixAdapter {
}
// FIX #3: Determine if room is a DM (2 members) or group.
let tok_for_count = tokens.read().await.0.as_str().to_string();
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
&tok_for_count,
room_id,
)
.await
@@ -485,10 +680,11 @@ impl ChannelAdapter for MatrixAdapter {
"timeout": 5000,
});
let token = self.current_access_token().await;
let _ = self
.client
.put(&url)
.bearer_auth(&*self.access_token)
.bearer_auth(&token)
.json(&body)
.send()
.await;
@@ -518,6 +714,83 @@ mod tests {
assert_eq!(adapter.name(), "matrix");
}
#[test]
fn test_is_unknown_token_body() {
// Real matrix.org body for M_UNKNOWN_TOKEN under MAS.
let body = r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Token is not active","soft_logout":true}"#;
assert!(is_unknown_token_body(body));
assert!(!is_hard_logout(body));
let hard = r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Invalidated","soft_logout":false}"#;
assert!(is_unknown_token_body(hard));
assert!(is_hard_logout(hard));
let other = r#"{"errcode":"M_FORBIDDEN","error":"You are not allowed"}"#;
assert!(!is_unknown_token_body(other));
assert!(!is_hard_logout(other));
// Empty / non-JSON must not trigger refresh.
assert!(!is_unknown_token_body(""));
assert!(!is_unknown_token_body("not json"));
}
#[tokio::test]
async fn test_refresh_tokens_rotates_pair() {
// Spin up a tiny axum server that mimics MSC2918 /refresh: rotates both
// access and refresh tokens and returns the new pair.
use axum::{routing::post, Json, Router};
async fn refresh_handler(
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
let incoming = body
.get("refresh_token")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(incoming, "old_refresh");
Json(serde_json::json!({
"access_token": "new_access",
"refresh_token": "new_refresh",
"expires_in_ms": 3_600_000u64,
}))
}
let app = Router::new().route("/_matrix/client/v3/refresh", post(refresh_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let homeserver = format!("http://{addr}");
let tokens: TokenPair = Arc::new(RwLock::new((
Zeroizing::new("old_access".to_string()),
Some(Zeroizing::new("old_refresh".to_string())),
)));
let client = reqwest::Client::new();
try_refresh_tokens(&client, &homeserver, &tokens)
.await
.expect("refresh succeeds");
let guard = tokens.read().await;
assert_eq!(guard.0.as_str(), "new_access");
assert_eq!(guard.1.as_ref().map(|s| s.as_str()), Some("new_refresh"));
drop(guard);
// Refresh with no refresh token configured must fail cleanly.
let no_refresh: TokenPair = Arc::new(RwLock::new((
Zeroizing::new("a".to_string()),
None,
)));
let err = try_refresh_tokens(&client, &homeserver, &no_refresh)
.await
.unwrap_err();
assert!(err.contains("no refresh token"));
server.abort();
}
#[test]
fn test_matrix_allowed_rooms() {
let adapter = MatrixAdapter::new(
+243 -24
View File
@@ -46,6 +46,11 @@ pub struct TelegramAdapter {
poll_interval: Duration,
/// Base URL for Telegram Bot API (supports proxies/mirrors).
api_base_url: String,
/// Maps Telegram forum topic `message_thread_id` to an agent name. When a
/// message arrives inside a configured topic, the inbound parser stamps
/// `target_agent` so the bridge dispatches to that agent instead of the
/// default. Empty map = default routing for every thread. Issue #780.
thread_routes: Arc<HashMap<i64, String>>,
/// Bot username (without @), populated from `getMe` during `start()`.
/// Used for @mention detection in group messages.
bot_username: Arc<tokio::sync::RwLock<Option<String>>>,
@@ -79,6 +84,19 @@ impl TelegramAdapter {
allowed_users: Vec<String>,
poll_interval: Duration,
api_url: Option<String>,
) -> Self {
Self::with_thread_routes(token, allowed_users, poll_interval, api_url, HashMap::new())
}
/// Same as [`new`] but accepts a `thread_routes` map for forum-topic
/// routing (issue #780). Keys are Telegram `message_thread_id` values,
/// values are agent names to dispatch matching messages to.
pub fn with_thread_routes(
token: String,
allowed_users: Vec<String>,
poll_interval: Duration,
api_url: Option<String>,
thread_routes: HashMap<i64, String>,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let api_base_url = api_url
@@ -91,6 +109,7 @@ impl TelegramAdapter {
allowed_users,
poll_interval,
api_base_url,
thread_routes: Arc::new(thread_routes),
bot_username: Arc::new(tokio::sync::RwLock::new(None)),
rejected_reactions: Arc::new(Mutex::new(HashSet::new())),
shutdown_tx: Arc::new(shutdown_tx),
@@ -597,6 +616,7 @@ impl ChannelAdapter for TelegramAdapter {
let poll_interval = self.poll_interval;
let api_base_url = self.api_base_url.clone();
let bot_username = self.bot_username.clone();
let thread_routes = self.thread_routes.clone();
let mut shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
@@ -717,6 +737,7 @@ impl ChannelAdapter for TelegramAdapter {
&client,
&api_base_url,
bot_uname.as_deref(),
&thread_routes,
)
.await
{
@@ -826,6 +847,7 @@ async fn parse_telegram_update(
client: &reqwest::Client,
api_base_url: &str,
bot_username: Option<&str>,
thread_routes: &HashMap<i64, String>,
) -> Option<ChannelMessage> {
let update_id = update["update_id"].as_i64().unwrap_or(0);
let message = match update
@@ -1008,9 +1030,15 @@ async fn parse_telegram_update(
// Extract forum topic thread_id (Telegram sends this as `message_thread_id`
// for messages inside forum topics / reply threads).
let thread_id = message["message_thread_id"]
.as_i64()
.map(|tid| tid.to_string());
let thread_id_raw = message["message_thread_id"].as_i64();
let thread_id = thread_id_raw.map(|tid| tid.to_string());
// Forum topic routing (issue #780). If the message lives inside a
// configured topic, stash the target agent name in metadata under
// `target_agent_name` so the bridge dispatcher prefers it over the
// channel default. Topics with no entry leave metadata untouched and
// fall through to the adapter's default_agent.
let target_agent_name = thread_id_raw.and_then(|tid| thread_routes.get(&tid).cloned());
// Detect @mention of the bot in entities / caption_entities for MentionOnly group policy.
let mut metadata = HashMap::new();
@@ -1043,6 +1071,16 @@ async fn parse_telegram_update(
}
}
// Stash the forum-topic route target so the bridge can dispatch to a
// specific agent for this topic. The bridge resolves the name via
// `find_agent_by_name` before the standard router fallback.
if let Some(name) = target_agent_name {
metadata.insert(
"target_agent_name".to_string(),
serde_json::Value::String(name),
);
}
Some(ChannelMessage {
channel: ChannelType::Telegram,
platform_message_id: message_id.to_string(),
@@ -1196,7 +1234,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Telegram);
@@ -1229,7 +1267,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
@@ -1270,7 +1308,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
@@ -1307,7 +1345,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
match &msg.content {
@@ -1342,7 +1380,7 @@ mod tests {
// Empty allowed_users = allow all
let msg =
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new()).await;
assert!(msg.is_some());
// Non-matching allowed_users = filter out
@@ -1354,6 +1392,7 @@ mod tests {
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await;
assert!(msg.is_none());
@@ -1367,6 +1406,7 @@ mod tests {
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await;
assert!(msg.is_some());
@@ -1394,7 +1434,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Telegram);
@@ -1432,7 +1472,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
match &msg.content {
@@ -1458,7 +1498,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
assert!(matches!(msg.content, ChannelContent::Location { .. }));
@@ -1484,7 +1524,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
// With a fake token, getFile will fail, so we get a text fallback
@@ -1521,7 +1561,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
match &msg.content {
@@ -1554,7 +1594,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
match &msg.content {
@@ -1587,7 +1627,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
assert_eq!(msg.thread_id, Some("42".to_string()));
@@ -1609,7 +1649,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
assert_eq!(msg.thread_id, None);
@@ -1633,12 +1673,185 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
assert_eq!(msg.thread_id, Some("99".to_string()));
}
// ---- Issue #780: forum topic -> agent routing ----
fn forum_topic_update(thread_id: i64, update_id: i64) -> serde_json::Value {
serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 1000 + update_id,
"message_thread_id": thread_id,
"from": { "id": 555, "first_name": "Operator" },
"chat": { "id": -1009998887776_i64, "type": "supergroup" },
"date": 1700000000,
"text": "scoped message"
}
})
}
#[tokio::test]
async fn test_thread_routes_dispatch_matching_topic_to_named_agent() {
// A message inside a configured forum topic must route to that agent
// via `target_agent`, overriding the channel default.
let update = forum_topic_update(42, 1);
let mut routes = HashMap::new();
routes.insert(42_i64, "support-agent".to_string());
routes.insert(99_i64, "ops-agent".to_string());
let client = test_client();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(msg.thread_id, Some("42".to_string()));
assert_eq!(
msg.metadata
.get("target_agent_name")
.and_then(|v| v.as_str()),
Some("support-agent")
);
}
#[tokio::test]
async fn test_thread_routes_unconfigured_topic_falls_through() {
// A thread id NOT in the route map must leave target_agent = None so
// the bridge applies the channel default_agent.
let update = forum_topic_update(7, 2);
let mut routes = HashMap::new();
routes.insert(42_i64, "support-agent".to_string());
let client = test_client();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(msg.thread_id, Some("7".to_string()));
assert!(!msg.metadata.contains_key("target_agent_name"));
}
#[tokio::test]
async fn test_thread_routes_ignored_for_non_topic_message() {
// Messages with no message_thread_id (regular group / DM) must never
// be matched against thread_routes, even if the map is non-empty.
let update = serde_json::json!({
"update_id": 3,
"message": {
"message_id": 1003,
"from": { "id": 555, "first_name": "Operator" },
"chat": { "id": -1009998887776_i64, "type": "supergroup" },
"date": 1700000000,
"text": "general group message"
}
});
let mut routes = HashMap::new();
routes.insert(42_i64, "support-agent".to_string());
let client = test_client();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(msg.thread_id, None);
assert!(!msg.metadata.contains_key("target_agent_name"));
}
#[tokio::test]
async fn test_thread_routes_multiple_topics_route_independently() {
// Two different topics in the same chat must dispatch to two different
// agents.
let mut routes = HashMap::new();
routes.insert(10_i64, "alpha".to_string());
routes.insert(20_i64, "beta".to_string());
let client = test_client();
let update_alpha = forum_topic_update(10, 4);
let msg_alpha = parse_telegram_update(
&update_alpha,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(
msg_alpha
.metadata
.get("target_agent_name")
.and_then(|v| v.as_str()),
Some("alpha")
);
let update_beta = forum_topic_update(20, 5);
let msg_beta = parse_telegram_update(
&update_beta,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(
msg_beta
.metadata
.get("target_agent_name")
.and_then(|v| v.as_str()),
Some("beta")
);
}
#[tokio::test]
async fn test_adapter_with_thread_routes_constructor_stores_map() {
// Sanity check that the new constructor wires the route map onto the
// adapter so the spawned polling task sees it.
let mut routes = HashMap::new();
routes.insert(1_i64, "first".to_string());
let adapter = TelegramAdapter::with_thread_routes(
"test:token".to_string(),
vec![],
Duration::from_millis(10),
Some("https://example.test".to_string()),
routes.clone(),
);
assert_eq!(adapter.thread_routes.as_ref(), &routes);
}
#[tokio::test]
async fn test_parse_sender_chat_fallback() {
// Messages sent on behalf of a channel have `sender_chat` instead of `from`.
@@ -1658,7 +1871,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
assert_eq!(msg.sender.display_name, "My Channel");
@@ -1683,7 +1896,7 @@ mod tests {
let client = test_client();
let msg =
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new()).await;
assert!(msg.is_none());
}
@@ -1714,6 +1927,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1746,6 +1960,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1780,6 +1995,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1817,6 +2033,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1854,6 +2071,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1890,6 +2108,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1943,7 +2162,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
match &msg.content {
@@ -1985,7 +2204,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
match &msg.content {
@@ -2026,7 +2245,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
match &msg.content {
@@ -2064,7 +2283,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
match &msg.content {
@@ -2091,7 +2310,7 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None, &HashMap::new())
.await
.unwrap();
match &msg.content {
+18
View File
@@ -341,6 +341,24 @@ pub trait ChannelAdapter: Send + Sync {
self.send(user, content).await
}
/// Determine whether to auto-create a thread for an incoming message.
/// Returns Some(thread_name) to create a thread, or None to reply directly.
/// Default implementation returns None (no auto-threading).
async fn should_auto_thread(&self, _message: &ChannelMessage) -> Option<String> {
None
}
/// Create a new thread (typically triggered after should_auto_thread returns Some).
/// Returns the new thread ID on success.
async fn create_thread(
&self,
_user: &ChannelUser,
_message_id: &str,
_thread_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
Err("Thread creation not supported for this adapter".into())
}
/// Whether this adapter should suppress sending internal agent errors back to the user.
///
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenFang",
"version": "0.6.6",
"version": "0.6.8",
"identifier": "ai.openfang.desktop",
"build": {},
"app": {
+2
View File
@@ -16,6 +16,8 @@ uuid = { workspace = true }
chrono = { workspace = true }
dashmap = { workspace = true }
dirs = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+167 -5
View File
@@ -8,10 +8,27 @@ use crate::{
use dashmap::DashMap;
use openfang_types::agent::AgentId;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tracing::{info, warn};
use uuid::Uuid;
/// Callback signature invoked on every successful hand load / reload.
///
/// Arguments: `(hand_id, sha256_hex_of_hand_toml)`. The kernel wires this
/// into the Merkle audit chain so reload events leave a tamper-evident
/// record (issue #1172). The callback must be cheap and non-blocking; it
/// runs inline on the loader thread.
pub type HandAuditCallback = Arc<dyn Fn(&str, &str) + Send + Sync>;
/// Compute the SHA-256 hex digest of raw HAND.toml content.
fn hand_toml_sha256(toml_content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(toml_content.as_bytes());
hex::encode(hasher.finalize())
}
// ─── Settings availability types ────────────────────────────────────────────
/// Availability status of a single setting option.
@@ -41,6 +58,10 @@ pub struct HandRegistry {
definitions: DashMap<String, HandDefinition>,
/// Active hand instances, keyed by instance UUID.
instances: DashMap<Uuid, HandInstance>,
/// Optional callback invoked on every successful HAND.toml load with
/// the computed SHA-256 of the file content. Wired by the kernel into
/// the Merkle audit chain (issue #1172).
audit_callback: RwLock<Option<HandAuditCallback>>,
}
impl HandRegistry {
@@ -49,9 +70,37 @@ impl HandRegistry {
Self {
definitions: DashMap::new(),
instances: DashMap::new(),
audit_callback: RwLock::new(None),
}
}
/// Install a callback invoked on every successful HAND.toml load /
/// reload with the file's SHA-256. The kernel wires this to
/// `AuditLog::record(AuditAction::ConfigChange, ...)` so reload events
/// leave a tamper-evident audit record (issue #1172).
pub fn set_audit_callback(&self, callback: HandAuditCallback) {
let mut guard = self
.audit_callback
.write()
.unwrap_or_else(|e| e.into_inner());
*guard = Some(callback);
}
/// Compute SHA-256 of the given HAND.toml content and invoke the audit
/// callback if one is registered. Returns the hex digest for callers
/// that want to log or compare it.
fn emit_hand_loaded_audit(&self, hand_id: &str, toml_content: &str) -> String {
let hash = hand_toml_sha256(toml_content);
let guard = self
.audit_callback
.read()
.unwrap_or_else(|e| e.into_inner());
if let Some(cb) = guard.as_ref() {
cb(hand_id, &hash);
}
hash
}
/// Persist active hand state to disk so it survives restarts.
pub fn persist_state(&self, path: &std::path::Path) -> HandResult<()> {
let entries: Vec<serde_json::Value> = self
@@ -112,7 +161,8 @@ impl HandRegistry {
for (id, toml_content, skill_content) in bundled {
match bundled::parse_bundled(id, toml_content, skill_content) {
Ok(def) => {
info!(hand = %def.id, name = %def.name, "Loaded bundled hand");
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Loaded bundled hand");
self.definitions.insert(def.id.clone(), def);
count += 1;
}
@@ -173,7 +223,8 @@ impl HandRegistry {
match bundled::parse_bundled("custom", &contents, &skill_content) {
Ok(def) => {
let hand_id = def.id.clone();
info!(hand = %hand_id, path = %path.display(), "Loaded workspace hand");
let hash = self.emit_hand_loaded_audit(&hand_id, &contents);
info!(hand = %hand_id, path = %path.display(), sha256 = %hash, "Loaded workspace hand");
self.definitions.insert(hand_id, def);
count += 1;
}
@@ -204,7 +255,8 @@ impl HandRegistry {
)));
}
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
let hash = self.emit_hand_loaded_audit(&def.id, &toml_content);
info!(hand = %def.id, name = %def.name, path = %path.display(), sha256 = %hash, "Installed hand from path");
self.definitions.insert(def.id.clone(), def.clone());
// Persist the hand to the user's data dir so it survives daemon
@@ -251,7 +303,8 @@ impl HandRegistry {
)));
}
info!(hand = %def.id, name = %def.name, "Installed hand from content");
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Installed hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
@@ -269,7 +322,8 @@ impl HandRegistry {
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
let existed = self.definitions.contains_key(&def.id);
let verb = if existed { "Updated" } else { "Installed" };
info!(hand = %def.id, name = %def.name, "{verb} hand from content");
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
info!(hand = %def.id, name = %def.name, sha256 = %hash, "{verb} hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
@@ -1145,6 +1199,114 @@ metrics = []
assert!(matches!(err, HandError::AlreadyActive(_)));
}
/// Issue #1172: HAND.toml SHA-256 must be emitted to the audit
/// callback on every successful load / reload.
#[test]
fn audit_callback_records_hand_toml_hash_on_load() {
use std::sync::Mutex;
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&captured);
let reg = HandRegistry::new();
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
sink.lock()
.unwrap()
.push((hand_id.to_string(), hash.to_string()));
}));
let toml_str = r#"
id = "audit-hand"
name = "Audit Hand"
description = "Used to verify audit-trail wiring"
category = "other"
tools = []
[agent]
name = "audit-agent"
description = "audit"
system_prompt = "audit."
"#;
// Precompute the expected hash so the test fails loudly if the
// registry ever changes how it digests the TOML content.
let expected_hash = {
let mut h = Sha256::new();
h.update(toml_str.as_bytes());
hex::encode(h.finalize())
};
let def = reg.install_from_content(toml_str, "").unwrap();
assert_eq!(def.id, "audit-hand");
let events = captured.lock().unwrap().clone();
assert_eq!(
events.len(),
1,
"exactly one audit event should be emitted per load"
);
assert_eq!(events[0].0, "audit-hand", "hand id propagated to callback");
assert_eq!(
events[0].1, expected_hash,
"callback received SHA-256 of the HAND.toml content"
);
assert_eq!(events[0].1.len(), 64, "SHA-256 hex is 64 chars");
}
/// Issue #1172: reloading the same HAND.toml via upsert must emit a
/// fresh audit event so the chain records when the swap took effect.
/// A content change must surface a different hash.
#[test]
fn audit_callback_fires_on_reload_with_new_hash() {
use std::sync::Mutex;
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&captured);
let reg = HandRegistry::new();
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
sink.lock()
.unwrap()
.push((hand_id.to_string(), hash.to_string()));
}));
let v1 = r#"
id = "reload-hand"
name = "Reload Hand v1"
description = "v1"
category = "other"
tools = []
[agent]
name = "reload-agent"
description = "reload"
system_prompt = "v1."
"#;
let v2 = r#"
id = "reload-hand"
name = "Reload Hand v2"
description = "v2"
category = "other"
tools = []
[agent]
name = "reload-agent"
description = "reload"
system_prompt = "v2 — schedule changed."
"#;
reg.upsert_from_content(v1, "").unwrap();
reg.upsert_from_content(v2, "").unwrap();
let events = captured.lock().unwrap().clone();
assert_eq!(events.len(), 2, "one event per upsert (load + reload)");
assert_eq!(events[0].0, "reload-hand");
assert_eq!(events[1].0, "reload-hand");
assert_ne!(
events[0].1, events[1].1,
"different HAND.toml content must yield different SHA-256"
);
}
/// Integration test for issue #809: `hand config` round-trip.
///
/// Simulates what `openfang hand config <id> --set KEY=VAL` does against
+1
View File
@@ -32,6 +32,7 @@ futures = { workspace = true }
subtle = { workspace = true }
rand = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
reqwest = { workspace = true }
rustls = { workspace = true }
cron = "0.16"
+1
View File
@@ -339,6 +339,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
File diff suppressed because it is too large Load Diff
+6
View File
@@ -234,6 +234,12 @@ pub struct BudgetStatus {
/// Order matters: more specific patterns must come before generic ones
/// (e.g. "gpt-4o-mini" before "gpt-4o", "gpt-4.1-mini" before "gpt-4.1").
fn estimate_cost_rates(model: &str) -> (f64, f64) {
// ── Requesty (issue #995) ──────────────────────────────────
// Router-style gateway. IDs are `requesty/<upstream>/<model>` and
// resolve via substring match on the upstream model name below
// (e.g. "sonnet", "gpt-4o", "gemini", "deepseek", "llama").
// No early-return here — fall through to upstream patterns.
// ── Anthropic ──────────────────────────────────────────────
if model.contains("haiku") {
return (0.25, 1.25);
+18
View File
@@ -134,6 +134,23 @@ impl AgentRegistry {
Ok(())
}
/// Update an agent's private state directory path. The state directory
/// holds identity files, sessions, and per-agent memory and is always
/// kept separate from the user-facing workspace. See issue #1097.
pub fn update_state_dir(
&self,
id: AgentId,
state_dir: Option<std::path::PathBuf>,
) -> OpenFangResult<()> {
let mut entry = self
.agents
.get_mut(&id)
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
entry.manifest.state_dir = state_dir;
entry.last_active = chrono::Utc::now();
Ok(())
}
/// Update an agent's visual identity (emoji, avatar, color).
pub fn update_identity(
&self,
@@ -391,6 +408,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
+1
View File
@@ -176,6 +176,7 @@ impl SetupWizard {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
profile: None,
fallback_models: vec![],
+47 -9
View File
@@ -158,22 +158,30 @@ fn build_assistant_message_preserving_thinking(
response_blocks: &[ContentBlock],
final_text: &str,
) -> Message {
let has_thinking = response_blocks
.iter()
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
if !has_thinking {
// Key on either Thinking or RedactedThinking — Anthropic/Bedrock both
// reject extended-thinking history that drops the redacted variant, so a
// turn that contains only RedactedThinking must still be preserved.
let has_reasoning = response_blocks.iter().any(|b| {
matches!(
b,
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. }
)
});
if !has_reasoning {
return Message::assistant(final_text.to_string());
}
// Preserve order: Thinking blocks first (in original order), then a
// single Text block carrying `final_text`. Tool blocks aren't expected
// here (StopReason::EndTurn path), but copy them through if present so
// we don't drop information.
// Preserve order: Thinking / RedactedThinking blocks first (in original
// order), then a single Text block carrying `final_text`. Tool blocks
// aren't expected here (StopReason::EndTurn path), but copy them through
// if present so we don't drop information.
let mut blocks: Vec<ContentBlock> = Vec::with_capacity(response_blocks.len() + 1);
let mut emitted_text = false;
for b in response_blocks {
match b {
ContentBlock::Thinking { .. } => blocks.push(b.clone()),
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
blocks.push(b.clone())
}
ContentBlock::Text { .. } if !emitted_text => {
blocks.push(ContentBlock::Text {
text: final_text.to_string(),
@@ -3380,6 +3388,36 @@ mod tests {
assert_eq!(saved_text, Some(final_text));
}
/// Issue #1187 — a turn that contains only `RedactedThinking` (no
/// `Thinking` block) must still trigger the block-preserving path. The
/// previous gate keyed solely on `Thinking`, so redacted-only turns were
/// downgraded to plain text and the encrypted blob was lost on the next
/// request, which Anthropic/Bedrock reject.
#[test]
fn test_build_assistant_message_preserves_redacted_only() {
let response_blocks = vec![
ContentBlock::RedactedThinking {
data: "encrypted_only".to_string(),
},
ContentBlock::Text {
text: "Answer".to_string(),
provider_metadata: None,
},
];
let msg = build_assistant_message_preserving_thinking(&response_blocks, "Answer");
let blocks = match &msg.content {
MessageContent::Blocks(b) => b,
other => panic!("expected Blocks content for redacted-only turn, got {other:?}"),
};
let has_redacted = blocks
.iter()
.any(|b| matches!(b, ContentBlock::RedactedThinking { data } if data == "encrypted_only"));
assert!(
has_redacted,
"RedactedThinking-only turn must be preserved as Blocks"
);
}
#[test]
fn test_retry_constants() {
assert_eq!(MAX_RETRIES, 3);
+76 -2
View File
@@ -92,6 +92,20 @@ enum BedrockContentBlock {
#[serde(rename = "toolResult")]
tool_result: BedrockToolResult,
},
// Bedrock Converse representation of Anthropic's `redacted_thinking`.
// The encrypted blob is echoed back verbatim under
// reasoningContent.redactedContent so Claude extended-thinking history
// is not rejected on resubmission.
ReasoningContent {
#[serde(rename = "reasoningContent")]
reasoning_content: BedrockReasoningContent,
},
}
#[derive(Debug, Serialize)]
struct BedrockReasoningContent {
#[serde(rename = "redactedContent")]
redacted_content: String,
}
#[derive(Debug, Serialize)]
@@ -299,10 +313,23 @@ fn convert_content_block(block: &ContentBlock) -> Option<BedrockContentBlock> {
},
},
}),
// Image, Thinking, RedactedThinking, and Unknown are not supported — silently drop
// Echo redacted_thinking verbatim. Bedrock Converse rejects history
// that drops these blocks on Claude extended-thinking models, mirroring
// the anthropic.rs path. Drop empty blobs (e.g. interrupted stream).
ContentBlock::RedactedThinking { data } => {
if data.is_empty() {
None
} else {
Some(BedrockContentBlock::ReasoningContent {
reasoning_content: BedrockReasoningContent {
redacted_content: data.clone(),
},
})
}
}
// Image, Thinking, and Unknown are not supported — silently drop
ContentBlock::Image { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. }
| ContentBlock::Unknown => None,
}
}
@@ -1126,6 +1153,53 @@ mod tests {
assert!(text_at_3 >= 1);
}
/// Issue #1187 — Bedrock Converse history must preserve
/// `redacted_thinking` blocks on Claude extended-thinking models.
/// A message containing only RedactedThinking must round-trip through
/// `convert_content_block` without being silently dropped, and the wire
/// format must use `reasoningContent.redactedContent`.
#[test]
fn test_bedrock_redacted_thinking_round_trip() {
let msg = Message::assistant_with_blocks(vec![ContentBlock::RedactedThinking {
data: "encrypted-blob-abc123".to_string(),
}]);
let bedrock_blocks = convert_message_content(&msg.content);
assert_eq!(
bedrock_blocks.len(),
1,
"RedactedThinking must survive convert_content_block"
);
match &bedrock_blocks[0] {
BedrockContentBlock::ReasoningContent { reasoning_content } => {
assert_eq!(reasoning_content.redacted_content, "encrypted-blob-abc123");
}
other => panic!("expected ReasoningContent block, got {other:?}"),
}
// Wire format check: serialized JSON must carry
// reasoningContent.redactedContent so Bedrock accepts the history.
let json = serde_json::to_value(&bedrock_blocks[0]).unwrap();
assert_eq!(
json["reasoningContent"]["redactedContent"],
"encrypted-blob-abc123"
);
}
/// Empty RedactedThinking blobs (interrupted stream) must be dropped on
/// outbound, matching the anthropic.rs behavior.
#[test]
fn test_bedrock_redacted_thinking_empty_dropped() {
let msg = Message::assistant_with_blocks(vec![ContentBlock::RedactedThinking {
data: String::new(),
}]);
let bedrock_blocks = convert_message_content(&msg.content);
assert!(
bedrock_blocks.is_empty(),
"empty redacted_thinking must be dropped"
);
}
#[test]
fn test_validate_tool_pairing_noop_on_correct() {
// already correct 2-for-2 → no change
+191 -2
View File
@@ -21,7 +21,7 @@ use openfang_types::model_catalog::{
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NOVITA_BASE_URL, NVIDIA_NIM_BASE_URL,
OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL,
QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL,
QWEN_BASE_URL, REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL,
VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
@@ -35,6 +35,64 @@ struct ProviderDefaults {
key_required: bool,
}
/// Resolve an OpenAI-compatible base URL for a local/self-hosted provider from
/// well-known environment variables. Returns `None` if no override is set.
///
/// This lets users point Ollama / LM Studio / vLLM / Lemonade at a remote host
/// (VPS, LXC, another box on the LAN) without editing `~/.openfang/config.toml`.
///
/// Recognised variables:
/// - `ollama` → `OLLAMA_BASE_URL`, then `OLLAMA_HOST` (Ollama CLI convention)
/// - `lmstudio` → `LMSTUDIO_BASE_URL`, then `LMSTUDIO_HOST`
/// - `vllm` → `VLLM_BASE_URL`, then `VLLM_HOST`
/// - `lemonade` → `LEMONADE_BASE_URL`, then `LEMONADE_HOST`
///
/// `*_HOST` values may omit the scheme and the `/v1` suffix
/// (e.g. `OLLAMA_HOST=192.168.1.50:11434`); both are normalised.
pub fn local_provider_url_from_env(provider: &str) -> Option<String> {
fn read(var: &str) -> Option<String> {
std::env::var(var)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
/// Normalise a host-style value into a full OpenAI-compatible base URL.
/// - Adds `http://` if no scheme is present.
/// - Appends `/v1` if not already present in the path.
fn normalize(raw: &str) -> String {
let mut url = if raw.contains("://") {
raw.trim_end_matches('/').to_string()
} else {
format!("http://{}", raw.trim_end_matches('/'))
};
// Add /v1 suffix if missing (OpenAI-compatible endpoints expect it).
// Be lenient: accept either `/v1` or `/v1/` already in place, and also
// `/openai/v1` style proxies.
let lower = url.to_lowercase();
if !lower.ends_with("/v1") && !lower.contains("/v1/") {
url.push_str("/v1");
}
url
}
let (primary, host_fallback) = match provider {
"ollama" => ("OLLAMA_BASE_URL", "OLLAMA_HOST"),
"lmstudio" => ("LMSTUDIO_BASE_URL", "LMSTUDIO_HOST"),
"vllm" => ("VLLM_BASE_URL", "VLLM_HOST"),
"lemonade" => ("LEMONADE_BASE_URL", "LEMONADE_HOST"),
_ => return None,
};
if let Some(v) = read(primary) {
return Some(normalize(&v));
}
if let Some(v) = read(host_fallback) {
return Some(normalize(&v));
}
None
}
/// Get defaults for known providers.
fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
match provider {
@@ -48,6 +106,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "OPENROUTER_API_KEY",
key_required: true,
}),
"requesty" => Some(ProviderDefaults {
base_url: REQUESTY_BASE_URL,
api_key_env: "REQUESTY_API_KEY",
key_required: true,
}),
"deepseek" => Some(ProviderDefaults {
base_url: DEEPSEEK_BASE_URL,
api_key_env: "DEEPSEEK_API_KEY",
@@ -473,9 +536,14 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
)));
}
// Precedence for the base URL:
// 1. Explicit `DriverConfig.base_url` (from config.toml or `[provider_urls]`)
// 2. Well-known env vars for local providers (`OLLAMA_HOST`, etc.) — issue #1154
// 3. Hard-coded provider default (localhost for ollama/lmstudio/vllm/lemonade)
let base_url = config
.base_url
.clone()
.or_else(|| local_provider_url_from_env(provider))
.unwrap_or_else(|| defaults.base_url.to_string());
return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
@@ -629,13 +697,24 @@ pub fn known_providers() -> &'static [&'static str] {
]
}
/// Cross-module env-var serialisation lock for tests that mutate process env.
///
/// Several tests in this crate (drivers, model_catalog) set/unset the same
/// `OLLAMA_*` / `LMSTUDIO_*` env vars and would race under cargo's parallel
/// test runner. Anything that mutates those vars must hold this lock.
#[cfg(test)]
pub(crate) fn env_lock_for_tests() -> &'static std::sync::Mutex<()> {
use std::ops::Deref;
tests::ENV_LOCK.deref()
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::{LazyLock, Mutex};
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
pub(super) static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
struct EnvVarGuard {
key: &'static str,
@@ -1118,4 +1197,114 @@ mod tests {
"unparseable env override should fall through to config field"
);
}
// ── Issue #1154: env-var URL overrides for local providers ──
#[test]
fn test_local_url_env_ollama_host_normalised() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "192.168.1.50:11434");
let url = local_provider_url_from_env("ollama").expect("env should resolve");
assert_eq!(url, "http://192.168.1.50:11434/v1");
}
#[test]
fn test_local_url_env_ollama_base_url_wins() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::set("OLLAMA_BASE_URL", "https://llm.example.com/v1");
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "should-be-ignored:11434");
let url = local_provider_url_from_env("ollama").expect("env should resolve");
assert_eq!(url, "https://llm.example.com/v1");
}
#[test]
fn test_local_url_env_lmstudio() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("LMSTUDIO_BASE_URL");
let _g2 = EnvVarGuard::set("LMSTUDIO_HOST", "http://10.0.0.5:1234");
let url = local_provider_url_from_env("lmstudio").expect("env should resolve");
assert_eq!(url, "http://10.0.0.5:1234/v1");
}
#[test]
fn test_local_url_env_vllm() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("VLLM_BASE_URL");
let _g2 = EnvVarGuard::set("VLLM_HOST", "vps.internal:8000");
let url = local_provider_url_from_env("vllm").expect("env should resolve");
assert_eq!(url, "http://vps.internal:8000/v1");
}
#[test]
fn test_local_url_env_unset_returns_none() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
let _g2 = EnvVarGuard::remove("OLLAMA_HOST");
assert!(local_provider_url_from_env("ollama").is_none());
}
#[test]
fn test_local_url_env_only_for_local_providers() {
// Cloud providers should never resolve via these helpers — they have
// their own *_API_KEY conventions and a fixed cloud base URL.
assert!(local_provider_url_from_env("openai").is_none());
assert!(local_provider_url_from_env("anthropic").is_none());
assert!(local_provider_url_from_env("groq").is_none());
}
#[test]
fn test_local_url_env_preserves_existing_v1_suffix() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::set("OLLAMA_BASE_URL", "http://1.2.3.4:11434/v1");
let _g2 = EnvVarGuard::remove("OLLAMA_HOST");
let url = local_provider_url_from_env("ollama").expect("env should resolve");
assert_eq!(url, "http://1.2.3.4:11434/v1");
}
#[test]
fn test_create_driver_ollama_uses_env_host() {
// End-to-end: when no explicit base_url and no OLLAMA_API_KEY, the
// driver should be constructed pointed at the env-supplied host.
// (We can't introspect the OpenAIDriver's base_url directly, but
// construction succeeds — separate unit covers URL resolution.)
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "10.20.30.40:11434");
let _g3 = EnvVarGuard::remove("OLLAMA_API_KEY");
let config = DriverConfig {
provider: "ollama".to_string(),
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(
driver.is_ok(),
"ollama with OLLAMA_HOST set and no API key should construct: {:?}",
driver.err()
);
}
#[test]
fn test_create_driver_lmstudio_no_key_no_env_still_works() {
// Pre-#1154 regression guard: lmstudio with no env vars and no API key
// should still construct (falls back to localhost default).
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("LMSTUDIO_BASE_URL");
let _g2 = EnvVarGuard::remove("LMSTUDIO_HOST");
let _g3 = EnvVarGuard::remove("LMSTUDIO_API_KEY");
let config = DriverConfig {
provider: "lmstudio".to_string(),
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(driver.is_ok(), "lmstudio default should construct");
}
}
+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(
+168 -2
View File
@@ -10,7 +10,7 @@ use openfang_types::model_catalog::{
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL,
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
@@ -339,6 +339,27 @@ impl ModelCatalog {
}
}
/// Apply environment-variable URL overrides for local providers.
///
/// Honours the same env vars the drivers respect (see
/// `drivers::local_provider_url_from_env`): `OLLAMA_HOST` / `OLLAMA_BASE_URL`,
/// `LMSTUDIO_HOST` / `LMSTUDIO_BASE_URL`, `VLLM_HOST` / `VLLM_BASE_URL`,
/// `LEMONADE_HOST` / `LEMONADE_BASE_URL`. This keeps the dashboard's
/// "Providers" view in sync with what the driver actually connects to,
/// without requiring users to edit `config.toml` for remote local-LLM hosts
/// (VPS, LXC, LAN). See issue #1154.
pub fn apply_local_env_overrides(&mut self) {
for provider in ["ollama", "lmstudio", "vllm", "lemonade"] {
if let Some(url) = crate::drivers::local_provider_url_from_env(provider) {
if let Some(p) = self.providers.iter_mut().find(|p| p.id == provider) {
p.base_url = url;
// A custom host indicates intentional setup, surface it as configured.
p.auth_status = AuthStatus::Configured;
}
}
}
}
/// Apply a batch of provider URL overrides from config.
///
/// Each entry maps a provider ID to a custom base URL.
@@ -604,6 +625,15 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "requesty".into(),
display_name: "Requesty".into(),
api_key_env: "REQUESTY_API_KEY".into(),
base_url: REQUESTY_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "mistral".into(),
display_name: "Mistral AI".into(),
@@ -2053,6 +2083,80 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec!["hunter-alpha".into()],
},
// ══════════════════════════════════════════════════════════════
// Requesty (5) — router-style OpenAI-compatible gateway (issue #995)
// Hundreds of upstream models accessible via https://router.requesty.ai/v1
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "requesty/anthropic/claude-sonnet-4".into(),
display_name: "Claude Sonnet 4 (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Smart,
context_window: 200_000,
max_output_tokens: 64_000,
input_cost_per_m: 3.0,
output_cost_per_m: 15.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "requesty/openai/gpt-4o".into(),
display_name: "GPT-4o (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 16_384,
input_cost_per_m: 2.5,
output_cost_per_m: 10.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "requesty/google/gemini-2.5-flash".into(),
display_name: "Gemini 2.5 Flash (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Smart,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 0.15,
output_cost_per_m: 0.60,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "requesty/deepseek/deepseek-chat".into(),
display_name: "DeepSeek V3 (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.14,
output_cost_per_m: 0.28,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "requesty/meta-llama/llama-3.3-70b-instruct".into(),
display_name: "Llama 3.3 70B (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Balanced,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.39,
output_cost_per_m: 0.39,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Mistral (6)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
@@ -3982,7 +4086,7 @@ mod tests {
#[test]
fn test_catalog_has_providers() {
let catalog = ModelCatalog::new();
assert_eq!(catalog.list_providers().len(), 41);
assert_eq!(catalog.list_providers().len(), 42);
}
#[test]
@@ -4702,4 +4806,66 @@ mod tests {
"qwen-2.5-7b-instruct:free has no tool-supporting free endpoint"
);
}
// ── Requesty provider (issue #995) ────────────────────────────────────
/// Requesty must be registered as a provider with the correct base URL
/// and env var, and at least one of its catalog models must resolve.
#[test]
fn test_requesty_provider_and_models_present() {
let catalog = ModelCatalog::new();
let provider = catalog
.list_providers()
.iter()
.find(|p| p.id == "requesty")
.expect("requesty provider must be registered");
assert_eq!(provider.display_name, "Requesty");
assert_eq!(provider.api_key_env, "REQUESTY_API_KEY");
assert_eq!(provider.base_url, "https://router.requesty.ai/v1");
assert!(provider.key_required);
assert!(
provider.model_count >= 1,
"requesty must have at least one model in catalog"
);
let entry = catalog
.find_model("requesty/anthropic/claude-sonnet-4")
.expect("requesty/anthropic/claude-sonnet-4 must resolve");
assert_eq!(entry.provider, "requesty");
assert!(entry.supports_tools);
}
// ── Issue #1154: env-var overrides for local provider URLs ──
/// Local guard so this catalog test doesn't clash with the driver tests
/// that touch the same env vars. We acquire the cross-module lock from
/// the drivers module to serialise.
#[test]
fn test_apply_local_env_overrides_ollama() {
// Serialise with driver-side env tests that touch OLLAMA_*.
let _lock = crate::drivers::env_lock_for_tests()
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev_base = std::env::var_os("OLLAMA_BASE_URL");
let prev_host = std::env::var_os("OLLAMA_HOST");
std::env::remove_var("OLLAMA_BASE_URL");
std::env::set_var("OLLAMA_HOST", "172.16.0.10:11434");
let mut catalog = ModelCatalog::new();
catalog.apply_local_env_overrides();
let ollama = catalog.get_provider("ollama").unwrap();
assert_eq!(ollama.base_url, "http://172.16.0.10:11434/v1");
assert_eq!(ollama.auth_status, AuthStatus::Configured);
// Restore env
if let Some(v) = prev_base {
std::env::set_var("OLLAMA_BASE_URL", v);
}
if let Some(v) = prev_host {
std::env::set_var("OLLAMA_HOST", v);
} else {
std::env::remove_var("OLLAMA_HOST");
}
}
}
@@ -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.
+280 -3
View File
@@ -332,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,
@@ -478,6 +478,11 @@ pub async fn execute_tool(
}
},
// Skill introspection tools (issue #1038)
"skill_list" => tool_skill_list(skill_registry),
"skill_describe" => tool_skill_describe(input, skill_registry),
"skill_execute" => tool_skill_execute(input, skill_registry).await,
// Canvas / A2UI tool
"canvas_present" => tool_canvas_present(input, workspace_root).await,
@@ -1308,6 +1313,42 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"required": ["html"]
}),
},
// --- Skill introspection tools (issue #1038) ---
// These let the agent discover and read installed skills without
// touching the filesystem. Global skills live at ~/.openfang/skills/
// which is outside the workspace sandbox — file_read cannot reach them.
ToolDefinition {
name: "skill_list".to_string(),
description: "List all installed skills available to this agent. Returns name, version, description, runtime type, and provided tool names. Use this instead of file_list on the skills directory.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "skill_describe".to_string(),
description: "Read the full description (SKILL.md body / prompt context) of an installed skill by name. Use this instead of file_read on a skill's SKILL.md file — global skills live outside the workspace sandbox and cannot be read with file_read.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "The skill name (as returned by skill_list)" }
},
"required": ["name"]
}),
},
ToolDefinition {
name: "skill_execute".to_string(),
description: "Execute a tool provided by an installed skill. For code-runtime skills (Python/Node/Shell) this invokes the underlying script. For prompt-only skills this returns the skill's instruction body so the agent can follow it using built-in tools.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"skill": { "type": "string", "description": "The skill name (as returned by skill_list)" },
"tool": { "type": "string", "description": "Optional name of a tool the skill provides. Omit to invoke the skill's default behavior (returns SKILL.md body for prompt-only skills)." },
"input": { "type": "object", "description": "Optional JSON input for the skill tool" }
},
"required": ["skill"]
}),
},
]
}
@@ -1667,7 +1708,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)]
@@ -3035,6 +3087,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()
@@ -3064,7 +3117,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 {
@@ -3521,6 +3578,165 @@ async fn tool_canvas_present(
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
}
// ---------------------------------------------------------------------------
// Skill introspection tools (issue #1038)
//
// Global skills live at ~/.openfang/skills/ which is outside the agent
// workspace sandbox. Without these tools the LLM falls back to file_read /
// shell_exec to inspect SKILL.md files — which fail with path-resolution
// errors because file_read is workspace-scoped. These tools surface the
// already-loaded skill registry directly to the agent.
// ---------------------------------------------------------------------------
/// List all skills available to this agent, with their provided tool names.
fn tool_skill_list(skill_registry: Option<&SkillRegistry>) -> Result<String, String> {
let registry = match skill_registry {
Some(r) => r,
None => return Ok("No skill registry available.".to_string()),
};
let skills = registry.list();
if skills.is_empty() {
return Ok("No skills installed. Install skills via the dashboard or `openfang skill install <name>`.".to_string());
}
let entries: Vec<serde_json::Value> = skills
.iter()
.map(|s| {
let tool_names: Vec<String> = s
.manifest
.tools
.provided
.iter()
.map(|t| t.name.clone())
.collect();
serde_json::json!({
"name": s.manifest.skill.name,
"version": s.manifest.skill.version,
"description": s.manifest.skill.description,
"runtime": format!("{:?}", s.manifest.runtime.runtime_type),
"enabled": s.enabled,
"tools": tool_names,
"has_prompt_context": s.manifest.prompt_context.as_ref().is_some_and(|c| !c.is_empty()),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"count": entries.len(),
"skills": entries,
}))
.map_err(|e| format!("Serialize error: {e}"))
}
/// Return the full description (SKILL.md body) of a named skill.
fn tool_skill_describe(
input: &serde_json::Value,
skill_registry: Option<&SkillRegistry>,
) -> Result<String, String> {
let name = input["name"]
.as_str()
.ok_or("Missing 'name' parameter")?
.trim();
let registry = skill_registry.ok_or("No skill registry available")?;
let skill = registry
.get(name)
.ok_or_else(|| format!("Skill '{name}' not found. Use skill_list to see installed skills."))?;
let body = skill
.manifest
.prompt_context
.clone()
.unwrap_or_else(|| "(No prompt context body — this skill provides executable tools only. Use skill_execute or call its tools directly.)".to_string());
let tool_names: Vec<String> = skill
.manifest
.tools
.provided
.iter()
.map(|t| t.name.clone())
.collect();
let response = serde_json::json!({
"name": skill.manifest.skill.name,
"version": skill.manifest.skill.version,
"description": skill.manifest.skill.description,
"runtime": format!("{:?}", skill.manifest.runtime.runtime_type),
"tools": tool_names,
"body": body,
});
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
}
/// Execute a skill's tool, or for prompt-only skills return the description body.
async fn tool_skill_execute(
input: &serde_json::Value,
skill_registry: Option<&SkillRegistry>,
) -> Result<String, String> {
let skill_name = input["skill"]
.as_str()
.ok_or("Missing 'skill' parameter")?
.trim();
let registry = skill_registry.ok_or("No skill registry available")?;
let skill = registry
.get(skill_name)
.ok_or_else(|| format!("Skill '{skill_name}' not found. Use skill_list to see installed skills."))?;
// If no tool name was given, default behavior depends on runtime.
// For prompt-only skills, return the SKILL.md body (most useful response
// for issue #1038's daily-journal style skills).
let tool_name = input["tool"].as_str().map(|s| s.trim());
let tool_input = input.get("input").cloned().unwrap_or(serde_json::json!({}));
let resolved_tool = match tool_name {
Some(t) if !t.is_empty() => t.to_string(),
_ => {
// No tool specified — return SKILL.md body so the agent can act on it.
if let Some(ref body) = skill.manifest.prompt_context {
if !body.is_empty() {
let response = serde_json::json!({
"skill": skill.manifest.skill.name,
"mode": "prompt_context",
"body": body,
"note": "This is a prompt-only skill. Follow the instructions in 'body' using your built-in tools.",
});
return serde_json::to_string_pretty(&response)
.map_err(|e| format!("Serialize error: {e}"));
}
}
// Fall through: pick the first provided tool if any
skill
.manifest
.tools
.provided
.first()
.map(|t| t.name.clone())
.ok_or_else(|| {
format!("Skill '{skill_name}' provides no tools and has no prompt body.")
})?
}
};
match openfang_skills::loader::execute_skill_tool(
&skill.manifest,
&skill.path,
&resolved_tool,
&tool_input,
)
.await
{
Ok(result) => {
let content = serde_json::to_string_pretty(&serde_json::json!({
"skill": skill.manifest.skill.name,
"tool": resolved_tool,
"output": result.output,
"is_error": result.is_error,
}))
.unwrap_or_else(|_| result.output.to_string());
if result.is_error {
Err(content)
} else {
Ok(content)
}
}
Err(e) => Err(format!("Skill execution failed: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3594,6 +3810,67 @@ mod tests {
assert!(names.contains(&"docker_exec"));
// Canvas tool
assert!(names.contains(&"canvas_present"));
// 3 skill introspection tools (issue #1038)
assert!(names.contains(&"skill_list"));
assert!(names.contains(&"skill_describe"));
assert!(names.contains(&"skill_execute"));
}
/// Issue #1038: skill_list, skill_describe, skill_execute work without
/// touching the filesystem so global skills (outside the workspace
/// sandbox) are reachable by the agent.
#[tokio::test]
async fn test_skill_tools_no_filesystem_access() {
use openfang_skills::registry::SkillRegistry;
use tempfile::TempDir;
// Build a skills directory containing one prompt-only SKILL.md skill
// (mirroring the user's daily-journal scenario from #1038).
let global_dir = TempDir::new().unwrap();
let skill_dir = global_dir.path().join("daily-journal");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: daily-journal\ndescription: Keep a daily journal\n---\n\
# Daily Journal\n\nWrite one paragraph per day about what you learned.",
)
.unwrap();
let mut registry = SkillRegistry::new(global_dir.path().to_path_buf());
registry.load_all().unwrap();
assert_eq!(registry.count(), 1);
// skill_list returns the global skill without any filesystem call
let list_out = tool_skill_list(Some(&registry)).unwrap();
assert!(list_out.contains("daily-journal"));
assert!(list_out.contains("Keep a daily journal"));
// skill_describe returns the SKILL.md body — no file_read needed
let desc_out = tool_skill_describe(
&serde_json::json!({ "name": "daily-journal" }),
Some(&registry),
)
.unwrap();
assert!(desc_out.contains("Daily Journal"));
assert!(desc_out.contains("Write one paragraph"));
// skill_execute on a prompt-only skill returns the body in 'prompt_context' mode
let exec_out = tool_skill_execute(
&serde_json::json!({ "skill": "daily-journal" }),
Some(&registry),
)
.await
.unwrap();
assert!(exec_out.contains("prompt_context"));
assert!(exec_out.contains("Daily Journal"));
// skill_describe on a missing skill returns a helpful error
let missing = tool_skill_describe(
&serde_json::json!({ "name": "no-such-skill" }),
Some(&registry),
);
assert!(missing.is_err());
assert!(missing.unwrap_err().contains("not found"));
}
#[test]
+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 }
+75 -4
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));
@@ -525,9 +547,24 @@ impl ClawHubClient {
};
info!(slug, sha256 = %sha256, "Downloaded skill");
// Create skill directory
let skill_dir = target_dir.join(slug);
std::fs::create_dir_all(&skill_dir)?;
// Codex Finding 1 (#1170 followup): extract into a private staging
// directory and atomically rename into the final location only after
// enforce_require_signed passes. Without this, a local writer with
// access to target_dir/<slug>/ can swap files between extraction
// and the binding check (the TOCTOU window).
let final_dir = target_dir.join(slug);
let staging_dir = target_dir.join(format!(
".staging-{}-{}-{}",
slug,
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
// Pre-clean any leftover staging dir from a prior crashed install.
let _ = std::fs::remove_dir_all(&staging_dir);
std::fs::create_dir_all(&staging_dir)?;
// Use the staging path for all extraction/conversion work.
// After enforcement passes the function renames it to final_dir.
let skill_dir = staging_dir.clone();
// Detect content type and extract accordingly
let content_str = String::from_utf8_lossy(&bytes);
@@ -637,6 +674,39 @@ 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);
}
// Step 9: Atomic rename staging -> final. Closes the TOCTOU window
// between extraction and enforcement (Codex Finding 1). If the final
// location already exists from a prior install, replace it
// atomically (where the filesystem allows) or fall back to
// remove-then-rename.
if final_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&final_dir) {
let _ = std::fs::remove_dir_all(&skill_dir);
return Err(SkillError::SecurityBlocked(format!(
"install: failed to clear existing {} before atomic rename: {e}",
final_dir.display()
)));
}
}
if let Err(e) = std::fs::rename(&skill_dir, &final_dir) {
let _ = std::fs::remove_dir_all(&skill_dir);
return Err(SkillError::SecurityBlocked(format!(
"install: atomic rename {} -> {} failed: {e}",
skill_dir.display(),
final_dir.display()
)));
}
// From this point on the skill lives at final_dir, not staging.
// (Variable kept for clarity even though unread; future tracing
// additions reference it.)
let _skill_dir = final_dir;
let result = ClawHubInstallResult {
skill_name: manifest.skill.name.clone(),
version: manifest.skill.version.clone(),
@@ -650,6 +720,7 @@ impl ClawHubClient {
slug,
skill_name = %result.skill_name,
warnings = result.warnings.len(),
require_signed = opts.require_signed,
"Installed skill from ClawHub"
);
+547
View File
@@ -0,0 +1,547 @@
//! 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",
];
/// Normalize a manifest text for content-binding comparison.
///
/// Strips a UTF-8 BOM if present and converts CRLF to LF. Without this,
/// a Windows checkout with git autocrlf would write `\r\n` line endings
/// to disk while the signed envelope captured `\n`, and the literal
/// byte-equality check would reject an otherwise-valid signed manifest.
/// Applied symmetrically to both sides of the binding compare.
fn normalize_manifest_text(text: &str) -> String {
let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
trimmed.replace("\r\n", "\n")
}
/// Verify that a path resolves to a regular file *inside* `dir`, not a
/// symlink and not an escape via `..` or canonicalization. Returns
/// `Ok(true)` if the entry is safe to open, `Ok(false)` if it doesn't
/// exist, and `Err` if the entry exists but fails the safety check.
///
/// Without this, an attacker shipping a crafted archive could place
/// `signature.json` or `skill.toml` as a symlink pointing outside the
/// skill directory and redirect manifest/envelope reads — bypassing the
/// intent of the binding enforcement.
fn safe_regular_file_in(dir: &Path, name: &str) -> Result<bool, SkillError> {
let path = dir.join(name);
let md = match std::fs::symlink_metadata(&path) {
Ok(md) => md,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: failed to stat {} for safety check: {e}",
path.display()
)))
}
};
if md.file_type().is_symlink() {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: refusing to follow symlink at {} \
(skill bundles must ship regular files only)",
path.display()
)));
}
if !md.is_file() {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: {} is not a regular file",
path.display()
)));
}
let canon_dir = std::fs::canonicalize(dir).map_err(|e| {
SkillError::SecurityBlocked(format!(
"require_signed: cannot canonicalize {}: {e}",
dir.display()
))
})?;
let canon_path = std::fs::canonicalize(&path).map_err(|e| {
SkillError::SecurityBlocked(format!(
"require_signed: cannot canonicalize {}: {e}",
path.display()
))
})?;
if !canon_path.starts_with(&canon_dir) {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: {} escapes skill directory {}",
canon_path.display(),
canon_dir.display()
)));
}
Ok(true)
}
/// 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 {
if !safe_regular_file_in(skill_dir, name)? {
continue;
}
let path = skill_dir.join(name);
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}"
)));
}
// Bind the signature to the installed bytes.
//
// envelope.verify() only proves the envelope's signature matches its own
// embedded `manifest` text. Without comparing that text to the actual
// skill.toml / SKILL.md / package.json on disk, an attacker could ship
// a benign signed envelope alongside malicious skill files and pass the
// check.
//
// Defense layers (Codex Findings 1-4 from audit of 4c496be):
// - Symlink + canonicalization checks: redirect-via-symlink closed
// - CRLF/BOM normalization: Windows false-reject closed
// - package.json in candidate list: OpenClaw gap closed
// - clawhub::install_with_options now uses staging-then-atomic-rename
// so this function runs inside a private dir not yet visible at the
// final install path. That closes the TOCTOU window where a local
// writer could swap files between extract and enforce.
//
// Read every candidate manifest file in the installed dir and require
// that at least one byte-matches envelope.manifest after BOM strip +
// CRLF→LF normalization (applied symmetrically to both sides).
// `package.json` is included because openclaw_compat treats it as a
// valid SKILL manifest source.
const MANIFEST_CANDIDATES: &[&str] =
&["skill.toml", "SKILL.md", "skill.md", "package.json"];
let normalized_envelope = normalize_manifest_text(&envelope.manifest);
let mut bound = false;
for name in MANIFEST_CANDIDATES {
if !safe_regular_file_in(skill_dir, name)? {
continue;
}
let path = skill_dir.join(name);
match std::fs::read_to_string(&path) {
Ok(actual) => {
if normalize_manifest_text(&actual) == normalized_envelope {
bound = true;
break;
}
}
Err(e) => {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: failed to read {} for binding check: {e}",
path.display()
)));
}
}
}
if !bound {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: signed envelope content does not match any \
installed manifest file in {} (signature was valid but the \
skill payload on disk differs from what was signed)",
skill_dir.display()
)));
}
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:?}"),
}
}
/// Critical: a valid signature for a different manifest must NOT pass
/// when the on-disk skill.toml differs. Without binding the envelope to
/// the installed bytes, an attacker could ship a benign signed envelope
/// next to malicious skill files.
#[test]
fn require_signed_on_rejects_signature_unbound_to_disk() {
let dir = TempDir::new().unwrap();
// Sign a BENIGN manifest body but never write that text to disk.
let benign_toml = r#"name = "benign"
version = "0.1.0"
description = "Looks fine."
[runtime]
type = "python"
entry = "main.py"
"#;
let signing_key = SigningKey::generate(&mut OsRng);
let envelope =
SignedManifest::sign(benign_toml.to_string(), &signing_key, "trusted-signer");
write_signature(dir.path(), &envelope, "signature.json");
// Write a DIFFERENT (malicious) skill.toml on disk.
let evil_toml = r#"name = "evil"
version = "0.1.0"
description = "Backdoor."
[runtime]
type = "python"
entry = "rm-rf.py"
"#;
std::fs::write(dir.path().join("skill.toml"), evil_toml).unwrap();
let opts = InstallOptions::require_signed();
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
match err {
SkillError::SecurityBlocked(msg) => {
assert!(
msg.contains("does not match any") || msg.contains("payload on disk differs"),
"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");
}
// Codex audit Finding 2: CRLF/BOM normalization. A Windows checkout
// may write \r\n line endings to disk; the signed envelope captured
// \n. Literal byte equality would false-reject. Both sides are
// normalize_manifest_text-ed before compare.
#[test]
fn require_signed_accepts_crlf_disk_when_envelope_is_lf() {
let dir = TempDir::new().unwrap();
let lf_toml = "[skill]\nname = \"x\"\nversion = \"0.1\"\n[runtime]\ntype = \"python\"\nentry = \"main.py\"\n";
let crlf_toml = lf_toml.replace('\n', "\r\n");
std::fs::write(dir.path().join("skill.toml"), &crlf_toml).unwrap();
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(lf_toml, &signing_key, "lf-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_accepts_utf8_bom_disk_when_envelope_is_clean() {
let dir = TempDir::new().unwrap();
let clean = "[skill]\nname = \"x\"\nversion = \"0.1\"\n[runtime]\ntype = \"python\"\nentry = \"main.py\"\n";
let with_bom = format!("\u{feff}{clean}");
std::fs::write(dir.path().join("skill.toml"), &with_bom).unwrap();
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(clean, &signing_key, "bom-signer");
write_signature(dir.path(), &envelope, "signature.json");
let opts = InstallOptions::require_signed();
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
}
// Codex audit Finding 3: package.json must be a valid binding candidate
// because openclaw_compat treats it as a SKILL manifest source.
#[test]
fn require_signed_binds_to_package_json() {
let dir = TempDir::new().unwrap();
let pkg = r#"{"name":"x","version":"0.1.0","openfang":{"skill":"x"}}"#;
std::fs::write(dir.path().join("package.json"), pkg).unwrap();
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(pkg, &signing_key, "pkg-signer");
write_signature(dir.path(), &envelope, "signature.json");
let opts = InstallOptions::require_signed();
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
}
// Codex audit Finding 4: symlinks for signature.json or manifest must
// be rejected even when they point to a valid file, to prevent crafted
// bundles from redirecting reads outside the skill directory.
#[cfg(unix)]
#[test]
fn require_signed_rejects_symlink_signature() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let toml = write_skill_toml(dir.path());
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(toml, &signing_key, "symlink-signer");
let outside_sig = outside.path().join("real-sig.json");
std::fs::write(
&outside_sig,
serde_json::to_string_pretty(&envelope).unwrap(),
)
.unwrap();
symlink(&outside_sig, dir.path().join("signature.json")).unwrap();
let opts = InstallOptions::require_signed();
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
match err {
SkillError::SecurityBlocked(msg) => {
assert!(msg.contains("symlink"), "got: {msg}");
}
other => panic!("expected SecurityBlocked, got {other:?}"),
}
}
}
+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)
}
}
+17 -3
View File
@@ -474,11 +474,23 @@ pub struct AgentManifest {
/// Pinned model override (used in Stable mode).
#[serde(default)]
pub pinned_model: Option<String>,
/// Agent workspace directory. Auto-created on spawn.
/// Default: `{workspaces_dir}/{agent_name}-{agent_id_prefix}/`
/// Agent workspace directory. User-facing working area for output, data,
/// skills, context.md, and uploads. When the user sets this in agent.toml
/// (e.g. `workspace = "/home/me/Documents"`) the runtime treats it as the
/// agent's working directory and will NOT scaffold private state files
/// there. Multiple agents can share the same workspace to collaborate on
/// files. See issue #1097.
/// Default: `{workspaces_dir}/{agent_name}/` (same as state_dir).
#[serde(default)]
pub workspace: Option<PathBuf>,
/// Whether to generate workspace identity files (SOUL.md, USER.md, etc.) on creation.
/// Agent private state directory. Stores identity files (SOUL.md, etc.),
/// AGENT.json, sessions/, and the daily memory log. Always lives under
/// `~/.openfang/workspaces/{name}/` regardless of where the user-facing
/// workspace points. Auto-derived on spawn. See issue #1097.
#[serde(default)]
pub state_dir: Option<PathBuf>,
/// Whether to generate identity files (SOUL.md, USER.md, etc.) in
/// `state_dir` on creation.
#[serde(default = "default_true")]
pub generate_identity_files: bool,
/// Per-agent exec policy override. If None, uses global exec_policy.
@@ -550,6 +562,7 @@ impl Default for AgentManifest {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: Vec::new(),
@@ -809,6 +822,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: Vec::new(),
+98
View File
@@ -969,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 {
@@ -990,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(),
}
}
}
@@ -1461,6 +1481,10 @@ fn default_true() -> bool {
true
}
fn default_auto_thread() -> String {
"false".to_string()
}
fn default_thread_ttl() -> u64 {
24
}
@@ -1909,6 +1933,13 @@ pub struct TelegramConfig {
/// Allows channel_send(channel="telegram", message="...") without a recipient.
#[serde(default)]
pub default_chat_id: Option<String>,
/// Forum topic routing: maps `message_thread_id` to an agent name.
/// When a message arrives inside a Telegram forum topic whose thread id is
/// listed here, the bridge dispatches it to the named agent instead of the
/// default. Threads not listed fall back to `default_agent`.
/// Issue #780.
#[serde(default)]
pub thread_routes: HashMap<i64, String>,
/// Per-channel behavior overrides.
#[serde(default)]
pub overrides: ChannelOverrides,
@@ -1923,6 +1954,7 @@ impl Default for TelegramConfig {
poll_interval_secs: 1,
api_url: None,
default_chat_id: None,
thread_routes: HashMap::new(),
overrides: ChannelOverrides::default(),
}
}
@@ -1956,6 +1988,10 @@ pub struct DiscordConfig {
/// In these channels, the bot responds to all group messages without needing to be mentioned.
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
pub free_response_channels: Vec<String>,
/// Auto-thread behavior: "true" (always create thread), "false" (never), "smart" (only when @mentioned).
/// Default: "false"
#[serde(default = "default_auto_thread")]
pub auto_thread: String,
/// Per-channel behavior overrides.
#[serde(default)]
pub overrides: ChannelOverrides,
@@ -1972,6 +2008,7 @@ impl Default for DiscordConfig {
ignore_bots: true,
default_channel_id: None,
free_response_channels: vec![],
auto_thread: "false".to_string(),
overrides: ChannelOverrides::default(),
}
}
@@ -2099,6 +2136,13 @@ pub struct MatrixConfig {
pub user_id: String,
/// Env var name holding the access token.
pub access_token_env: String,
/// Env var name holding the MSC2918 refresh token (optional).
///
/// When set, the adapter auto-recovers from `M_UNKNOWN_TOKEN` 401 responses
/// by calling `POST /_matrix/client/v3/refresh`. Required for matrix.org
/// since the 2025-04-07 migration to MAS (Matrix Authentication Service).
#[serde(default)]
pub refresh_token_env: Option<String>,
/// Room IDs to listen in (empty = all joined rooms).
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
pub allowed_rooms: Vec<String>,
@@ -2118,6 +2162,7 @@ impl Default for MatrixConfig {
homeserver_url: "https://matrix.org".to_string(),
user_id: String::new(),
access_token_env: "MATRIX_ACCESS_TOKEN".to_string(),
refresh_token_env: None,
allowed_rooms: vec![],
default_agent: None,
auto_accept_invites: false,
@@ -4126,6 +4171,9 @@ mod tests {
let mx = MatrixConfig::default();
assert_eq!(mx.homeserver_url, "https://matrix.org");
assert_eq!(mx.access_token_env, "MATRIX_ACCESS_TOKEN");
// MSC2918 refresh token env defaults to None; operators opt in via
// `refresh_token_env = "MATRIX_REFRESH_TOKEN"` in config.toml.
assert!(mx.refresh_token_env.is_none());
assert!(mx.allowed_rooms.is_empty());
}
@@ -4600,4 +4648,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]
@@ -14,6 +14,7 @@ pub const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com";
pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
pub const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1";
pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
pub const REQUESTY_BASE_URL: &str = "https://router.requesty.ai/v1";
pub const MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1";
pub const TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
pub const FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
+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:-}
+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`: