Skip to content
Draft
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
64 changes: 64 additions & 0 deletions backend/__tests__/service/operatorOnlyPodPolicy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const mongoose = require('mongoose');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils');
const express = require('express');
const request = require('supertest');
const { hash } = require('../../utils/secret');
const { AgentInstallation } = require('../../models/AgentRegistry');
const app = express();
app.use('/api/agents/runtime', require('../../routes/agentsRuntime'));
const User = require('../../models/User');
const Pod = require('../../models/Pod');
const Integration = require('../../models/Integration');
const { operatorOnlyPodAllowed } = require('../../services/operatorOnlyPodPolicy');
let owner; let bot; let pod; let installations;
beforeAll(setupMongoDb);
afterAll(closeMongoDb);
beforeEach(async () => {
await clearMongoDb();
owner = await User.create({ username: 'native-owner', email: 'native-owner@test.com', password: 'password123' });
bot = await User.create({ username: 'native-fixture', email: 'native-fixture@test.com', password: 'password123', isBot: true, botMetadata: { agentName: 'native-fixture', instanceId: 'default' }, agentRuntimeTokens: [{ tokenHash: hash('cm_agent_fixture'), label: 'fixture', createdAt: new Date(), lastUsedAt: new Date() }] });
pod = await Pod.create({ name: 'Private', type: 'chat', createdBy: owner._id,
members: [owner._id, bot._id], joinPolicy: 'invite-only', publicRead: false, communityListed: false });
installations = [{ agentName: 'native-fixture', instanceId: 'default', podId: pod._id, installedBy: owner._id }];
});
const allowed = () => operatorOnlyPodAllowed(String(pod._id), installations, bot);
test('only private operator and exact listener are allowed', async () => {
expect(await allowed()).toBe(true);
expect(await operatorOnlyPodAllowed(String(pod._id), installations, null)).toBe(true);
expect(await operatorOnlyPodAllowed('invalid', installations, bot)).toBe(false);
expect(await operatorOnlyPodAllowed(String(pod._id), [], bot)).toBe(false);
});
test.each([false, true])('added human/bot (%s) revokes eligibility', async (isBot) => {
const peer = await User.create({ username: 'peer', email: 'peer@test.com', password: 'password123', isBot });
await Pod.updateOne({ _id: pod._id }, { $push: { members: peer._id } });
expect(await allowed()).toBe(false);
});
test.each([{ type: 'agent-dm' }, { type: 'agent-room' }, { type: 'agent-admin' }, { publicRead: true }, { communityListed: true }, { joinPolicy: 'open' },
{ createdBy: new mongoose.Types.ObjectId() }, { members: [] }])('unsafe pod %j is refused', async (change) => {
await Pod.updateOne({ _id: pod._id }, { $set: change });
expect(await allowed()).toBe(false);
});
test('missing listener, mixed owners and other identity tokens are refused', async () => {
expect(await operatorOnlyPodAllowed(String(pod._id), installations, { _id: owner._id })).toBe(false);
installations.push({ ...installations[0], installedBy: new mongoose.Types.ObjectId() });
expect(await allowed()).toBe(false);
installations.pop();
await User.deleteOne({ _id: bot._id });
expect(await allowed()).toBe(false);
});
test('active bridge revokes private audience', async () => {
await Integration.create({ podId: pod._id, scope: 'pod', type: 'slack', status: 'connected',
isActive: true, createdBy: owner._id, config: {} });
expect(await allowed()).toBe(false);
});

test('actual runtime-token route checks current Mongo audience and denies unknown credentials', async () => {
await AgentInstallation.create({ ...installations[0], version: '1.0.0', status: 'active', scopes: ['context:read'] });
const get = (token) => request(app).get(`/api/agents/runtime/pods/${pod._id}/operator-only`).set('Authorization', `Bearer ${token}`);
expect((await get('cm_agent_invalid')).status).toBe(401);
const good = await get('cm_agent_fixture');
expect(good.status).toBe(200);
expect(good.body).toEqual({ allowed: true, podId: String(pod._id), agentName: 'native-fixture', instanceId: 'default' });
await Pod.updateOne({ _id: pod._id }, { $push: { members: new mongoose.Types.ObjectId() } });
expect((await get('cm_agent_fixture')).status).toBe(403);
});
22 changes: 22 additions & 0 deletions backend/__tests__/unit/routes/agentsRuntime.installations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

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

let mockOperatorAllowed = true;
jest.mock('../../../services/operatorOnlyPodPolicy', () => ({ operatorOnlyPodAllowed: jest.fn(async () => mockOperatorAllowed) }));

const mockInstallation = {
podId: 'pod-1',
instanceId: 'writer',
Expand Down Expand Up @@ -103,3 +106,22 @@ describe('GET /installations runtime adapter projection', () => {
}
});
});

