Skip to content

Commit d9e72ab

Browse files
committed
uninstall agent
1 parent 505a8e8 commit d9e72ab

5 files changed

Lines changed: 259 additions & 0 deletions

File tree

crates/openfang-api/src/routes.rs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,98 @@ pub async fn kill_agent(
692692
}
693693
}
694694

695+
/// DELETE /api/agents/{id}/uninstall — Permanently uninstall an agent.
696+
///
697+
/// Issue #1163: in addition to killing the agent (registry + memory + cron),
698+
/// this also removes the on-disk `~/.openfang/agents/<name>/` directory so
699+
/// the agent does not auto-respawn on the next daemon start.
700+
pub async fn uninstall_agent(
701+
State(state): State<Arc<AppState>>,
702+
Path(id): Path<String>,
703+
) -> impl IntoResponse {
704+
let agent_id: AgentId = match id.parse() {
705+
Ok(id) => id,
706+
Err(_) => {
707+
return (
708+
StatusCode::BAD_REQUEST,
709+
Json(serde_json::json!({"error": "Invalid agent ID"})),
710+
);
711+
}
712+
};
713+
714+
// Capture the agent name BEFORE killing — registry entry is gone after.
715+
let agent_name = match state.kernel.registry.get(agent_id) {
716+
Some(entry) => entry.name.clone(),
717+
None => {
718+
return (
719+
StatusCode::NOT_FOUND,
720+
Json(serde_json::json!({"error": "Agent not found"})),
721+
);
722+
}
723+
};
724+
725+
// Step 1: kill the agent (registry, memory, cron, triggers, caps).
726+
if let Err(e) = state.kernel.kill_agent(agent_id) {
727+
tracing::warn!("kill_agent failed during uninstall for {id}: {e}");
728+
return (
729+
StatusCode::NOT_FOUND,
730+
Json(serde_json::json!({"error": "Agent not found or already terminated"})),
731+
);
732+
}
733+
734+
// Step 2: remove ~/.openfang/agents/<name>/ so the agent does NOT
735+
// auto-respawn from disk on the next daemon start.
736+
let agents_dir = state.kernel.config.home_dir.join("agents");
737+
let agent_dir = agents_dir.join(&agent_name);
738+
739+
let dir_removed = if agent_dir.is_dir() {
740+
// Safety: only allow removal if the parent is exactly the agents root.
741+
let parent_ok = agent_dir
742+
.parent()
743+
.map(|p| p == agents_dir.as_path())
744+
.unwrap_or(false);
745+
if !parent_ok {
746+
tracing::warn!(
747+
agent = %agent_name,
748+
path = %agent_dir.display(),
749+
"Refusing to remove agent dir outside agents root"
750+
);
751+
false
752+
} else {
753+
match std::fs::remove_dir_all(&agent_dir) {
754+
Ok(()) => {
755+
tracing::info!(
756+
agent = %agent_name,
757+
path = %agent_dir.display(),
758+
"Removed agent directory on uninstall (#1163)"
759+
);
760+
true
761+
}
762+
Err(e) => {
763+
tracing::warn!(
764+
agent = %agent_name,
765+
path = %agent_dir.display(),
766+
"Failed to remove agent directory: {e}"
767+
);
768+
false
769+
}
770+
}
771+
}
772+
} else {
773+
false
774+
};
775+
776+
(
777+
StatusCode::OK,
778+
Json(serde_json::json!({
779+
"status": "uninstalled",
780+
"agent_id": id,
781+
"name": agent_name,
782+
"dir_removed": dir_removed,
783+
})),
784+
)
785+
}
786+
695787
/// POST /api/agents/{id}/restart — Restart a crashed/stuck agent.
696788
///
697789
/// Cancels any active task, resets agent state to Running, and updates last_active.
@@ -12614,3 +12706,110 @@ mod skill_config_tests {
1261412706
assert_eq!(back, doc);
1261512707
}
1261612708
}
12709+
12710+
#[cfg(test)]
12711+
mod uninstall_agent_tests {
12712+
//! Issue #1163 — directory-removal portion of the uninstall flow.
12713+
//!
12714+
//! These tests exercise the same logic the route handler runs after
12715+
//! `kernel.kill_agent()`: locate `<home>/agents/<name>/`, verify it is
12716+
//! directly under the agents root, and remove it. Live end-to-end
12717+
//! coverage (real HTTP + kernel) belongs in `tests/api_integration_test.rs`.
12718+
use std::path::Path;
12719+
12720+
/// Mirror of the dir-removal logic in `uninstall_agent`. Kept in sync
12721+
/// with the route handler so the rules can be unit-tested without a
12722+
/// running kernel. Returns whether the directory was removed.
12723+
fn remove_agent_dir(home_dir: &Path, agent_name: &str) -> bool {
12724+
let agents_dir = home_dir.join("agents");
12725+
let agent_dir = agents_dir.join(agent_name);
12726+
if !agent_dir.is_dir() {
12727+
return false;
12728+
}
12729+
let parent_ok = agent_dir
12730+
.parent()
12731+
.map(|p| p == agents_dir.as_path())
12732+
.unwrap_or(false);
12733+
if !parent_ok {
12734+
return false;
12735+
}
12736+
std::fs::remove_dir_all(&agent_dir).is_ok()
12737+
}
12738+
12739+
#[test]
12740+
fn removes_agent_directory_under_agents_root() {
12741+
let tmp = tempfile::tempdir().unwrap();
12742+
let home = tmp.path().to_path_buf();
12743+
let agents = home.join("agents");
12744+
std::fs::create_dir_all(agents.join("trash-agent")).unwrap();
12745+
std::fs::write(
12746+
agents.join("trash-agent").join("agent.toml"),
12747+
"name = \"trash-agent\"\n",
12748+
)
12749+
.unwrap();
12750+
12751+
assert!(agents.join("trash-agent").is_dir());
12752+
let removed = remove_agent_dir(&home, "trash-agent");
12753+
assert!(removed, "agent directory must be removed");
12754+
assert!(!agents.join("trash-agent").exists());
12755+
}
12756+
12757+
#[test]
12758+
fn returns_false_when_no_directory_exists() {
12759+
let tmp = tempfile::tempdir().unwrap();
12760+
let home = tmp.path().to_path_buf();
12761+
std::fs::create_dir_all(home.join("agents")).unwrap();
12762+
12763+
let removed = remove_agent_dir(&home, "ghost-agent");
12764+
assert!(!removed, "no dir => false, but uninstall still succeeds");
12765+
}
12766+
12767+
#[test]
12768+
fn does_not_touch_siblings() {
12769+
let tmp = tempfile::tempdir().unwrap();
12770+
let home = tmp.path().to_path_buf();
12771+
let agents = home.join("agents");
12772+
std::fs::create_dir_all(agents.join("trash-agent")).unwrap();
12773+
std::fs::create_dir_all(agents.join("keep-me")).unwrap();
12774+
std::fs::write(
12775+
agents.join("trash-agent").join("agent.toml"),
12776+
"name = \"trash-agent\"\n",
12777+
)
12778+
.unwrap();
12779+
std::fs::write(
12780+
agents.join("keep-me").join("agent.toml"),
12781+
"name = \"keep-me\"\n",
12782+
)
12783+
.unwrap();
12784+
12785+
assert!(remove_agent_dir(&home, "trash-agent"));
12786+
assert!(!agents.join("trash-agent").exists());
12787+
assert!(
12788+
agents.join("keep-me").is_dir(),
12789+
"sibling agent dirs must not be touched by uninstall"
12790+
);
12791+
}
12792+
12793+
#[test]
12794+
fn rejects_path_traversal_attempt() {
12795+
// A name like "../escape" would join to a path whose parent is the
12796+
// agents root only if the file system resolves it that way — but
12797+
// `parent()` on a non-canonicalized Path returns the textual parent,
12798+
// which for `<home>/agents/../escape` is `<home>/agents/..`, not
12799+
// `<home>/agents`. The check rejects it.
12800+
let tmp = tempfile::tempdir().unwrap();
12801+
let home = tmp.path().to_path_buf();
12802+
std::fs::create_dir_all(home.join("agents")).unwrap();
12803+
// Create a sibling dir outside agents/ that an attacker might want
12804+
// to delete.
12805+
std::fs::create_dir_all(home.join("escape")).unwrap();
12806+
std::fs::write(home.join("escape").join("secret.toml"), "x = 1\n").unwrap();
12807+
12808+
let removed = remove_agent_dir(&home, "../escape");
12809+
assert!(!removed, "must reject path-traversal names");
12810+
assert!(
12811+
home.join("escape").is_dir(),
12812+
"sibling dir outside agents/ must NOT be deleted"
12813+
);
12814+
}
12815+
}

