From 203024eb33ee1b36033b810fb37e99f54629aedb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 01:03:38 -0700 Subject: [PATCH] fix(agents): preserve survivor-owned databases offline (#129085) Prepare surviving agent-directory ownership before filtering journaled SQLite paths, addressing the post-merge review of #129017. Refs #129014 --- src/commands/agents.commands.delete.ts | 12 +++++++++++- src/commands/agents.delete.test.ts | 20 +++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts index 1e93eba0307f..d6b6414153a0 100644 --- a/src/commands/agents.commands.delete.ts +++ b/src/commands/agents.commands.delete.ts @@ -5,12 +5,16 @@ import { formatSharedAuthStoreOwnerDeleteError, isSharedAuthStoreOwner, } from "../agents/agent-delete-safety.js"; -import { normalizeAgentDirRegistryPath } from "../agents/agent-dir-registry.js"; +import { + isPathOwnedByAnotherRegisteredAgent, + normalizeAgentDirRegistryPath, +} from "../agents/agent-dir-registry.js"; import { beginAgentDeletion, claimCompletedAgentDeletion, } from "../agents/agent-lifecycle-registry.js"; import { + listAgentIds, resolveAgentDir, resolveAgentWorkspaceDir, tryResolveSoleAgentId, @@ -358,6 +362,11 @@ export async function agentsDeleteCommand( } } if (deleteFiles) { + // Directory ownership is process-local; prepare every survivor before filtering journal paths, + // or a deleted agent's database nested beneath a survivor's agentDir could be trashed. + for (const survivingAgentId of listAgentIds(result.config)) { + resolveAgentDir(result.config, survivingAgentId); + } const canonicalAgentDir = normalizeAgentDirRegistryPath(agentDir); const survivingDatabasePaths = new Set( listOpenClawRegisteredAgentDatabases() @@ -370,6 +379,7 @@ export async function agentsDeleteCommand( return ( !isPathInside(canonicalAgentDir, canonicalPath) && !survivingDatabasePaths.has(canonicalPath) && + !isPathOwnedByAnotherRegisteredAgent({ agentId, pathname }) && findOverlappingWorkspaceAgentIds(result.config, agentId, canonicalPath).length === 0 ); }); diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index be090dd21f36..fe6d3897b1b1 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -675,10 +675,15 @@ describe("agents delete command", () => { it("deregisters the agent database after offline deletion", async () => { await withStateDirEnv("openclaw-agents-delete-registry-", async ({ tempRoot, stateDir }) => { + const mainAgentDir = path.join(tempRoot, "main-agent"); const cfg: OpenClawConfig = { agents: { list: [ - { id: "main", workspace: path.join(stateDir, "workspace-main") }, + { + id: "main", + agentDir: mainAgentDir, + workspace: path.join(stateDir, "workspace-main"), + }, { id: "ops", workspace: path.join(stateDir, "workspace-ops") }, ], }, @@ -687,8 +692,10 @@ describe("agents delete command", () => { const databasePath = path.join(stateDir, "agents", "ops", "agent", "openclaw-agent.sqlite"); const externalDatabaseDir = path.join(tempRoot, "external-databases"); await fs.mkdir(externalDatabaseDir); + await fs.mkdir(mainAgentDir); const externalDatabasePath = path.join(externalDatabaseDir, "ops.sqlite"); const sharedDatabasePath = path.join(externalDatabaseDir, "shared.sqlite"); + const survivorOwnedDatabasePath = path.join(mainAgentDir, "ops.sqlite"); const externalDatabasePaths = [ externalDatabasePath, `${externalDatabasePath}-wal`, @@ -696,14 +703,16 @@ describe("agents delete command", () => { `${externalDatabasePath}-journal`, ]; await Promise.all( - [...externalDatabasePaths, sharedDatabasePath].map((sqlitePath) => - fs.writeFile(sqlitePath, ""), + [...externalDatabasePaths, sharedDatabasePath, survivorOwnedDatabasePath].map( + (sqlitePath) => fs.writeFile(sqlitePath, ""), ), ); const canonicalExternalDatabaseDir = await fs.realpath(externalDatabaseDir); + const canonicalMainAgentDir = await fs.realpath(mainAgentDir); registerOpenClawAgentDatabase({ agentId: "ops", path: databasePath }); registerOpenClawAgentDatabase({ agentId: "ops", path: externalDatabasePath }); registerOpenClawAgentDatabase({ agentId: "ops", path: sharedDatabasePath }); + registerOpenClawAgentDatabase({ agentId: "ops", path: survivorOwnedDatabasePath }); registerOpenClawAgentDatabase({ agentId: "main", path: sharedDatabasePath }); recordAgentProvenance("ops", { createdVia: "operator" }); recordAgentProvenance("child", { createdVia: "agent", creatorAgentId: "ops" }); @@ -726,6 +735,11 @@ describe("agents delete command", () => { path.join(canonicalExternalDatabaseDir, path.basename(sharedDatabasePath)), expect.anything(), ); + expect(fsSafeMocks.movePathToTrash).not.toHaveBeenCalledWith( + path.join(canonicalMainAgentDir, path.basename(survivorOwnedDatabasePath)), + expect.anything(), + ); + expect((await fs.stat(survivorOwnedDatabasePath)).isFile()).toBe(true); const registeredDatabases = listOpenClawRegisteredAgentDatabases(); expect(registeredDatabases.map((entry) => entry.agentId)).not.toContain("ops"); expect(registeredDatabases).toEqual(