describe('GET /pods/:podId/operator-only', () => {
test('projects authenticated identity or refuses without private details', async () => {
mockOperatorAllowed = true;
const ok = await request(app).get('/api/agents/runtime/pods/pod-1/operator-only');
expect(ok.status).toBe(200);
expect(ok.headers['cache-control']).toBe('no-store');
expect(ok.body).toEqual({ allowed: true, podId: 'pod-1', instanceId: 'writer' });
mockOperatorAllowed = false;
const denied = await request(app).get('/api/agents/runtime/pods/pod-1/operator-only');
expect(denied.status).toBe(403);
});
test('database failure refuses instead of authorizing', async () => {
require('../../../services/operatorOnlyPodPolicy').operatorOnlyPodAllowed.mockRejectedValueOnce(Error('DB unavailable'));
const result = await request(app).get('/api/agents/runtime/pods/pod-1/operator-only');
expect(result.status).toBe(503);
expect(result.body).not.toHaveProperty('allowed');
});
});
17 changes: 17 additions & 0 deletions backend/routes/agentsRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const MAX_UPSTREAM_STATUS = 599;
// ESM import shape but has trouble tracing rate-limit middleware through
// require() returns; using `import` here makes the recognition unambiguous.
import rateLimit from 'express-rate-limit';
import { operatorOnlyPodAllowed } from '../services/operatorOnlyPodPolicy';
import { platformIpRateLimit } from '../middleware/platformRateLimit';

const express = require('express');
Expand Down Expand Up @@ -502,6 +503,22 @@ router.delete('/messages/:messageId/claim', phase4RateLimit, agentRuntimeAuth, a
}
});

// Fail closed for full-tool in-process harnesses. Re-read before every turn.
router.get('/pods/:podId/operator-only', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => {
res.set('Cache-Control', 'no-store');
try {
const installations = req.agentInstallations || [];
if (!await operatorOnlyPodAllowed(req.params.podId, installations, req.agentUser)) {
return res.status(403).json({ code: 'operator_only_required' });
}
const identity = installations[0];
return res.json({ allowed: true, podId: req.params.podId,
agentName: identity.agentName, instanceId: identity.instanceId || 'default' });
} catch {
return res.status(503).json({ code: 'operator_only_unavailable' });
}
});

router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => {
try {
const installations = req.agentInstallations || [];
Expand Down
37 changes: 37 additions & 0 deletions backend/services/operatorOnlyPodPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Types } from 'mongoose';
import Pod from '../models/Pod';
import User from '../models/User';
import { podIsBridged } from './outsiderGrantPodEligibility';
import AgentIdentityService from './agentIdentityService';

/** Full-tool native sessions accept only their human operator and exact agent. */
export const operatorOnlyPodAllowed = async (
podId: string, installations: any[], agentUser: any,
): Promise<boolean> => {
if (!/^[a-f0-9]{24}$/i.test(podId) || !installations.length) return false;
const first = installations[0];
const owner = String(first.installedBy || '');
const instanceId = first.instanceId || 'default';
if (!/^[a-f0-9]{24}$/i.test(owner) || installations.some((row) => (
String(row.installedBy || '') !== owner || row.agentName !== first.agentName
|| (row.instanceId || 'default') !== instanceId
)) || !installations.some((row) => String(row.podId) === podId)) return false;
const listener = await User.findOne({
username: AgentIdentityService.buildAgentUsername(
AgentIdentityService.resolveAgentType(first.agentName), instanceId,
),
isBot: true,
}).select('_id').lean();
if (!listener || (agentUser && String(agentUser._id) !== String(listener._id))) return false;
const human = await User.findById(owner).select('isBot botType').lean();
if (!human || human.isBot || human.botType) return false;
const pod = await Pod.findById(new Types.ObjectId(podId))
.select('type createdBy members joinPolicy publicRead communityListed').lean();
if (!pod || ['agent-dm', 'agent-room', 'agent-admin'].includes(pod.type)
|| pod.joinPolicy !== 'invite-only' || pod.publicRead !== false
|| pod.communityListed !== false || String(pod.createdBy) !== owner) return false;
const members = (pod.members || []).map(String);
if (!members.includes(owner) || !members.includes(String(listener._id))
|| members.some((member) => member !== owner && member !== String(listener._id))) return false;
return !await podIsBridged(String(pod._id));
};
2 changes: 1 addition & 1 deletion cli/__tests__/run-loop.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ await jest.unstable_mockModule('../src/lib/pod-focus.js', () => ({
}));

const { createClient } = await import('../src/lib/api.js');
const { performRun } = await import('../src/commands/agent.js');
const { performRun } = await import('../src/runtime.js');
const {
getSession,
setSession,
Expand Down
23 changes: 23 additions & 0 deletions cli/src/runtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* In-process runtime entry for harness plugins. This is the same consumer used
* by `commonly agent run`: claims, cascade admission, pod-separated batches,
* memory, delivery suppression and acknowledgement stay in the CLI.
*
* A native adapter implements `spawn(prompt, options)` and returns a Promise
* of `{ text, newSessionId? }`. Resolve only for the exact completed input,
* never merely because a shared session became idle. `options.metadata.event`
* identifies the source pod/batch; native adapters must keep separate sessions
* per pod. Runtime tokens in options are credentials, never prompt content.
*
* `performRun` returns `{ stop() }`. Stop prevents further polls but does not
* cancel an already-started adapter or HTTP request. A plugin owns its adapter
* cancellation/lifetime and must not cancel unrelated human work.
*
* This entry does not register/login an agent, enforce an OS sandbox around
* an in-process harness, or make mutable global process.env safe per turn.
* Full-tool native sessions must verify each bound pod through the deployed
* GET /api/agents/runtime/pods/:podId/operator-only policy before a turn and
* before releasing its response. This entry alone does not authorize a pod.
* Do not replace adapter-scoped credentials with a shared global token.
*/
export { performRun } from './commands/agent.js';
Loading