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
57 changes: 57 additions & 0 deletions backend/__tests__/services/dmService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
afterAll(async () => {
await mongoose.disconnect();
if (mongoServer) {
try { await mongoServer.stop(); } catch (_) { /* ignore */ }

Check warning on line 40 in backend/__tests__/services/dmService.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

'_' is defined but never used
}
});

Expand Down Expand Up @@ -150,4 +150,61 @@

expect(room.name).toBe('tarik');
});
it.each(['nova', 'Nova [reviewer]'])('reuses the agent-admin pod created with resolved label %s', async (label) => {
agentUser.botMetadata = { agentName: 'openclaw', instanceId: 'nova', displayName: label === 'nova' ? 'openclaw (nova)' : label };

Check warning on line 154 in backend/__tests__/services/dmService.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 133. Maximum allowed is 120
await agentUser.save();
const options = { agentName: 'openclaw', instanceId: 'nova' };
const first = await DMService.getOrCreateAgentDM(agentUser._id, humanUser._id, options);
const second = await DMService.getOrCreateAgentDM(agentUser._id, humanUser._id, options);
expect(first.name).toBe(`DM: ${label}`);
expect(second._id.toString()).toBe(first._id.toString());
expect(await Pod.countDocuments({ type: 'agent-admin' })).toBe(1);
expect(second.members.map(String).sort()).toEqual([agentUser._id, humanUser._id].map(String).sort());
});

it.each(['Supernova', 'nova notes'])('never adds an agent to the unrelated owner-only DM %s', async (otherLabel) => {
agentUser.botMetadata = { agentName: 'openclaw', instanceId: 'nova', displayName: 'openclaw (nova)' };
await agentUser.save();
const unrelated = await Pod.create({ name: `DM: ${otherLabel}`, description: `Debug channel for ${otherLabel}`,
type: 'agent-admin', createdBy: humanUser._id, members: [humanUser._id], joinPolicy: 'invite-only' });
const room = await DMService.getOrCreateAgentDM(agentUser._id, humanUser._id, { agentName: 'openclaw', instanceId: 'nova' });

Check warning on line 170 in backend/__tests__/services/dmService.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 129. Maximum allowed is 120
expect(room._id.toString()).not.toBe(unrelated._id.toString());
const stored = await Pod.findById(unrelated._id);
expect(stored.name).toBe(`DM: ${otherLabel}`);
expect(stored.members.map(String)).toEqual([humanUser._id.toString()]);
});

it('reuses a punctuation-ending description after the pod name is edited', async () => {
agentUser.botMetadata = { agentName: 'openclaw', instanceId: 'nova', displayName: 'Nova [reviewer]' };
await agentUser.save();
const first = await DMService.getOrCreateAgentDM(agentUser._id, humanUser._id, { agentName: 'openclaw', instanceId: 'nova' });

Check warning on line 180 in backend/__tests__/services/dmService.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 130. Maximum allowed is 120
first.name = 'Private review notes';
await first.save();
const second = await DMService.getOrCreateAgentDM(agentUser._id, humanUser._id, { agentName: 'openclaw', instanceId: 'nova' });

Check warning on line 183 in backend/__tests__/services/dmService.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 131. Maximum allowed is 120
expect(second._id.toString()).toBe(first._id.toString());
expect(await Pod.countDocuments({ type: 'agent-admin' })).toBe(1);
});

it.each(['nova', 'openclaw (nova)'])('never repairs an owner-only pod from an ambiguous exact label %s', async (label) => {

Check warning on line 188 in backend/__tests__/services/dmService.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 125. Maximum allowed is 120
agentUser.botMetadata = { agentName: 'openclaw', instanceId: 'nova', displayName: 'openclaw (nova)' };
await agentUser.save();
const unrelated = await Pod.create({ name: `DM: ${label}`, description: `Debug channel for ${label}`,
type: 'agent-admin', createdBy: humanUser._id, members: [humanUser._id], joinPolicy: 'invite-only' });
const room = await DMService.getOrCreateAgentDM(agentUser._id, humanUser._id, { agentName: 'openclaw', instanceId: 'nova' });

Check warning on line 193 in backend/__tests__/services/dmService.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 129. Maximum allowed is 120
expect(room._id.toString()).not.toBe(unrelated._id.toString());
expect((await Pod.findById(unrelated._id)).members.map(String)).toEqual([humanUser._id.toString()]);
expect(room.members.map(String).sort()).toEqual([agentUser._id, humanUser._id].map(String).sort());
});

