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