Skip to content
Open
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
86 changes: 86 additions & 0 deletions backend/__tests__/service/agentsRuntime.crossAgent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,92 @@ describe('Cross-agent HTTP routes (ADR-003 Phase 4)', () => {
bobToken = await installAndIssueToken('bob');
});

describe('agent pod network routes', () => {
it('creates a team pod through the runtime route', async () => {
const res = await request(app)
.post('/api/agents/runtime/pods')
.set('Authorization', `Bearer ${aliceToken}`)
.send({
name: `Agent Sub-pod ${Date.now()}`,
description: 'Created by a collaborating agent',
type: 'team',
});

expect(res.status).toBe(201);
expect(res.body.type).toBe('team');
expect(res.body.members.map((member) => String(member._id || member)))
.toContain(String(res.body.createdBy._id || res.body.createdBy));
});

it('does not expose 1:1 agent rooms through pod discovery', async () => {
const aliceUser = await User.findOne({
isBot: true,
'botMetadata.agentName': 'alice',
});
const privateRoom = await Pod.create({
name: `Private Agent Room ${Date.now()}`,
type: 'agent-room',
createdBy: adminUser._id,
members: [adminUser._id, aliceUser._id],
});

const res = await request(app)
.get('/api/agents/runtime/pods')
.set('Authorization', `Bearer ${aliceToken}`);

expect(res.status).toBe(200);
expect(res.headers).toHaveProperty('ratelimit-policy');
expect(res.body.pods.map((candidate) => candidate.podId))
.not.toContain(privateRoom._id.toString());
});

it('refuses self-install into an invite-only agent-owned pod', async () => {
const aliceUser = await User.findOne({
isBot: true,
'botMetadata.agentName': 'alice',
});
const inviteOnlyPod = await Pod.create({
name: `Invite-only Agent Pod ${Date.now()}`,
type: 'team',
joinPolicy: 'invite-only',
createdBy: aliceUser._id,
members: [aliceUser._id],
});

const res = await request(app)
.post(`/api/agents/runtime/pods/${inviteOnlyPod._id}/self-install`)
.set('Authorization', `Bearer ${bobToken}`)
.send({});

expect(res.status).toBe(403);
expect(res.headers).toHaveProperty('ratelimit-policy');
expect(res.body.message).toMatch(/invite-only/i);
});

it('self-installs into an open agent-owned pod', async () => {
const aliceUser = await User.findOne({
isBot: true,
'botMetadata.agentName': 'alice',
});
const openPod = await Pod.create({
name: `Open Agent Pod ${Date.now()}`,
type: 'team',
joinPolicy: 'open',
createdBy: aliceUser._id,
members: [aliceUser._id],
});

const res = await request(app)
.post(`/api/agents/runtime/pods/${openPod._id}/self-install`)
.set('Authorization', `Bearer ${bobToken}`)
.send({});

expect(res.status).toBe(201);
expect(res.body.podId).toBe(openPod._id.toString());
expect(await AgentInstallation.isInstalled('bob', openPod._id, 'default')).toBe(true);
});
});

// ----------------------------------------------------------------------- //
// GET /memory/shared //
// ----------------------------------------------------------------------- //
Expand Down
20 changes: 15 additions & 5 deletions backend/routes/agentsRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2396,10 +2396,15 @@ async function ensureCommonlyBotInstalled(podId: any, installedBy: any) {
* List public pods the agent can discover and join.
* Returns pods ordered by recent activity, excluding DM pods.
*/
router.get('/pods', agentRuntimeAuth, async (req: any, res: any) => {
router.get('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 50);
const pods = await Pod.find({ type: { $nin: ['dm', 'agent-admin'] } })
const nonDiscoverableTypes = [
'dm', // legacy rows
'agent-admin',
...AgentIdentityService.DM_POD_TYPES_GUARD,
];
const pods = await Pod.find({ type: { $nin: nonDiscoverableTypes } })
.sort({ updatedAt: -1 })
.limit(limit)
.select('name description type members updatedAt')
Expand Down Expand Up @@ -2477,7 +2482,10 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an
return res.status(400).json({ message: 'name and type are required' });
}

const VALID_POD_TYPES = ['chat', 'study', 'games', 'agent-ensemble', 'agent-admin'];
// Keep parity with the Pod model and human creation route. `team` is the
// ordinary multi-human pod shape used by the v2 shell; excluding it here
// made agent-created sub-pods disappear from the Team filter.
const VALID_POD_TYPES = ['chat', 'study', 'games', 'agent-ensemble', 'agent-admin', 'team'];
if (!VALID_POD_TYPES.includes(type)) {
return res.status(400).json({ message: `Invalid pod type. Must be one of: ${VALID_POD_TYPES.join(', ')}` });
}
Expand Down Expand Up @@ -2604,15 +2612,17 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an
* member list. This allows agents to join pods they (or other agents) created without waiting
* for the 2-hour auto-join cron.
*/
router.post('/pods/:podId/self-install', agentRuntimeAuth, async (req: any, res: any) => {
router.post('/pods/:podId/self-install', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => {
try {
const agentUser = req.agentUser;
if (!agentUser) {
return res.status(403).json({ message: 'No bot user associated with this runtime token' });
}

const { podId } = req.params;
const pod = await Pod.findById(podId).select('_id name type createdBy members').lean();
const pod = await Pod.findById(podId)
.select('_id name type joinPolicy createdBy members')
.lean();
if (!pod) {
return res.status(404).json({ message: 'Pod not found' });
}
Expand Down
Loading
Loading