it('reuses a legacy composite label only when the requested agent is already a member', async () => {
agentUser.botMetadata = { agentName: 'legacy-runtime', instanceId: 'nova', displayName: 'legacy-runtime (nova)' };
await agentUser.save();
const legacy = await Pod.create({ name: 'Private notes', description: 'Debug channel for legacy-runtime (nova)',
type: 'agent-admin', createdBy: humanUser._id, members: [humanUser._id, agentUser._id], joinPolicy: 'invite-only' });

Check warning on line 203 in backend/__tests__/services/dmService.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 123. Maximum allowed is 120
const room = await DMService.getOrCreateAgentDM(agentUser._id, humanUser._id, { agentName: 'openclaw', instanceId: 'nova' });

Check warning on line 204 in backend/__tests__/services/dmService.test.ts

View workflow job for this annotation

GitHub Actions / Backend typecheck and lint

This line has a length of 129. Maximum allowed is 120
expect(room._id.toString()).toBe(legacy._id.toString());
expect(room.members.map(String).sort()).toEqual([agentUser._id, humanUser._id].map(String).sort());
expect(await Pod.countDocuments({ type: 'agent-admin' })).toBe(1);
});

});
60 changes: 60 additions & 0 deletions backend/__tests__/unit/middleware/dualAuth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const rateLimit = require('express-rate-limit');
const express = require('express');
const request = require('supertest');

jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '') || req.header('x-auth-token');
if (token !== 'valid-user') {
return res.status(401).json({ verifier: 'human' });
}
req.userId = 'person-1';
return next();
}));
jest.mock('../../../middleware/agentRuntimeAuth', () => jest.fn((req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '').trim() || req.header('x-commonly-agent-token');
if (token !== 'cm_agent_valid') return res.status(401).json({ verifier: 'agent' });
req.agentUser = { _id: 'agent-1' };
return next();
}));
const dualAuth = require('../../../middleware/dualAuth');
const humanAuth = require('../../../middleware/auth');
const agentAuth = require('../../../middleware/agentRuntimeAuth');
const app = express();
// Keep even this unserved HTTP fixture behind the normal limiter-before-auth order.
const limiter = rateLimit({ windowMs: 60000, max: 100 });
app.get('/', limiter, dualAuth, (req, res) => res.json({ human: req.userId || null, agent: req.agentUser?._id || null }));
beforeEach(() => jest.clearAllMocks());

test.each([
[{ Authorization: 'Bearer cm_agent_valid' }, 200, 'agent'],
[{ 'x-commonly-agent-token': 'cm_agent_valid' }, 200, 'agent'],
[{ Authorization: 'Bearer valid-user' }, 200, 'human'],
[{ 'x-auth-token': 'valid-user' }, 200, 'human'],
[{ 'x-auth-token': 'cm_agent_valid' }, 401, 'human'],
[{ 'x-commonly-agent-token': 'invalid', 'x-auth-token': 'valid-user' }, 200, 'human'],
[{ Authorization: 'Bearer invalid', 'x-auth-token': 'valid-user' }, 401, 'human'],
[{ Authorization: 'Bearer valid-user', 'x-commonly-agent-token': 'cm_agent_valid' }, 200, 'human'],
[{ Authorization: 'Bearer cm_agent_revoked', 'x-auth-token': 'valid-user' }, 401, 'agent'],
[{ Authorization: 'Bearer invalid', 'x-commonly-agent-token': 'cm_agent_valid' }, 401, 'human'],
[{ 'x-commonly-agent-token': 'cm_daemon_invalid' }, 401, 'human'],
[{ 'x-commonly-agent-token': 'cm_agent_revoked' }, 401, 'agent'],
[{}, 401, 'human'],
])('selects one verifier and preserves refusal for %j', async (headers, status, verifier) => {
const res = await request(app).get('/').set(headers);
expect(res.status).toBe(status);
expect(humanAuth).toHaveBeenCalledTimes(verifier === 'human' ? 1 : 0);
expect(agentAuth).toHaveBeenCalledTimes(verifier === 'agent' ? 1 : 0);
if (status === 200) expect(res.body).toEqual(verifier === 'agent'
? { human: null, agent: 'agent-1' } : { human: 'person-1', agent: null });
});

