diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index dfacd937b0d7..49c64311f91a 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -846,6 +846,22 @@ describe("gateway session utils", () => { ).toBeNull(); }); + test("resolveDeletedAgentIdFromSessionKey rejects deleted configured ACP binding owners", () => { + const cfg = { + agents: { list: [{ id: "main", default: true }] }, + } as OpenClawConfig; + + expect( + resolveDeletedAgentIdFromSessionKey( + cfg, + "agent:deleted-agent:acp:binding:discord:default:feedface", + ), + ).toBe("deleted-agent"); + expect( + resolveDeletedAgentIdFromSessionKey(cfg, "agent:main:acp:binding:discord:default:feedface"), + ).toBeNull(); + }); + test("resolveSessionStoreKey canonicalizes bare keys to default agent", () => { const cfg = { session: { mainKey: "main" }, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 0ffbcfcabd4d..40069107f489 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -922,14 +922,15 @@ export function resolveDeletedAgentIdFromSessionKey( cfg: OpenClawConfig, sessionKey: string, ): string | null { - // ACP keys use agent::acp:; harness ids are not agents.list entries. - if (isAcpSessionKey(sessionKey)) { - return null; - } const parsed = parseAgentSessionKey(sessionKey); if (!parsed) { return null; } + // Free ACP spawn keys use agent::acp:, but configured ACP + // bindings use agent::acp:binding:* where agentId remains the owner. + if (isAcpSessionKey(sessionKey) && !parsed.rest.startsWith("acp:binding:")) { + return null; + } const agentId = normalizeAgentId(parsed.agentId); if (listAgentIds(cfg).includes(agentId)) { return null; diff --git a/src/gateway/sessions-resolve-store.test.ts b/src/gateway/sessions-resolve-store.test.ts index ca09a611587c..03f64c7fab82 100644 --- a/src/gateway/sessions-resolve-store.test.ts +++ b/src/gateway/sessions-resolve-store.test.ts @@ -248,6 +248,51 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => { }); }); + it("rejects configured ACP binding sessions when their owning agent is deleted", async () => { + await withStateDirEnv("openclaw-sessions-resolve-acp-binding-deleted-", async () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }] }, + }; + const acpBindingKey = "agent:deleted-agent:acp:binding:discord:default:feedface"; + const deletedStorePath = resolveStorePath(cfg.session?.store, { agentId: "deleted-agent" }); + await saveSessionStore(deletedStorePath, { + [acpBindingKey]: { + sessionId: "sess-acp-binding-deleted", + label: "deleted-binding", + updatedAt: freshUpdatedAt(), + }, + }); + const expected = { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: 'Agent "deleted-agent" no longer exists in configuration', + }, + }; + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { key: acpBindingKey }, + }), + ).resolves.toEqual(expected); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { sessionId: "sess-acp-binding-deleted" }, + }), + ).resolves.toEqual(expected); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { label: "deleted-binding" }, + }), + ).resolves.toEqual(expected); + }); + }); + it("rejects an explicit listed deleted main key instead of remapping to the live default main", async () => { await withStateDirEnv("openclaw-sessions-resolve-key-deleted-main-", async () => { const cfg: OpenClawConfig = {