crates/openfang-api/src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ pub async fn build_router(
186186
.delete(routes::kill_agent)
187187
.patch(routes::patch_agent),
188188
)
189+
.route(
190+
"/api/agents/{id}/uninstall",
191+
axum::routing::delete(routes::uninstall_agent),
192+
)
189193
.route(
190194
"/api/agents/{id}/mode",
191195
axum::routing::put(routes::set_agent_mode),

crates/openfang-api/static/index_body.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,7 @@ <h3 style="color:var(--text-secondary);margin-bottom:4px">No Recent Activity</h3
580580
<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>
581581
</button>
582582
<button class="btn btn-danger btn-sm" @click="killAgent()">Stop</button>
583+
<button class="btn btn-danger btn-sm" @click="uninstallAgent()" title="Stop and remove agent files from workspace">Uninstall</button>
583584
</div>
584585
</div>
585586

@@ -988,6 +989,7 @@ <h3>
988989
<button class="btn btn-ghost" @click="cloneAgent(detailAgent)">Clone</button>
989990
<button class="btn btn-ghost" @click="clearHistory(detailAgent)">Clear History</button>
990991
<button class="btn btn-danger" @click="killAgent(detailAgent)">Stop</button>
992+
<button class="btn btn-danger" @click="uninstallAgent(detailAgent)" title="Stop and remove agent files from workspace">Uninstall</button>
991993
</div>
992994
</div>
993995

crates/openfang-api/static/js/pages/agents.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,29 @@ function agentsPage() {
376376
});
377377
},
378378