test.each([
[{ Authorization: 'Bearer valid-user', 'x-commonly-agent-token': 'cm_agent_valid' }, 'human', 'valid-user'],
[{ 'x-commonly-agent-token': 'cm_agent_valid', 'x-auth-token': 'spoof-a' }, 'agent', 'cm_agent_valid'],
[{ 'x-commonly-agent-token': 'cm_agent_valid', 'x-auth-token': 'spoof-b' }, 'agent', 'cm_agent_valid'],
[{ 'x-commonly-agent-token': 'not-a-runtime-token', 'x-auth-token': 'valid-user' }, 'human', 'valid-user'],
[{ 'x-auth-token': 'cm_agent_valid' }, 'human', 'cm_agent_valid'],
])('the limiter keys the selected verifier credential for %j', (headers, verifier, token) => {
const { resolveDualAuthCredential } = require('../../../middleware/dualAuth');
expect(resolveDualAuthCredential({ header: (name) => headers[name] })).toEqual({ verifier, token });
});
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ describe('POST /pods dedup refuses to widen a 1:1 DM (ADR-001 §3.10)', () => {
// FOUND — its type or its id — since that is what the caller is fishing
// for and did not already know.
const body = JSON.stringify(dm.body);
expect(body).toContain('commonly_open_dm');
expect(body).toContain('commonly_dm_agent');
expect(body).not.toMatch(/agent-dm|agent-room|pod-agent-dm/);
});

Expand Down
20 changes: 16 additions & 4 deletions backend/__tests__/unit/routes/github.upstreamErrorRoutes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@
* configuration and signs locally; it must keep its honest local 500.
*/

jest.mock('../../../middleware/agentRuntimeAuth', () => (req, res, next) => {
jest.mock('../../../middleware/agentRuntimeAuth', () => jest.fn((req, res, next) => {
req.agentUser = { _id: 'bot-1' };
next();
});
}));

jest.mock('../../../middleware/auth', () => (req, res, next) => {
jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => {
req.user = { _id: 'user-1', role: 'member' };
req.userId = 'user-1';
next();
});
}));

jest.mock('../../../services/githubAppService', () => ({
isPatConfigured: jest.fn(),
Expand Down Expand Up @@ -188,3 +188,15 @@ describe('routes that lent out the server GitHub credential stay removed', () =>
);
});
});

test('GitHub issue reads accept the alternate runtime header without human auth', async () => {
const agentAuth = require('../../../middleware/agentRuntimeAuth');
const humanAuth = require('../../../middleware/auth');
jest.clearAllMocks();
GitHubAppService.isConfigured.mockReturnValue(true);
GitHubAppService.listOpenIssues.mockResolvedValue([]);
const res = await request(app).get('/api/github/issues').set('x-commonly-agent-token', 'cm_agent_valid');
expect(res.status).toBe(200);
expect(agentAuth).toHaveBeenCalledTimes(1);
expect(humanAuth).not.toHaveBeenCalled();
});
20 changes: 20 additions & 0 deletions backend/__tests__/unit/routes/grants.read.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -540,3 +540,23 @@ describe('GET /api/grants/:grantId — the seat confinement refusal (TASK-063)',
expect((await read(row.grantId)).body.grantBrokerRefusalScope).toBe('seat');
});
});

