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
23 changes: 23 additions & 0 deletions backend/__tests__/unit/cap/session-token.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const { MongoMemoryServer } = require('mongodb-memory-server');
const { MONGO_BINARY_VERSION, MONGOMS_DOWNLOAD_DIR } = require('../../utils/mongoBinaryConfig');
const jwt = require('jsonwebtoken');

jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({
podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false),
}));
const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy');

// Models
const User = require('../../../models/User');
const Pod = require('../../../models/Pod');
Expand Down Expand Up @@ -93,6 +98,7 @@ afterAll(async () => {
});

afterEach(async () => {
podHasOutsiderWritableGrant.mockResolvedValue(false);
// Clean up agent users and installations; restore pod to original membership
await User.deleteMany({ isBot: true });
await AgentInstallation.deleteMany({});
Expand All @@ -102,6 +108,23 @@ afterEach(async () => {
// ── tests ─────────────────────────────────────────────────────────────────────

describe('POST /api/registry/admin/agents/claude-code/session-token', () => {
test('refuses an admin seat attach in a protected pod before any identity, membership, install, or token write', async () => {
podHasOutsiderWritableGrant.mockResolvedValueOnce(true);
const before = await Pod.findById(testPod._id).lean();
const res = await request(app)
.post('/api/registry/admin/agents/claude-code/session-token')
.send({ podId: testPod._id.toString(), instanceId: 'protected-pod' });

expect(res.status).toBe(403);
expect(res.body).toEqual(expect.objectContaining({
code: 'outsider_grant_non_native_agent_join',
message: expect.stringContaining('Use a separate pod'),
}));
expect((await Pod.findById(testPod._id)).members.map(String)).toEqual(before.members.map(String));
expect(await User.findOne({ isBot: true, botType: 'agent' })).toBeNull();
expect(await AgentInstallation.findOne({ podId: testPod._id, instanceId: 'protected-pod' })).toBeNull();
});

test('returns 200 with token starting cm_agent_ and expiresAt ~24h from now', async () => {
const before = Date.now();
const res = await request(app)
Expand Down
28 changes: 28 additions & 0 deletions backend/__tests__/unit/controllers/podController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ jest.mock('../../../services/agentIdentityService', () => ({
// Mirror the production set so the test exercises the real guard.
DM_POD_TYPES_GUARD: new Set(['agent-room', 'agent-dm']),
}));
jest.mock('../../../services/outsiderGrantAgentJoinPolicy', () => ({
assertInstalledAgentMayJoinPod: jest.fn(),
}));
const { assertInstalledAgentMayJoinPod } = require('../../../services/outsiderGrantAgentJoinPolicy');

describe('podController', () => {
beforeEach(() => {
Expand Down Expand Up @@ -400,6 +404,30 @@ describe('podController', () => {
expect(res.status).not.toHaveBeenCalledWith(403);
});

it('joinPod refuses a bot before membership when its installation cannot enter a protected pod', async () => {
const pod = {
_id: 'chat-1', type: 'chat', members: ['human-id'],
joinPolicy: 'open', publicRead: true, communityListed: true,
};
Pod.findById.mockResolvedValue(pod);
User.findById.mockReturnValue({ select: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue({
isBot: true, botMetadata: { agentName: 'outside-agent', instanceId: 'default' },
}),
}) });
assertInstalledAgentMayJoinPod.mockRejectedValue(Object.assign(new Error('Use a separate pod'), {
code: 'outsider_grant_non_native_agent_join',
}));
const req = { params: { id: 'chat-1' }, userId: 'bot-id', user: {} };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await podController.joinPod(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
code: 'outsider_grant_non_native_agent_join',
}));
expect(Pod.updateOne).not.toHaveBeenCalled();
});

it('getPodsByType filters agent-room to caller membership for non-admins', async () => {
const otherPod = { _id: 'p1', type: 'agent-room', members: [{ _id: 'agent-id' }, { _id: 'someone-else' }] };
const myPod = { _id: 'p2', type: 'agent-room', members: [{ _id: 'agent-id' }, { _id: 'me' }] };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@

jest.mock('jsonwebtoken', () => ({ sign: jest.fn(), verify: jest.fn(), decode: jest.fn() }));

const mockSourceConfig = { value: {} };
jest.mock('../../../middleware/agentRuntimeAuth', () => (req, res, next) => {
req.agentUser = { _id: 'bot-1' };
req.agentInstallation = {
agentName: 'openclaw', instanceId: 'nova', version: '1.0.0', config: {}, scopes: [],
agentName: 'openclaw', instanceId: 'nova', version: '1.0.0', config: mockSourceConfig.value, scopes: [],
};
next();
});
Expand Down Expand Up @@ -71,10 +72,14 @@ jest.mock('../../../models/Activity', () => ({}));
jest.mock('../../../models/User', () => ({ find: jest.fn(), findById: jest.fn() }));
jest.mock('../../../models/Post', () => ({ findById: jest.fn() }));
jest.mock('../../../models/Integration', () => ({ find: jest.fn(), findOne: jest.fn() }));
jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({
podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false),
}));

const express = require('express');
const request = require('supertest');
const router = require('../../../routes/agentsRuntime');
const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy');

const app = express();
app.use(express.json());
Expand Down Expand Up @@ -105,6 +110,8 @@ describe('POST /pods dedup refuses to widen a 1:1 DM (ADR-001 §3.10)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFoundPod.value = null;
mockSourceConfig.value = {};
podHasOutsiderWritableGrant.mockResolvedValue(false);
});

describe.each(['agent-room', 'agent-dm'])('name collides with an existing %s', (type) => {
Expand Down Expand Up @@ -152,6 +159,29 @@ describe('POST /pods dedup refuses to widen a 1:1 DM (ADR-001 §3.10)', () => {
expect(res.status).not.toBe(403);
});

test('a protected pod refuses non-native dedup before member and install writes', async () => {
podHasOutsiderWritableGrant.mockResolvedValue(true);
mockSourceConfig.value = new Map([['runtime', { runtimeType: 'opencode' }]]);
mockFoundPod.value = existing('chat', asMember, { joinPolicy: 'invite-only' });
const res = await createPod();
expect(res.status).toBe(403);
expect(res.body.code).toBe('outsider_grant_non_native_agent_join');
expect(podSave).not.toHaveBeenCalled();
expect(mockInstall).not.toHaveBeenCalled();
});

test('a native Map config keeps its runtime declaration when copied into a protected pod', async () => {
podHasOutsiderWritableGrant.mockResolvedValue(true);
mockSourceConfig.value = new Map([['runtime', { runtimeType: 'native' }]]);
mockFoundPod.value = existing('chat', asMember, { joinPolicy: 'invite-only' });
const res = await createPod();
expect(res.status).toBe(200);
expect(mockInstall).toHaveBeenCalledWith('openclaw', mockFoundPod.value._id, expect.objectContaining({
config: expect.objectContaining({ runtime: { runtimeType: 'native' } }),
}));
expect(mockInstall.mock.calls.map(([agentName]) => agentName)).not.toContain('commonly-bot');
});

// ------------------------------------------------------------------
// A guessed name was a credential for joining any non-DM pod. The five
// other writers to `members` all gate on joinability; this branch gated on
Expand Down
30 changes: 30 additions & 0 deletions backend/__tests__/unit/routes/podInvites.management.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@ jest.mock('../../../models/PodInvite', () => ({
const Pod = require('../../../models/Pod');

jest.mock('../../../models/Pod');
jest.mock('../../../models/User', () => ({ findById: jest.fn() }));
jest.mock('../../../services/outsiderGrantAgentJoinPolicy', () => ({
assertInstalledAgentMayJoinPod: jest.fn(),
}));

jest.mock('../../../services/agentIdentityService', () => ({
DM_POD_TYPES_GUARD: new Set(['agent-room', 'agent-dm']),
}));

// eslint-disable-next-line import/no-unresolved, import/extensions
const routes = require('../../../routes/podInvites');
const User = require('../../../models/User');
const { assertInstalledAgentMayJoinPod } = require('../../../services/outsiderGrantAgentJoinPolicy');

const POD_ID = '507f1f77bcf86cd799439011';
const TOKEN = 'a'.repeat(32);
Expand Down Expand Up @@ -197,4 +203,28 @@ describe('pod invite management routes', () => {

expect(res.body.code).toBe('dm_membership_refused');
});

it('refuses a bot invite before membership when the pod has an outsider-writable grant', async () => {
mockFindOne.mockResolvedValue({ token: TOKEN, podId: POD_ID, isUsable: () => true });
const pod = memberPod({ members: [], save: jest.fn() });
Pod.findById.mockResolvedValue(pod);
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({
isBot: true, botMetadata: { agentName: 'outside-agent', instanceId: 'default' },
}) }),
});
assertInstalledAgentMayJoinPod.mockRejectedValue(Object.assign(new Error('Use a separate pod'), {
code: 'outsider_grant_non_native_agent_join',
}));

const res = await request(app).post(`/api/invites/${TOKEN}/redeem`).send({});
expect(res.status).toBe(403);
expect(res.body).toEqual(expect.objectContaining({
code: 'outsider_grant_non_native_agent_join',
message: expect.stringContaining('Use a separate pod'),
}));
expect(assertInstalledAgentMayJoinPod).toHaveBeenCalledWith(POD_ID, 'outside-agent', 'default');
expect(pod.members).toEqual([]);
expect(pod.save).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ jest.mock('../../../models/AgentProfile', () => ({
jest.mock('../../../models/User', () => ({
findById: jest.fn(),
}));
jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({
podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false),
}));

const Pod = require('../../../models/Pod');
const { AgentInstallation } = require('../../../models/AgentRegistry');
const AgentProfile = require('../../../models/AgentProfile');
const User = require('../../../models/User');
const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy');
const registryRoutes = require('../../../routes/registry');

const app = express();
Expand Down Expand Up @@ -85,9 +89,74 @@ const setPod = (over = {}) => {
describe('agent config PATCH — installer gate', () => {
beforeEach(() => {
jest.clearAllMocks();
podHasOutsiderWritableGrant.mockResolvedValue(false);
setCallerRole('user');
});

it('refuses a non-admin runtime switch before saving any fan-out row', async () => {
podHasOutsiderWritableGrant.mockResolvedValue(true);
const primary = installation({
installedBy: MEMBER,
config: new Map([['runtime', { runtimeType: 'native' }]]),
});
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValue([primary]);

const res = await request(app)
.patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', config: { runtime: { runtimeType: 'opencode' } } });

expect(res.status).toBe(403);
expect(res.body.code).toBe('runtime_type_change');
expect(primary.save).not.toHaveBeenCalled();
expect(primary.config.get('runtime')).toEqual({ runtimeType: 'native' });
expect(podHasOutsiderWritableGrant).not.toHaveBeenCalled();
});

it('allows an existing non-native installation to reactivate once the grant is revoked', async () => {
const primary = installation({
installedBy: MEMBER,
status: 'paused',
config: new Map([['runtime', { runtimeType: 'opencode' }]]),
});
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValue([primary]);
const res = await request(app)
.patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', status: 'active' });
expect(res.status).toBe(200);
expect(primary.save).toHaveBeenCalledTimes(1);
expect(primary.status).toBe('active');
});

it('refuses the entire runtime fan-out when a later pod holds a live grant', async () => {
podHasOutsiderWritableGrant.mockImplementation(async (podId) => String(podId) === 'pod-2');
const primary = installation({ installedBy: MEMBER, config: new Map([
['runtime', { runtimeType: 'native' }],
]) });
const peer = installation({ podId: 'pod-2', installedBy: MEMBER, config: new Map([
['runtime', { runtimeType: 'native' }],
]) });
setCallerRole('admin');
setPod();
Pod.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([
{ _id: 'pod-1', members: [{ userId: MEMBER }] },
{ _id: 'pod-2', members: [{ userId: MEMBER }] },
]) }) });
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', config: { runtime: { runtimeType: 'opencode' } } });
expect(res.status).toBe(403);
expect(res.body.code).toBe('outsider_grant_non_native_agent_join');
expect(primary.save).not.toHaveBeenCalled();
expect(peer.save).not.toHaveBeenCalled();
});

it('refuses a non-installer member changing config, and leaves the row alone', async () => {
const primary = installation();
setPod();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ jest.mock('../../../models/AgentRegistry', () => ({
install: jest.fn(),
},
}));
jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({
podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false),
}));

jest.mock('../../../models/Pod', () => ({
findById: jest.fn(),
Expand Down Expand Up @@ -66,6 +69,7 @@ const AgentProfile = require('../../../models/AgentProfile');
const AgentTemplate = require('../../../models/AgentTemplate');
const Activity = require('../../../models/Activity');
const AgentIdentityService = require('../../../services/agentIdentityService');
const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy');
const FirstContactService = require('../../../services/firstContactService');
const installRouter = require('../../../routes/registry/install');

Expand Down Expand Up @@ -110,6 +114,7 @@ describe('registry install runtimeType fallback', () => {

beforeEach(() => {
jest.clearAllMocks();
podHasOutsiderWritableGrant.mockResolvedValue(false);

Pod.findById.mockReturnValue(buildLeanChain({
_id: 'pod-1',
Expand Down Expand Up @@ -245,6 +250,30 @@ describe('registry install runtimeType fallback', () => {
expect(res.status).not.toHaveBeenCalledWith(500);
});

it('refuses a non-native install into a protected pod before any installation or membership write', async () => {
podHasOutsiderWritableGrant.mockResolvedValue(true);
AgentRegistry.getByName.mockResolvedValue({
agentName: 'sample-agent', displayName: 'Sample Agent', latestVersion: '1.0.0',
manifest: persistedManifest({
name: 'sample-agent', version: '1.0.0', context: { required: [] },
runtime: { type: 'standalone', runtimeType: 'opencode' },
}),
});
const req = {
body: { agentName: 'sample-agent', podId: 'pod-1', version: '1.0.0', config: {}, scopes: [] },
user: { id: 'user-1', username: 'installer' }, userId: 'user-1',
};
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await installHandler(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
code: 'outsider_grant_non_native_agent_join',
message: expect.stringContaining('Use a separate pod'),
}));
expect(AgentInstallation.install).not.toHaveBeenCalled();
expect(AgentIdentityService.ensureAgentInPod).not.toHaveBeenCalled();
});

it('does not copy manifest.runtime.type deployment metadata into runtimeType', async () => {
AgentRegistry.getByName.mockResolvedValue({
agentName: 'sample-agent',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
jest.mock('../../../services/agentIdentityService', () => ({
getAgentTypeConfig: jest.fn(() => ({ runtime: 'moltbot' })),
getOrCreateAgentUser: jest.fn(),
ensureAgentInPod: jest.fn(),
}));
jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({
podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(true),
}));

const AgentIdentityService = require('../../../services/agentIdentityService');
const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy');
const { reprovisionInstallation } = require('../../../routes/registry/reprovision');

describe('reprovision into a pod holding an outsider-writable grant', () => {
beforeEach(() => {
jest.clearAllMocks();
podHasOutsiderWritableGrant.mockResolvedValue(true);
});

it('refuses a non-native runtime before identity, token, or runtime side effects', async () => {
const installation = {
podId: 'pod-1', agentName: 'openclaw', instanceId: 'default', save: jest.fn(),
};
await expect(reprovisionInstallation({ installation })).rejects.toMatchObject({
code: 'outsider_grant_non_native_agent_join',
message: expect.stringContaining('Use a separate pod'),
});
expect(AgentIdentityService.getOrCreateAgentUser).not.toHaveBeenCalled();
expect(installation.save).not.toHaveBeenCalled();
});
});
Loading
Loading