diff --git a/backend/__tests__/service/install.preserves-displayname.test.js b/backend/__tests__/service/install.preserves-displayname.test.js index 421926624d..cc1d1380f3 100644 --- a/backend/__tests__/service/install.preserves-displayname.test.js +++ b/backend/__tests__/service/install.preserves-displayname.test.js @@ -139,6 +139,50 @@ describe('Install endpoint preserves curated displayName (cycle-of-Aria regressi expect(profile.name).not.toBe('Cuz 🦞'); }); + it('does not promote a legacy runtime-shaped User displayName into either higher-precedence label', async () => { + await User.create({ + username: 'openclaw-nova', + email: 'openclaw-nova@bot.local', + password: 'bot-no-login', + isBot: true, + botMetadata: { + agentName: 'openclaw', + instanceId: 'nova', + // A historical writer stored the runtime rather than Nova’s label. + displayName: 'openclaw (nova)', + }, + }); + + const res = await request(app) + .post('/api/registry/install') + .set('Authorization', `Bearer ${installerToken}`) + .send({ + agentName: 'openclaw', + instanceId: 'nova', + podId: pod._id.toString(), + version: '1.0.0', + scopes: ['context:read', 'summaries:read', 'messages:write'], + }); + + expect(res.status).toBe(200); + + const installation = await AgentInstallation.findOne({ + podId: pod._id, + agentName: 'openclaw', + instanceId: 'nova', + }).lean(); + expect(installation.displayName).toBe('nova'); + expect(installation.displayName).not.toBe('openclaw (nova)'); + + const profile = await AgentProfile.findOne({ + podId: pod._id, + agentName: 'openclaw', + instanceId: 'nova', + }).lean(); + expect(profile.name).toBe('nova'); + expect(profile.name).not.toBe('openclaw (nova)'); + }); + it('respects explicit displayName in request body over both registry default AND existing User row', async () => { const res = await request(app) .post('/api/registry/install') diff --git a/backend/__tests__/unit/controllers/userController.test.js b/backend/__tests__/unit/controllers/userController.test.js index e4f71f0c19..b36a01fd69 100644 --- a/backend/__tests__/unit/controllers/userController.test.js +++ b/backend/__tests__/unit/controllers/userController.test.js @@ -1,9 +1,11 @@ jest.mock('../../../services/agentIdentityService', () => ({ syncUserToPostgreSQL: jest.fn().mockResolvedValue(undefined), + resolveAgentDisplayLabel: jest.fn((user, fallback) => user.botMetadata?.displayName || fallback), })); const User = require('../../../models/User'); const userController = require('../../../controllers/userController'); +const { resolveAgentDisplayLabel } = require('../../../services/agentIdentityService'); const mockUserDoc = (fields) => ({ ...fields, @@ -91,6 +93,26 @@ describe('User Controller', () => { expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ _id: 'u1', username: 'test' })); }); + it('returns the curated display label for an agent seat', async () => { + const mockUser = mockUserDoc({ + _id: 'u1', + username: 'vale', + isBot: true, + botMetadata: { displayName: 'Vale', agentName: 'openclaw', instanceId: 'vale' }, + }); + User.findById = jest.fn().mockReturnValue({ select: jest.fn().mockResolvedValueOnce(mockUser) }); + const req = { params: { id: 'u1' }, user: { id: 'viewer' } }; + const res = { json: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() }; + + await userController.getUserById(req, res); + + expect(resolveAgentDisplayLabel).toHaveBeenCalledWith(mockUser, 'vale'); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + username: 'vale', + displayName: 'Vale', + })); + }); + it('returns 404 if the user is not found', async () => { User.findById = jest.fn().mockReturnValue({ select: jest.fn().mockResolvedValueOnce(null) }); const req = { params: { id: 'missing' }, user: { id: 'viewer' } }; diff --git a/backend/__tests__/unit/routes/agentProfile.avatar.test.js b/backend/__tests__/unit/routes/agentProfile.avatar.test.js index 5cd7c8a27b..65cbb0366d 100644 --- a/backend/__tests__/unit/routes/agentProfile.avatar.test.js +++ b/backend/__tests__/unit/routes/agentProfile.avatar.test.js @@ -14,6 +14,7 @@ jest.mock('../../../models/User', () => ({ })); jest.mock('../../../models/Pod', () => ({})); jest.mock('../../../models/AgentMemory', () => ({})); +jest.mock('../../../models/AgentProfile', () => ({})); jest.mock('../../../models/AgentRun', () => ({})); jest.mock('../../../models/PodAsset', () => ({})); jest.mock('../../../models/pg/Message', () => ({})); diff --git a/backend/__tests__/unit/routes/agentProfile.displayLabel.test.js b/backend/__tests__/unit/routes/agentProfile.displayLabel.test.js new file mode 100644 index 0000000000..1eaede9f0f --- /dev/null +++ b/backend/__tests__/unit/routes/agentProfile.displayLabel.test.js @@ -0,0 +1,75 @@ +jest.mock('../../../models/User', () => ({ findOne: jest.fn() })); +jest.mock('../../../models/Pod', () => ({ find: jest.fn() })); +jest.mock('../../../models/AgentMemory', () => ({ findOne: jest.fn() })); +jest.mock('../../../models/AgentProfile', () => ({ find: jest.fn() })); +jest.mock('../../../models/AgentRun', () => ({ find: jest.fn() })); +jest.mock('../../../models/PodAsset', () => ({ find: jest.fn() })); +jest.mock('../../../models/pg/Message', () => ({ findMostRecentPodActivity: jest.fn() })); +jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { find: jest.fn() } })); +jest.mock('../../../services/agentIdentityService', () => ({ + resolveAgentDisplayLabel: jest.fn(() => 'runtime-seat'), +})); +jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => next())); + +const User = require('../../../models/User'); +const Pod = require('../../../models/Pod'); +const AgentMemory = require('../../../models/AgentMemory'); +const AgentProfile = require('../../../models/AgentProfile'); +const AgentRun = require('../../../models/AgentRun'); +const PodAsset = require('../../../models/PodAsset'); +const PGMessage = require('../../../models/pg/Message'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { resolveAgentDisplayLabel } = require('../../../services/agentIdentityService'); +const router = require('../../../routes/agentProfile'); + +const getHandler = (method, path) => { + const layer = router.stack.find((entry) => ( + entry.route && entry.route.path === path && entry.route.methods[method] + )); + if (!layer) throw new Error(`${method.toUpperCase()} ${path} handler not found`); + return layer.route.stack[layer.route.stack.length - 1].handle; +}; + +const response = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); +const leanResult = (value) => ({ lean: jest.fn().mockResolvedValue(value) }); +const selectLeanResult = (value) => ({ select: jest.fn().mockReturnValue(leanResult(value)) }); + +const getProfile = getHandler('get', '/:agentName/:instanceId?'); + +describe('public agent profile display label', () => { + beforeEach(() => { + jest.clearAllMocks(); + User.findOne.mockReturnValue(selectLeanResult({ + username: 'vale', + profilePicture: 'default', + isBot: true, + // `agentName` is the legacy seat identifier. It must not become the + // public runtime badge when the dedicated `runtimeId` is absent. + botMetadata: { agentName: 'vale', instanceId: 'vale' }, + agentConfig: {}, + createdAt: new Date(), + })); + PodAsset.find.mockReturnValue(selectLeanResult([])); + AgentInstallation.find.mockReturnValue({ + sort: jest.fn().mockReturnValue(selectLeanResult([{ podId: 'pod-1', displayName: 'Vale install' }])), + }); + AgentProfile.find.mockReturnValue(selectLeanResult([{ podId: 'pod-1', name: 'Vale' }])); + Pod.find.mockReturnValue(selectLeanResult([])); + AgentMemory.findOne.mockReturnValue(selectLeanResult(null)); + AgentRun.find.mockReturnValue({ + sort: jest.fn().mockReturnValue({ limit: jest.fn().mockReturnValue(selectLeanResult([])) }), + }); + PGMessage.findMostRecentPodActivity.mockResolvedValue([]); + }); + + it('prefers the active pod profile label over the raw runtime seat username', async () => { + const res = response(); + + await getProfile({ params: { agentName: 'openclaw', instanceId: 'vale' } }, res); + + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + agent: expect.objectContaining({ displayName: 'Vale', runtime: null }), + })); + expect(resolveAgentDisplayLabel).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/controllers/userController.ts b/backend/controllers/userController.ts index 8305e8f686..3e0899e30e 100644 --- a/backend/controllers/userController.ts +++ b/backend/controllers/userController.ts @@ -60,8 +60,18 @@ const toSocialProfile = (userDoc: any, viewerId: any = null) => { for (const field of PRIVATE_USER_FIELDS) delete plain[field]; } + // Agent User rows keep their durable seat username for authentication and + // routing (for example, `vale`). The social surface must use the same + // curated identity label as the rest of the product (for example, `Vale`). + // Humans intentionally retain their username: their display label is not a + // separate identity field. + const displayName = userDoc.isBot + ? AgentIdentityService.resolveAgentDisplayLabel(userDoc, userDoc.username) + : userDoc.username; + return { ...plain, + displayName, followersCount: followers.length, followingCount: following.length, followedThreadsCount: Array.isArray(userDoc.followedThreads) ? userDoc.followedThreads.length : 0, diff --git a/backend/routes/agentProfile.ts b/backend/routes/agentProfile.ts index 92de49a917..66eb91f9a1 100644 --- a/backend/routes/agentProfile.ts +++ b/backend/routes/agentProfile.ts @@ -31,6 +31,8 @@ const Pod = require('../models/Pod'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { AgentInstallation } = require('../models/AgentRegistry'); // eslint-disable-next-line @typescript-eslint/no-require-imports +const AgentProfile = require('../models/AgentProfile'); +// eslint-disable-next-line @typescript-eslint/no-require-imports const AgentMemory = require('../models/AgentMemory'); // eslint-disable-next-line @typescript-eslint/no-require-imports const AgentRun = require('../models/AgentRun'); @@ -129,11 +131,40 @@ router.get('/:agentName/:instanceId?', async (req: Req, res: Res) => { instanceId, status: 'active', }) - .select('podId') + // A public profile is identity-wide, while these two label fields are + // pod-scoped. Sort so the first active attachment is deterministic; its + // profile label has the same precedence as the Your Team payload. + .sort({ createdAt: 1, _id: 1 }) + .select('podId displayName') .lean(); const ownerPodIds = (installs as Array<{ podId?: { toString(): string } }>) .map((i) => (i?.podId ? String(i.podId) : '')) .filter(Boolean); + // AgentProfile.name and AgentInstallation.displayName are the curated + // labels written at install time. The User row's username is a stable + // runtime seat identifier, not necessarily a human-facing name. Pair each + // profile with its active installation so a stale, detached profile cannot + // affect this public identity card. + const scopedProfiles = ownerPodIds.length + ? await AgentProfile.find({ + podId: { $in: ownerPodIds }, + agentName, + instanceId, + status: 'active', + }).select('podId name').lean() + : []; + const profileNameByPodId = new Map( + (scopedProfiles as Array<{ podId?: unknown; name?: string }>).map((profile) => [ + String(profile.podId), + typeof profile.name === 'string' ? profile.name.trim() : '', + ]), + ); + const scopedDisplayName = (installs as Array<{ podId?: unknown; displayName?: string }>) + .map((installation) => ( + profileNameByPodId.get(String(installation.podId)) + || (typeof installation.displayName === 'string' ? installation.displayName.trim() : '') + )) + .find(Boolean); const publicPodDocs = ownerPodIds.length ? await Pod.find({ _id: { $in: ownerPodIds }, publicRead: true }).select('name').lean() : []; @@ -198,9 +229,11 @@ router.get('/:agentName/:instanceId?', async (req: Req, res: Res) => { agent: { agentName, instanceId, - displayName: resolveAgentDisplayLabel(user, user.username), + displayName: scopedDisplayName || resolveAgentDisplayLabel(user, user.username), profilePicture: user.profilePicture || 'default', - runtime: bm.runtimeId ? String(bm.runtimeId) : (bm.agentName ? String(bm.agentName) : null), + // agentName is a legacy identity field, not a runtime descriptor. Falling + // back to it rendered the raw seat name as a misleading runtime badge. + runtime: bm.runtimeId ? String(bm.runtimeId) : null, officialAgent: !!bm.officialAgent, description: bm.description ? String(bm.description) : undefined, capabilities: Array.isArray(bm.capabilities) ? (bm.capabilities as string[]) : [], diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index 6c4adbb0da..3b7b4e5568 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -396,7 +396,10 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any // AND AgentProfile.name — and the V2 member list reads AgentProfile FIRST, // so users see "Cuz" on every member row even though the underlying // identity has the right name. Order: explicit caller intent > existing - // identity > registry default. + // identity > registry default. Resolve the existing identity through the + // same leak guard every display surface uses: historical rows may contain + // a runtime-shaped displayName such as "openclaw (nova)", which must not + // become a higher-precedence installation or profile label. let effectiveDisplayName: string = displayName || ''; if (!effectiveDisplayName) { try { @@ -404,9 +407,9 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any 'botMetadata.agentName': agent.agentName, 'botMetadata.instanceId': normalizedInstanceId, isBot: true, - }).select('botMetadata.displayName').lean(); + }).select('username botMetadata.displayName botMetadata.agentName botMetadata.instanceId').lean(); if (existingAgentUser?.botMetadata?.displayName) { - effectiveDisplayName = String(existingAgentUser.botMetadata.displayName); + effectiveDisplayName = AgentIdentityService.resolveAgentDisplayLabel(existingAgentUser, ''); } } catch (lookupErr) { // Non-fatal — fall through to registry default below. diff --git a/frontend/src/components/UserProfile.test.tsx b/frontend/src/components/UserProfile.test.tsx index 9019238496..b4ed1dd921 100644 --- a/frontend/src/components/UserProfile.test.tsx +++ b/frontend/src/components/UserProfile.test.tsx @@ -14,6 +14,12 @@ jest.mock('axios', () => ({ })); jest.mock('../context/AppContext', () => ({ useAppContext: jest.fn() })); jest.mock('../context/AuthContext', () => ({ useAuth: jest.fn() })); +jest.mock('../utils/avatarUtils', () => ({ + avatarOptions: [], + getAvatarColor: jest.fn(() => '#000'), + getAvatarSrc: jest.fn(() => undefined), + presetFaceOptions: jest.fn(() => []), +})); jest.mock('./admin/AdminUsers', () => () => null); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -40,9 +46,9 @@ afterEach(() => { localStorage.clear(); }); -async function renderProfile() { +async function renderProfile(profile = { _id: 'u', username: 'user', email: 'e@example.com', createdAt: '2023-01-01', profilePicture: 'default' }) { axios.get - .mockResolvedValueOnce({ data: { _id: 'u', username: 'user', email: 'e@example.com', createdAt: '2023-01-01', profilePicture: 'default' } }) + .mockResolvedValueOnce({ data: profile }) .mockResolvedValueOnce({ data: [] }) .mockResolvedValueOnce({ data: { hasToken: false } }) .mockResolvedValueOnce({ data: { recentPublicPosts: [], joinedPods: [] } }); @@ -62,6 +68,19 @@ test('displays user info after fetch', async () => { expect(container.textContent).toContain('user'); }); +test('uses an agent display label instead of its raw seat username', async () => { + await renderProfile({ + _id: 'u', + username: 'vale', + displayName: 'Vale', + email: 'vale@agents.commonly.local', + createdAt: '2023-01-01', + profilePicture: 'default', + }); + + expect(container.querySelector('h4')?.textContent).toBe('Vale'); +}); + test('shows error when fetch fails', async () => { axios.get.mockRejectedValue(new Error('fail')); await TestUtils.act(async () => { diff --git a/frontend/src/components/UserProfile.tsx b/frontend/src/components/UserProfile.tsx index 64b9c0afd6..1f9f4e9637 100644 --- a/frontend/src/components/UserProfile.tsx +++ b/frontend/src/components/UserProfile.tsx @@ -46,6 +46,7 @@ import { useV2Embedded } from '../v2/hooks/useV2Embedded'; interface ProfileUser { _id: string; username: string; + displayName?: string; email: string; role?: string; profilePicture?: string; @@ -384,6 +385,7 @@ const UserProfile = () => { ); const isOwnProfile = !profileId || (currentUser && currentUser._id === user._id); const isSelectedAvatarColor = avatarOptions.some((avatar) => avatar.id === selectedAvatar); + const profileDisplayName = user.displayName || user.username; return ( @@ -402,7 +404,7 @@ const UserProfile = () => { }} src={getAvatarSrc(user.profilePicture)} > - {user.username.charAt(0).toUpperCase()} + {profileDisplayName.charAt(0).toUpperCase()} {isOwnProfile && ( { and keep the secondary metadata only. */} {!v2Embedded && ( - {user.username} + {profileDisplayName} )} {v2Embedded && ( - {user.username} + {profileDisplayName} )} @@ -827,7 +829,7 @@ const UserProfile = () => { }} src={avatarPreview || (isSelectedAvatarColor ? undefined : getAvatarSrc(selectedAvatar)) || undefined} > - {user.username.charAt(0).toUpperCase()} + {profileDisplayName.charAt(0).toUpperCase()}