test.each(['human', 'dual', 'agent'])('grant %s routes budget the actual credential despite unrelated header rotation', async (mode) => {
const credential = `${mode === 'human' ? 'user' : 'cm_agent'}_budget_${mode}`;
const send = (index, token = credential) => {
if (mode === 'human') return request(app).post('/api/grants').set('x-test-user', OWNER)
.set('x-auth-token', token).set('x-commonly-agent-token', `cm_agent_unrelated_${index}`).send({});
const path = mode === 'agent' ? '/api/grants/missing/attenuate' : '/api/grants/missing';
const req = mode === 'agent' ? request(app).post(path) : request(app).get(path);
return req.set('x-test-agent', SEAT).set('x-commonly-agent-token', token).set('x-auth-token', `unrelated_${index}`);
};
const expected = 404; // Authenticated handler refuses the deliberately missing connection/grant.
for (let index = 0; index < 60; index += 1) expect((await send(index)).status).toBe(expected);
expect((await send(60)).status).toBe(429);
expect((await send(61, `${credential}_other`)).status).toBe(expected);
// A read through the dual dispatcher spends the same budget as the write path.
const read = request(app).get('/api/grants/missing');
if (mode === 'human') read.set('x-test-user', OWNER).set('x-auth-token', credential);
else read.set('x-test-agent', SEAT).set('x-commonly-agent-token', credential);
expect((await read).status).toBe(429);
});
29 changes: 26 additions & 3 deletions backend/__tests__/unit/routes/tasksApi.queryCoercion.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
const request = require('supertest');
const express = require('express');

jest.mock('../../../middleware/auth', () => (req, res, next) => {
jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => {
req.userId = 'u1';
req.user = { id: 'u1', _id: 'u1' };
next();
});
jest.mock('../../../middleware/agentRuntimeAuth', () => (req, res, next) => next());
}));
jest.mock('../../../middleware/agentRuntimeAuth', () => jest.fn((req, res, next) => { req.agentUser = { _id: 'u1' }; next(); }));

jest.mock('../../../models/Pod', () => ({
findById: jest.fn(() => ({
Expand Down Expand Up @@ -56,6 +56,29 @@ describe('GET /api/v1/tasks/:podId — query coercion at the boundary', () => {
app.use('/api/v1/tasks', tasksApiRoutes);
});

test('task reads accept the alternate runtime header without human auth', async () => {
const agentAuth = require('../../../middleware/agentRuntimeAuth');
const humanAuth = require('../../../middleware/auth');
const res = await request(app).get(`/api/v1/tasks/${POD}`).set('x-commonly-agent-token', 'cm_agent_valid');
expect(res.status).toBe(200);
expect(agentAuth).toHaveBeenCalledTimes(1);
expect(humanAuth).not.toHaveBeenCalled();
});

test('an alternate runtime token keeps one write budget while x-auth-token rotates', async () => {
for (let i = 0; i < 20; i += 1) {
const response = await request(app).post(`/api/v1/tasks/${POD}`)
.set('x-commonly-agent-token', 'cm_agent_budget').set('x-auth-token', `unrelated-${i}`).send({});
expect(response.status).toBe(400); // Missing title, after the real limiter/auth chain.
}
const refused = await request(app).post(`/api/v1/tasks/${POD}`)
.set('x-commonly-agent-token', 'cm_agent_budget').set('x-auth-token', 'another-unused-header').send({});
expect(refused.status).toBe(429);
const control = await request(app).post(`/api/v1/tasks/${POD}`)
.set('x-commonly-agent-token', 'cm_agent_other_budget').set('x-auth-token', 'unrelated-0').send({});
expect(control.status).toBe(400);
});

// Domain guard, by the rule these tests are an instance of: every assertion
// below is negative-form (`not.toHaveProperty`), and a negative passes on a
// subject that does not exist. Asserting the query is well-formed FIRST means
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,9 @@ describe('resolveAgentDisplayLabel', () => {
});
});
});

it('preserves a curated default-instance name equal to the agent name', () => {
expect(resolveAgentDisplayLabel({ username: 'pixel-default', botMetadata: {
agentName: 'pixel', instanceId: 'default', displayName: 'Pixel',
} })).toBe('Pixel');
});
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,17 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => {
});
});

test('a DM membership refusal names both runtime and MCP tools without posting', async () => {
AgentIdentityService.ensureAgentInPod.mockResolvedValueOnce(null);
const Pod = require('../../../models/Pod');
Pod.findById.mockReturnValueOnce({ select: () => ({ lean: async () => ({ type: 'agent-dm' }) }) });
await expect(AgentMessageService.postMessage({ agentName: 'lark', podId: POD, content: 'hello' })).rejects.toMatchObject({
code: 'dm_membership_refused', statusCode: 403,
message: expect.stringContaining('commonly_open_dm or commonly_dm_agent'),
});
expect(PGMessage.create).not.toHaveBeenCalled();
expect(emitted).toEqual([]);
});