379+
// Issue #1163: uninstall an agent (kill + remove ~/.openfang/agents/<name>/).
380+
uninstallAgent(agent) {
381+
var self = this;
382+
OpenFangToast.confirm(
383+
'Uninstall Agent',
384+
'Uninstall agent "' + agent.name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
385+
async function() {
386+
try {
387+
var res = await OpenFangAPI.del('/api/agents/' + agent.id + '/uninstall');
388+
var msg = 'Agent "' + agent.name + '" uninstalled';
389+
if (res && res.dir_removed === false) {
390+
msg += ' (no on-disk files found)';
391+
}
392+
OpenFangToast.success(msg);
393+
self.showDetailModal = false;
394+
await Alpine.store('app').refreshAgents();
395+
} catch(e) {
396+
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
397+
}
398+
}
399+
);
400+
},
401+
379402
killAllAgents() {
380403
var list = this.filteredAgents;
381404
if (!list.length) return;

crates/openfang-api/static/js/pages/chat.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,37 @@ function chatPage() {
12121212
});
12131213
},
12141214

1215+
// Permanently uninstall the agent: kill + remove ~/.openfang/agents/<name>/
1216+
// Issue #1163.
1217+
uninstallAgent: function() {
1218+
if (!this.currentAgent) return;
1219+
var self = this;
1220+
var name = this.currentAgent.name;
1221+
var agentId = this.currentAgent.id;
1222+
OpenFangToast.confirm(
1223+
'Uninstall Agent',
1224+
'Uninstall agent "' + name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
1225+
async function() {
1226+
try {
1227+
var res = await OpenFangAPI.del('/api/agents/' + agentId + '/uninstall');
1228+
OpenFangAPI.wsDisconnect();
1229+
self._wsAgent = null;
1230+
self.currentAgent = null;
1231+
self.messages = [];
1232+
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
1233+
var msg = 'Agent "' + name + '" uninstalled';
1234+
if (res && res.dir_removed === false) {
1235+
msg += ' (no on-disk files found)';
1236+
}
1237+
OpenFangToast.success(msg);
1238+
Alpine.store('app').refreshAgents();
1239+
} catch(e) {
1240+
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
1241+
}
1242+
}
1243+
);
1244+
},
1245+
12151246
_latexTimer: null,
12161247
scrollToBottom() {
12171248
var self = this;

0 commit comments

Comments
 (0)