From 294111d362c1c7fa287cfe6eaa40b340dad25be8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:20:45 -0700 Subject: [PATCH] fix(agents): refuse a rename that collides with any active card in the pod The owner-scoped label check only read cards installed by the affected installers, so another owner's card in a shared pod could take the name. Check every active installation in the pods the rename reaches, plus the affected installers' own cards elsewhere. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: tern Agent-Model: claude/claude-haiku-5-5/default --- ...gistry.agent-config-installer-gate.test.js | 30 +++++++++++++++++-- backend/routes/registry/agent-config.ts | 10 ++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js index c33d9d634a..e663edcad2 100644 --- a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js +++ b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js @@ -512,6 +512,30 @@ describe('agent config PATCH — installer gate', () => { expect(AgentProfile.updateMany).not.toHaveBeenCalled(); }); + it('refuses a rename that collides with another installer\'s card in the same pod', async () => { + const primary = installation({ installedBy: MEMBER, displayName: 'Curator' }); + const otherOwnersCard = installation({ + agentName: 'guide', instanceId: 'u1234567890', podId: 'pod-1', + installedBy: INSTALLER, displayName: 'Scout', + }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValueOnce([primary]).mockResolvedValueOnce([primary, otherOwnersCard]); + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'scout' }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('agent_display_name_taken'); + expect(AgentIdentityService.renameAgentDisplayName).not.toHaveBeenCalled(); + expect(primary.save).not.toHaveBeenCalled(); + expect(AgentInstallation.find).toHaveBeenLastCalledWith({ + $or: [{ podId: { $in: ['pod-1'] } }, { installedBy: { $in: [MEMBER] } }], + status: 'active', + }); + }); + it('refuses an admin rename that collides with the target installer\'s visible card', async () => { const primary = installation({ installedBy: INSTALLER, displayName: 'Curator' }); const other = installation({ @@ -530,7 +554,8 @@ describe('agent config PATCH — installer gate', () => { expect(res.status).toBe(409); expect(res.body.code).toBe('agent_display_name_taken'); expect(AgentInstallation.find).toHaveBeenLastCalledWith({ - installedBy: { $in: [INSTALLER] }, status: 'active', + $or: [{ podId: { $in: ['pod-1'] } }, { installedBy: { $in: [INSTALLER] } }], + status: 'active', }); expect(primary.displayName).toBe('Curator'); expect(primary.save).not.toHaveBeenCalled(); @@ -561,7 +586,8 @@ describe('agent config PATCH — installer gate', () => { expect(res.status).toBe(409); expect(res.body.code).toBe('agent_display_name_taken'); expect(AgentInstallation.find).toHaveBeenLastCalledWith({ - installedBy: { $in: [INSTALLER, 'other-installer'] }, status: 'active', + $or: [{ podId: { $in: ['pod-1', 'pod-2'] } }, { installedBy: { $in: [INSTALLER, 'other-installer'] } }], + status: 'active', }); expect(primary.save).not.toHaveBeenCalled(); expect(peer.save).not.toHaveBeenCalled(); diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts index eb6ac5c525..ec5d61249d 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -349,16 +349,18 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: const { status: refusalStatus, ...refusalBody } = writeRefusal; return res.status(refusalStatus).json(refusalBody); } - // An installation can show a different label from its bot User. Guard - // every affected installer's active cards, including when an admin edits - // someone else's agent, before renaming the shared identity. + // An installation can show a different label from its bot User, and a pod + // lists every installer's card side by side. Guard every active card in + // each pod the rename reaches, whoever installed it, plus every card the + // affected installers hold elsewhere, before renaming the shared identity. if (displayName !== undefined) { const requestedName = displayName.trim().toLowerCase(); const installerIds = Array.from(new Set(installationsToUpdate .map((entry: any) => entry.installedBy?.toString?.()) .filter(Boolean))); const ownerInstallations = await AgentInstallation.find({ - installedBy: { $in: installerIds }, status: 'active', + $or: [{ podId: { $in: writablePodIds } }, { installedBy: { $in: installerIds } }], + status: 'active', }); const nameTaken = ownerInstallations.some((entry: any) => ( !(entry.agentName === name.toLowerCase()