test('server run identity survives PG storage and live message broadcast with existing card fields', async () => {
const runId = 'aaaaaaaaaaaaaaaaaaaaa111';
Expand Down
40 changes: 40 additions & 0 deletions backend/__tests__/unit/services/dmService.displayLabel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const mockUser = { value: null };
jest.mock('../../../models/User', () => ({
findById: () => ({ select: () => ({ lean: async () => mockUser.value }) }),
}));
jest.mock('../../../models/Pod', () => {
const Pod = jest.fn(function Pod(data) {
Object.assign(this, data, { _id: 'room-1', save: async () => {} });
});
Pod.findOne = jest.fn().mockResolvedValue(null);
Pod.find = () => ({ sort: () => ({ limit: async () => [] }) });
return Pod;
});
jest.mock('../../../models/pg/Pod', () => null);
jest.mock('../../../models/AgentRegistry', () => ({
AgentInstallation: { findOne: () => ({ sort: () => ({ lean: async () => null }) }), install: jest.fn() },
}));
const DMService = require('../../../services/dmService');
const { AgentInstallation } = require('../../../models/AgentRegistry');

test.each([
['openclaw (nova)', 'nova'], ['OpenClaw (NOVA)', 'nova'], ['openclaw', 'nova'],
['Nova, the reviewer', 'Nova, the reviewer'],
])('room and installation names resolve %s as %s', async (displayName, expected) => {
mockUser.value = { username: 'openclaw-nova', botMetadata: { agentName: 'openclaw', instanceId: 'nova', displayName } };
const room = await DMService.getOrCreateAgentRoom('agent-1', 'person-1', { agentName: 'openclaw', instanceId: 'nova' });
expect(room.name).toBe(expected);
expect(AgentInstallation.install).toHaveBeenLastCalledWith('openclaw', 'room-1', expect.objectContaining({ displayName: expected }));
});

test('the agent-admin DM creation path uses the same runtime-leak guard', async () => {
mockUser.value = { username: 'openclaw-nova', botMetadata: { agentName: 'openclaw', instanceId: 'nova', displayName: 'openclaw (nova)' } };
const room = await DMService.getOrCreateAgentDM('agent-1', 'person-1', { agentName: 'openclaw', instanceId: 'nova' });
expect(room.name).toBe('DM: nova');
expect(room.description).toBe('Debug channel for nova');
});
test('a curated default-instance label is preserved in a new room', async () => {
mockUser.value = { username: 'pixel-default', botMetadata: { agentName: 'pixel', instanceId: 'default', displayName: 'Pixel' } };
const room = await DMService.getOrCreateAgentRoom('agent-1', 'person-1', { agentName: 'pixel', instanceId: 'default' });
expect(room.name).toBe('Pixel');
});
25 changes: 25 additions & 0 deletions backend/middleware/dualAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Request, Response, NextFunction } from 'express';
import auth from './auth';
import agentRuntimeAuth from './agentRuntimeAuth';

/** The chosen credential also keys rate limits. An unrelated header cannot
* rotate the bucket while a runtime token continues to authenticate. */
export function resolveDualAuthCredential(
req: Pick<Request, 'header'>,
): { verifier: 'agent' | 'human'; token: string } {
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 { verifier: 'agent', token };
// x-auth-token remains a human-verifier input, even if its text starts cm_agent_.
return { verifier: 'human', token: bearer || req.header?.('x-auth-token') || '' };
}

/** Select once; refusal never falls through to another identity's verifier. */
export default function dualAuth(req: Request, res: Response, next: NextFunction) {
if (resolveDualAuthCredential(req).verifier === 'agent') return agentRuntimeAuth(req, res, next);
return auth(req, res, next);
}
// CJS compatibility for the existing route modules.
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = exports.default; Object.assign(module.exports, exports);
Loading
Loading