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 |
Generated
+16
-14
@@ -3959,7 +3959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
@@ -4001,7 +4001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"async-trait",
|
||||
@@ -4040,7 +4040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4068,7 +4068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -4094,7 +4094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4122,7 +4122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4140,7 +4140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4179,7 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4199,7 +4199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4218,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4254,11 +4254,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.6.6"
|
||||
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.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 2.11.0",
|
||||
@@ -4297,7 +4299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -9231,7 +9233,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.6"
|
||||
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.6-green?style=flat-square" alt="v0.6.6" />
|
||||
<img src="https://img.shields.io/badge/tests-2,625%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/version-0.6.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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -198,6 +198,10 @@ function chatPage() {
|
||||
if (store.pendingAgent) {
|
||||
self.selectAgent(store.pendingAgent);
|
||||
store.pendingAgent = null;
|
||||
} else {
|
||||
// Restore previously active agent after page refresh (#1179).
|
||||
// The agent list may not be loaded yet, so resolve once it appears.
|
||||
self._restoreActiveAgent();
|
||||
}
|
||||
|
||||
// Watch for future pending agent selections (e.g., user clicks agent while on chat)
|
||||
@@ -208,6 +212,13 @@ function chatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Re-attempt restore once the agent list arrives from the server
|
||||
this.$watch('$store.app.agents', function(agents) {
|
||||
if (!self.currentAgent && agents && agents.length) {
|
||||
self._restoreActiveAgent();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for slash commands + model autocomplete
|
||||
this.$watch('inputText', function(val) {
|
||||
var modelMatch = val.match(/^\/model\s+(.*)$/i);
|
||||
@@ -551,6 +562,7 @@ function chatPage() {
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
window.dispatchEvent(new Event('close-chat'));
|
||||
break;
|
||||
case '/budget':
|
||||
@@ -586,9 +598,27 @@ function chatPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// Restore the previously-active agent (set in selectAgent) after a page
|
||||
// refresh, so the WebSocket re-attaches to the same session and any
|
||||
// in-flight tool output streams back into the chat (#1179).
|
||||
_restoreActiveAgent: function() {
|
||||
var storedId = null;
|
||||
try { storedId = localStorage.getItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
if (!storedId) return;
|
||||
var agents = (Alpine.store('app') && Alpine.store('app').agents) || [];
|
||||
var match = null;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i] && agents[i].id === storedId) { match = agents[i]; break; }
|
||||
}
|
||||
if (match) {
|
||||
this.selectAgent(match);
|
||||
}
|
||||
},
|
||||
|
||||
selectAgent(agent) {
|
||||
this.currentAgent = agent;
|
||||
this.messages = [];
|
||||
try { localStorage.setItem('of-active-agent', agent.id); } catch(e) { /* ignore */ }
|
||||
this.connectWs(agent.id);
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
// Show welcome tips on first use
|
||||
@@ -1173,6 +1203,7 @@ function chatPage() {
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
OpenFangToast.success(t('chat.agent_stopped') + ' "' + name + '"');
|
||||
Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
@@ -1181,6 +1212,37 @@ function chatPage() {
|
||||
});
|
||||
},
|
||||
|
||||
// Permanently uninstall the agent: kill + remove ~/.openfang/agents/<name>/
|
||||
// Issue #1163.
|
||||
uninstallAgent: function() {
|
||||
if (!this.currentAgent) return;
|
||||
var self = this;
|
||||
var name = this.currentAgent.name;
|
||||
var agentId = this.currentAgent.id;
|
||||
OpenFangToast.confirm(
|
||||
'Uninstall Agent',
|
||||
'Uninstall agent "' + name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
var res = await OpenFangAPI.del('/api/agents/' + agentId + '/uninstall');
|
||||
OpenFangAPI.wsDisconnect();
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
var msg = 'Agent "' + name + '" uninstalled';
|
||||
if (res && res.dir_removed === false) {
|
||||
msg += ' (no on-disk files found)';
|
||||
}
|
||||
OpenFangToast.success(msg);
|
||||
Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
_latexTimer: null,
|
||||
scrollToBottom() {
|
||||
var self = this;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenFang",
|
||||
"version": "0.6.6",
|
||||
"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
|
||||
@@ -8125,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
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -332,7 +332,7 @@ pub async fn execute_tool(
|
||||
"media_transcribe" => tool_media_transcribe(input, media_engine).await,
|
||||
|
||||
// Image generation tool
|
||||
"image_generate" => tool_image_generate(input, workspace_root).await,
|
||||
"image_generate" => tool_image_generate(input, workspace_root, media_engine).await,
|
||||
|
||||
// TTS/STT tools
|
||||
"text_to_speech" => tool_text_to_speech(input, tts_engine, workspace_root).await,
|
||||
@@ -1667,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)]
|
||||
@@ -3035,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()
|
||||
@@ -3064,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,6 +969,25 @@ pub struct ExecPolicy {
|
||||
/// produce no stdout/stderr output for this duration. Default: 30.
|
||||
#[serde(default = "default_no_output_timeout")]
|
||||
pub no_output_timeout_secs: u64,
|
||||
/// Environment variables to forward from the OpenFang process into
|
||||
/// `shell_exec` subprocesses.
|
||||
///
|
||||
/// By default, subprocesses run with `env_clear()` and only receive a
|
||||
/// minimal safe set (PATH, HOME, TMPDIR, LANG, TERM, etc. — see
|
||||
/// `subprocess_sandbox::SAFE_ENV_VARS`). Anything else — including
|
||||
/// user-defined variables present in the container/host environment —
|
||||
/// is stripped. This list lets operators explicitly re-add specific
|
||||
/// variables to the subprocess environment.
|
||||
///
|
||||
/// Each entry is an env var name. A single entry of `"*"` forwards
|
||||
/// every variable present in the parent process. Use with care — `*`
|
||||
/// will leak API keys and other secrets into child processes.
|
||||
///
|
||||
/// Aliases `env_passthrough` and `env_allowlist` are accepted for
|
||||
/// backwards compatibility with users who configured these names
|
||||
/// before the field existed (issue #1169).
|
||||
#[serde(default, alias = "env_passthrough", alias = "env_allowlist")]
|
||||
pub shell_env_passthrough: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_no_output_timeout() -> u64 {
|
||||
@@ -990,6 +1009,7 @@ impl Default for ExecPolicy {
|
||||
timeout_secs: 30,
|
||||
max_output_bytes: 100 * 1024,
|
||||
no_output_timeout_secs: default_no_output_timeout(),
|
||||
shell_env_passthrough: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4600,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]
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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