diff --git a/backend/__tests__/service/agent-identity-settings.test.js b/backend/__tests__/service/agent-identity-settings.test.js new file mode 100644 index 0000000000..43ab630b6f --- /dev/null +++ b/backend/__tests__/service/agent-identity-settings.test.js @@ -0,0 +1,195 @@ +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const AgentProfile = require('../../models/AgentProfile'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const agentConfig = require('../../routes/registry/agent-config'); + +const SECRET = 'agent-identity-settings-test'; +const bearer = (user) => `Bearer ${jwt.sign({ id: String(user._id) }, SECRET)}`; + +describe('agent identity settings with stored installations', () => { + let app; let installer; let other; let admin; let podA; let podB; let identity; + + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + await setupMongoDb(); + app = express(); + app.use(express.json()); + app.use('/api/registry', agentConfig); + installer = await User.create({ username: 'identity-installer', email: 'identity-installer@test.com', password: 'password123' }); + other = await User.create({ username: 'identity-other', email: 'identity-other@test.com', password: 'password123' }); + admin = await User.create({ + username: 'identity-admin', email: 'identity-admin@test.com', password: 'password123', role: 'admin', + }); + podA = await Pod.create({ + name: 'Identity A', type: 'chat', createdBy: installer._id, members: [installer._id, other._id], + }); + podB = await Pod.create({ + name: 'Identity B', type: 'chat', createdBy: other._id, members: [installer._id, other._id], + }); + }); + + afterAll(async () => closeMongoDb()); + + beforeEach(async () => { + await AgentInstallation.deleteMany({ agentName: 'identity-agent' }); + await AgentProfile.deleteMany({ agentName: 'identity-agent' }); + await User.deleteMany({ username: 'identity-agent-seat' }); + identity = await User.create({ + username: 'identity-agent-seat', + email: 'identity-agent-seat@test.com', + password: 'password123', + isBot: true, + botType: 'agent', + botMetadata: { agentName: 'identity-agent', instanceId: 'seat', displayName: 'Old name' }, + }); + await AgentInstallation.create([ + { + agentName: 'identity-agent', instanceId: 'seat', podId: podA._id, version: '1', installedBy: installer._id, displayName: 'Old name', + }, + { + agentName: 'identity-agent', instanceId: 'seat', podId: podB._id, version: '1', installedBy: other._id, displayName: 'Old name', + }, + ]); + await AgentProfile.create([ + { + agentId: 'identity-agent:seat', + agentName: 'identity-agent', + instanceId: 'seat', + podId: podA._id, + name: 'Old name', + purpose: 'Test', + createdBy: installer._id, + }, + { + agentId: 'identity-agent:seat', + agentName: 'identity-agent', + instanceId: 'seat', + podId: podB._id, + name: 'Old name', + purpose: 'Test', + createdBy: other._id, + }, + ]); + }); + + const url = (podId) => `/api/registry/pods/${podId}/agents/identity-agent`; + + it('refuses a pod member who did not install the agent', async () => { + const response = await request(app).patch(url(podA._id)).set('Authorization', bearer(other)) + .send({ instanceId: 'seat', instructions: 'Replace the owner instructions' }); + expect(response.status).toBe(403); + expect((await AgentProfile.findOne({ podId: podA._id, agentName: 'identity-agent' })).instructions).toBe(''); + }); + + it('saves installer instructions and persona only to the installer-owned pod', async () => { + const persona = { + tone: 'technical', specialties: ['research'], boundaries: ['no secrets'], customInstructions: 'Be concise', + }; + const response = await request(app).patch(url(podA._id)).set('Authorization', bearer(installer)) + .send({ instanceId: 'seat', instructions: 'Help with research', persona }); + expect(response.status).toBe(200); + const [a, b] = await Promise.all([ + AgentProfile.findOne({ podId: podA._id, agentName: 'identity-agent' }), + AgentProfile.findOne({ podId: podB._id, agentName: 'identity-agent' }), + ]); + expect(a.instructions).toBe('Help with research'); + expect(a.persona.specialties).toEqual(['research']); + expect(b.instructions).toBe(''); + expect(b.persona.specialties).toEqual([]); + }); + + it('refuses a partial installer rename before changing the portable identity', async () => { + const response = await request(app).patch(url(podA._id)).set('Authorization', bearer(installer)) + .send({ instanceId: 'seat', displayName: 'New name' }); + expect([403, 409]).toContain(response.status); + expect((await User.findById(identity._id)).botMetadata.displayName).toBe('Old name'); + expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'identity-agent' })).displayName).toBe('Old name'); + }); + + it('lets an admin rename the portable identity and both pod projections', async () => { + const response = await request(app).patch(url(podA._id)).set('Authorization', bearer(admin)) + .send({ instanceId: 'seat', displayName: 'New name' }); + expect(response.status).toBe(200); + expect((await User.findById(identity._id)).botMetadata).toEqual(expect.objectContaining({ + displayName: 'New name', agentName: 'identity-agent', instanceId: 'seat', + })); + const installations = await AgentInstallation.find({ agentName: 'identity-agent', instanceId: 'seat' }); + expect(installations.map((row) => row.displayName)).toEqual(['New name', 'New name']); + const profiles = await AgentProfile.find({ agentName: 'identity-agent', instanceId: 'seat' }); + expect(profiles.map((row) => row.name)).toEqual(['New name', 'New name']); + }); + + it('repairs a partial identity rename when the same request is retried', async () => { + const updateProfiles = AgentProfile.updateMany.bind(AgentProfile); + const profileWrite = jest.spyOn(AgentProfile, 'updateMany') + .mockRejectedValueOnce(new Error('injected profile write failure')) + .mockImplementation((...args) => updateProfiles(...args)); + const sendRename = () => request(app).patch(url(podA._id)).set('Authorization', bearer(admin)) + .send({ instanceId: 'seat', displayName: 'Recovered name' }); + let first; + let second; + try { + first = await sendRename(); + expect(first.status).toBe(503); + expect(first.body.code).toBe('identity_projection_retryable'); + expect((await User.findById(identity._id)).botMetadata.displayName).toBe('Recovered name'); + expect((await AgentProfile.findOne({ podId: podA._id, agentName: 'identity-agent' })).name) + .toBe('Old name'); + second = await sendRename(); + } finally { + profileWrite.mockRestore(); + } + expect(second.status).toBe(200); + expect((await AgentInstallation.find({ agentName: 'identity-agent', instanceId: 'seat' })) + .map((row) => row.displayName)).toEqual(['Recovered name', 'Recovered name']); + expect((await AgentProfile.find({ agentName: 'identity-agent', instanceId: 'seat' })) + .map((row) => row.name)).toEqual(['Recovered name', 'Recovered name']); + }); + + it('repairs an interrupted installation fan-out on retry', async () => { + const saveInstallation = AgentInstallation.prototype.save; + let failed = false; + const installationWrite = jest.spyOn(AgentInstallation.prototype, 'save').mockImplementation(function (...args) { + if (!failed && String(this.podId) === String(podB._id)) { + failed = true; + return Promise.reject(new Error('injected installation write failure')); + } + return saveInstallation.apply(this, args); + }); + const sendRename = () => request(app).patch(url(podA._id)).set('Authorization', bearer(admin)) + .send({ instanceId: 'seat', displayName: 'Recovered name' }); + let second; + try { + const first = await sendRename(); + expect(first.status).toBe(503); + expect(first.body.code).toBe('identity_projection_retryable'); + expect((await User.findById(identity._id)).botMetadata.displayName).toBe('Recovered name'); + const installations = await AgentInstallation.find({ agentName: 'identity-agent', instanceId: 'seat' }); + expect(installations.map((row) => row.displayName).sort()).toEqual(['Old name', 'Recovered name']); + second = await sendRename(); + } finally { + installationWrite.mockRestore(); + } + expect(second.status).toBe(200); + expect((await AgentInstallation.find({ agentName: 'identity-agent', instanceId: 'seat' })) + .map((row) => row.displayName)).toEqual(['Recovered name', 'Recovered name']); + expect((await AgentProfile.find({ agentName: 'identity-agent', instanceId: 'seat' })) + .map((row) => row.name)).toEqual(['Recovered name', 'Recovered name']); + }); + + it('lets a sole installer rename a paused identity', async () => { + await AgentInstallation.updateOne({ podId: podB._id, agentName: 'identity-agent' }, { status: 'uninstalled' }); + await AgentInstallation.updateOne({ podId: podA._id, agentName: 'identity-agent' }, { status: 'paused' }); + const response = await request(app).patch(url(podA._id)).set('Authorization', bearer(installer)) + .send({ instanceId: 'seat', displayName: 'Paused name' }); + expect(response.status).toBe(200); + expect((await User.findById(identity._id)).botMetadata.displayName).toBe('Paused name'); + expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'identity-agent' })).displayName) + .toBe('Paused name'); + }); +}); 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..47c3c205fd 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,104 @@ 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'] } }, + { $set: expect.objectContaining({ name: 'New name', instructions: 'Help the pod', persona }) }, + { runValidators: true }, + ); + }); + + it('refuses an identity rename by an installer who shares that identity', async () => { + const primary = installation({ installedBy: MEMBER }); + const peer = installation({ podId: 'pod-2', installedBy: 'other-installer' }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary, peer]); + + 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('refuses a rename when the portable identity has an inaccessible sibling pod', async () => { + const primary = installation({ installedBy: MEMBER }); + const sibling = installation({ podId: 'pod-2', installedBy: MEMBER }); + setPod(); + Pod.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([ + { _id: 'pod-1', members: [{ userId: MEMBER }] }, + { _id: 'pod-2', members: [] }, + ]) }) }); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([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(409); + expect(res.body.code).toBe('identity_rename_scope_incomplete'); + expect(primary.save).not.toHaveBeenCalled(); + expect(sibling.save).not.toHaveBeenCalled(); + expect(AgentIdentityService.renameAgentDisplayName).not.toHaveBeenCalled(); + }); + + it('returns a distinct refusal before changing projections when a peer owns the requested label', async () => { + const primary = installation({ installedBy: MEMBER }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + AgentIdentityService.renameAgentDisplayName.mockRejectedValue( + Object.assign(new Error('Another agent already uses this name'), { code: 'agent_display_name_taken' }), + ); + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', displayName: 'Scout (Cody)' }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('agent_display_name_taken'); + expect(primary.save).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 () => { @@ -233,7 +340,7 @@ describe('agent config PATCH — installer gate', () => { select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([ { _id: 'pod-1', createdBy: 'someone-else', members: [{ userId: MEMBER }] }, - { _id: 'pod-2', createdBy: 'someone-else', members: [{ userId: MEMBER }] }, + { _id: 'pod-2', createdBy: 'someone-else', members: [] }, ]), }), }); @@ -254,7 +361,8 @@ describe('agent config PATCH — installer gate', () => { expect(otherOwnersRow.save).toHaveBeenCalledTimes(1); expect(AgentProfile.updateMany).toHaveBeenCalledWith( { agentId: 'openclaw:curator', podId: { $in: ['pod-1', 'pod-2'] } }, - expect.objectContaining({ name: 'Renamed' }), + { $set: expect.objectContaining({ name: 'Renamed' }) }, + { runValidators: true }, ); }); @@ -351,7 +459,6 @@ describe('agent config PATCH — installer gate', () => { .send({ instanceId: 'curator', config: { environment: { version: 1, sandbox: { mode: 'workspace' } } }, - displayName: 'Renamed', }); expect(res.status).toBe(200); @@ -359,9 +466,6 @@ describe('agent config PATCH — installer gate', () => { expect(primary.save).toHaveBeenCalledTimes(1); expect(otherOwnersRow.save).not.toHaveBeenCalled(); expect(otherOwnersRow.config.get('environment')).toBeUndefined(); - expect(AgentProfile.updateMany).toHaveBeenCalledWith( - { agentId: 'openclaw:curator', podId: { $in: ['pod-1'] } }, - expect.objectContaining({ name: 'Renamed' }), - ); + expect(AgentProfile.updateMany).not.toHaveBeenCalled(); }); }); diff --git a/backend/__tests__/unit/routes/registry.catalog-verified-gate.test.js b/backend/__tests__/unit/routes/registry.catalog-verified-gate.test.js index 782b77968b..b6e73940c4 100644 --- a/backend/__tests__/unit/routes/registry.catalog-verified-gate.test.js +++ b/backend/__tests__/unit/routes/registry.catalog-verified-gate.test.js @@ -16,7 +16,7 @@ jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => { })); jest.mock('../../../models/Pod', () => ({})); jest.mock('../../../models/AgentRegistry', () => ({ - AgentRegistry: { search: jest.fn() }, + AgentRegistry: { search: jest.fn(), getByName: jest.fn() }, AgentInstallation: { find: jest.fn(), getInstalledAgents: jest.fn() }, })); jest.mock('../../../models/User', () => ({ @@ -67,6 +67,7 @@ const roleLookup = (role) => { }; const listAgents = getHandler('get', '/agents'); +const agentDetails = getHandler('get', '/agents/:name'); describe('hire catalog verified gate', () => { beforeEach(() => { @@ -115,4 +116,18 @@ describe('hire catalog verified gate', () => { const payload = res.json.mock.calls[0][0]; expect(payload.agents.map((a) => a.name)).toEqual(['claude-code']); }); + + it('returns listing visibility fields to the owner settings page', async () => { + AgentRegistry.getByName.mockResolvedValue({ + agentName: 'maya-claude', displayName: 'Maya', latestVersion: '1.0.0', + status: 'active', registry: 'commonly-community', ephemeral: false, verified: false, + versions: [], manifest: {}, + }); + const res = response(); + await agentDetails({ params: { name: 'maya-claude' } }, res); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + name: 'maya-claude', status: 'active', registry: 'commonly-community', + ephemeral: false, verified: false, + })); + }); }); diff --git a/backend/__tests__/unit/routes/registry.environment-mcp-shape.test.js b/backend/__tests__/unit/routes/registry.environment-mcp-shape.test.js index acb33e7be3..608b5a1f68 100644 --- a/backend/__tests__/unit/routes/registry.environment-mcp-shape.test.js +++ b/backend/__tests__/unit/routes/registry.environment-mcp-shape.test.js @@ -40,11 +40,17 @@ jest.mock('../../../models/AgentProfile', () => ({ jest.mock('../../../models/User', () => ({ findById: jest.fn(), })); +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 registryRoutes = require('../../../routes/registry'); const app = express(); @@ -95,6 +101,10 @@ describe('agent config PATCH — write-time mcp entry shape (TASK-071)', () => { beforeEach(() => { jest.clearAllMocks(); + ownsRuntimeIdentity.mockResolvedValue({ owned: true }); + jest.spyOn(AgentIdentityService, 'renameAgentDisplayName').mockImplementation(async (_name, _instanceId, label) => ({ + displayName: label, mirrorSynced: true, + })); User.findById.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ _id: ME, role: 'user' }) }), }); @@ -103,6 +113,7 @@ describe('agent config PATCH — write-time mcp entry shape (TASK-071)', () => { AgentInstallation.findOne.mockResolvedValue(primary); AgentInstallation.find.mockResolvedValue([primary]); }); + afterEach(() => jest.restoreAllMocks()); it("Vera's gate: refuses transport 'http' carrying both a url and a command, and writes nothing", async () => { const res = await patch({ diff --git a/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js b/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js index 22a7799108..fac4ed4e8f 100644 --- a/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js +++ b/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js @@ -174,10 +174,11 @@ describe('registry shared agent settings', () => { agentId: 'openclaw:x-curator', podId: { $in: ['pod-1', 'pod-2'] }, }, - expect.objectContaining({ + { $set: expect.objectContaining({ modelPreferences: { preferred: 'gemini-2.5-flash' }, instructions: 'Keep reports concise.', - }), + }) }, + { runValidators: true }, ); }); }); diff --git a/backend/__tests__/unit/routes/registry.tool-policy.test.js b/backend/__tests__/unit/routes/registry.tool-policy.test.js index 2d8faf9b97..cc001f9895 100644 --- a/backend/__tests__/unit/routes/registry.tool-policy.test.js +++ b/backend/__tests__/unit/routes/registry.tool-policy.test.js @@ -95,13 +95,14 @@ describe('registry tool policy update', () => { agentId: 'openclaw:default', podId: { $in: ['pod-1'] }, }, - expect.objectContaining({ + { $set: expect.objectContaining({ toolPolicy: { allowed: ['commonly', 'commonly_search'], blocked: ['commonly_write'], requireApproval: ['commonly_write'], }, - }), + }) }, + { runValidators: true }, ); }); }); diff --git a/backend/__tests__/unit/services/agentIdentityService.collisionResolver.test.js b/backend/__tests__/unit/services/agentIdentityService.collisionResolver.test.js index 492a48f8ef..0fe3c6b777 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))); @@ -63,7 +68,10 @@ jest.mock('../../../models/User', () => { lean: async () => { const dn = query['botMetadata.displayName']; return mockPeers - .filter((p) => p.botMetadata?.displayName === dn) + .filter((p) => (dn instanceof RegExp + ? dn.test(p.botMetadata?.displayName || '') + : p.botMetadata?.displayName === dn)) + .filter((p) => !query.$or || p.isBot || p.botType === 'agent') .filter((p) => { if (!query._id || !query._id.$ne) return true; return String(p._id) !== String(query._id.$ne); @@ -83,6 +91,48 @@ 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('an explicit rename cannot impersonate another agent with a parenthetical label', async () => { + reset(); + mockExisting = { + _id: 'curator-user', username: 'my-private-seat', isBot: true, + botMetadata: { agentName: 'my-private', instanceId: 'seat', displayName: 'Old name' }, + }; + mockPeers = [{ + _id: 'other-agent', isBot: true, + botMetadata: { agentName: 'openclaw', instanceId: 'cody', displayName: 'Scout (Cody)' }, + }]; + + await expect(AgentIdentityService.renameAgentDisplayName('my-private', 'seat', 'scout (cody)')) + .rejects.toMatchObject({ code: 'agent_display_name_taken' }); + expect(mockSaved).toHaveLength(0); + }); + + 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..b0af939475 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,10 @@ 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' }); + } // 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 @@ -215,6 +220,22 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: instanceId: normalizedInstanceId, status: { $ne: 'uninstalled' }, }); + // A display name belongs to the portable User principal. Check every + // installed projection, including paused ones: runtime-token ownership + // intentionally looks only at active rows and would reject a paused seat + // even when this caller is its sole installer. + if (displayName !== undefined && !isInstanceAdmin) { + const missingInstaller = peerInstallations.some((entry: any) => !entry.installedBy); + const anotherInstaller = peerInstallations.some( + (entry: any) => entry.installedBy && entry.installedBy.toString() !== userId.toString(), + ); + if (missingInstaller || anotherInstaller) { + return res.status(403).json({ + code: missingInstaller ? 'unknown_installer' : 'another_installer', + error: 'Only the sole installer or an instance admin can rename this agent identity', + }); + } + } const peerByPod = new Map( peerInstallations.map((entry: any) => [entry.podId?.toString?.() || '', entry]), @@ -230,7 +251,7 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: .select('_id members createdBy') .lean(); accessiblePodIds = peerPods - .filter((entry: any) => userHasPodAccess(entry, userId)) + .filter((entry: any) => isInstanceAdmin || userHasPodAccess(entry, userId)) .map((entry: any) => entry._id.toString()); if (!accessiblePodIds.includes(podId.toString())) { accessiblePodIds.push(podId.toString()); @@ -253,6 +274,14 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: const installationsToUpdate: any[] = isInstanceAdmin ? accessibleInstallations : accessibleInstallations.filter(ownsInstallation); + // A rename changes the portable User label seen in every pod. Refuse it + // unless the same request will update every non-uninstalled projection. + if (displayName !== undefined && installationsToUpdate.length !== peerByPod.size) { + return res.status(409).json({ + code: 'identity_rename_scope_incomplete', + error: 'This identity has installations outside your writable pods', + }); + } const writablePodIds = Array.from(new Set( installationsToUpdate .map((entry: any) => entry.podId?.toString?.() || '') @@ -293,43 +322,76 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: } } - for (const targetInstallation of installationsToUpdate) { - applyInstallationSettings(targetInstallation); - // eslint-disable-next-line no-await-in-loop - await targetInstallation.save(); + // 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. + let renamedIdentity; + try { + renamedIdentity = displayName === undefined ? undefined + : await AgentIdentityService.renameAgentDisplayName(name, normalizedInstanceId, displayName.trim()); + } catch (renameError: any) { + if (renameError?.code === 'agent_display_name_taken') { + return res.status(409).json({ code: renameError.code, error: renameError.message }); + } + throw renameError; } + if (renamedIdentity === null) { + return res.status(404).json({ error: 'Agent identity not found' }); + } + const effectiveDisplayName = renamedIdentity?.displayName; + try { + for (const targetInstallation of installationsToUpdate) { + applyInstallationSettings(targetInstallation); + if (effectiveDisplayName) targetInstallation.displayName = effectiveDisplayName; + // eslint-disable-next-line no-await-in-loop + await targetInstallation.save(); + } - if ( - status - || modelPreferences - || displayName - || instructions !== undefined - || persona !== undefined - || normalizedToolPolicy !== null - || normalizedContextPolicy !== null - ) { - const updates: any = {}; - if (status) updates.status = status; - if (modelPreferences) updates.modelPreferences = modelPreferences; - if (displayName) updates.name = displayName; - if (instructions !== undefined) updates.instructions = instructions; - if (persona !== undefined) updates.persona = persona; - if (normalizedToolPolicy !== null) updates.toolPolicy = normalizedToolPolicy; - if (normalizedContextPolicy !== null) updates.contextPolicy = normalizedContextPolicy; - await AgentProfile.updateMany( - { - agentId: buildAgentProfileId(name, normalizedInstanceId), - podId: { $in: writablePodIds }, - }, - updates, - ); + if ( + status + || modelPreferences + || displayName + || instructions !== undefined + || persona !== undefined + || normalizedToolPolicy !== null + || normalizedContextPolicy !== null + ) { + const updates: any = {}; + if (status) updates.status = status; + if (modelPreferences) updates.modelPreferences = modelPreferences; + if (effectiveDisplayName) updates.name = effectiveDisplayName; + if (instructions !== undefined) updates.instructions = instructions; + if (persona !== undefined) updates.persona = persona; + if (normalizedToolPolicy !== null) updates.toolPolicy = normalizedToolPolicy; + if (normalizedContextPolicy !== null) updates.contextPolicy = normalizedContextPolicy; + await AgentProfile.updateMany( + { + agentId: buildAgentProfileId(name, normalizedInstanceId), + podId: { $in: writablePodIds }, + }, + { $set: updates }, + { runValidators: true }, + ); - if ((persona !== undefined || displayName) && normalizedInstanceId && name.toLowerCase() === 'openclaw') { - const identityContent = buildIdentityContent(displayName || normalizedInstanceId, persona || {}); - writeWorkspaceIdentityFile(normalizedInstanceId, identityContent).catch((err: any) => { - console.warn('[registry] Failed to sync IDENTITY.md for', normalizedInstanceId, err.message); + if ((persona !== undefined || displayName) && normalizedInstanceId && name.toLowerCase() === 'openclaw') { + const identityContent = buildIdentityContent(effectiveDisplayName || normalizedInstanceId, persona || {}); + writeWorkspaceIdentityFile(normalizedInstanceId, identityContent).catch((err: any) => { + console.warn('[registry] Failed to sync IDENTITY.md for', normalizedInstanceId, err.message); + }); + } + } + } catch (projectionError) { + if (displayName !== undefined) { + // A retry with the same name repairs any rows saved before this error. + // The collision resolver accepts the same User principal, and every + // projection write is idempotent. Expose the repair action explicitly + // instead of reporting a generic 500 over a partially saved rename. + console.error('Agent identity projection update interrupted'); + return res.status(503).json({ + code: 'identity_projection_retryable', + error: 'Identity update interrupted. Save again to finish syncing pod names.', }); } + throw projectionError; } const skillSync = config?.skillSync || null; @@ -363,6 +425,13 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: }); } + if (renamedIdentity && !renamedIdentity.mirrorSynced) { + return res.status(503).json({ + code: 'identity_mirror_pending', + error: 'The name was saved, but chat is catching up. Save again in a moment.', + }); + } + res.json({ success: true, installation: { diff --git a/backend/routes/registry/catalog.ts b/backend/routes/registry/catalog.ts index 9fe5558ec8..8bb1a1aa2e 100644 --- a/backend/routes/registry/catalog.ts +++ b/backend/routes/registry/catalog.ts @@ -181,6 +181,9 @@ catalogRouter.get('/agents/:name', auth, async (req: any, res: any) => { deprecated: v.deprecated, })), manifest: agent.manifest, + status: agent.status, + registry: agent.registry, + ephemeral: agent.ephemeral === true, verified: agent.verified, publisher: agent.publisher, categories: agent.categories, diff --git a/backend/services/agentIdentityService.ts b/backend/services/agentIdentityService.ts index e16e770ca6..0d3d947d79 100644 --- a/backend/services/agentIdentityService.ts +++ b/backend/services/agentIdentityService.ts @@ -160,6 +160,7 @@ export const isReservedAgentEmail = (email: unknown): boolean => ( /** The refusal's declared code: callers identify it structurally. */ export const AGENT_USERNAME_CONFLICT_CODE = 'agent_username_conflict'; export const AGENT_IDENTITY_CONFLICT_CODE = 'agent_identity_conflict'; +export const AGENT_DISPLAY_NAME_TAKEN_CODE = 'agent_display_name_taken'; export class AgentUsernameConflictError extends Error { readonly code = AGENT_USERNAME_CONFLICT_CODE; @@ -192,6 +193,17 @@ export class AgentIdentityConflictError extends Error { } } +export class AgentDisplayNameTakenError extends Error { + readonly code = AGENT_DISPLAY_NAME_TAKEN_CODE; + + readonly status = 409; + + constructor() { + super('Another agent already uses this name'); + this.name = 'AgentDisplayNameTakenError'; + } +} + /** * A row an agent install may adopt: one that already carries agent markers. * `isBot` alone is not the test — the branch this guards exists precisely for @@ -475,6 +487,38 @@ 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; + // Explicit renames must not inherit the install-time resolver's + // already-suffixed fast path. That path preserves generated labels during + // reprovision, but here the caller can type a peer's exact label. + const escapedName = desiredName.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const peers = await User.find({ + _id: { $ne: user._id }, + $or: [{ isBot: true }, { botType: 'agent' }], + 'botMetadata.displayName': new RegExp(`^${escapedName}$`, 'i'), + }).select('_id').lean(); + if (peers.length) { + throw new AgentDisplayNameTakenError(); + } + 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..d8c6ee166d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2305,6 +2305,35 @@ } }, "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.", + "identityNameTakenError": "Another agent already uses this name.", + "identityPermissionError": "You don't have permission to change this agent's identity.", + "identityScopeError": "This agent is installed in pods you cannot manage. An admin can rename it across every pod.", + "identityMirrorError": "Name saved, but chat has not caught up. Try saving again in a moment.", + "identityProjectionError": "Name update was interrupted. Save again to finish updating every pod.", + "identitySaveError": "Could not save identity settings.", + "listingTitle": "Marketplace listing", + "listingLoading": "Checking listing…", + "listingPublished": "Published", + "listingNotInCatalog": "Published, not visible in marketplace browse", + "listingNotPublished": "Not published", + "listingInReview": "Pending review", + "listingDeprecated": "Deprecated", + "listingUnavailable": "Listing status unavailable", + "viewPublicProfile": "View public profile", + "listingSeparate": "Publishing a listing is managed separately from 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..6bbf9b7a4b 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2296,6 +2296,35 @@ } }, "agentSettings": { + "identityTitle": "身份", + "displayName": "显示名称", + "instructions": "指令", + "persona": "角色设定", + "tone": "语气", + "toneOptions": { "friendly": "友好", "professional": "专业", "casual": "随和", "formal": "正式", "technical": "技术型" }, + "specialties": "专长(用逗号分隔)", + "boundaries": "行为边界(用逗号分隔)", + "personaNotes": "角色备注", + "saveIdentity": "保存身份设定", + "saving": "正在保存…", + "identitySaved": "已保存", + "identityNameError": "显示名称须为1–80个字符。", + "identityNameTakenError": "其他智能体已使用此名称。", + "identityPermissionError": "你没有权限修改此智能体的身份。", + "identityScopeError": "此智能体也安装在你无权管理的 Pod 中。管理员可以为所有 Pod 更改其名称。", + "identityMirrorError": "名称已保存,但聊天尚未同步。请稍后重试。", + "identityProjectionError": "名称更新中断。请再次保存,以完成所有 Pod 的更新。", + "identitySaveError": "无法保存身份设定。", + "listingTitle": "市场条目", + "listingLoading": "正在检查条目…", + "listingPublished": "已发布", + "listingNotInCatalog": "已发布,但未在市场浏览页展示", + "listingNotPublished": "尚未发布", + "listingInReview": "等待审核", + "listingDeprecated": "已弃用", + "listingUnavailable": "暂时无法查看条目状态", + "viewPublicProfile": "查看公开主页", + "listingSeparate": "发布市场条目需在设置页之外完成。", "wakeTitle": "唤醒", "mentionsAlwaysWake": "提及始终会唤醒此智能体。", "wakeOnMessage": "收到消息时唤醒", diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 916104db8b..e41e54a596 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -34,20 +34,45 @@ beforeEach(() => { if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'webhook' }, } } }); + if (url === '/api/registry/agents/maya-claude') return Promise.resolve({ data: { + status: 'active', registry: 'commonly-community', ephemeral: false, verified: true, + } }); return Promise.resolve({ data: [{ _id: 'tok-1', label: 'Laptop', createdAt: '2026-10-01T00:00:00Z', lastUsedAt: null, revokedAt: null, status: 'active', }] }); }); }); +test('Identity shows the listing state and links to the public profile without publishing', async () => { + renderPage(); + expect(await screen.findByText('Published')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View public profile' })) + .toHaveAttribute('href', '/v2/agent/maya-claude/default'); + expect(axios.post).not.toHaveBeenCalled(); +}); + +test('an ephemeral registry row is not presented as a marketplace listing', async () => { + const defaultGet = axios.get.getMockImplementation(); + axios.get.mockImplementation((url, options) => ( + url === '/api/registry/agents/maya-claude' + ? Promise.resolve({ data: { status: 'active', registry: 'commonly-community', ephemeral: true, verified: true } }) + : defaultGet(url, options) + )); + renderPage(); + expect(await screen.findByText('Not published')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View public profile' })).toBeInTheDocument(); +}); + test('wrapper key replacement cannot bypass the daemon reconnect flow', 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.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'wrapper' }, } } }); @@ -102,11 +127,78 @@ 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.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); + 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.each([ + [403, "You don't have permission to change this agent's identity."], + [409, 'This agent is installed in pods you cannot manage. An admin can rename it across every pod.'], + [503, 'Name saved, but chat has not caught up. Try saving again in a moment.'], +])('identity refusal %i uses localized UI copy', async (status, expectedMessage) => { + axios.patch.mockRejectedValue({ response: { status, data: { error: 'Raw English server text' } } }); + renderPage(); + await screen.findByRole('button', { name: 'Save identity' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Maya 2' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); + expect(await screen.findByText(expectedMessage)).toBeInTheDocument(); + expect(screen.queryByText('Raw English server text')).not.toBeInTheDocument(); +}); + +test('a taken display name has its own refusal, separate from the writable-pods 409', async () => { + axios.patch.mockRejectedValue({ response: { status: 409, data: { + code: 'agent_display_name_taken', error: 'Raw English server text', + } } }); + renderPage(); + await screen.findByRole('button', { name: 'Save identity' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Scout (Cody)' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); + expect(await screen.findByText('Another agent already uses this name.')).toBeInTheDocument(); + expect(screen.queryByText('Raw English server text')).not.toBeInTheDocument(); +}); + +test('a partial rename tells the installer to retry Save', async () => { + axios.patch.mockRejectedValue({ response: { + status: 503, data: { code: 'identity_projection_retryable', error: 'Raw server text' }, + } }); + renderPage(); + await screen.findByRole('button', { name: 'Save identity' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Maya 2' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); + expect(await screen.findByText('Name update was interrupted. Save again to finish updating every pod.')) + .toBeInTheDocument(); +}); + 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: { displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'second', displayName: 'Maya', runtime: { runtimeType: 'wrapper' }, } } }); @@ -144,6 +236,7 @@ test('an in-flight replacement never reveals its key on another agent page', asy if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: other ? 'Other agent' : 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { name: other ? 'other' : 'maya-claude', instanceId: 'default', displayName: other ? 'Other agent' : 'Maya', runtime: { runtimeType: 'webhook' }, @@ -186,6 +279,7 @@ test('a partial installation owner cannot see identity-wide key controls', async if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'webhook' }, } } }); @@ -209,11 +303,12 @@ test('revocation uses the credential id only after confirmation', async () => { })); }); -test('selects a pod the owner can read when the first identity pod is inaccessible', async () => { +test('selects the owner-writable pod even when another identity pod comes first', async () => { axios.get.mockImplementation((url) => { if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: 'Maya', pods: [{ id: 'foreign', name: 'Other pod' }, { id: 'owned', name: 'My pod' }], } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'owned' } }); if (url.includes('/pods/foreign/')) return Promise.reject({ response: { status: 403 } }); if (url.includes('/pods/owned/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'webhook' }, @@ -237,6 +332,7 @@ test('runtime editor writes environment controls through the shared settings hoo if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'wrapper' }, diff --git a/frontend/src/v2/__tests__/useAgentSettings.test.tsx b/frontend/src/v2/__tests__/useAgentSettings.test.tsx index 84cacbe4c8..0c3473705b 100644 --- a/frontend/src/v2/__tests__/useAgentSettings.test.tsx +++ b/frontend/src/v2/__tests__/useAgentSettings.test.tsx @@ -31,8 +31,9 @@ const Probe = () => { test('reads one accessible installation and patches it with the instance id', async () => { axios.get.mockImplementation((url) => { if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { - displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + displayName: 'Maya', pods: [{ id: 'pod-other', name: 'Other owner' }, { id: 'pod-1', name: 'Workspace' }], } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', config: {}, } } }); @@ -40,6 +41,12 @@ test('reads one accessible installation and patches it with the instance id', as axios.patch.mockResolvedValue({ data: { agent: {} } }); render(); await screen.findByText('Maya'); + expect(axios.get).toHaveBeenCalledWith( + '/api/registry/pods/pod-1/agents/maya-claude', { params: { instanceId: 'default' } }, + ); + expect(axios.get).not.toHaveBeenCalledWith( + '/api/registry/pods/pod-other/agents/maya-claude', expect.anything(), + ); fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( '/api/registry/pods/pod-1/agents/maya-claude', diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 34cec8f189..9e87921548 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4718,3 +4718,17 @@ 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)'); + }); + test('listing status and public-profile link remain visible in the Settings flow', () => { + expect(ruleBody(css, '.v2-agent-settings__listing')).toContain('display: grid'); + expect(ruleBody(css, '.v2-root .v2-agent-settings .v2-agent-settings__listing a')).toContain('color: var(--v2-accent)'); + expect(ruleBody(css, '.v2-root .v2-agent-settings .v2-agent-settings__listing a:focus-visible')).toContain('box-shadow: var(--v2-focus-ring)'); + }); +}); diff --git a/frontend/src/v2/agents/V2AgentIdentitySection.tsx b/frontend/src/v2/agents/V2AgentIdentitySection.tsx new file mode 100644 index 0000000000..8fb3e38fd0 --- /dev/null +++ b/frontend/src/v2/agents/V2AgentIdentitySection.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import axios from '../../utils/axiosConfig'; +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); + const [listingStatus, setListingStatus] = useState('listingLoading'); + + useEffect(() => { + let active = true; + setListingStatus('listingLoading'); + void axios.get<{ + status?: string; registry?: string; ephemeral?: boolean; verified?: boolean; + }>(`/api/registry/agents/${encodeURIComponent(installation.name)}`) + .then(({ data }) => { + if (!active) return; + if (data.ephemeral || data.registry === 'private' || data.status === 'unpublished') { + setListingStatus('listingNotPublished'); + } else if (data.status === 'pending-review') { + setListingStatus('listingInReview'); + } else if (data.status === 'deprecated') { + setListingStatus('listingDeprecated'); + } else if (data.status === 'active') { + setListingStatus(data.verified ? 'listingPublished' : 'listingNotInCatalog'); + } else { + setListingStatus('listingNotPublished'); + } + }).catch((requestError) => { + if (!active) return; + const status = (requestError as { response?: { status?: number } })?.response?.status; + setListingStatus(status === 404 ? 'listingNotPublished' : 'listingUnavailable'); + }); + return () => { active = false; }; + }, [installation.name]); + + 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 response = (requestError as { response?: { status?: number; data?: { code?: string } } })?.response; + const status = response?.status; + const messageKey: Record = { + 400: 'agentSettings.identityNameError', + 403: 'agentSettings.identityPermissionError', + 409: 'agentSettings.identityScopeError', + 503: 'agentSettings.identityMirrorError', + }; + const code = response?.data?.code; + setError(t(code === 'agent_display_name_taken' + ? 'agentSettings.identityNameTakenError' + : code === 'identity_projection_retryable' + ? 'agentSettings.identityProjectionError' + : messageKey[status || 0] || 'agentSettings.identitySaveError')); + } finally { + setBusy(false); + } + }; + + return + {t('agentSettings.identityTitle')} + + {t('agentSettings.displayName')} + setDisplayName(event.target.value)} /> + + {t('agentSettings.instructions')} + setInstructions(event.target.value)} /> + + {t('agentSettings.persona')} + {t('agentSettings.tone')} + setPersona({ ...persona, tone: event.target.value })}> + {['friendly', 'professional', 'casual', 'formal', 'technical'].map((tone) => ( + {t(`agentSettings.toneOptions.${tone}`)} + ))} + + + {t('agentSettings.specialties')} + setPersona({ ...persona, specialties: event.target.value })} /> + + {t('agentSettings.boundaries')} + setPersona({ ...persona, boundaries: event.target.value })} /> + + {t('agentSettings.personaNotes')} + setPersona({ ...persona, customInstructions: event.target.value })} /> + + + + {busy ? t('agentSettings.saving') : t('agentSettings.saveIdentity')} + {saved && {t('agentSettings.identitySaved')}} + + {error && {error}} + + + {t('agentSettings.listingTitle')} + {t(`agentSettings.${listingStatus}`)} + + {t('agentSettings.viewPublicProfile')} + + {t('agentSettings.listingSeparate')} + + ; +}; + +export default V2AgentIdentitySection; diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 2b554961b9..aeaef5bca9 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -7,6 +7,7 @@ import V2AgentRuntimeGrants, { Runtime } from '../components/V2AgentRuntimeGrant import { AgentControlLoader } from '../components/V2AgentControlSection'; import { AgentConnection } from '../utils/agentConnection'; import { useAgentSettings } from './useAgentSettings'; +import V2AgentIdentitySection from './V2AgentIdentitySection'; import V2AgentWakePods from './V2AgentWakePods'; import './v2-agent-settings.css'; @@ -228,6 +229,7 @@ const V2AgentSettingsPage: React.FC = () => { )} + {installation && } | null; + profile?: { + instructions?: string; + persona?: { tone?: string; specialties?: string[]; boundaries?: string[]; customInstructions?: string }; + } | null; [field: string]: unknown; } @@ -15,7 +19,7 @@ export interface AgentSettingsPatch { config?: Record; displayName?: string; instructions?: string; - persona?: string; + persona?: { tone?: string; specialties?: string[]; boundaries?: string[]; customInstructions?: string }; modelPreferences?: unknown; toolPolicy?: unknown; contextPolicy?: unknown; @@ -54,27 +58,25 @@ export const useAgentSettings = (agentName: string, instanceId: string) => { `/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`, ); const nextAccess = accessResponse.data; - // Memory lists every pod for the identity. A viewer can be a member of - // only some of them, so find a readable installation before writing. - for (const pod of nextAccess.pods || []) { - try { - const response = await axios.get<{ agent: AgentSettingsInstallation }>(installationUrl(pod.id), { - params: { instanceId }, - }); - if (currentRequest === requestId.current) { - setAccess(nextAccess); - setInstallation(response.data.agent); - setPodId(pod.id); - setError(null); - } - return response.data.agent; - } catch (candidateError) { - if (![403, 404].includes((candidateError as { response?: { status?: number } })?.response?.status || 0)) { - throw candidateError; - } - } + // Memory includes readable pods that may have another installer. Settings + // selects an installer/admin-owned row so every write targets a pod the + // viewer can actually manage. + const settingsResponse = await axios.get<{ writablePodId: string | null }>( + `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`, + { params: { instanceId } }, + ); + const writablePodId = settingsResponse.data.writablePodId; + if (!writablePodId) throw new Error('No manageable installation'); + const response = await axios.get<{ agent: AgentSettingsInstallation }>(installationUrl(writablePodId), { + params: { instanceId }, + }); + if (currentRequest === requestId.current) { + setAccess(nextAccess); + setInstallation(response.data.agent); + setPodId(writablePodId); + setError(null); } - throw new Error('No accessible installation'); + return response.data.agent; }, [agentName, instanceId, installationUrl]); useEffect(() => { diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css index c44dbc01b0..44473bbd38 100644 --- a/frontend/src/v2/agents/v2-agent-settings.css +++ b/frontend/src/v2/agents/v2-agent-settings.css @@ -81,6 +81,24 @@ .v2-agent-settings__reveal { display: grid; gap: 12px; font-size: 13px; } .v2-agent-settings__reveal code { display: block; overflow-wrap: anywhere; padding: 10px; background: var(--v2-surface-tint); font-family: var(--v2-font-mono); } .v2-agent-settings__reveal button { justify-self: start; } +.v2-agent-settings__identity { display: grid; gap: 18px; max-width: 680px; margin-top: 20px; } +.v2-agent-settings__identity label { display: grid; gap: 7px; font-size: 13px; font-weight: 600; } +.v2-agent-settings__identity fieldset { display: grid; gap: 16px; margin: 0; padding: 18px; border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius); } +.v2-agent-settings__identity legend { padding: 0 6px; font-weight: 700; } +.v2-root .v2-agent-settings .v2-agent-settings__identity input, +.v2-root .v2-agent-settings .v2-agent-settings__identity textarea, +.v2-root .v2-agent-settings .v2-agent-settings__identity select { + width: 100%; padding: 9px 10px; border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); background: var(--v2-surface); color: var(--v2-text-primary); + font: inherit; font-weight: 400; +} +.v2-agent-settings__identity textarea { resize: vertical; } +.v2-agent-settings__identity .v2-agent-settings__actions { align-items: center; font-size: 13px; color: var(--v2-text-secondary); } +.v2-agent-settings__listing { display: grid; gap: 8px; max-width: 680px; margin-top: 26px; padding-top: 20px; border-top: 1px solid var(--v2-border-soft); } +.v2-agent-settings__listing h3 { margin: 0; font-family: var(--v2-font-display); font-size: 16px; } +.v2-agent-settings__listing p { margin: 0; color: var(--v2-text-secondary); font-size: 13px; } +.v2-root .v2-agent-settings .v2-agent-settings__listing a { justify-self: start; color: var(--v2-accent); font-size: 13px; font-weight: 600; } +.v2-root .v2-agent-settings .v2-agent-settings__listing a:focus-visible { box-shadow: var(--v2-focus-ring); outline: 1px solid var(--v2-accent); } @media (max-width: 760px) { .v2-agent-settings { padding: 16px; }
{error}
{t(`agentSettings.${listingStatus}`)}
{t('agentSettings.listingSeparate')}