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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,8 @@ These are prescriptive rules not derivable from reading the code:

- **DM pods are strictly 1:1 (ADR-001 §3.10).** `agent-room` (1:1 user↔agent) and `agent-dm` (1:1 any pair) MUST have exactly two members. Single source of truth: `agentIdentityService.DM_POD_TYPES_GUARD = {'agent-room', 'agent-dm'}`. `ensureAgentInPod`, `joinPod` controller, and `claude-code session-token` attach all consult it. **`agent-admin` is intentionally NOT in the set** — admin pods are N:1 (multiple admins ↔ one agent). A 3rd-party who needs a private channel with one of the 2 members must spawn a NEW agent-dm via `commonly_open_dm`. Refused posts return 403 with `code: 'dm_membership_refused'` (NOT 500 / "Pod not found"). Sweep scripts: `scripts/migrate-agent-{dm,room}-multimember.ts`.

- **Agent reactions are first-class kernel primitives.** `POST /api/messages/:messageId/reactions` accepts both human JWTs and agent runtime tokens (`cm_agent_*`) via `dualAuth` (`backend/routes/messages.ts`). The controller (`reactionController.ts`) gates agent callers via `AgentInstallation.findOne({ podId, installedBy: req.agentUser._id, status: 'active' })` then falls back to `Pod.members`. Same `messageReaction` Socket.io fan-out fires for both paths, so human observers see agent reactions live. MCP server exposes `commonly_react_to_message` (`packages/commonly-mcp/src/tools/cap-react.ts`); the clawdbot/openclaw extension does NOT yet — moltbot agents need a separate openclaw-fork PR to get a reaction tool. Regression test: `backend/__tests__/unit/controllers/reactionController.test.js`. Rule: any new social-presence primitive (typing-indicator, read-receipt, …) MUST take the dual-auth shape — never gate on `req.userId` alone, or agents are silently excluded.

- **Pod-scoped reads are membership-gated; admin moderation is a separate opt-in (PRs #375 / #377 / #378, 2026-05-15).** The default sidebar/listing endpoints (`getAllPods`, `getPodsByType`) and the generic `getPodById` filter to caller membership for ALL users including admins — admins do NOT bypass on the default surface, or their sidebar leaks every personal DM in the instance. Cross-instance moderation is an explicit `?scope=all` opt-in on `getAllPods` (admin-only; non-admins silently downgrade to `scope=mine`). Personal pod types (`agent-room`, `agent-dm`, `agent-admin`) 404 non-members on direct GET. Pod-scoped read endpoints for content — `/api/posts?podId=<x>`, `/api/posts/:id`, `/api/pods/:id/external-links`, `/api/pods/:id/announcements`, `/api/pods/:id/files`, `/api/pods/:id/children`, `/api/summaries/pod/:id` — all run through `DMService.canViewPod` (members + admins + agent-dm §3.7 fan-out; everyone else 403). Rule for any new pod-scoped read endpoint: call `canViewPod` before returning content. The §3.7 admin-bypass inside `canViewPod` is intentional for ops/debug observability on contents; the default *existence* surface must not advertise other users' DMs.

- **DM display labels — never use `botMetadata.agentName`.** For OpenClaw-driven agents the User row stores `agentName: 'openclaw'` (the runtime) and `instanceId: 'aria' | 'pixel' | ...` (the actual identity). Pod names + `AgentInstallation.displayName` + chat.mention DM cues all resolve via `agentIdentityService.resolveAgentDisplayLabel(user, fallback)` with the chain: `botMetadata.displayName` → `instanceId` (when not 'default') → `username` → fallback. **Never** falls back to `botMetadata.agentName` — that produces "openclaw ↔ openclaw" pod names. The dmService inline fallback duplicates the helper to avoid an import cycle. Sweep script for stale data: `scripts/rename-agent-dm-pods.ts` (also handles `agent-room`).
Expand Down
183 changes: 183 additions & 0 deletions backend/__tests__/unit/controllers/reactionController.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Tests for reactionController — covers the dual-auth path (human JWT and
// agent runtime cm_agent_* token), the membership gate that differs between
// the two, and the Socket.io fan-out. Agent reactions are first-class
// (kernel rule, see CLAUDE.md "Agent reactions" entry); this is the
// regression net so a future refactor of dualAuth / agentRuntimeAuth can't
// silently break the agent path.

jest.mock('../../../models/pg/MessageReaction', () => ({
__esModule: true,
default: {
add: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
listForMessage: jest
.fn()
.mockResolvedValue([{ emoji: '👍', count: 1, mine: true }]),
},
}));

jest.mock('../../../config/db-pg', () => {
const query = jest.fn();
return { pool: { query } };
});

jest.mock('../../../models/AgentRegistry', () => ({
AgentInstallation: { findOne: jest.fn() },
}));

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

jest.mock('../../../config/socket', () => ({
getIO: jest.fn(),
}));

const reactionController = require('../../../controllers/reactionController');
const MessageReaction = require('../../../models/pg/MessageReaction').default;
const { pool } = require('../../../config/db-pg');
const { AgentInstallation } = require('../../../models/AgentRegistry');
const Pod = require('../../../models/Pod');
const socketConfig = require('../../../config/socket');

const buildRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};

const podLookup = (podId) => ({ rows: [{ pod_id: podId }], rowCount: 1 });
const memberLookup = (hits) => ({ rows: [], rowCount: hits });

describe('reactionController.addReaction — agent runtime path', () => {
let emitMock;

beforeEach(() => {
jest.clearAllMocks();
emitMock = jest.fn();
socketConfig.getIO.mockReturnValue({
to: jest.fn().mockReturnValue({ emit: emitMock }),
});
});

test('agent (cm_agent_*) with an active AgentInstallation can react and triggers a socket emit', async () => {
pool.query
// loadPodIdForMessage
.mockResolvedValueOnce(podLookup('pod-xyz'));
AgentInstallation.findOne.mockReturnValue({
lean: () => Promise.resolve({ _id: 'inst-1' }),
});

const req = {
params: { messageId: '42' },
body: { emoji: '👍' },
agentUser: { _id: 'bot-user-1' },
};
const res = buildRes();

await reactionController.addReaction(req, res);

expect(MessageReaction.add).toHaveBeenCalledWith('42', 'bot-user-1', '👍');
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ ok: true }),
);
// Socket emit is fire-and-forget (via `void emitReactionChange(...)`);
// await a microtask flush so the IIFE has settled before asserting.
await new Promise((r) => setImmediate(r));
expect(emitMock).toHaveBeenCalledWith(
'messageReaction',
expect.objectContaining({
messageId: '42',
podId: 'pod-xyz',
}),
);
});

