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
195 changes: 195 additions & 0 deletions backend/__tests__/service/agent-identity-settings.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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: [] },
]),
}),
});
Expand All @@ -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 },
);
});

Expand Down Expand Up @@ -351,17 +459,13 @@ describe('agent config PATCH — installer gate', () => {
.send({
instanceId: 'curator',
config: { environment: { version: 1, sandbox: { mode: 'workspace' } } },
displayName: 'Renamed',
});

expect(res.status).toBe(200);
expect(res.body.updatedPods).toBe(1);
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();
});
});
Loading
Loading