From 140fb71d92a83b8e034571e8051f2569e8e42987 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:44:19 -0700 Subject: [PATCH 01/16] feat(agents): edit per-agent identity in settings Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- ...gistry.agent-config-installer-gate.test.js | 66 ++++++++++ ...tIdentityService.collisionResolver.test.js | 33 ++++- backend/routes/registry/agent-config.ts | 36 +++++- backend/services/agentIdentityService.ts | 20 ++++ frontend/src/i18n/locales/en.json | 14 +++ frontend/src/i18n/locales/zh-CN.json | 14 +++ .../v2/__tests__/V2AgentSettingsPage.test.tsx | 27 +++++ .../v2/__tests__/v2-layout-invariants.test.ts | 9 ++ .../src/v2/agents/V2AgentIdentitySection.tsx | 113 ++++++++++++++++++ .../src/v2/agents/V2AgentSettingsPage.tsx | 2 + frontend/src/v2/agents/useAgentSettings.ts | 6 +- frontend/src/v2/agents/v2-agent-settings.css | 13 ++ 12 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 frontend/src/v2/agents/V2AgentIdentitySection.tsx 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 44039bb2a6..05256c31c4 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 @@ -38,11 +38,17 @@ jest.mock('../../../models/User', () => ({ jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), })); +jest.mock('../../../services/runtimeIdentityOwnership', () => ({ + ...jest.requireActual('../../../services/runtimeIdentityOwnership'), + ownsRuntimeIdentity: jest.fn().mockResolvedValue({ owned: true }), +})); const Pod = require('../../../models/Pod'); const { AgentInstallation } = require('../../../models/AgentRegistry'); const AgentProfile = require('../../../models/AgentProfile'); const User = require('../../../models/User'); +const AgentIdentityService = require('../../../services/agentIdentityService'); +const { ownsRuntimeIdentity } = require('../../../services/runtimeIdentityOwnership'); const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); const registryRoutes = require('../../../routes/registry'); @@ -89,9 +95,12 @@ const setPod = (over = {}) => { describe('agent config PATCH — installer gate', () => { beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(AgentIdentityService, 'renameAgentDisplayName').mockImplementation(async (_name, _instanceId, label) => ({ displayName: label, mirrorSynced: true })); + ownsRuntimeIdentity.mockResolvedValue({ owned: true }); podHasOutsiderWritableGrant.mockResolvedValue(false); setCallerRole('user'); }); + afterEach(() => jest.restoreAllMocks()); it('refuses a non-admin runtime switch before saving any fan-out row', async () => { podHasOutsiderWritableGrant.mockResolvedValue(true); @@ -199,6 +208,63 @@ describe('agent config PATCH — installer gate', () => { expect(res.body.fields).toEqual(['status', 'displayName']); expect(primary.save).not.toHaveBeenCalled(); expect(AgentProfile.updateMany).not.toHaveBeenCalled(); + expect(AgentIdentityService.renameAgentDisplayName).not.toHaveBeenCalled(); + }); + + it('persists identity edits in the installation, profile and portable User label', async () => { + const primary = installation({ installedBy: MEMBER, displayName: 'Old name' }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + const persona = { tone: 'technical', specialties: ['research'], boundaries: [], customInstructions: 'Be precise' }; + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'New name', instructions: 'Help the pod', persona }); + + expect(res.status).toBe(200); + expect(AgentIdentityService.renameAgentDisplayName).toHaveBeenCalledWith('openclaw', 'curator', 'New name'); + expect(primary.displayName).toBe('New name'); + expect(primary.save).toHaveBeenCalledTimes(1); + expect(AgentProfile.updateMany).toHaveBeenCalledWith( + { agentId: 'openclaw:curator', podId: { $in: ['pod-1'] } }, + expect.objectContaining({ name: 'New name', instructions: 'Help the pod', persona }), + ); + }); + + it('refuses an identity rename by an installer who shares that identity', async () => { + const primary = installation({ installedBy: MEMBER }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + ownsRuntimeIdentity.mockResolvedValue({ owned: false, failure: 'another_installer' }); + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'Wrong owner' }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('another_installer'); + expect(primary.save).not.toHaveBeenCalled(); + expect(AgentIdentityService.renameAgentDisplayName).not.toHaveBeenCalled(); + }); + + it('does not claim a completed rename when the chat mirror is behind', async () => { + const primary = installation({ installedBy: MEMBER }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + AgentIdentityService.renameAgentDisplayName.mockResolvedValue({ + displayName: 'New name', mirrorSynced: false, + }); + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'New name' }); + + expect(res.status).toBe(503); + expect(primary.displayName).toBe('New name'); + expect(primary.save).toHaveBeenCalledTimes(1); }); it('lets the installer write config', async () => { diff --git a/backend/__tests__/unit/services/agentIdentityService.collisionResolver.test.js b/backend/__tests__/unit/services/agentIdentityService.collisionResolver.test.js index 492a48f8ef..28a32e2f20 100644 --- a/backend/__tests__/unit/services/agentIdentityService.collisionResolver.test.js +++ b/backend/__tests__/unit/services/agentIdentityService.collisionResolver.test.js @@ -34,7 +34,12 @@ jest.mock('../../../models/User', () => { // agent user with `.select('+apiToken')`. The mock therefore has to be a // thenable query builder, not a bare promise. const findOneResult = (query) => { - if (mockExisting && query.username === mockExisting.username) { + if (mockExisting && query.username === mockExisting.username + && (query.isBot === undefined || query.isBot === mockExisting.isBot) + && (query['botMetadata.agentName'] === undefined + || query['botMetadata.agentName'] === mockExisting.botMetadata?.agentName) + && (query['botMetadata.instanceId'] === undefined + || query['botMetadata.instanceId'] === mockExisting.botMetadata?.instanceId)) { const doc = JSON.parse(JSON.stringify(mockExisting)); doc.save = async function save() { mockSaved.push(JSON.parse(JSON.stringify(this))); @@ -83,6 +88,32 @@ const reset = () => { }; describe('inline displayName collision resolver (sticky dedup)', () => { + test('an explicit rename persists on the portable bot User', async () => { + reset(); + mockExisting = { + _id: 'curator-user', username: 'my-private-seat', isBot: true, + botMetadata: { agentName: 'my-private', instanceId: 'seat', displayName: 'Old name' }, + }; + const mirror = jest.spyOn(AgentIdentityService, 'syncUserToPostgreSQL').mockResolvedValue(true); + const result = await AgentIdentityService.renameAgentDisplayName('my-private', 'seat', 'New name'); + expect(result).toEqual({ displayName: 'New name', mirrorSynced: true }); + expect(mirror).toHaveBeenCalledWith(expect.objectContaining({ botMetadata: expect.objectContaining({ displayName: 'New name' }) })); + mirror.mockRestore(); + expect(mockSaved[0].botMetadata).toEqual(expect.objectContaining({ + agentName: 'my-private', instanceId: 'seat', displayName: 'New name', + })); + }); + + test('a rename cannot adopt a human or another agent principal', async () => { + reset(); + mockExisting = { + _id: 'human', username: 'my-private-seat', isBot: false, + botMetadata: { agentName: 'my-private', instanceId: 'seat', displayName: 'Human' }, + }; + await AgentIdentityService.renameAgentDisplayName('my-private', 'seat', 'New name'); + expect(mockSaved).toHaveLength(0); + }); + test('a username collision cannot adopt a bot for another agent identity', async () => { mockExisting = { _id: 'first-owner-bot', diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts index 91027cfcbe..cfeb7243ae 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -4,6 +4,7 @@ const express = require('express'); const auth = require('../../middleware/auth'); const { AgentInstallation } = require('../../models/AgentRegistry'); const AgentProfile = require('../../models/AgentProfile'); +const AgentIdentityService = require('../../services/agentIdentityService'); const Pod = require('../../models/Pod'); const { writeWorkspaceIdentityFile, @@ -127,6 +128,22 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: }); } + if (displayName !== undefined && (typeof displayName !== 'string' + || !displayName.trim() || displayName.trim().length > 80)) { + return res.status(400).json({ error: 'Display name must be 1–80 characters' }); + } + // The visible name belongs to the portable User principal, not one pod. + // A partial installer must not rename an identity used by other owners. + if (displayName !== undefined && !isInstanceAdmin) { + const ownership = await ownsRuntimeIdentity(userId, name.toLowerCase(), normalizedInstanceId); + if (!ownership.owned) { + return res.status(403).json({ + code: ownership.failure, + error: 'Only the sole installer or an instance admin can rename this agent identity', + }); + } + } + // WRITE-TIME SHAPE CHECK (TASK-071, Vera's ruling). This route is one of // TWO backend writers of `config.environment` (the other is // POST /api/registry/install, which checks the same thing), and that field @@ -293,8 +310,17 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: } } + // The User row is the name source for chat and the pod roster. An explicit + // rename must update that portable identity, not only per-pod projections. + const renamedIdentity = displayName === undefined ? undefined + : await AgentIdentityService.renameAgentDisplayName(name, normalizedInstanceId, displayName.trim()); + if (renamedIdentity === null) { + return res.status(404).json({ error: 'Agent identity not found' }); + } + const effectiveDisplayName = renamedIdentity?.displayName; for (const targetInstallation of installationsToUpdate) { applyInstallationSettings(targetInstallation); + if (effectiveDisplayName) targetInstallation.displayName = effectiveDisplayName; // eslint-disable-next-line no-await-in-loop await targetInstallation.save(); } @@ -311,7 +337,7 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: const updates: any = {}; if (status) updates.status = status; if (modelPreferences) updates.modelPreferences = modelPreferences; - if (displayName) updates.name = displayName; + if (effectiveDisplayName) updates.name = effectiveDisplayName; if (instructions !== undefined) updates.instructions = instructions; if (persona !== undefined) updates.persona = persona; if (normalizedToolPolicy !== null) updates.toolPolicy = normalizedToolPolicy; @@ -325,7 +351,7 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: ); if ((persona !== undefined || displayName) && normalizedInstanceId && name.toLowerCase() === 'openclaw') { - const identityContent = buildIdentityContent(displayName || normalizedInstanceId, persona || {}); + const identityContent = buildIdentityContent(effectiveDisplayName || normalizedInstanceId, persona || {}); writeWorkspaceIdentityFile(normalizedInstanceId, identityContent).catch((err: any) => { console.warn('[registry] Failed to sync IDENTITY.md for', normalizedInstanceId, err.message); }); @@ -363,6 +389,12 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: }); } + if (renamedIdentity && !renamedIdentity.mirrorSynced) { + return res.status(503).json({ + error: 'The name was saved, but chat is catching up. Save again in a moment.', + }); + } + res.json({ success: true, installation: { diff --git a/backend/services/agentIdentityService.ts b/backend/services/agentIdentityService.ts index e16e770ca6..76e7488411 100644 --- a/backend/services/agentIdentityService.ts +++ b/backend/services/agentIdentityService.ts @@ -475,6 +475,26 @@ async function resolveCollisionFreeDisplayName( } class AgentIdentityService { + /** Explicitly rename the portable principal; routine installation refreshes preserve it. */ + static async renameAgentDisplayName(agentType: string, instanceId: string, desiredName: string): Promise<{ + displayName: string; mirrorSynced: boolean; + } | null> { + const resolvedType = AgentIdentityService.resolveAgentType(agentType); + const username = buildAgentUsername(resolvedType, instanceId); + const user = await User.findOne({ + username, + isBot: true, + 'botMetadata.agentName': resolvedType, + 'botMetadata.instanceId': instanceId, + }); + if (!user) return null; + const displayName = await resolveCollisionFreeDisplayName(desiredName, instanceId, user._id); + user.botMetadata = { ...user.botMetadata, displayName }; + await user.save(); + const mirrorSynced = await AgentIdentityService.syncUserToPostgreSQL(user); + return { displayName, mirrorSynced }; + } + /** * Get or create an agent user with proper bot metadata */ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 572b1abfdd..784a14fd80 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2305,6 +2305,20 @@ } }, "agentSettings": { + "identityTitle": "Identity", + "displayName": "Display name", + "instructions": "Instructions", + "persona": "Persona", + "tone": "Tone", + "toneOptions": { "friendly": "Friendly", "professional": "Professional", "casual": "Casual", "formal": "Formal", "technical": "Technical" }, + "specialties": "Specialties (comma separated)", + "boundaries": "Boundaries (comma separated)", + "personaNotes": "Persona notes", + "saveIdentity": "Save identity", + "saving": "Saving…", + "identitySaved": "Saved", + "identityNameError": "Enter a display name of 1–80 characters.", + "identitySaveError": "Could not save identity settings.", "wakeTitle": "Wake", "mentionsAlwaysWake": "Mentions always wake this agent.", "wakeOnMessage": "Wake on messages", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 877c99cd5d..abaa229a32 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2296,6 +2296,20 @@ } }, "agentSettings": { + "identityTitle": "身份", + "displayName": "显示名称", + "instructions": "指令", + "persona": "角色设定", + "tone": "语气", + "toneOptions": { "friendly": "友好", "professional": "专业", "casual": "随和", "formal": "正式", "technical": "技术型" }, + "specialties": "专长(用逗号分隔)", + "boundaries": "行为边界(用逗号分隔)", + "personaNotes": "角色备注", + "saveIdentity": "保存身份设定", + "saving": "正在保存…", + "identitySaved": "已保存", + "identityNameError": "显示名称须为 1–80 个字符。", + "identitySaveError": "无法保存身份设定。", "wakeTitle": "唤醒", "mentionsAlwaysWake": "提及始终会唤醒此智能体。", "wakeOnMessage": "收到消息时唤醒", diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 916104db8b..054a6c45d8 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -102,6 +102,33 @@ test('connected nondefault wrapper regenerates from Control without a second Key expect(screen.queryByRole('button', { name: 'Regenerate key' })).toBeNull(); }); +test('identity edits go through the shared installation PATCH with the exact instance', async () => { + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { + displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: 'maya-claude', instanceId: 'default', displayName: 'Maya', + profile: { instructions: 'Old', persona: { tone: 'friendly', specialties: ['writing'] } }, + } } }); + return Promise.resolve({ data: [] }); + }); + axios.patch.mockResolvedValue({ data: { success: true } }); + renderPage(); + await screen.findByRole('button', { name: 'Save identity' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Maya 2' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Instructions' }), { target: { value: 'New instructions' } }); + fireEvent.change(screen.getByRole('combobox', { name: 'Tone' }), { target: { value: 'technical' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/registry/pods/pod-1/agents/maya-claude', + expect.objectContaining({ + instanceId: 'default', displayName: 'Maya 2', instructions: 'New instructions', + persona: expect.objectContaining({ tone: 'technical', specialties: ['writing'] }), + }), + )); +}); + test('nondefault wrapper instances never link to a default-instance reconnect', async () => { axios.get.mockImplementation((url) => { if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 34cec8f189..c256f08c40 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4718,3 +4718,12 @@ describe('agent runtime and grants fit the Settings column', () => { expect(ruleBody(css, '.v2-agent-runtime-grants__list li p')).toContain('overflow-wrap: anywhere'); }); }); + +describe('agent Identity editor', () => { + const css = read('../agents/v2-agent-settings.css'); + test('form fields retain a visible boundary over the v2 input reset', () => { + const input = selectorRuleBody(css, '.v2-root .v2-agent-settings .v2-agent-settings__identity input,'); + expect(input).toContain('border: 1px solid var(--v2-border)'); + expect(input).toContain('background: var(--v2-surface)'); + }); +}); diff --git a/frontend/src/v2/agents/V2AgentIdentitySection.tsx b/frontend/src/v2/agents/V2AgentIdentitySection.tsx new file mode 100644 index 0000000000..b9739c07d2 --- /dev/null +++ b/frontend/src/v2/agents/V2AgentIdentitySection.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { AgentSettingsInstallation, AgentSettingsPatch } from './useAgentSettings'; + +interface Props { + installation: AgentSettingsInstallation; + update: (patch: AgentSettingsPatch) => Promise; +} + +interface PersonaFields { + tone: string; + specialties: string; + boundaries: string; + customInstructions: string; +} + +const commaList = (value: string): string[] => value.split(',').map((part) => part.trim()).filter(Boolean); + +const asPersona = (value: unknown): PersonaFields => { + const persona = value && typeof value === 'object' ? value as Record : {}; + return { + tone: typeof persona.tone === 'string' ? persona.tone : 'friendly', + specialties: Array.isArray(persona.specialties) ? persona.specialties.join(', ') : '', + boundaries: Array.isArray(persona.boundaries) ? persona.boundaries.join(', ') : '', + customInstructions: typeof persona.customInstructions === 'string' ? persona.customInstructions : '', + }; +}; + +const V2AgentIdentitySection: React.FC = ({ installation, update }) => { + const { t } = useTranslation(); + const profile = installation.profile; + const [displayName, setDisplayName] = useState(installation.displayName || ''); + const [instructions, setInstructions] = useState(profile?.instructions || ''); + const [persona, setPersona] = useState(() => asPersona(profile?.persona)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + useEffect(() => { + setDisplayName(installation.displayName || ''); + setInstructions(profile?.instructions || ''); + setPersona(asPersona(profile?.persona)); + setError(null); + setSaved(false); + }, [installation, profile]); + + const save = async (event: React.FormEvent) => { + event.preventDefault(); + const name = displayName.trim(); + if (!name || name.length > 80) { + setError(t('agentSettings.identityNameError')); + return; + } + setBusy(true); + setError(null); + setSaved(false); + try { + await update({ + ...(name !== installation.displayName ? { displayName: name } : {}), + instructions, + persona: { + tone: persona.tone, + specialties: commaList(persona.specialties), + boundaries: commaList(persona.boundaries), + customInstructions: persona.customInstructions, + }, + }); + setSaved(true); + } catch (requestError) { + const apiError = (requestError as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(apiError || t('agentSettings.identitySaveError')); + } finally { + setBusy(false); + } + }; + + return
+

{t('agentSettings.identityTitle')}

+
+ +