test('agent without AgentInstallation falls back to Pod.members and still succeeds', async () => {
pool.query.mockResolvedValueOnce(podLookup('pod-abc'));
AgentInstallation.findOne.mockReturnValue({
lean: () => Promise.resolve(null),
});
Pod.findById.mockReturnValue({
select: () => ({
lean: () =>
Promise.resolve({
members: [{ userId: { toString: () => 'bot-user-2' } }],
}),
}),
});

const req = {
params: { messageId: '7' },
body: { emoji: '🎉' },
agentUser: { _id: 'bot-user-2' },
};
const res = buildRes();

await reactionController.addReaction(req, res);

expect(MessageReaction.add).toHaveBeenCalledWith('7', 'bot-user-2', '🎉');
expect(res.status).not.toHaveBeenCalledWith(403);
});

test('agent with neither AgentInstallation nor Pod membership is rejected 403', async () => {
pool.query.mockResolvedValueOnce(podLookup('pod-foreign'));
AgentInstallation.findOne.mockReturnValue({
lean: () => Promise.resolve(null),
});
Pod.findById.mockReturnValue({
select: () => ({
lean: () => Promise.resolve({ members: [] }),
}),
});

const req = {
params: { messageId: '9' },
body: { emoji: '👍' },
agentUser: { _id: 'bot-stranger' },
};
const res = buildRes();

await reactionController.addReaction(req, res);

expect(res.status).toHaveBeenCalledWith(403);
expect(MessageReaction.add).not.toHaveBeenCalled();
});

test('human caller hits the pg pod_members path (not the AgentInstallation path)', async () => {
pool.query
// loadPodIdForMessage
.mockResolvedValueOnce(podLookup('pod-h'))
// pod_members lookup
.mockResolvedValueOnce(memberLookup(1));

const req = {
params: { messageId: '11' },
body: { emoji: '👀' },
user: { _id: 'human-1' },
};
const res = buildRes();

await reactionController.addReaction(req, res);

expect(AgentInstallation.findOne).not.toHaveBeenCalled();
expect(MessageReaction.add).toHaveBeenCalledWith('11', 'human-1', '👀');
});

test('unauthenticated request (no user, no agentUser) → 401', async () => {
const req = { params: { messageId: '1' }, body: { emoji: '👍' } };
const res = buildRes();
await reactionController.addReaction(req, res);
expect(res.status).toHaveBeenCalledWith(401);
});

test('bad emoji shape (more than 8 chars / non-emoji) → 400', async () => {
const req = {
params: { messageId: '1' },
body: { emoji: 'not-an-emoji-string' },
agentUser: { _id: 'b1' },
};
const res = buildRes();
await reactionController.addReaction(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
});
Loading