@@ -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+ }
0 commit comments