diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
new file mode 100644
index 0000000000..ff85ec0bb3
--- /dev/null
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -0,0 +1,371 @@
+const express = require('express');
+const rateLimit = require('express-rate-limit');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils');
+
+process.env.PG_HOST = '';
+process.env.NODE_ENV = 'test';
+
+const mockOwner = { id: new mongoose.Types.ObjectId().toString() };
+jest.mock('../../middleware/auth', () => {
+ const auth = (req, _res, next) => { req.userId = mockOwner.id; req.user = { id: mockOwner.id }; next(); };
+ auth.touchLastActive = () => {};
+ return auth;
+});
+jest.mock('../../services/starterTaskService', () => ({ completeConnectAgentStarterTask: jest.fn() }));
+
+const User = require('../../models/User');
+const Pod = require('../../models/Pod');
+const Integration = require('../../models/Integration');
+const AgentCredential = require('../../models/AgentCredential');
+const Machine = require('../../models/Machine');
+const { AgentInstallation, AgentRegistry } = require('../../models/AgentRegistry');
+const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth');
+const { hash } = require('../../utils/secret');
+const { privateListenerPodAllowed } = require('../../services/privateListenerPolicy');
+
+const app = express();
+app.use(express.json());
+app.use('/api/registry', require('../../routes/registry/install'));
+app.use('/api/registry', require('../../routes/registry/agent-config'));
+app.use('/api/registry', require('../../routes/registry/agent-tokens'));
+app.use('/api/agent-binding', require('../../routes/agentBinding'));
+app.use('/api/agents/runtime', require('../../routes/agentsRuntime'));
+app.get('/probe', rateLimit({ windowMs: 60000, max: 10000 }), agentRuntimeAuth,
+ (_req, res) => res.json({ ok: true }));
+
+const AGENT = 'private-listener-test';
+const DAEMON = `cm_daemon_${'a'.repeat(32)}`;
+let pod; let bot;
+
+const issue = () => request(app)
+ .post(`/api/registry/pods/${pod._id}/agents/${AGENT}/runtime-tokens`)
+ .send({ label: 'private listener' });
+const assigned = () => request(app).get('/api/agent-binding/assigned')
+ .set('Authorization', `Bearer ${DAEMON}`);
+const install = (agentName, path = '~/projects/example', targetPod = pod) => request(app).post('/api/registry/install').send({
+ agentName, podId: String(targetPod._id), scopes: ['context:read'],
+ config: { runtime: { runtimeType: 'webhook' }, environment: {
+ workspace: { path }, sandbox: { trust: 'private' },
+ } },
+});
+
+beforeAll(async () => { await setupMongoDb(); });
+afterAll(async () => { await closeMongoDb(); });
+beforeEach(async () => {
+ await clearMongoDb();
+ await User.create({ _id: mockOwner.id, username: 'private-owner', email: 'private-owner@test.com', password: 'password123' });
+ bot = await User.create({
+ username: AGENT, email: `${AGENT}@agents.commonly.local`, password: 'password123',
+ isBot: true, botMetadata: { agentName: AGENT, instanceId: 'default', machineId: 'private-machine' },
+ });
+ pod = await Pod.create({
+ name: 'Sole owner', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: mockOwner.id, members: [mockOwner.id, bot._id],
+ });
+ await AgentInstallation.create({
+ agentName: AGENT, instanceId: 'default', podId: pod._id,
+ installedBy: mockOwner.id, status: 'active', version: '1.0.0',
+ config: { runtime: { runtimeType: 'webhook' }, environment: {
+ workspace: { path: '~/projects/example' }, sandbox: { trust: 'private' },
+ } },
+ });
+ await AgentCredential.create({
+ tokenHash: hash(DAEMON), kind: 'daemon', ownerUserId: mockOwner.id,
+ machineId: 'private-machine', scopes: ['agents:adopt'],
+ });
+ await Machine.create({
+ machineId: 'private-machine', ownerUserId: mockOwner.id,
+ name: 'Private listener test', registeredByDeviceTokenHash: null,
+ });
+});
+
+test('a query-shaped pod id cannot broaden private-listener eligibility', async () => {
+ expect(await privateListenerPodAllowed({ $ne: null }, mockOwner.id)).toBe(false);
+ expect(await privateListenerPodAllowed(String(pod._id), mockOwner.id, AGENT)).toBe(true);
+ await Integration.create({
+ podId: pod._id, scope: 'pod', type: 'slack', status: 'connected', isActive: true,
+ createdBy: mockOwner.id, config: {},
+ });
+ expect(await privateListenerPodAllowed(String(pod._id).toUpperCase(), mockOwner.id)).toBe(false);
+});
+
+test('sole owner can issue; runtime bootstrap receives only the selected path and trust', async () => {
+ const minted = await issue();
+ expect(minted.status).toBe(200);
+ expect(minted.body.token).toMatch(/^cm_agent_/);
+ const installations = await request(app).get('/api/agents/runtime/installations')
+ .set('Authorization', `Bearer ${minted.body.token}`);
+ expect(installations.status).toBe(200);
+ expect(installations.body.installations[0].listenerEnvironment).toEqual({
+ workspace: { path: '~/projects/example' }, sandbox: { trust: 'private' },
+ });
+ expect(installations.body.installations[0]).not.toHaveProperty('config');
+ expect((await assigned()).body.agents).toHaveLength(1);
+});
+
+test('second human pauses existing token and daemon assignment; new issuance is refused without rotation', async () => {
+ const minted = await issue();
+ expect(minted.status).toBe(200);
+ const second = await User.create({ username: 'second', email: 'second@test.com', password: 'password123' });
+ await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: second._id } });
+ expect((await request(app).get('/probe').set('Authorization', `Bearer ${minted.body.token}`)).status).toBe(403);
+ expect((await assigned()).body.agents).toEqual([]);
+ const refused = await issue();
+ expect(refused.status).toBe(403);
+ expect(refused.body.code).toBe('private_listener_not_allowed');
+ expect(await User.countDocuments({ 'agentRuntimeTokens.tokenHash': hash(minted.body.token) })).toBe(1);
+});
+
+test('a live group bridge pauses a private listener and refuses new issuance', async () => {
+ const minted = await issue();
+ expect(minted.status).toBe(200);
+ await Integration.create({
+ podId: pod._id, scope: 'pod', type: 'slack', status: 'connected', isActive: true,
+ createdBy: mockOwner.id, config: {},
+ });
+ expect((await request(app).get('/probe').set('Authorization', `Bearer ${minted.body.token}`)).status).toBe(403);
+ expect((await assigned()).body.agents).toEqual([]);
+ const refused = await issue();
+ expect(refused.status).toBe(403);
+ expect(refused.body.code).toBe('private_listener_not_allowed');
+});
+
+test('an extra bot pauses current and legacy tokens, assignment, and private config writes', async () => {
+ const minted = await issue();
+ expect(minted.status).toBe(200);
+ const legacy = `cm_agent_${'d'.repeat(32)}`;
+ await AgentInstallation.updateOne({ agentName: AGENT }, {
+ $push: { runtimeTokens: { tokenHash: hash(legacy), createdAt: new Date() } },
+ });
+ const otherBot = await User.create({
+ username: 'other-bot', email: 'other-bot@agents.commonly.local', password: 'password123',
+ isBot: true, botMetadata: { agentName: 'other-bot', instanceId: 'default' },
+ });
+ await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: otherBot._id } });
+ for (const token of [minted.body.token, legacy]) {
+ const denied = await request(app).get('/probe').set('Authorization', `Bearer ${token}`);
+ expect(denied.status).toBe(403);
+ expect(denied.body.code).toBe('private_listener_paused');
+ }
+ expect((await assigned()).body.agents).toEqual([]);
+ expect((await issue()).status).toBe(403);
+ const changed = await request(app).patch(`/api/registry/pods/${pod._id}/agents/${AGENT}`).send({
+ config: { environment: { workspace: { path: '~/other' }, sandbox: { trust: 'private' } } },
+ });
+ expect(changed.status).toBe(403);
+ const row = await AgentInstallation.findOne({ agentName: AGENT });
+ expect(row.config.get('environment').workspace.path).toBe('~/projects/example');
+ expect(await User.countDocuments({ 'agentRuntimeTokens.tokenHash': hash(minted.body.token) })).toBe(1);
+});
+
+test('a different listener identity in the pod refuses a new private install before any write', async () => {
+ const denied = await install('other-private-listener');
+ expect(denied.status).toBe(403);
+ expect(denied.body.code).toBe('private_listener_not_allowed');
+ expect(await AgentRegistry.findOne({ agentName: 'other-private-listener' })).toBeNull();
+ expect(await AgentInstallation.countDocuments({ agentName: 'other-private-listener' })).toBe(0);
+});
+
+test('second human also pauses an older installation-bound runtime token', async () => {
+ const legacy = `cm_agent_${'b'.repeat(32)}`;
+ await AgentInstallation.updateOne({ agentName: AGENT }, {
+ $push: { runtimeTokens: { tokenHash: hash(legacy), createdAt: new Date() } },
+ });
+ expect((await request(app).get('/probe').set('Authorization', `Bearer ${legacy}`)).status).toBe(200);
+ const second = await User.create({ username: 'legacy-second', email: 'legacy-second@test.com', password: 'password123' });
+ await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: second._id } });
+ expect((await request(app).get('/probe').set('Authorization', `Bearer ${legacy}`)).status).toBe(403);
+});
+
+test.each([
+ ['open pod', { joinPolicy: 'open' }],
+ ['public pod', { publicRead: true }],
+])('%s cannot issue a private listener token', async (_label, change) => {
+ await Pod.updateOne({ _id: pod._id }, { $set: change });
+ const refused = await issue();
+ expect(refused.status).toBe(403);
+ expect(refused.body.code).toBe('private_listener_not_allowed');
+ expect(await User.countDocuments({ 'agentRuntimeTokens.0': { $exists: true } })).toBe(0);
+});
+
+test('install refuses private trust in a shared pod before creating a registry identity', async () => {
+ const second = await User.create({ username: 'install-second', email: 'install-second@test.com', password: 'password123' });
+ await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: second._id } });
+ const refused = await install('private-install-refused');
+ expect(refused.status).toBe(403);
+ expect(refused.body.code).toBe('private_listener_not_allowed');
+ expect(await AgentRegistry.findOne({ agentName: 'private-install-refused' })).toBeNull();
+});
+
+test('sole owner can install the private listener with the selected repo path', async () => {
+ await Pod.updateOne({ _id: pod._id }, { $pull: { members: bot._id } });
+ const created = await install('private-install-allowed');
+ expect(created.status).toBe(200);
+ const row = await AgentInstallation.findOne({ agentName: 'private-install-allowed' });
+ expect(row.config.get('environment')).toEqual({
+ workspace: { path: '~/projects/example' }, sandbox: { trust: 'private' },
+ });
+});
+
+test('relative repo paths are refused before creating an agent', async () => {
+ const refused = await install('private-relative-path', '../another-repo');
+ expect(refused.status).toBe(403);
+ expect(refused.body.code).toBe('private_listener_not_allowed');
+ expect(await AgentRegistry.findOne({ agentName: 'private-relative-path' })).toBeNull();
+});
+
+test('a newer private install cannot be shadowed by an older read-only installation', async () => {
+ const first = await request(app).post('/api/registry/install').send({
+ agentName: 'mixed-trust-agent', podId: String(pod._id), scopes: ['context:read'],
+ config: { runtime: { runtimeType: 'webhook' } },
+ });
+ expect(first.status).toBe(200);
+ const peerPod = await Pod.create({ name: 'Second owned pod', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: mockOwner.id, members: [mockOwner.id] });
+ const refused = await install('mixed-trust-agent', '~/projects/example', peerPod);
+ expect(refused.status).toBe(403);
+ expect(refused.body.code).toBe('private_listener_not_allowed');
+ expect(await AgentInstallation.countDocuments({ agentName: 'mixed-trust-agent' })).toBe(1);
+});
+
+test('a read-only choice cannot inherit private trust from an older installation', async () => {
+ await Pod.updateOne({ _id: pod._id }, { $pull: { members: bot._id } });
+ expect((await install('private-then-public')).status).toBe(200);
+ const peerPod = await Pod.create({ name: 'Read-only peer pod', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: mockOwner.id, members: [mockOwner.id] });
+ const refused = await request(app).post('/api/registry/install').send({
+ agentName: 'private-then-public', podId: String(peerPod._id), scopes: ['context:read'],
+ config: { runtime: { runtimeType: 'webhook' } },
+ });
+ expect(refused.status).toBe(403);
+ expect(refused.body.code).toBe('private_listener_requires_downgrade');
+ expect(await AgentInstallation.countDocuments({ agentName: 'private-then-public' })).toBe(1);
+});
+
+test('a config edit cannot renew private trust after another human joins', async () => {
+ const second = await User.create({ username: 'config-second', email: 'config-second@test.com', password: 'password123' });
+ await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: second._id } });
+ const refused = await request(app).patch(`/api/registry/pods/${pod._id}/agents/${AGENT}`).send({
+ config: { environment: { workspace: { path: '~/projects/changed' }, sandbox: { trust: 'private' } } },
+ });
+ expect(refused.status).toBe(403);
+ expect(refused.body.code).toBe('private_listener_not_allowed');
+ const row = await AgentInstallation.findOne({ agentName: AGENT });
+ expect(row.config.get('environment').workspace.path).toBe('~/projects/example');
+});
+
+test('owner can promote a read-only listener in a sole-human invite pod', async () => {
+ await AgentInstallation.updateOne({ agentName: AGENT }, {
+ $set: { 'config.environment': { sandbox: { trust: 'public' } } },
+ });
+ const changed = await request(app).patch(`/api/registry/pods/${pod._id}/agents/${AGENT}`).send({
+ config: { environment: { workspace: { path: '~/projects/promoted' }, sandbox: { trust: 'private' } } },
+ });
+ expect(changed.status).toBe(200);
+ const row = await AgentInstallation.findOne({ agentName: AGENT });
+ expect(row.config.get('environment').workspace.path).toBe('~/projects/promoted');
+});
+
+test('owner can downgrade to read-only after a second human joins', async () => {
+ const old = await issue();
+ expect(old.status).toBe(200);
+ const retainedMcp = [{ name: 'existing', transport: 'http', url: 'https://example.com/mcp' }];
+ const existing = await AgentInstallation.findOne({ agentName: AGENT });
+ existing.config.set('environment', { ...existing.config.get('environment'), mcp: retainedMcp });
+ await existing.save();
+ const legacy = `cm_agent_${'c'.repeat(32)}`;
+ await AgentInstallation.updateOne({ agentName: AGENT }, {
+ $push: { runtimeTokens: { tokenHash: hash(legacy), createdAt: new Date() } },
+ });
+ const second = await User.create({ username: 'downgrade-second', email: 'downgrade-second@test.com', password: 'password123' });
+ await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: second._id } });
+ const changed = await request(app).patch(`/api/registry/pods/${pod._id}/agents/${AGENT}`).send({
+ config: { environment: { sandbox: { trust: 'public' } } },
+ });
+ expect(changed.status).toBe(200);
+ // The same running CLI still has a private environment in memory. Its old
+ // bearer must stay dead even though the server config is now read-only.
+ expect((await request(app).get('/probe').set('Authorization', `Bearer ${old.body.token}`)).status).toBe(401);
+ expect((await request(app).get('/probe').set('Authorization', `Bearer ${legacy}`)).status).toBe(401);
+ expect(await User.countDocuments({ 'agentRuntimeTokens.tokenHash': hash(old.body.token) })).toBe(0);
+ expect((await issue()).status).toBe(200);
+ expect((await assigned()).body.agents[0].environment.sandbox.trust).toBe('public');
+ const row = await AgentInstallation.findOne({ agentName: AGENT });
+ expect(row.config.get('environment')).toEqual({ sandbox: { trust: 'public' }, mcp: retainedMcp });
+});
+
+test('downgrade fences a private mint paused before the public config save', async () => {
+ const original = await issue();
+ expect(original.status).toBe(200);
+ let releaseSave;
+ let reachedSave;
+ const release = new Promise((resolve) => { releaseSave = resolve; });
+ const reached = new Promise((resolve) => { reachedSave = resolve; });
+ const originalSave = AgentInstallation.prototype.save;
+ let intercepted = false;
+ const save = jest.spyOn(AgentInstallation.prototype, 'save').mockImplementation(async function (...args) {
+ if (!intercepted && this.agentName === AGENT && this.config.get('environment')?.sandbox?.trust === 'public') {
+ intercepted = true;
+ reachedSave();
+ await release;
+ }
+ return originalSave.apply(this, args);
+ });
+ const changing = request(app).patch(`/api/registry/pods/${pod._id}/agents/${AGENT}`)
+ .send({ config: { environment: { sandbox: { trust: 'public' } } } }).then((value) => value);
+ let minted;
+ try {
+ await reached;
+ expect((await request(app).get('/probe').set('Authorization', `Bearer ${original.body.token}`)).status).toBe(401);
+ minted = await issue();
+ } finally {
+ releaseSave();
+ await changing;
+ save.mockRestore();
+ }
+ const row = await AgentInstallation.findOne({ agentName: AGENT });
+ expect(row.config.get('environment').sandbox.trust).toBe('public');
+ if (minted.status === 200) {
+ expect((await request(app).get('/probe').set('Authorization', `Bearer ${minted.body.token}`)).status).toBe(401);
+ } else {
+ expect(minted.status).toBe(403);
+ }
+ const second = await User.create({ username: 'race-second', email: 'race-second@test.com', password: 'password123' });
+ await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: second._id } });
+ expect(await User.countDocuments({ 'agentRuntimeTokens.0': { $exists: true } })).toBe(0);
+});
+
+test('a private mint paused before its token save cannot revive after downgrade', async () => {
+ let releaseSave;
+ let reachedSave;
+ const release = new Promise((resolve) => { releaseSave = resolve; });
+ const reached = new Promise((resolve) => { reachedSave = resolve; });
+ const originalSave = User.prototype.save;
+ let intercepted = false;
+ const save = jest.spyOn(User.prototype, 'save').mockImplementation(async function (...args) {
+ if (!intercepted && String(this._id) === String(bot._id) && this.agentRuntimeTokens?.length) {
+ intercepted = true;
+ reachedSave();
+ await release;
+ }
+ return originalSave.apply(this, args);
+ });
+ const minting = issue().then((value) => value);
+ let minted;
+ try {
+ await reached;
+ const changed = await request(app).patch(`/api/registry/pods/${pod._id}/agents/${AGENT}`)
+ .send({ config: { environment: { sandbox: { trust: 'public' } } } });
+ expect(changed.status).toBe(200);
+ } finally {
+ releaseSave();
+ minted = await minting;
+ save.mockRestore();
+ }
+ expect(minted.status).toBe(403);
+ expect(minted.body.code).toBe('private_listener_not_allowed');
+ expect(await User.countDocuments({ 'agentRuntimeTokens.0': { $exists: true } })).toBe(0);
+ expect(await AgentInstallation.countDocuments({ 'runtimeTokens.0': { $exists: true } })).toBe(0);
+});
diff --git a/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js b/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js
index 61fb2317b1..10417548a2 100644
--- a/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js
+++ b/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js
@@ -53,11 +53,12 @@ const seed = async (sandbox, runtime = { runtimeType: 'wrapper', model: 'claude-
const owner = await User.create({ username: `o${tag % 1e6}`, email: `o${tag}@x.com`, password: 'x'.repeat(12) });
global.__CALLER_ID = String(owner._id);
const bot = await User.create({
- username: `b${tag % 1e6}`, email: `b${tag}@agents.commonly.local`, password: 'x'.repeat(12),
+ username: AGENT_NAME, email: `${AGENT_NAME}@agents.commonly.local`, password: 'x'.repeat(12),
isBot: true,
botMetadata: { agentName: AGENT_NAME, instanceId: 'default', machineId: 'machine-a' },
});
- const pod = await Pod.create({ name: `p${tag}`, createdBy: owner._id, members: [owner._id, bot._id] });
+ const pod = await Pod.create({ name: `p${tag}`, createdBy: owner._id,
+ joinPolicy: 'invite-only', members: [owner._id, bot._id] });
const environment = {
version: 1,
model: 'claude-opus-5',
diff --git a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js
index c1c09b014e..31631ea451 100644
--- a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js
+++ b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js
@@ -58,6 +58,13 @@ jest.mock('../../../services/agentIdentityService', () => ({
ensureAgentInPod: jest.fn().mockResolvedValue(undefined),
}));
+// This suite measures sole-installer authority; private-listener pod
+// eligibility has its own service suite with real installations and members.
+jest.mock('../../../services/privateListenerPolicy', () => ({
+ privateListenerIdentityAllowed: jest.fn().mockResolvedValue(true),
+ isPrivateListenerEnvironment: jest.fn((environment) => environment?.sandbox?.trust === 'private'),
+}));
+
const mockIsAdmin = jest.fn().mockResolvedValue(false);
jest.mock('../../../routes/registry/helpers', () => ({
getUserId: jest.fn((req) => req.userId),
diff --git a/backend/middleware/agentRuntimeAuth.ts b/backend/middleware/agentRuntimeAuth.ts
index 72759e67f5..d0bdb7797b 100644
--- a/backend/middleware/agentRuntimeAuth.ts
+++ b/backend/middleware/agentRuntimeAuth.ts
@@ -10,6 +10,7 @@ import AgentCredential from '../models/AgentCredential';
import { markByoConnectionAttempt } from '../services/onboardingMarks';
import { isSpawnCredential } from '../services/spawnCredentialService';
import { machineHasLiveAuthorization } from '../services/machineService';
+import { isPrivateListenerEnvironment, privateListenerIdentityAllowed } from '../services/privateListenerPolicy';
// eslint-disable-next-line global-require
const { hash } = require('../utils/secret') as { hash: (value: string) => string };
@@ -176,6 +177,13 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next
if (installations.length === 0) {
return res.status(401).json({ message: 'Agent not installed' });
}
+ if (installations.some((installation: any) => isPrivateListenerEnvironment(
+ installation.config instanceof Map
+ ? installation.config.get('environment')
+ : installation.config?.environment,
+ )) && !await privateListenerIdentityAllowed(agentName, instanceId, installations[0].installedBy)) {
+ return res.status(403).json({ code: 'private_listener_paused', message: 'Private listener pod is no longer owner-only' });
+ }
const installationPodIds = installations
.map((inst) => inst?.podId?.toString())
.filter(Boolean) as string[];
@@ -244,6 +252,13 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next
instanceId: installation.instanceId || 'default',
status: 'active',
});
+ if (allActiveInstallations.some((row: any) => isPrivateListenerEnvironment(
+ row.config instanceof Map ? row.config.get('environment') : row.config?.environment,
+ )) && !await privateListenerIdentityAllowed(
+ installation.agentName, installation.instanceId || 'default', installation.installedBy,
+ )) {
+ return res.status(403).json({ code: 'private_listener_paused', message: 'Private listener pod is no longer owner-only' });
+ }
req.agentInstallation = installation as never;
req.agentInstallations = allActiveInstallations as never[];
diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts
index 4eb11dbb3d..e90e6c0367 100644
--- a/backend/routes/agentBinding.ts
+++ b/backend/routes/agentBinding.ts
@@ -22,6 +22,7 @@ import {
import { ownsRuntimeIdentity, OwnershipFailure, describeRuntimeIdentityBlockers } from '../services/runtimeIdentityOwnership';
import { logAgentInstallRefusal } from '../middleware/logAgentInstallRefusal';
import { machineHasLiveAuthorization } from '../services/machineService';
+import { privateListenerIdentityAllowed } from '../services/privateListenerPolicy';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const auth = require('../middleware/auth');
@@ -216,6 +217,9 @@ router.post('/adopt', bindingRateLimit, daemonAuth('agents:adopt'), async (req:
code: ownership.failure,
});
}
+ if (!await privateListenerIdentityAllowed(agentName, instanceId, machine.ownerUserId)) {
+ return res.status(403).json({ code: 'private_listener_not_allowed', message: 'Private listener pod is no longer owner-only' });
+ }
// The CAS: only an UNBOUND identity transitions. matchedCount 0 with an
// existing identity means someone else holds the binding → 409.
const result = await User.updateOne(
@@ -268,6 +272,10 @@ router.post('/request', logAgentInstallRefusal('/api/agent-binding/request', 'wr
return res.status(403).json(await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure));
}
+ if (requestedMachineId && !await privateListenerIdentityAllowed(agentName, instanceId, req.user?.id)) {
+ return res.status(403).json({ code: 'private_listener_not_allowed', message: 'Private listener pod is no longer owner-only' });
+ }
+
// An agent Commonly runs in-process (the person's hosted agent) can't also
// run on a machine: its daemon would be refused at the runtime-token mint,
// so the page said "requested" and nothing ever ran. Refused here, with the
@@ -433,9 +441,13 @@ router.get('/assigned', bindingRateLimit, daemonAuth('agents:adopt'), async (req
],
}).select('_id botMetadata.agentName botMetadata.instanceId botMetadata.machineId botMetadata.requestedMachineId botMetadata.reconnectRequestedAt').lean() as AssignedIdentity[];
- const grantServers = await grantServersForIdentities(identities, byIdentity);
+ const allowed = await Promise.all(identities.map((identity) => privateListenerIdentityAllowed(
+ normalize(identity.botMetadata?.agentName), normalize(identity.botMetadata?.instanceId) || 'default', machine.ownerUserId,
+ )));
+ const safeIdentities = identities.filter((_identity, index) => allowed[index]);
+ const grantServers = await grantServersForIdentities(safeIdentities, byIdentity);
- const agents = identities.flatMap((identity: AssignedIdentity) => {
+ const agents = safeIdentities.flatMap((identity: AssignedIdentity) => {
const meta = identity.botMetadata || {};
const key = seatEnvironmentKey(meta.agentName, meta.instanceId);
const entry = byIdentity.get(key);
@@ -502,6 +514,10 @@ router.post('/runtime-token', bindingRateLimit, daemonAuth('agents:adopt'), asyn
});
}
+ if (!await privateListenerIdentityAllowed(agentName, instanceId, machine.ownerUserId)) {
+ return res.status(403).json({ code: 'private_listener_not_allowed', message: 'Private listener pod is no longer owner-only' });
+ }
+
const agentUser = await User.findOne({
isBot: true, 'botMetadata.agentName': agentName, 'botMetadata.instanceId': instanceId,
});
diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts
index 61fb4b1e55..46557bb77a 100644
--- a/backend/routes/agentsRuntime.ts
+++ b/backend/routes/agentsRuntime.ts
@@ -534,6 +534,10 @@ router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any,
const installationList = installations.map((inst: any) => {
const pod = podMap[String(inst?.podId)] || {};
+ const environment = inst?.config instanceof Map
+ ? inst.config.get('environment') : inst?.config?.environment;
+ const privateWorkspace = environment?.sandbox?.trust === 'private'
+ ? environment?.workspace?.path : null;
return {
podId: String(inst?.podId || ''),
podName: pod.name || null,
@@ -545,6 +549,9 @@ router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any,
// Known runtime kinds (such as a connect-page `webhook`) stay null.
// Expose only this non-secret discriminator, never the full config.
runtimeAdapter: adapterForRuntimeBinding(inst?.config?.runtime),
+ ...(typeof privateWorkspace === 'string' && privateWorkspace.trim()
+ ? { listenerEnvironment: { workspace: { path: privateWorkspace }, sandbox: { trust: 'private' } } }
+ : {}),
type: 'installation',
};
});
diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts
index f9d6ef407b..6db62d96cc 100644
--- a/backend/routes/registry/agent-config.ts
+++ b/backend/routes/registry/agent-config.ts
@@ -3,8 +3,9 @@
const express = require('express');
const auth = require('../../middleware/auth');
const { AgentInstallation } = require('../../models/AgentRegistry');
-const AgentProfile = require('../../models/AgentProfile');
+const User = require('../../models/User');
const AgentIdentityService = require('../../services/agentIdentityService');
+const AgentProfile = require('../../models/AgentProfile');
const Pod = require('../../models/Pod');
const {
writeWorkspaceIdentityFile,
@@ -24,12 +25,17 @@ const {
const {
normalizeToolPolicy,
normalizeContextPolicy,
+ revokeRuntimeTokensForAgent,
} = require('./tokens');
const { validateEnvironmentMcpEntries } = require('../../utils/environmentSpecValidation');
const { ownsRuntimeIdentity, webhookUrlRefusal } = require('../../services/runtimeIdentityOwnership');
const { configWriteRefusal, runtimeKeepingType } = require('../../services/configWriteGuard');
const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy');
const { hostedEngineInEffect } = require('../../services/hostedEngine');
+const {
+ isPrivateListenerEnvironment, privateListenerConfigurationValid,
+ privateListenerPodAllowed, privateListenerIdentityAllowed,
+} = require('../../services/privateListenerPolicy');
const agentConfigRouter = express.Router();
@@ -172,6 +178,19 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res:
fields: environmentErrors,
});
}
+ if (isPrivateListenerEnvironment(config.environment)) {
+ const existingConfig = normalizeConfigMap(installation.config) || {};
+ const runtimeType = config.runtime?.runtimeType || existingConfig.runtime?.runtimeType;
+ const eligible = privateListenerConfigurationValid(config.environment)
+ && await privateListenerPodAllowed(podId, userId, name.toLowerCase(), normalizedInstanceId)
+ && await privateListenerIdentityAllowed(name.toLowerCase(), normalizedInstanceId, userId, true);
+ if (!eligible || !['wrapper', 'webhook'].includes(runtimeType)) {
+ return res.status(403).json({
+ code: 'private_listener_not_allowed',
+ error: 'Full computer access requires an invite-only pod with only you and this listener, and an absolute repo path',
+ });
+ }
+ }
}
// Being this row's installer is not enough to point its push webhook
@@ -208,6 +227,17 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res:
...(existingConfig.environment?.sandbox || {}), ...environmentControls.sandbox,
} } : {}) };
}
+ const requestedTrust = config.environment?.sandbox?.trust;
+ if ((requestedTrust === 'private' || requestedTrust === 'public')
+ && existingConfig.environment && typeof existingConfig.environment === 'object') {
+ const nextEnvironment = { ...existingConfig.environment, ...config.environment };
+ if (isPrivateListenerEnvironment(existingConfig.environment)
+ && !isPrivateListenerEnvironment(nextEnvironment)
+ && !Object.prototype.hasOwnProperty.call(config.environment, 'workspace')) {
+ delete nextEnvironment.workspace;
+ }
+ nextConfig.environment = nextEnvironment;
+ }
if (config.runtime && typeof config.runtime === 'object') {
nextConfig.runtime = runtimeKeepingType(config.runtime, existingConfig, { mayChangeType: isInstanceAdmin });
}
@@ -370,6 +400,29 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res:
}
}
+ const downgradesPrivateListener = config?.environment !== undefined
+ && !isPrivateListenerEnvironment(config.environment)
+ && installationsToUpdate.some((entry: any) => isPrivateListenerEnvironment(
+ normalizeConfigMap(entry.config)?.environment,
+ ));
+ const revokePrivateListenerTokens = async () => {
+ // A running one-command listener cached its private environment at boot.
+ // Reload the User on each pass: a concurrent mint may have saved a new
+ // token on that row since the previous pass.
+ const resolvedType = AgentIdentityService.resolveAgentType(name.toLowerCase());
+ const username = AgentIdentityService.buildAgentUsername(resolvedType, normalizedInstanceId);
+ const agentUser = await User.findOne({ username, isBot: true });
+ await revokeRuntimeTokensForAgent({
+ agentUser, agentName: name.toLowerCase(), instanceId: normalizedInstanceId,
+ });
+ };
+ if (downgradesPrivateListener) {
+ // The first pass stops the running private listener before the public
+ // config is visible. The second catches any mint that passed its private
+ // eligibility read while this config write was in flight.
+ await revokePrivateListenerTokens();
+ }
+
// The User row is the name source for chat and the pod roster. An explicit
// rename must update that portable identity, not only per-pod projections.
let renamedIdentity;
@@ -393,6 +446,7 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res:
// eslint-disable-next-line no-await-in-loop
await targetInstallation.save();
}
+ if (downgradesPrivateListener) await revokePrivateListenerTokens();
if (
status
diff --git a/backend/routes/registry/agent-tokens.ts b/backend/routes/registry/agent-tokens.ts
index 4887aa41ee..ad2cc73198 100644
--- a/backend/routes/registry/agent-tokens.ts
+++ b/backend/routes/registry/agent-tokens.ts
@@ -35,6 +35,7 @@ const auth = require('../../middleware/auth');
const Pod = require('../../models/Pod');
const User = require('../../models/User');
const AgentCredential = require('../../models/AgentCredential');
+const { AgentInstallation } = require('../../models/AgentRegistry');
const AgentIdentityService = require('../../services/agentIdentityService');
const {
getUserId,
@@ -42,9 +43,14 @@ const {
resolveInstallation,
resolveRuntimeInstanceId,
serializeRuntimeTokens,
+ normalizeConfigMap,
} = require('./helpers');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ownsRuntimeIdentity, runtimeCredentialRefusal } = require('../../services/runtimeIdentityOwnership');
+const {
+ isPrivateListenerEnvironment,
+ privateListenerIdentityAllowed,
+} = require('../../services/privateListenerPolicy');
const {
normalizeScopes,
revokeRuntimeTokenEverywhere,
@@ -196,6 +202,12 @@ agentTokensRouter.post('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLim
const refusal = await credentialRefusal(userId, installation.agentName || name, normalizedInstanceId);
if (refusal) return res.status(403).json(refusal);
+ if (!await privateListenerIdentityAllowed(installation.agentName || name, normalizedInstanceId, userId)) {
+ return res.status(403).json({ code: 'private_listener_not_allowed', error: 'Private listener pod is no longer owner-only' });
+ }
+ const wasPrivateListener = isPrivateListenerEnvironment(
+ normalizeConfigMap(installation.config)?.environment,
+ );
const resolvedType = AgentIdentityService.resolveAgentType(name);
const agentUser = await AgentIdentityService.getOrCreateAgentUser(resolvedType, {
@@ -226,6 +238,31 @@ agentTokensRouter.post('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLim
installation,
{ ownerUserId: userId },
);
+ if (wasPrivateListener) {
+ // A downgrade may revoke old tokens, then pause while its public config
+ // is saved. A mint that read private trust before that pause must not
+ // outlive the downgrade. The matching post-save revoke catches mints
+ // that finish before the save; this recheck catches mints that finish
+ // afterward. Both sides are needed across backend workers.
+ const current = await AgentInstallation.findById(installation._id).select('config').lean();
+ const stillPrivate = isPrivateListenerEnvironment(
+ normalizeConfigMap(current?.config)?.environment,
+ );
+ if (!stillPrivate || !await privateListenerIdentityAllowed(
+ installation.agentName || name, normalizedInstanceId, userId, true,
+ )) {
+ const freshAgentUser = await User.findById(agentUser._id);
+ await revokeRuntimeTokensForAgent({
+ agentUser: freshAgentUser,
+ agentName: installation.agentName || name,
+ instanceId: normalizedInstanceId,
+ });
+ return res.status(403).json({
+ code: 'private_listener_not_allowed',
+ error: 'Private listener pod is no longer owner-only',
+ });
+ }
+ }
return res.json(issued);
} catch (error) {
if ((error as { code?: string })?.code === 'in_process_agent_no_token') {
diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts
index 24c07439c3..d2205f58de 100644
--- a/backend/routes/registry/install.ts
+++ b/backend/routes/registry/install.ts
@@ -51,6 +51,10 @@ const { willSolelyInstallRuntimeIdentity, webhookUrlRefusal } = require('../../s
const { personAgentWakeDefault } = require('../../services/wakeDefaultService');
const { logAgentInstallRefusal } = require('../../middleware/logAgentInstallRefusal');
const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy');
+const {
+ isPrivateListenerEnvironment, privateListenerConfigurationValid,
+ privateListenerPodAllowed, privateListenerIdentityAllowed,
+} = require('../../services/privateListenerPolicy');
// Inlined per-route limiter. This comment used to attribute its clean CodeQL
// status to `js/missing-rate-limiting` "only seeing express-rate-limit calls
@@ -248,6 +252,19 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'),
}
if (res.locals) res.locals.agentInstallInstanceId = normalizedInstanceId;
+ const requestedEnvironment = normalizeConfigMap(config)?.environment;
+ if (isPrivateListenerEnvironment(requestedEnvironment)) {
+ const allowed = privateListenerConfigurationValid(requestedEnvironment)
+ && await privateListenerPodAllowed(podId, userId, agentName, normalizedInstanceId)
+ && ['wrapper', 'webhook'].includes(config?.runtime?.runtimeType);
+ if (!allowed) {
+ return res.status(403).json({
+ code: 'private_listener_not_allowed',
+ error: 'Full computer access requires an invite-only pod with only you and this listener, and an absolute repo path',
+ });
+ }
+ }
+
let agent = await AgentRegistry.getByName(agentName);
if (agent && agent.status === 'unpublished') {
@@ -409,6 +426,16 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'),
const globalAgent = await findExistingAgentInstance(agentName, normalizedInstanceId);
const isReusingExistingAgent = globalAgent.exists;
+ if (!isPrivateListenerEnvironment(requestedEnvironment)
+ && globalAgent.installations.some((peer: any) => {
+ const peerConfig = normalizeConfigMap(peer.config) || {};
+ return isPrivateListenerEnvironment(peerConfig.environment);
+ })) {
+ return res.status(403).json({
+ code: 'private_listener_requires_downgrade',
+ error: 'This agent already has full local tools. Change its existing listener to read-only before adding it to another pod.',
+ });
+ }
const requiredScopes = agent.manifest.context?.required || [];
const missingScopes = requiredScopes.filter((s: any) => !scopes.includes(s));
@@ -420,6 +447,24 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'),
}
const installConfig = normalizeConfigMap(config) || {};
+ if (isPrivateListenerEnvironment(installConfig.environment)) {
+ const peers = globalAgent.installations || [];
+ // The daemon takes the runtime+environment pair from the oldest row.
+ // A new private row after an older read-only row would never reach it.
+ // Promote the existing listener first (the config route fans out).
+ const allPeersPrivate = peers.every((peer: any) => {
+ const peerConfig = normalizeConfigMap(peer.config) || {};
+ return isPrivateListenerEnvironment(peerConfig.environment);
+ });
+ const eligible = allPeersPrivate && (!globalAgent.exists
+ || await privateListenerIdentityAllowed(safeAgentName, normalizedInstanceId, userId, true));
+ if (!eligible) {
+ return res.status(403).json({
+ code: 'private_listener_not_allowed',
+ error: 'Full computer access requires owner-only pods and an existing private listener for this agent identity',
+ });
+ }
+ }
// WRITE-TIME SHAPE CHECK (TASK-071, Vera's ruling). This route is the OTHER
// backend writer of `config.environment` — the first is the agent-config
// PATCH — and it stores the body's config unchecked. Same rule, same
diff --git a/backend/services/privateListenerPolicy.ts b/backend/services/privateListenerPolicy.ts
new file mode 100644
index 0000000000..257a14ad79
--- /dev/null
+++ b/backend/services/privateListenerPolicy.ts
@@ -0,0 +1,81 @@
+import { Types } from 'mongoose';
+import Pod from '../models/Pod';
+import User from '../models/User';
+import AgentIdentityService from './agentIdentityService';
+import { AgentInstallation } from '../models/AgentRegistry';
+import { podIsBridged } from './outsiderGrantPodEligibility';
+
+const id = (value: unknown): string => String(value || '');
+
+export const isPrivateListenerEnvironment = (environment: unknown): boolean => (
+ Boolean(environment && typeof environment === 'object'
+ && (environment as any).sandbox?.trust === 'private')
+);
+
+const configOf = (installation: any): Record