mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 06:32:17 +00:00
uninstall agent
This commit is contained in:
@@ -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.
|
||||
@@ -12614,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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1212,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;
|
||||
|
||||
Reference in New Issue
Block a user