Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
npm ci --include=dev
cd ../frontend
npm install --legacy-peer-deps
cd ../commonly-mcp
npm ci --include=dev
cd ..

- name: Backend TypeScript check
Expand All @@ -60,6 +62,11 @@ jobs:
cd frontend
npx jest --coverage --watchAll=false --forceExit

- name: Run commonly-mcp tests
run: |
cd commonly-mcp
npm test

- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
Expand Down
206 changes: 206 additions & 0 deletions backend/__tests__/service/agentsRuntime.room.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* ADR-010 Phase 1 — `POST /api/agents/runtime/room` dual-auth integration test.
*
* The route used to be human-JWT only. Phase 1 refactors it to dual-auth via
* the `tasksApi.ts:34-36` pattern so agents holding a `cm_agent_*` token can
* open agent↔agent 1:1 rooms (the `commonly_dm_agent` MCP tool calls this).
*
* Exercises:
* - 401 when no auth header is present.
* - Human path (legacy): JWT user opens a room with an installed agent.
* - Agent path (new): runtime token holder opens a room with another agent.
* - 1:1 invariant: repeated calls return the same pod (idempotent upsert).
* - Self-DM rejected (would degenerate the 1:1).
* - Missing agentName → 400.
*/

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 { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry');

jest.mock('../../services/agentEventService', () => ({
enqueue: jest.fn(async () => ({ _id: 'stub-event' })),
}));

const registryRoutes = require('../../routes/registry');
const agentsRuntimeRoutes = require('../../routes/agentsRuntime');

const JWT_SECRET = 'test-jwt-secret-for-room';

jest.setTimeout(60_000);

describe('POST /api/agents/runtime/room — dual-auth (ADR-010 Phase 1)', () => {
let app;
let humanUser;
let humanToken;
let pod;
let aliceToken;
let bobToken;

const installAndIssueToken = async (agentName) => {
await request(app)
.post('/api/registry/install')
.set('Authorization', `Bearer ${humanToken}`)
.send({ agentName, podId: pod._id.toString(), scopes: ['context:read'] });
const res = await request(app)
.post(`/api/registry/pods/${pod._id}/agents/${agentName}/runtime-tokens`)
.set('Authorization', `Bearer ${humanToken}`)
.send({ label: `${agentName} test token` });
expect(res.status).toBe(200);
expect(res.body.token).toMatch(/^cm_agent_/);
return res.body.token;
};

const registerAgent = async (agentName, displayName) => {
await AgentRegistry.create({
agentName,
displayName,
description: `Test agent ${displayName}`,
registry: 'commonly-official',
verified: true,
manifest: {
name: agentName,
version: '1.0.0',
capabilities: [{ name: 'memory', description: 'memory' }],
context: { required: ['context:read'] },
runtime: { type: 'standalone', connection: 'rest' },
},
latestVersion: '1.0.0',
versions: [{ version: '1.0.0', publishedAt: new Date() }],
});
};

beforeAll(async () => {
process.env.JWT_SECRET = JWT_SECRET;
await setupMongoDb();

app = express();
app.use(express.json());
app.use('/api/registry', registryRoutes);
app.use('/api/agents/runtime', agentsRuntimeRoutes);

humanUser = await User.create({
username: 'room-test-human',
email: 'room-test@test.com',
password: 'password123',
});
humanToken = jwt.sign({ id: humanUser._id.toString() }, JWT_SECRET);

pod = await Pod.create({
name: 'Room Test Pod',
type: 'chat',
createdBy: humanUser._id,
members: [humanUser._id],
});

await registerAgent('alice', 'Alice');
await registerAgent('bob', 'Bob');
});

afterAll(async () => {
await closeMongoDb();
});

beforeEach(async () => {
await AgentInstallation.deleteMany({});
await Pod.deleteMany({ type: 'agent-room' });
await User.updateMany({ isBot: true }, { $set: { agentRuntimeTokens: [] } });

aliceToken = await installAndIssueToken('alice');
bobToken = await installAndIssueToken('bob');
});

it('returns 401 with no auth header', async () => {
const res = await request(app)
.post('/api/agents/runtime/room')
.send({ agentName: 'alice' });
expect(res.status).toBe(401);
});

describe('Human path (JWT)', () => {
it('opens a human↔agent room', async () => {
const res = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${humanToken}`)
.send({ agentName: 'alice' });
expect(res.status).toBe(200);
expect(res.body.room).toBeDefined();
expect(res.body.room.type).toBe('agent-room');
expect(res.body.room.members).toHaveLength(2);
});

it('returns 400 when agentName is missing', async () => {
const res = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${humanToken}`)
.send({});
expect(res.status).toBe(400);
});
});

describe('Agent path (cm_agent_* runtime token)', () => {
it('opens an agent↔agent room', async () => {
const res = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${aliceToken}`)
.send({ agentName: 'bob' });
expect(res.status).toBe(200);
expect(res.body.room).toBeDefined();
expect(res.body.room.type).toBe('agent-room');
expect(res.body.room.members).toHaveLength(2);
});

it('is idempotent — repeat call returns the same pod', async () => {
const first = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${aliceToken}`)
.send({ agentName: 'bob' });
const second = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${aliceToken}`)
.send({ agentName: 'bob' });
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(String(first.body.room._id)).toBe(String(second.body.room._id));
const allRooms = await Pod.find({ type: 'agent-room' });
expect(allRooms).toHaveLength(1);
});

it('opens the same room regardless of which side initiates', async () => {
const fromAlice = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${aliceToken}`)
.send({ agentName: 'bob' });
const fromBob = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${bobToken}`)
.send({ agentName: 'alice' });
expect(fromAlice.status).toBe(200);
expect(fromBob.status).toBe(200);
expect(String(fromAlice.body.room._id)).toBe(String(fromBob.body.room._id));
});

it('returns 400 on self-DM', async () => {
const res = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${aliceToken}`)
.send({ agentName: 'alice' });
expect(res.status).toBe(400);
expect(res.body.message).toMatch(/yourself/i);
});

it('returns 400 when agentName is missing', async () => {
const res = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${aliceToken}`)
.send({});
expect(res.status).toBe(400);
});
});
});
86 changes: 81 additions & 5 deletions backend/routes/agentsRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@
}),
});

// Dual-auth dispatcher (mirrors `backend/routes/tasksApi.ts:34-36`). Routes
// that accept BOTH human JWTs and agent runtime tokens use this — the token
// prefix distinguishes the two. `cm_agent_*` → `agentRuntimeAuth` (stamps
// `req.agentUser`), anything else → `auth` (stamps `req.userId`/`req.user`).
//
// Both `Authorization: Bearer <token>` and `x-commonly-agent-token: <token>`
// are checked because `agentRuntimeAuth` accepts either; missing one would
// silently route the alternate-header caller to the human path and 401.
//
// Per ADR-010, MCP tools wrap routes the agent runtime token can already
// authenticate against; for `/room` (the agent-room endpoint) that means
// adding the agent path without breaking the existing human path.
const dualAuth = (req: any, res: any, next: any) => {
const bearer = ((req.header?.('Authorization') || '').replace('Bearer ', '')).trim();
const altHeader = (req.header?.('x-commonly-agent-token') || '').trim();
const token = bearer || altHeader;
if (token.startsWith('cm_agent_')) return agentRuntimeAuth(req, res, next);
return auth(req, res, next);
};

const Integration = require('../models/Integration');
const AgentMemory = require('../models/AgentMemory');
const {
Expand Down Expand Up @@ -520,16 +540,72 @@
* office" framing was rejected during product review; the join/auto-install
* paths in podController/agentIdentityService now enforce strict 1:1.
*
* This endpoint currently accepts only `auth` (human JWT), so callers
* always create human↔agent DMs through it. Agent-initiated agent↔agent
* DMs are supported by `getOrCreateAgentRoom` at the service level but
* have no agent-runtime endpoint yet — file a follow-up if needed.
* Dual-auth (ADR-010 Phase 1):
* - Human path (JWT): existing semantics — caller must be the agent's
* installer or a member of an installed pod, then opens a human↔agent
* room with the target agent.
* - Agent path (`cm_agent_*` runtime token): caller is identified by the
* token's resolved User row (`req.agentUser`). No installation match
* required — any agent can open a 1:1 with any other agent. The 1:1
* invariant in `getOrCreateAgentRoom` is the correctness guard, and the
* only side effect is a new pod with two agent members. Self-DMs are
* refused because they would degenerate the 1:1.
*
* Request: { agentName, instanceId?, podId? }
* Response: { room: Pod }
*/
router.post('/room', auth, async (req: any, res: any) => {
router.post('/room', dualAuth, phase4RateLimit, async (req: any, res: any) => {
Comment thread
lilyshen0722 marked this conversation as resolved.
Dismissed
try {
// Agent-initiated path — caller authorized purely by their runtime token.
// `agentRuntimeAuth` populates `req.agentUser` for User-row tokens
// (`User.agentRuntimeTokens`) but NOT for the legacy installation-token
// path (`AgentInstallation.runtimeTokens`, line 119-122 in the middleware).
// For Phase 1 we resolve the missing User row from the installation's
// (agentName, instanceId) so both shapes work.
let callerAgentUser = req.agentUser;
if (!callerAgentUser && req.agentInstallation) {
callerAgentUser = await AgentIdentityService.getOrCreateAgentUser(
req.agentInstallation.agentName,
{ instanceId: req.agentInstallation.instanceId || 'default' },
);
}
const callerAgentUserId = callerAgentUser?._id;
if (callerAgentUserId) {
const {
agentName: rawAgentName,
instanceId: rawInstanceId,
} = req.body || {};
const agentName = String(rawAgentName || '').trim().toLowerCase();
const instanceId = String(rawInstanceId || '').trim() || 'default';
if (!agentName) {
return res.status(400).json({ message: 'agentName is required' });
}

// Resolve target agent's User row. `getOrCreateAgentUser` is upsert,
// which means a misspelled `agentName` materialises a ghost bot User
// row. ADR-010 Phase 1 accepts this side effect (it mirrors the
// existing human-path semantics on the same route, and the blast
// radius is bounded — the ghost is just an unattached User). A name-
// existence check or rate limit is filed for v1.x if abuse surfaces.
const targetAgentUser = await AgentIdentityService.getOrCreateAgentUser(
agentName,
{ instanceId },
);

if (String(targetAgentUser._id) === String(callerAgentUserId)) {
return res.status(400).json({ message: 'Cannot DM yourself' });
}

const room = await DMService.getOrCreateAgentRoom(
targetAgentUser._id,
callerAgentUserId,
{ agentName, instanceId },
);

return res.json({ room });
}

// Human path — existing implementation, unchanged below this line.
const userId = req.userId || req.user?.id;
if (!userId) {
return res.status(401).json({ message: 'Authentication required' });
Expand Down
Loading
Loading