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 @@ -290,6 +290,104 @@ describe('agent config PATCH — installer gate', () => {
expect(primary.save).not.toHaveBeenCalled();
});

it('refuses a name shown on another owned agent card even when its bot label differs', async () => {
const primary = installation({ installedBy: MEMBER, displayName: 'Curator' });
const other = installation({
agentName: 'guide', instanceId: 'u1234567890', podId: 'pod-2',
installedBy: MEMBER, displayName: 'uda599d9d45',
});
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValueOnce([primary]).mockResolvedValueOnce([primary, other]);

const res = await request(app)
.patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', displayName: ' UDA599D9D45 ' });

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(AgentProfile.updateMany).not.toHaveBeenCalled();
});

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({
agentName: 'guide', instanceId: 'another', podId: 'pod-2',
installedBy: INSTALLER, displayName: 'Scout',
});
setCallerRole('admin');
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValueOnce([primary]).mockResolvedValueOnce([primary, other]);

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(AgentInstallation.find).toHaveBeenLastCalledWith({
installedBy: { $in: [INSTALLER] }, status: 'active',
});
expect(primary.displayName).toBe('Curator');
expect(primary.save).not.toHaveBeenCalled();
expect(AgentIdentityService.renameAgentDisplayName).not.toHaveBeenCalled();
expect(AgentProfile.updateMany).not.toHaveBeenCalled();
});

it('checks every affected installer when an admin renames a shared identity', async () => {
const primary = installation({ installedBy: INSTALLER, displayName: 'Curator' });
const peer = installation({ podId: 'pod-2', installedBy: 'other-installer', displayName: 'Curator' });
const other = installation({
agentName: 'guide', instanceId: 'another', podId: 'pod-3',
installedBy: 'other-installer', displayName: 'Scout',
});
setCallerRole('admin');
setPod();
Pod.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([
{ _id: 'pod-1', members: [{ userId: MEMBER }] },
{ _id: 'pod-2', members: [{ userId: MEMBER }] },
]) }) });
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValueOnce([primary, peer]).mockResolvedValueOnce([primary, peer, other]);

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(AgentInstallation.find).toHaveBeenLastCalledWith({
installedBy: { $in: [INSTALLER, 'other-installer'] }, status: 'active',
});
expect(primary.save).not.toHaveBeenCalled();
expect(peer.save).not.toHaveBeenCalled();
expect(AgentIdentityService.renameAgentDisplayName).not.toHaveBeenCalled();
expect(AgentProfile.updateMany).not.toHaveBeenCalled();
});

it('does not treat another pod for the same identity as a name collision', async () => {
const primary = installation({ installedBy: MEMBER, displayName: 'Curator' });
const sibling = installation({ podId: 'pod-2', installedBy: MEMBER, displayName: 'New name' });
setPod();
Pod.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([
{ _id: 'pod-1', members: [{ userId: MEMBER }] },
{ _id: 'pod-2', members: [{ userId: MEMBER }] },
]) }) });
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValueOnce([primary, sibling]).mockResolvedValueOnce([primary, sibling]);

const res = await request(app)
.patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', displayName: 'New name' });

expect(res.status).toBe(200);
expect(primary.displayName).toBe('New name');
expect(sibling.displayName).toBe('New name');
});

it('does not claim a completed rename when the chat mirror is behind', async () => {
const primary = installation({ installedBy: MEMBER });
setPod();
Expand Down
23 changes: 23 additions & 0 deletions backend/routes/registry/agent-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,29 @@ 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.
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',
});
const nameTaken = ownerInstallations.some((entry: any) => (
!(entry.agentName === name.toLowerCase()
&& (entry.instanceId || 'default') === normalizedInstanceId)
&& String(entry.displayName || entry.agentName || '').trim().toLowerCase() === requestedName
));
if (nameTaken) {
return res.status(409).json({
code: 'agent_display_name_taken',
error: 'Another agent already uses this name',
});
}
}
// Decide for every fan-out target before saving any row. A runtime object
// without runtimeType also removes an existing native declaration here.
if (config?.runtime !== undefined || status === 'active') {
Expand Down
Loading