Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 6 additions & 4 deletions backend/routes/registry/agent-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading