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
44 changes: 44 additions & 0 deletions backend/__tests__/service/install.preserves-displayname.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
22 changes: 22 additions & 0 deletions backend/__tests__/unit/controllers/userController.test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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' } };
Expand Down
1 change: 1 addition & 0 deletions backend/__tests__/unit/routes/agentProfile.avatar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({}));
Expand Down
75 changes: 75 additions & 0 deletions backend/__tests__/unit/routes/agentProfile.displayLabel.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
10 changes: 10 additions & 0 deletions backend/controllers/userController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 36 additions & 3 deletions backend/routes/agentProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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()
: [];
Expand Down Expand Up @@ -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[]) : [],
Expand Down
9 changes: 6 additions & 3 deletions backend/routes/registry/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,17 +396,20 @@ 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 {
const existingAgentUser = await User.findOne({
'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.
Expand Down
23 changes: 21 additions & 2 deletions frontend/src/components/UserProfile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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: [] } });
Expand All @@ -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 () => {
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/UserProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { useV2Embedded } from '../v2/hooks/useV2Embedded';
interface ProfileUser {
_id: string;
username: string;
displayName?: string;
email: string;
role?: string;
profilePicture?: string;
Expand Down Expand Up @@ -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 (
<Box sx={{ maxWidth: 1000, mx: 'auto', p: { xs: 2, md: 4 }, mt: { xs: 2, md: 6 } }}>
Expand All @@ -402,7 +404,7 @@ const UserProfile = () => {
}}
src={getAvatarSrc(user.profilePicture)}
>
{user.username.charAt(0).toUpperCase()}
{profileDisplayName.charAt(0).toUpperCase()}
</Avatar>
{isOwnProfile && (
<IconButton
Expand All @@ -428,12 +430,12 @@ const UserProfile = () => {
and keep the secondary metadata only. */}
{!v2Embedded && (
<Typography variant="h4" gutterBottom>
{user.username}
{profileDisplayName}
</Typography>
)}
{v2Embedded && (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{user.username}
{profileDisplayName}
</Typography>
)}
<Typography variant="body2" color="text.secondary">
Expand Down Expand Up @@ -827,7 +829,7 @@ const UserProfile = () => {
}}
src={avatarPreview || (isSelectedAvatarColor ? undefined : getAvatarSrc(selectedAvatar)) || undefined}
>
{user.username.charAt(0).toUpperCase()}
{profileDisplayName.charAt(0).toUpperCase()}
</Avatar>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
Expand Down
Loading