From 7647e7469760ba0cee13fd62c3f92b897992ea06 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 00:47:57 -0700
Subject: [PATCH 01/15] Allow owner-approved private local listeners in
sole-human pods
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
.../service/privateListenerTrust.test.js | 173 ++++++++++++++++++
backend/middleware/agentRuntimeAuth.ts | 15 ++
backend/routes/agentBinding.ts | 20 +-
backend/routes/agentsRuntime.ts | 7 +
backend/routes/registry/agent-config.ts | 17 ++
backend/routes/registry/agent-tokens.ts | 4 +
backend/routes/registry/install.ts | 27 +++
backend/services/privateListenerPolicy.ts | 62 +++++++
.../adapters.claude.environment.test.mjs | 12 ++
cli/__tests__/attach.test.mjs | 32 ++++
cli/__tests__/default-environment.test.mjs | 4 +
cli/src/commands/agent.js | 31 +++-
cli/src/lib/adapters/claude.js | 6 +
cli/src/lib/default-environment.js | 3 +
cli/src/lib/environment.js | 8 +-
frontend/src/i18n/locales/en.json | 6 +
frontend/src/i18n/locales/zh-CN.json | 6 +
.../v2/__tests__/V2AgentBYOMachine.test.tsx | 73 +++++++-
frontend/src/v2/components/V2AgentBYO.tsx | 67 ++++++-
19 files changed, 561 insertions(+), 12 deletions(-)
create mode 100644 backend/__tests__/service/privateListenerTrust.test.js
create mode 100644 backend/services/privateListenerPolicy.ts
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
new file mode 100644
index 0000000000..a6415de032
--- /dev/null
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -0,0 +1,173 @@
+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 AgentCredential = require('../../models/AgentCredential');
+const { AgentInstallation, AgentRegistry } = require('../../models/AgentRegistry');
+const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth');
+const { hash } = require('../../utils/secret');
+
+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') => request(app).post('/api/registry/install').send({
+ agentName, podId: String(pod._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'],
+ });
+});
+
+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('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 () => {
+ 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 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 downgrade to read-only after a second human joins', async () => {
+ 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);
+ expect((await issue()).status).toBe(200);
+ const row = await AgentInstallation.findOne({ agentName: AGENT });
+ expect(row.config.get('environment')).toEqual({ sandbox: { trust: 'public' } });
+});
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..e01fe14a51 100644
--- a/backend/routes/registry/agent-config.ts
+++ b/backend/routes/registry/agent-config.ts
@@ -30,6 +30,10 @@ const { ownsRuntimeIdentity, webhookUrlRefusal } = require('../../services/runti
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 +176,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)
+ && 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 your sole-human invite-only pod and an absolute repo path',
+ });
+ }
+ }
}
// Being this row's installer is not enough to point its push webhook
diff --git a/backend/routes/registry/agent-tokens.ts b/backend/routes/registry/agent-tokens.ts
index 4887aa41ee..7f8385862e 100644
--- a/backend/routes/registry/agent-tokens.ts
+++ b/backend/routes/registry/agent-tokens.ts
@@ -45,6 +45,7 @@ const {
} = require('./helpers');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ownsRuntimeIdentity, runtimeCredentialRefusal } = require('../../services/runtimeIdentityOwnership');
+const { privateListenerIdentityAllowed } = require('../../services/privateListenerPolicy');
const {
normalizeScopes,
revokeRuntimeTokenEverywhere,
@@ -196,6 +197,9 @@ 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 resolvedType = AgentIdentityService.resolveAgentType(name);
const agentUser = await AgentIdentityService.getOrCreateAgentUser(resolvedType, {
diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts
index 24c07439c3..df311c37fb 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)
+ && ['wrapper', 'webhook'].includes(config?.runtime?.runtimeType);
+ if (!allowed) {
+ return res.status(403).json({
+ code: 'private_listener_not_allowed',
+ error: 'Full computer access requires your sole-human invite-only pod and an absolute repo path',
+ });
+ }
+ }
+
let agent = await AgentRegistry.getByName(agentName);
if (agent && agent.status === 'unpublished') {
@@ -420,6 +437,16 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'),
}
const installConfig = normalizeConfigMap(config) || {};
+ if (isPrivateListenerEnvironment(installConfig.environment)) {
+ const eligible = !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 your sole-human invite-only pod and an absolute repo path',
+ });
+ }
+ }
// 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..f9f653d9de
--- /dev/null
+++ b/backend/services/privateListenerPolicy.ts
@@ -0,0 +1,62 @@
+import Pod from '../models/Pod';
+import User from '../models/User';
+import { AgentInstallation } from '../models/AgentRegistry';
+
+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 => (
+ installation.config instanceof Map
+ ? Object.fromEntries(installation.config)
+ : (installation.config || {})
+);
+
+/** A private listener may take instructions only from its owner. */
+export const privateListenerPodAllowed = async (podId: unknown, ownerUserId: unknown): Promise => {
+ const pod = await Pod.findById(podId).select('createdBy members joinPolicy publicRead communityListed').lean();
+ if (!pod || pod.joinPolicy !== 'invite-only' || pod.publicRead || pod.communityListed
+ || id(pod.createdBy) !== id(ownerUserId)
+ || !(pod.members || []).some((member) => id(member) === id(ownerUserId))) return false;
+ const members = await User.find({ _id: { $in: pod.members } }).select('_id isBot botType').lean();
+ // A missing member row has unknown authority. Refuse rather than assume bot.
+ if (members.length !== new Set((pod.members || []).map(id)).size) return false;
+ const humans = members.filter((member: any) => !member.isBot && !member.botType);
+ return humans.length === 1 && id(humans[0]._id) === id(ownerUserId);
+};
+
+/** Re-evaluate the full identity, since its runtime and token span every installation. */
+export const privateListenerIdentityAllowed = async (
+ agentName: string, instanceId: string, ownerUserId: unknown, requirePrivate = false,
+): Promise => {
+ const installations = await AgentInstallation.find({ agentName, instanceId, status: 'active' })
+ .select('podId installedBy config').lean();
+ if (!requirePrivate && !installations.some(
+ (installation: any) => isPrivateListenerEnvironment(configOf(installation).environment),
+ )) {
+ return true;
+ }
+ if (!installations.length || installations.some(
+ (installation: any) => id(installation.installedBy) !== id(ownerUserId),
+ )) {
+ return false;
+ }
+ for (const installation of installations) {
+ // eslint-disable-next-line no-await-in-loop
+ if (!await privateListenerPodAllowed(installation.podId, ownerUserId)) return false;
+ }
+ return true;
+};
+
+export const privateListenerConfigurationValid = (environment: unknown): boolean => {
+ if (!isPrivateListenerEnvironment(environment)) return true;
+ const spec = environment as any;
+ const path = spec.workspace?.path;
+ return spec.sandbox?.mode === undefined
+ && typeof path === 'string'
+ && (path.startsWith('/') || path.startsWith('~/'))
+ && path.trim().length > 1;
+};
diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs
index 81416875d4..7e84438bcc 100644
--- a/cli/__tests__/adapters.claude.environment.test.mjs
+++ b/cli/__tests__/adapters.claude.environment.test.mjs
@@ -70,6 +70,18 @@ describe('claude adapter — ctx.environment', () => {
fs.rmSync(cwd, { recursive: true, force: true });
});
+ test('explicit private trust runs full tools without the public deny policy', async () => {
+ const { impl, calls } = makeSpawnImpl();
+ await claude.spawn('hi', {
+ cwd, environment: { workspace: { path: cwd }, sandbox: { trust: 'private' } },
+ _spawnImpl: impl,
+ });
+ expect(calls).toHaveLength(1);
+ expect(calls[0].args).toContain('--dangerously-skip-permissions');
+ expect(calls[0].args).not.toContain('--setting-sources');
+ expect(calls[0].args).not.toContain('--disallowedTools');
+ });
+
test('keeps MCP config outside the workspace at 0600 for only the spawn lifetime', async () => {
const { impl, calls } = makeSpawnImpl();
const environment = {
diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs
index 51f8d6da2f..8f604594d3 100644
--- a/cli/__tests__/attach.test.mjs
+++ b/cli/__tests__/attach.test.mjs
@@ -736,6 +736,38 @@ describe('bootstrapAgentRecordFromEnv', () => {
expect(record.environment.sandbox).toEqual({ trust: 'public' });
});
+ test('an owner-approved private listener starts in an existing repo with full-tool trust', async () => {
+ const repo = fs.mkdtempSync(path.join(tokensTmpDir, 'repo-'));
+ const response = {
+ ...identityResponse,
+ installations: [{ ...identityResponse.installations[1], listenerEnvironment: {
+ workspace: { path: repo }, sandbox: { trust: 'private' },
+ } }],
+ };
+ const record = await bootstrapAgentRecordFromEnv({
+ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123' },
+ clientFactory: makeFactory(response), adapterRegistry: makeRegistry(),
+ });
+ expect(record.workspacePath).toBe(repo);
+ expect(record.environment.sandbox).toEqual({ trust: 'private' });
+ expect(record.environment.mcp[0].name).toBe('commonly');
+ fs.rmSync(repo, { recursive: true, force: true });
+ });
+
+ test('a private listener refuses a missing repo rather than creating one', async () => {
+ const missing = path.join(tokensTmpDir, 'missing-repo');
+ await expect(bootstrapAgentRecordFromEnv({
+ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123' },
+ clientFactory: makeFactory({ ...identityResponse, installations: [
+ { ...identityResponse.installations[1], listenerEnvironment: {
+ workspace: { path: missing }, sandbox: { trust: 'private' },
+ } },
+ ] }),
+ adapterRegistry: makeRegistry(),
+ })).rejects.toThrow(/not an existing directory/);
+ expect(fs.existsSync(missing)).toBe(false);
+ });
+
test('a pi seat bootstrapped from env gets the kernel server but no sandbox block, which pi refuses', async () => {
const record = await bootstrapAgentRecordFromEnv({
name: 'smoke-agent',
diff --git a/cli/__tests__/default-environment.test.mjs b/cli/__tests__/default-environment.test.mjs
index e408c085c0..6b50caa4ff 100644
--- a/cli/__tests__/default-environment.test.mjs
+++ b/cli/__tests__/default-environment.test.mjs
@@ -106,6 +106,10 @@ describe('withDefaultMcpServer', () => {
// defaults to 'none' in the adapters, so "no sandbox block" and "mode: none"
// are the same thing to the spawn.
describe('withDefaultSandbox', () => {
+ test('keeps an explicit private listener unconfined after owner consent', () => {
+ const environment = { workspace: { path: '/repo' }, sandbox: { trust: 'private' } };
+ expect(withDefaultSandbox(environment)).toBe(environment);
+ });
test('declares the default sandbox when there is none', () => {
expect(withDefaultSandbox(null)).toEqual({ sandbox: defaultSeatSandbox() });
expect(withDefaultSandbox({ model: 'opus' })).toEqual({
diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js
index 8e9110a533..ef69759db1 100644
--- a/cli/src/commands/agent.js
+++ b/cli/src/commands/agent.js
@@ -11,8 +11,8 @@
* heartbeat — manually trigger an agent's heartbeat
*/
-import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'fs';
-import { join, dirname, basename, resolve as pathResolve } from 'path';
+import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync, statSync } from 'fs';
+import { join, dirname, basename, resolve as pathResolve, isAbsolute } from 'path';
import { homedir, tmpdir } from 'os';
import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
@@ -385,6 +385,29 @@ export const bootstrapAgentRecordFromEnv = async ({
});
if (bindingRefusal) throw new Error(bindingRefusal);
+ const privateSpec = primary?.listenerEnvironment;
+ let privateWorkspacePath = null;
+ if (privateSpec?.sandbox?.trust === 'private') {
+ if (!['claude', 'codex'].includes(adapterName)) {
+ throw new Error('Full local tools require Claude Code or Codex for this listener.');
+ }
+ const declared = privateSpec.workspace?.path;
+ if (typeof declared !== 'string' || !declared.trim()) {
+ throw new Error('Private listener is missing the owner-selected repo path.');
+ }
+ const expanded = declared === '~' ? homedir()
+ : (declared.startsWith('~/') ? join(homedir(), declared.slice(2)) : declared);
+ privateWorkspacePath = isAbsolute(expanded) ? expanded : pathResolve(expanded);
+ if (!existsSync(privateWorkspacePath) || !statSync(privateWorkspacePath).isDirectory()) {
+ throw new Error(`Selected repo path is not an existing directory on this machine: ${privateWorkspacePath}`);
+ }
+ }
+
+ const environment = {
+ ...(buildDefaultEnvironment(adapterName) || {}),
+ ...(privateSpec?.sandbox?.trust === 'private' ? privateSpec : {}),
+ };
+
return {
agentName,
displayName: identity?.displayName || primary?.displayName || agentName,
@@ -395,8 +418,8 @@ export const bootstrapAgentRecordFromEnv = async ({
adapter: adapterName,
// The daemon's baseline for a seat nobody wrote a spec for, sandbox
// included: this record is derived from nothing the person declared.
- environment: seatBaseline(buildDefaultEnvironment(adapterName), adapterName, { sandbox: true }),
- workspacePath: null,
+ environment: seatBaseline(environment, adapterName, { sandbox: true }),
+ workspacePath: privateWorkspacePath,
};
};
diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js
index 300d76250f..331f7143c5 100644
--- a/cli/src/lib/adapters/claude.js
+++ b/cli/src/lib/adapters/claude.js
@@ -618,6 +618,12 @@ const prepareArgv = async (innerArgv, ctx) => {
const sandboxTrust = env.sandbox?.trust;
const sandboxMode = resolvePublicSandboxMode(env.sandbox);
+ if (sandboxTrust === 'private' && sandboxMode === undefined) {
+ // The Connect page obtained an explicit owner choice for full local tools.
+ // Print mode cannot prompt, so this is the only way that choice can run
+ // Bash/Edit without relying on a separately hand-edited Claude setting.
+ innerArgv = [...innerArgv, '--dangerously-skip-permissions'];
+ }
const publicNativeSandbox = sandboxTrust === 'public'
&& PUBLIC_SANDBOX_MODES.has(sandboxMode);
// A public trust is ONE contract on both platforms: the jail bounds the
diff --git a/cli/src/lib/default-environment.js b/cli/src/lib/default-environment.js
index c4cd55b288..67142d8973 100644
--- a/cli/src/lib/default-environment.js
+++ b/cli/src/lib/default-environment.js
@@ -151,6 +151,9 @@ export const withDefaultSandbox = (environment) => {
// gate accepts — benign (same effective trust) but not a mirror, and the
// comment would be false. The ORIGINAL block is what gets returned below.
const sandbox = normalizeSandboxTrust(environment?.sandbox);
+ // The owner explicitly chose full local tools for their sole-human pod.
+ // The backend re-checks that pod on every assignment and runtime request.
+ if (sandbox?.trust === 'private' && sandbox.mode === undefined) return environment;
// An ENFORCED declaration is one the adapters act on: a public trust (whose
// mode they resolve, and refuse to spawn without) or an explicit non-'none'
// mode. `mode: 'none'`, an empty block and a missing one are all the same
diff --git a/cli/src/lib/environment.js b/cli/src/lib/environment.js
index e8865b43db..70dfa6dbe8 100644
--- a/cli/src/lib/environment.js
+++ b/cli/src/lib/environment.js
@@ -47,7 +47,7 @@ const ALLOWED_TOP_KEYS = new Set([
const ALLOWED_SANDBOX_MODES = new Set([
'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
]);
-const ALLOWED_SANDBOX_TRUST = new Set(['public']);
+const ALLOWED_SANDBOX_TRUST = new Set(['public', 'private']);
const ALLOWED_NETWORK_POLICIES = new Set(['unrestricted', 'restricted']);
const INLINE_PROVIDER_CREDENTIAL_FIELD = /(?:key|token|secret|password|authorization|header|credential|auth)/i;
@@ -301,11 +301,13 @@ export const validateEnvironmentSpec = (spec) => {
}
if (trust !== undefined && !ALLOWED_SANDBOX_TRUST.has(trust)) {
errors.push(
- "sandbox.trust must be 'public' — it marks a seat anyone in the pod can "
- + 'talk to; omit it for your own seat '
+ "sandbox.trust must be 'public' or 'private' — private is an explicit full-tool owner choice "
+ `(got ${JSON.stringify(trust)})`,
);
}
+ if (trust === 'private' && mode !== undefined) {
+ errors.push('sandbox.trust=private requires no sandbox.mode');
+ }
if (network !== undefined) {
if (typeof network !== 'object' || network === null) {
errors.push('sandbox.network must be an object');
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index bdf4deb8a0..3e842df319 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1417,6 +1417,12 @@
"nameLabel": "Agent name",
"nameHint": "Lower-case letters, digits, and dashes. It posts under this name.",
"podLabel": "Pod it joins",
+ "trustLabel": "Access on your computer",
+ "readOnly": "Read-only (recommended)",
+ "privateTrust": "Work in my repo on this machine",
+ "repoPath": "Repo folder on that computer",
+ "privateWarning": "Full tools: the agent can run commands, edit files, and use your network and signed-in services on this computer. The repo is its starting folder, not a security boundary. Only use this in your invite-only pod when you are its sole human member.",
+ "privateUnavailable": "Choose your sole-human invite-only pod and enter an existing repo folder for full computer access.",
"loadingPods": "Loading pods…",
"machineLabel": "Computer",
"adapterLabel": "Adapter",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index dd53a2303c..68788791ab 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1477,6 +1477,12 @@
"nameLabel": "智能体名称",
"nameHint": "仅支持小写字母、数字和连字符。它会用这个名字发言。",
"podLabel": "要加入的 Pod",
+ "trustLabel": "在电脑上的访问权限",
+ "readOnly": "只读(推荐)",
+ "privateTrust": "在这台电脑上操作我的代码仓库",
+ "repoPath": "这台电脑上的仓库文件夹",
+ "privateWarning": "完整工具权限:智能体可以在这台电脑上运行命令、编辑文件,并使用网络和已登录的服务。仓库只是起始目录,不是安全边界。仅在你是唯一人类成员的邀请制 Pod 中使用。",
+ "privateUnavailable": "要授予完整电脑权限,请选择仅有你一位人类成员的邀请制 Pod,并填写已有的仓库文件夹。",
"loadingPods": "正在加载 Pod…",
"machineLabel": "电脑",
"adapterLabel": "适配器",
diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
index ff2c219172..dc7a140c81 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
@@ -43,13 +43,13 @@ const machineRow = {
id: 'm1', machineId: 'mach-a', name: 'Sam’s MacBook', status: 'online', agentStates: [],
};
-const mockGet = ({ machines = [machineRow] } = {}) => {
+const mockGet = ({ machines = [machineRow], pods = [{ _id: 'p1', name: 'Workspace', type: 'chat', createdBy: { _id: 'u1' } }] } = {}) => {
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/registry/agents/next-free-name?')) return Promise.resolve({ data: { name: new URLSearchParams(url.split('?')[1]).get('base') } });
if (url.startsWith('/api/hosted/availability')) {
return Promise.resolve({ data: { configured: false, caps: { agentsPerUser: 1, turnsPerDay: 200 } } });
}
- if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Workspace', type: 'chat', createdBy: { _id: 'u1' } }] });
+ if (url === '/api/pods') return Promise.resolve({ data: pods });
if (url === '/api/machines') return Promise.resolve({ data: { machines, offlineAfterMs: 90000 } });
return Promise.resolve({ data: {} });
});
@@ -69,6 +69,75 @@ afterEach(() => {
});
describe('BYO on-my-computer mode', () => {
+ test('one-command listener installs with private trust and the selected repo path', async () => {
+ mockGet({ pods: [{ _id: 'p1', name: 'Workspace', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }] }] });
+ axios.post.mockResolvedValue({ data: { token: 'cm_agent_test' } });
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('byo-mode-byo')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('byo-mode-byo'));
+ fireEvent.click(screen.getByLabelText('Work in my repo on this machine'));
+ fireEvent.change(screen.getByTestId('byo-repo-path'), { target: { value: '~/projects/example' } });
+ await waitFor(() => expect(screen.getByText('Get my connect command')).toBeEnabled());
+ fireEvent.click(screen.getByText('Get my connect command'));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/registry/install',
+ expect.objectContaining({ config: expect.objectContaining({ runtime: { runtimeType: 'webhook' },
+ environment: { workspace: { path: '~/projects/example' }, sandbox: { trust: 'private' } },
+ }) }), expect.anything()));
+ });
+
+ test('reconnecting an existing listener applies its chosen trust before key rotation', async () => {
+ mockGet({ pods: [{ _id: 'p1', name: 'Workspace', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }] }] });
+ axios.post.mockRejectedValueOnce({ response: { data: { error: 'Agent already installed in this pod' } } });
+ axios.post.mockResolvedValueOnce({ data: { token: 'cm_agent_replaced' } });
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('byo-mode-byo')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('byo-mode-byo'));
+ fireEvent.click(screen.getByLabelText('Work in my repo on this machine'));
+ fireEvent.change(screen.getByTestId('byo-repo-path'), { target: { value: '~/projects/example' } });
+ await waitFor(() => expect(screen.getByText('Get my connect command')).toBeEnabled());
+ fireEvent.click(screen.getByText('Get my connect command'));
+ await waitFor(() => expect(screen.getByTestId('byo-replace-key-confirmation')).toBeInTheDocument());
+ fireEvent.click(screen.getByText('Replace the running agent’s key'));
+ await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
+ expect.stringMatching(/\/api\/registry\/pods\/p1\/agents\//),
+ { config: { environment: { workspace: { path: '~/projects/example' }, sandbox: { trust: 'private' } } } },
+ expect.anything(),
+ ));
+ expect(axios.post).toHaveBeenCalledWith(
+ expect.stringMatching(/\/runtime-tokens$/),
+ { label: 'BYO MCP — replacement', force: true },
+ expect.anything(),
+ );
+ });
+
+ test('full tools are offered only in the owner-only invite pod and carry the repo path', async () => {
+ mockGet({ pods: [{ _id: 'p1', name: 'Workspace', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }, { _id: 'b1', isBot: true }] }] });
+ axios.post.mockResolvedValue({ data: {} });
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('byo-mode-machine'));
+ fireEvent.click(screen.getByLabelText('Work in my repo on this machine'));
+ fireEvent.change(screen.getByTestId('byo-repo-path'), { target: { value: '~/projects/example' } });
+ fireEvent.click(screen.getByText('Add to this computer'));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/registry/install',
+ expect.objectContaining({ config: expect.objectContaining({ environment: {
+ workspace: { path: '~/projects/example' }, sandbox: { trust: 'private' },
+ } }) }), expect.anything()));
+ });
+
+ test('shared invite pod and open pod expose read-only only', async () => {
+ mockGet({ pods: [{ _id: 'p1', name: 'Shared', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }, { _id: 'u2', isBot: false }] }] });
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('byo-mode-machine'));
+ expect(screen.getByLabelText('Read-only (recommended)')).toBeChecked();
+ expect(screen.queryByLabelText('Work in my repo on this machine')).toBeNull();
+ });
test('a registered machine surfaces the mode card and picker', async () => {
mockGet();
renderPage();
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index 22937647c0..6a6b81f020 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -151,10 +151,24 @@ const V2AgentBYO: React.FC = () => {
const personaCard = PERSONA_CARDS.find((c) => c.key === prefill.persona) || null;
const [pods, setPods] = useState([]);
const [podId, setPodId] = useState(prefill.pod);
+ const [listenerTrust, setListenerTrust] = useState<'read-only' | 'private'>('read-only');
+ const [repoPath, setRepoPath] = useState('');
// Personalized default: a global-namespace collision guard (#613) means a
// shared literal default ("my-mcp-agent") 409s for every user after the
// first one to accept it. Seed from the username so defaults never collide.
const { currentUser } = useAuth();
+ const selectedPod = pods.find((pod) => pod._id === podId);
+ const privateListenerEligible = Boolean(selectedPod
+ && selectedPod.joinPolicy === 'invite-only'
+ && selectedPod.publicRead !== true
+ && selectedPod.communityListed !== true
+ && String(selectedPod.createdBy?._id) === String(currentUser?._id)
+ && selectedPod.members?.filter((member) => typeof member !== 'string' && !member.isBot).length === 1
+ && selectedPod.members?.some((member) => typeof member !== 'string'
+ && !member.isBot && String(member._id) === String(currentUser?._id)));
+ useEffect(() => {
+ if (!privateListenerEligible) setListenerTrust('read-only');
+ }, [privateListenerEligible]);
const userSlug = (currentUser?.username || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
// The default name follows where the agent runs (C14 rehearsal, 2026-10-20):
// "Run it here" takes `-agent`, so an own agent defaulting to the same
@@ -591,6 +605,9 @@ const V2AgentBYO: React.FC = () => {
...(adapter ? { adapter } : {}),
...(model ? { model } : {}),
},
+ ...(listenerTrust === 'private' ? {
+ environment: { workspace: { path: repoPath.trim() }, sandbox: { trust: 'private' } },
+ } : {}),
...(personaCard ? { persona: personaCard.key } : {}),
},
displayName: personaCard?.name || cleanName,
@@ -656,6 +673,11 @@ const V2AgentBYO: React.FC = () => {
setError(t('agentByo.errors.podRequired'));
return;
}
+ if (listenerTrust === 'private'
+ && (!privateListenerEligible || !repoPath.trim())) {
+ setError(t('agentByo.form.privateUnavailable'));
+ return;
+ }
if (!mode) return;
if (mode === 'hosted') {
await submitHosted(cleanName);
@@ -672,7 +694,13 @@ const V2AgentBYO: React.FC = () => {
agentName: cleanName,
podId,
scopes: DEFAULT_INSTALL_SCOPES,
- config: { runtime: { runtimeType: 'webhook' }, ...(personaCard ? { persona: personaCard.key } : {}) },
+ config: {
+ runtime: { runtimeType: 'webhook' },
+ ...(listenerTrust === 'private' ? {
+ environment: { workspace: { path: repoPath.trim() }, sandbox: { trust: 'private' } },
+ } : {}),
+ ...(personaCard ? { persona: personaCard.key } : {}),
+ },
displayName: personaCard?.name || cleanName,
// #1649: the seat carries the persona's own sentence, so Your Team never shows a placeholder.
...(personaCard ? { description: personaCard.card } : {}),
@@ -724,6 +752,15 @@ const V2AgentBYO: React.FC = () => {
setError(null);
try {
const { agentName, podId: targetPodId } = target;
+ // A repeat connection can change the owner's trust choice. Apply it
+ // before rotating the key so a rejected private grant leaves the
+ // existing listener and token untouched.
+ await api.patch(
+ `/api/registry/pods/${encodeURIComponent(targetPodId)}/agents/${encodeURIComponent(agentName)}`,
+ { config: { environment: listenerTrust === 'private'
+ ? { workspace: { path: repoPath.trim() }, sandbox: { trust: 'private' } }
+ : { sandbox: { trust: 'public' } } } },
+ );
const tokenRes = await api.post<{ token?: string }>(
`/api/registry/pods/${encodeURIComponent(targetPodId)}/agents/${encodeURIComponent(agentName)}/runtime-tokens`,
{ label: 'BYO MCP — replacement', force: true, instanceId: prefill.instanceId },
@@ -1186,6 +1223,34 @@ const V2AgentBYO: React.FC = () => {
))}
+ {(mode === 'byo' || mode === 'machine') && (
+
+ )}
{/* The act sits right under the fields it acts on. Below "Add a computer" it
fell under the fold at 1440x900 with "Run it here" chosen (QA 2026-10-16:
its top edge at y=882), so a stranger saw a selected card and no button. */}
From 28f13f604c42c15741da31f81aa1af2509c5d673 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 00:50:25 -0700
Subject: [PATCH 02/15] Select private listener across peer installations
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
.../service/privateListenerTrust.test.js | 12 ++++++++++++
cli/__tests__/attach.test.mjs | 18 ++++++++++++++++++
cli/src/commands/agent.js | 4 +++-
3 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index a6415de032..0b9d5c842f 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -160,6 +160,18 @@ test('a config edit cannot renew private trust after another human joins', async
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 second = await User.create({ username: 'downgrade-second', email: 'downgrade-second@test.com', password: 'password123' });
await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: second._id } });
diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs
index 8f604594d3..366f66d625 100644
--- a/cli/__tests__/attach.test.mjs
+++ b/cli/__tests__/attach.test.mjs
@@ -754,6 +754,24 @@ describe('bootstrapAgentRecordFromEnv', () => {
fs.rmSync(repo, { recursive: true, force: true });
});
+ test('a private installation wins when another installation of the same identity is read-only', async () => {
+ const repo = fs.mkdtempSync(path.join(tokensTmpDir, 'repo-peer-'));
+ const response = { ...identityResponse, installations: [
+ identityResponse.installations[1],
+ { ...identityResponse.installations[1], podId: 'pod-private', listenerEnvironment: {
+ workspace: { path: repo }, sandbox: { trust: 'private' },
+ } },
+ ] };
+ const record = await bootstrapAgentRecordFromEnv({
+ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123' },
+ clientFactory: makeFactory(response), adapterRegistry: makeRegistry(),
+ });
+ expect(record.podId).toBe('pod-private');
+ expect(record.workspacePath).toBe(repo);
+ expect(record.environment.sandbox.trust).toBe('private');
+ fs.rmSync(repo, { recursive: true, force: true });
+ });
+
test('a private listener refuses a missing repo rather than creating one', async () => {
const missing = path.join(tokensTmpDir, 'missing-repo');
await expect(bootstrapAgentRecordFromEnv({
diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js
index ef69759db1..a5f768943d 100644
--- a/cli/src/commands/agent.js
+++ b/cli/src/commands/agent.js
@@ -327,7 +327,9 @@ export const bootstrapAgentRecordFromEnv = async ({
}
const installs = Array.isArray(identity?.installations) ? identity.installations : [];
- const primary = installs.find((i) => i.type === 'installation' && i.status === 'active')
+ const primary = installs.find((i) => i.type === 'installation' && i.status === 'active'
+ && i.listenerEnvironment?.sandbox?.trust === 'private')
+ || installs.find((i) => i.type === 'installation' && i.status === 'active')
|| installs[0] || null;
// The adapter names a binary on THIS machine — the one fact the server
From 4026f6683e46e92e0be6804fd66f18a73374ae0d Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 00:52:26 -0700
Subject: [PATCH 03/15] Offer trust choice in compact connect flow
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
.../v2/__tests__/V2AgentBYOFromCard.test.tsx | 25 ++++++++
frontend/src/v2/components/V2AgentBYO.tsx | 59 ++++++++++---------
2 files changed, 56 insertions(+), 28 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2AgentBYOFromCard.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOFromCard.test.tsx
index 4938430d02..dcf6d78f7e 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOFromCard.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOFromCard.test.tsx
@@ -92,6 +92,31 @@ describe('BYO from an approved connect card', () => {
expect(screen.queryByRole('textbox')).toBeNull();
});
+ test('compact one-command path offers the private repo choice for an eligible pod', async () => {
+ axios.get.mockImplementation((url) => {
+ if (url.startsWith('/api/registry/agents/next-free-name?')) return Promise.resolve({ data: { name: 'sam-claude' } });
+ if (url.startsWith('/api/hosted/availability')) return Promise.resolve({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 200 } } });
+ if (url === '/api/pods') return Promise.resolve({ data: [{
+ _id: 'bbb222', name: 'Launch', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }],
+ }] });
+ if (url === '/api/machines') return Promise.resolve({ data: { machines: [] } });
+ return Promise.resolve({ data: {} });
+ });
+ renderAt(CARD);
+ const compact = await screen.findByTestId('byo-card-compact');
+ expect(compact).toHaveTextContent('Read-only (recommended)');
+ fireEvent.click(screen.getByLabelText('Work in my repo on this machine'));
+ fireEvent.change(screen.getByTestId('byo-repo-path'), { target: { value: '~/projects/example' } });
+ const act = screen.getByRole('button', { name: /get my connect command/i });
+ await waitFor(() => expect(act).toBeEnabled());
+ fireEvent.click(act);
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/registry/install',
+ expect.objectContaining({ config: expect.objectContaining({ environment: {
+ workspace: { path: '~/projects/example' }, sandbox: { trust: 'private' },
+ } }) }), expect.anything()));
+ });
+
test('no key is minted on load; the click mints it for the card\'s name and pod', async () => {
renderAt(CARD);
const act = await screen.findByRole('button', { name: /get my connect command/i });
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index 6a6b81f020..2dc9fb6742 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -937,6 +937,35 @@ const V2AgentBYO: React.FC = () => {
: '';
const cursorInstallHref = cursorMcpInstallLink(apiUrl);
+ const trustChoice = (
+
+ );
+
return (
{
@@ -1023,6 +1052,7 @@ const V2AgentBYO: React.FC = () => {
)}
+ {trustChoice}
{error && {error}
}
{existingConnection && (
@@ -1223,34 +1253,7 @@ const V2AgentBYO: React.FC = () => {
))}
- {(mode === 'byo' || mode === 'machine') && (
-
- )}
+ {(mode === 'byo' || mode === 'machine') && trustChoice}
{/* The act sits right under the fields it acts on. Below "Add a computer" it
fell under the fold at 1440x900 with "Run it here" chosen (QA 2026-10-16:
its top edge at y=882), so a stranger saw a selected card and no button. */}
From e361150a014de59574d89ec4aeb7706b77c836d6 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 00:55:59 -0700
Subject: [PATCH 04/15] Refuse mixed listener trust across pod installs
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
.../service/privateListenerTrust.test.js | 31 +++++++++++++++++--
backend/routes/registry/install.ts | 24 ++++++++++++--
2 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index 0b9d5c842f..344755654c 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -41,8 +41,8 @@ const issue = () => request(app)
.send({ label: 'private listener' });
const assigned = () => request(app).get('/api/agent-binding/assigned')
.set('Authorization', `Bearer ${DAEMON}`);
-const install = (agentName, path = '~/projects/example') => request(app).post('/api/registry/install').send({
- agentName, podId: String(pod._id), scopes: ['context:read'],
+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' },
} },
@@ -148,6 +148,33 @@ test('relative repo paths are refused before creating an agent', async () => {
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 () => {
+ 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 } });
diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts
index df311c37fb..98c3248844 100644
--- a/backend/routes/registry/install.ts
+++ b/backend/routes/registry/install.ts
@@ -426,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));
@@ -438,12 +448,20 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'),
const installConfig = normalizeConfigMap(config) || {};
if (isPrivateListenerEnvironment(installConfig.environment)) {
- const eligible = !globalAgent.exists
- || await privateListenerIdentityAllowed(safeAgentName, normalizedInstanceId, userId, true);
+ 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 your sole-human invite-only pod and an absolute repo path',
+ error: 'Full computer access requires owner-only pods and an existing private listener for this agent identity',
});
}
}
From 000899ea276f515993904266c5d7cfae821be9c9 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 01:03:58 -0700
Subject: [PATCH 05/15] Block private listener trust in bridged pods
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
.../service/privateListenerTrust.test.js | 15 +++++++++++++++
backend/services/privateListenerPolicy.ts | 4 ++++
2 files changed, 19 insertions(+)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index 344755654c..078cc624a9 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -17,6 +17,7 @@ jest.mock('../../services/starterTaskService', () => ({ completeConnectAgentStar
const User = require('../../models/User');
const Pod = require('../../models/Pod');
+const Integration = require('../../models/Integration');
const AgentCredential = require('../../models/AgentCredential');
const { AgentInstallation, AgentRegistry } = require('../../models/AgentRegistry');
const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth');
@@ -101,6 +102,20 @@ test('second human pauses existing token and daemon assignment; new issuance is
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('second human also pauses an older installation-bound runtime token', async () => {
const legacy = `cm_agent_${'b'.repeat(32)}`;
await AgentInstallation.updateOne({ agentName: AGENT }, {
diff --git a/backend/services/privateListenerPolicy.ts b/backend/services/privateListenerPolicy.ts
index f9f653d9de..6fe3c8213a 100644
--- a/backend/services/privateListenerPolicy.ts
+++ b/backend/services/privateListenerPolicy.ts
@@ -1,6 +1,7 @@
import Pod from '../models/Pod';
import User from '../models/User';
import { AgentInstallation } from '../models/AgentRegistry';
+import { podIsBridged } from './outsiderGrantPodEligibility';
const id = (value: unknown): string => String(value || '');
@@ -21,6 +22,9 @@ export const privateListenerPodAllowed = async (podId: unknown, ownerUserId: unk
if (!pod || pod.joinPolicy !== 'invite-only' || pod.publicRead || pod.communityListed
|| id(pod.createdBy) !== id(ownerUserId)
|| !(pod.members || []).some((member) => id(member) === id(ownerUserId))) return false;
+ // A group bridge relays other people's words into the pod and can carry the
+ // private seat's output out. It is a second human authority by proxy.
+ if (await podIsBridged(id(podId))) return false;
const members = await User.find({ _id: { $in: pod.members } }).select('_id isBot botType').lean();
// A missing member row has unknown authority. Refuse rather than assume bot.
if (members.length !== new Set((pod.members || []).map(id)).size) return false;
From d9d565cb64d585279bc51468cd1a63b4e3c55250 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 01:18:13 -0700
Subject: [PATCH 06/15] fix: revoke private listener access on trust downgrade
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
.../service/privateListenerTrust.test.js | 12 ++++++
.../agentBinding.grantConfinement.test.js | 3 +-
.../registry.runtimeTokenOwnership.test.js | 6 +++
backend/routes/registry/agent-config.ts | 21 +++++++++-
cli/__tests__/environment.test.mjs | 9 ++--
.../v2/__tests__/V2AgentBYOMachine.test.tsx | 41 +++++++++++++++++++
.../v2/__tests__/V2AgentBYOMemory.test.tsx | 29 ++++++++-----
frontend/src/v2/components/V2AgentBYO.tsx | 8 ++++
8 files changed, 112 insertions(+), 17 deletions(-)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index 078cc624a9..cd02856451 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -215,13 +215,25 @@ test('owner can promote a read-only listener in a sole-human invite pod', async
});
test('owner can downgrade to read-only after a second human joins', async () => {
+ const old = await issue();
+ expect(old.status).toBe(200);
+ 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' } });
});
diff --git a/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js b/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js
index 61fb2317b1..ba9746f9d7 100644
--- a/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js
+++ b/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js
@@ -57,7 +57,8 @@ const seed = async (sandbox, runtime = { runtimeType: 'wrapper', model: 'claude-
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..27a578a5c1 100644
--- a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js
+++ b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js
@@ -58,6 +58,12 @@ 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),
+}));
+
const mockIsAdmin = jest.fn().mockResolvedValue(false);
jest.mock('../../../routes/registry/helpers', () => ({
getUserId: jest.fn((req) => req.userId),
diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts
index e01fe14a51..f93602d763 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,6 +25,7 @@ const {
const {
normalizeToolPolicy,
normalizeContextPolicy,
+ revokeRuntimeTokensForAgent,
} = require('./tokens');
const { validateEnvironmentMcpEntries } = require('../../utils/environmentSpecValidation');
const { ownsRuntimeIdentity, webhookUrlRefusal } = require('../../services/runtimeIdentityOwnership');
@@ -387,6 +389,23 @@ 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,
+ ));
+ if (downgradesPrivateListener) {
+ // A running one-command listener cached its private environment at boot.
+ // Revoking before the new public config is visible makes its saved bearer
+ // unusable; the owner must reconnect against the public projection.
+ 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,
+ });
+ }
+
// 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;
diff --git a/cli/__tests__/environment.test.mjs b/cli/__tests__/environment.test.mjs
index 517dcc2a50..59675f73c2 100644
--- a/cli/__tests__/environment.test.mjs
+++ b/cli/__tests__/environment.test.mjs
@@ -218,15 +218,16 @@ describe('validateEnvironmentSpec', () => {
sandbox: { mode: 'workspace', trust: 'internal' },
});
expect(res.ok).toBe(false);
- // The message has to name what DOES something, or the next author just
- // guesses again: `public`, or no trust field at all.
+ // The message names both supported choices instead of teaching the old
+ // private-means-absence convention.
expect(res.errors.join(' ')).toMatch(/sandbox\.trust must be 'public'/);
- expect(res.errors.join(' ')).toMatch(/omit it for your own seat/);
+ expect(res.errors.join(' ')).toMatch(/'private'.*explicit full-tool owner choice/);
expect(res.errors.join(' ')).toMatch(/got "internal"/);
});
- test('accepts the two declarations that do something: public trust, or none', () => {
+ test('accepts public, private, or an omitted trust declaration', () => {
expect(validateEnvironmentSpec({ sandbox: { trust: 'public' } }).ok).toBe(true);
+ expect(validateEnvironmentSpec({ sandbox: { trust: 'private' } }).ok).toBe(true);
expect(validateEnvironmentSpec({ sandbox: { mode: 'bwrap' } }).ok).toBe(true);
});
diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
index dc7a140c81..c4519bda2f 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
@@ -69,6 +69,47 @@ afterEach(() => {
});
describe('BYO on-my-computer mode', () => {
+ test.each([
+ ['Read-only', { sandbox: { trust: 'public' } }],
+ ['Work in my repo on this machine', { workspace: { path: '~/projects/example' }, sandbox: { trust: 'private' } }],
+ ])('existing machine listener applies %s before placement', async (choice, environment) => {
+ mockGet({ pods: [{ _id: 'p1', name: 'Workspace', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }] }] });
+ axios.post.mockRejectedValueOnce({ response: { data: { error: 'Agent already installed in this pod' } } });
+ axios.post.mockResolvedValue({ data: {} });
+ axios.patch.mockResolvedValue({ data: {} });
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('byo-mode-machine'));
+ if (choice !== 'Read-only') {
+ fireEvent.click(screen.getByLabelText(choice));
+ fireEvent.change(screen.getByTestId('byo-repo-path'), { target: { value: '~/projects/example' } });
+ }
+ await waitFor(() => expect(screen.getByText('Add to this computer')).toBeEnabled());
+ fireEvent.click(screen.getByText('Add to this computer'));
+ await waitFor(() => expect(screen.getByTestId('byo-machine-result')).toBeInTheDocument());
+ expect(axios.patch).toHaveBeenCalledWith(
+ expect.stringMatching(/\/api\/registry\/pods\/p1\/agents\//),
+ { config: { environment } }, expect.anything(),
+ );
+ expect(axios.patch.mock.invocationCallOrder[0]).toBeLessThan(
+ axios.post.mock.invocationCallOrder[1],
+ );
+ });
+
+ test('existing machine listener is not placed when access change fails', async () => {
+ mockGet();
+ axios.post.mockRejectedValueOnce({ response: { data: { error: 'Agent already installed in this pod' } } });
+ axios.patch.mockRejectedValueOnce({ response: { data: { error: 'Access change refused' } } });
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('byo-mode-machine'));
+ await waitFor(() => expect(screen.getByText('Add to this computer')).toBeEnabled());
+ fireEvent.click(screen.getByText('Add to this computer'));
+ await waitFor(() => expect(screen.getByText('Access change refused')).toBeInTheDocument());
+ expect(axios.post).toHaveBeenCalledTimes(1);
+ });
+
test('one-command listener installs with private trust and the selected repo path', async () => {
mockGet({ pods: [{ _id: 'p1', name: 'Workspace', type: 'chat', joinPolicy: 'invite-only',
createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }] }] });
diff --git a/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx
index 67283c8dc8..453e2ff058 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx
@@ -52,7 +52,6 @@ const renderPage = (entry = '/') => render(
);
const issueToken = async () => {
- axios.get.mockResolvedValueOnce({ data: [{ _id: 'p1', name: 'Workspace', type: 'chat' }] });
axios.post
.mockResolvedValueOnce({ data: { ok: true } }) // /api/registry/install
.mockResolvedValueOnce({ data: { token: AGENT_TOKEN } }); // runtime-tokens
@@ -66,7 +65,9 @@ const issueToken = async () => {
beforeEach(() => {
axios.get.mockImplementation((url) => (
- url.startsWith('/api/registry/agents/next-free-name?')
+ url === '/api/pods'
+ ? Promise.resolve({ data: [{ _id: 'p1', name: 'Workspace', type: 'chat' }] })
+ : url.startsWith('/api/registry/agents/next-free-name?')
? Promise.resolve({ data: { name: new URLSearchParams(url.split('?')[1]).get('base') } })
: Promise.resolve({ data: {} })
));
@@ -166,10 +167,12 @@ describe('V2AgentBYO memory import', () => {
});
test('already-installed agent requires explicit key replacement for lost-token recovery', async () => {
- axios.get.mockResolvedValueOnce({ data: [{ _id: 'p1', name: 'Workspace', type: 'chat' }] });
- axios.post
- .mockRejectedValueOnce({ response: { data: { error: 'Agent already installed in this pod' } } })
- .mockResolvedValueOnce({ data: { token: AGENT_TOKEN } });
+ axios.post.mockImplementation((url) => (
+ url === '/api/registry/install'
+ ? Promise.reject({ response: { data: { error: 'Agent already installed in this pod' } } })
+ : Promise.resolve({ data: { token: AGENT_TOKEN } })
+ ));
+ axios.patch.mockResolvedValue({ data: {} });
renderPage();
await waitFor(() => expect(screen.getByText('Workspace (chat)')).toBeInTheDocument());
@@ -185,11 +188,15 @@ describe('V2AgentBYO memory import', () => {
});
test('a token shared from another pod is not silently replaced after install', async () => {
- axios.get.mockResolvedValueOnce({ data: [{ _id: 'p1', name: 'Workspace', type: 'chat' }] });
- axios.post
- .mockResolvedValueOnce({ data: { ok: true } })
- .mockResolvedValueOnce({ data: { existing: true } })
- .mockResolvedValueOnce({ data: { token: AGENT_TOKEN } });
+ let issues = 0;
+ axios.post.mockImplementation((url) => {
+ if (/runtime-tokens/.test(url)) {
+ issues += 1;
+ return Promise.resolve({ data: issues === 1 ? { existing: true } : { token: AGENT_TOKEN } });
+ }
+ return Promise.resolve({ data: { ok: true } });
+ });
+ axios.patch.mockResolvedValue({ data: {} });
renderPage();
await waitFor(() => expect(screen.getByText('Workspace (chat)')).toBeInTheDocument());
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index 2dc9fb6742..912c3af152 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -619,6 +619,14 @@ const V2AgentBYO: React.FC = () => {
const msg = (installErr as { response?: { data?: { error?: string } } })
?.response?.data?.error || '';
if (!/already installed/i.test(msg)) throw installErr;
+ // An existing machine listener keeps its previous environment. Apply
+ // the owner's current choice before placement can resume it.
+ await api.patch(
+ `/api/registry/pods/${encodeURIComponent(podId)}/agents/${encodeURIComponent(cleanName)}`,
+ { config: { environment: listenerTrust === 'private'
+ ? { workspace: { path: repoPath.trim() }, sandbox: { trust: 'private' } }
+ : { sandbox: { trust: 'public' } } } },
+ );
}
await api.post('/api/agent-binding/request', {
agentName: cleanName, instanceId: 'default', machineId,
From bb7ac1ea26073497e25f5585cfe11dacdc8d8bb6 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 01:24:08 -0700
Subject: [PATCH 07/15] fix: validate private listener pod ID before lookup
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
backend/__tests__/service/privateListenerTrust.test.js | 6 ++++++
backend/services/privateListenerPolicy.ts | 8 ++++++--
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index cd02856451..c150d4992e 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -22,6 +22,7 @@ const AgentCredential = require('../../models/AgentCredential');
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());
@@ -75,6 +76,11 @@ beforeEach(async () => {
});
});
+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)).toBe(true);
+});
+
test('sole owner can issue; runtime bootstrap receives only the selected path and trust', async () => {
const minted = await issue();
expect(minted.status).toBe(200);
diff --git a/backend/services/privateListenerPolicy.ts b/backend/services/privateListenerPolicy.ts
index 6fe3c8213a..07a34f8647 100644
--- a/backend/services/privateListenerPolicy.ts
+++ b/backend/services/privateListenerPolicy.ts
@@ -1,3 +1,4 @@
+import { Types } from 'mongoose';
import Pod from '../models/Pod';
import User from '../models/User';
import { AgentInstallation } from '../models/AgentRegistry';
@@ -18,13 +19,16 @@ const configOf = (installation: any): Record => (
/** A private listener may take instructions only from its owner. */
export const privateListenerPodAllowed = async (podId: unknown, ownerUserId: unknown): Promise => {
- const pod = await Pod.findById(podId).select('createdBy members joinPolicy publicRead communityListed').lean();
+ const podIdString = id(podId);
+ if (!/^[0-9a-fA-F]{24}$/.test(podIdString)) return false;
+ const pod = await Pod.findById(new Types.ObjectId(podIdString))
+ .select('createdBy members joinPolicy publicRead communityListed').lean();
if (!pod || pod.joinPolicy !== 'invite-only' || pod.publicRead || pod.communityListed
|| id(pod.createdBy) !== id(ownerUserId)
|| !(pod.members || []).some((member) => id(member) === id(ownerUserId))) return false;
// A group bridge relays other people's words into the pod and can carry the
// private seat's output out. It is a second human authority by proxy.
- if (await podIsBridged(id(podId))) return false;
+ if (await podIsBridged(podIdString)) return false;
const members = await User.find({ _id: { $in: pod.members } }).select('_id isBot botType').lean();
// A missing member row has unknown authority. Refuse rather than assume bot.
if (members.length !== new Set((pod.members || []).map(id)).size) return false;
From e0f6437f61cdbd09b98717a6840ed38f3f05f892 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 01:26:37 -0700
Subject: [PATCH 08/15] fix: preserve listener environment on trust changes
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
.../__tests__/service/privateListenerTrust.test.js | 6 +++++-
backend/routes/registry/agent-config.ts | 11 +++++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index c150d4992e..f3fbd7ebba 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -223,6 +223,10 @@ test('owner can promote a read-only listener in a sole-human invite pod', async
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() } },
@@ -241,5 +245,5 @@ test('owner can downgrade to read-only after a second human joins', async () =>
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' } });
+ expect(row.config.get('environment')).toEqual({ sandbox: { trust: 'public' }, mcp: retainedMcp });
});
diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts
index f93602d763..72056d291f 100644
--- a/backend/routes/registry/agent-config.ts
+++ b/backend/routes/registry/agent-config.ts
@@ -227,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 });
}
From c47010f2472d560a2dc38f0e0301c9b99589d4d2 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 01:32:17 -0700
Subject: [PATCH 09/15] fix: check bridges with canonical pod ID
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
backend/__tests__/service/privateListenerTrust.test.js | 5 +++++
backend/services/privateListenerPolicy.ts | 2 +-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index f3fbd7ebba..2719b80f22 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -79,6 +79,11 @@ beforeEach(async () => {
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)).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 () => {
diff --git a/backend/services/privateListenerPolicy.ts b/backend/services/privateListenerPolicy.ts
index 07a34f8647..fe494dfacb 100644
--- a/backend/services/privateListenerPolicy.ts
+++ b/backend/services/privateListenerPolicy.ts
@@ -28,7 +28,7 @@ export const privateListenerPodAllowed = async (podId: unknown, ownerUserId: unk
|| !(pod.members || []).some((member) => id(member) === id(ownerUserId))) return false;
// A group bridge relays other people's words into the pod and can carry the
// private seat's output out. It is a second human authority by proxy.
- if (await podIsBridged(podIdString)) return false;
+ if (await podIsBridged(id(pod._id))) return false;
const members = await User.find({ _id: { $in: pod.members } }).select('_id isBot botType').lean();
// A missing member row has unknown authority. Refuse rather than assume bot.
if (members.length !== new Set((pod.members || []).map(id)).size) return false;
From 5ff61dad8b81824af259b9132dd3eb5d8195661e Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 01:52:30 -0700
Subject: [PATCH 10/15] Fence private listener mint across trust downgrade
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
.../service/privateListenerTrust.test.js | 74 +++++++++++++++++++
backend/routes/registry/agent-config.ts | 13 +++-
backend/routes/registry/agent-tokens.ts | 35 ++++++++-
3 files changed, 118 insertions(+), 4 deletions(-)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index 2719b80f22..d2eda101de 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -252,3 +252,77 @@ test('owner can downgrade to read-only after a second human joins', async () =>
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/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts
index 72056d291f..0cd680e97c 100644
--- a/backend/routes/registry/agent-config.ts
+++ b/backend/routes/registry/agent-config.ts
@@ -405,16 +405,22 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res:
&& installationsToUpdate.some((entry: any) => isPrivateListenerEnvironment(
normalizeConfigMap(entry.config)?.environment,
));
- if (downgradesPrivateListener) {
+ const revokePrivateListenerTokens = async () => {
// A running one-command listener cached its private environment at boot.
- // Revoking before the new public config is visible makes its saved bearer
- // unusable; the owner must reconnect against the public projection.
+ // 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
@@ -440,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 7f8385862e..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,10 +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 { privateListenerIdentityAllowed } = require('../../services/privateListenerPolicy');
+const {
+ isPrivateListenerEnvironment,
+ privateListenerIdentityAllowed,
+} = require('../../services/privateListenerPolicy');
const {
normalizeScopes,
revokeRuntimeTokenEverywhere,
@@ -200,6 +205,9 @@ agentTokensRouter.post('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLim
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, {
@@ -230,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') {
From 1228f3ca18b39f424a8a249f360b7f344302c079 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 01:56:00 -0700
Subject: [PATCH 11/15] Keep token ownership fixture aligned with private
policy
Co-authored-by: kai
Agent-Model: codex/gpt-6-sol/medium
Agent-Model: codex/gpt-6.1-sol/medium
---
.../__tests__/unit/routes/registry.runtimeTokenOwnership.test.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js
index 27a578a5c1..31631ea451 100644
--- a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js
+++ b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js
@@ -62,6 +62,7 @@ jest.mock('../../../services/agentIdentityService', () => ({
// 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);
From 172ebeb667a4af6295a918bee994ebb36c19bc50 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 24 Oct 2026 05:30:22 -0700
Subject: [PATCH 12/15] test: retain instance identity in listener reconnect
assertion
Co-authored-by: kai
Agent-Model: codex/gpt-6.1-sol/medium
---
frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
index c4519bda2f..b2db81d6a7 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
@@ -149,7 +149,7 @@ describe('BYO on-my-computer mode', () => {
));
expect(axios.post).toHaveBeenCalledWith(
expect.stringMatching(/\/runtime-tokens$/),
- { label: 'BYO MCP — replacement', force: true },
+ { label: 'BYO MCP — replacement', force: true, instanceId: 'default' },
expect.anything(),
);
});
From c291e16109808ed712d9c2f0a98b1302145a4c40 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 24 Oct 2026 05:31:09 -0700
Subject: [PATCH 13/15] fix: align private listener fixtures with current
machine auth
Co-authored-by: kai
Agent-Model: codex/gpt-6.1-sol/medium
---
backend/__tests__/service/privateListenerTrust.test.js | 5 +++++
backend/routes/registry/agent-config.ts | 2 +-
frontend/src/i18n/locales/en.json | 1 +
frontend/src/i18n/locales/zh-CN.json | 1 +
frontend/src/v2/components/V2AgentBYO.tsx | 2 +-
5 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index d2eda101de..33f9c67459 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -19,6 +19,7 @@ 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');
@@ -74,6 +75,10 @@ beforeEach(async () => {
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 () => {
diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts
index 0cd680e97c..9590b1ab8f 100644
--- a/backend/routes/registry/agent-config.ts
+++ b/backend/routes/registry/agent-config.ts
@@ -446,7 +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 (downgradesPrivateListener) await revokePrivateListenerTokens();
if (
status
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 3e842df319..1876546929 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1421,6 +1421,7 @@
"readOnly": "Read-only (recommended)",
"privateTrust": "Work in my repo on this machine",
"repoPath": "Repo folder on that computer",
+ "repoPathPlaceholder": "~/projects/my-repo",
"privateWarning": "Full tools: the agent can run commands, edit files, and use your network and signed-in services on this computer. The repo is its starting folder, not a security boundary. Only use this in your invite-only pod when you are its sole human member.",
"privateUnavailable": "Choose your sole-human invite-only pod and enter an existing repo folder for full computer access.",
"loadingPods": "Loading pods…",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 68788791ab..e784a536c1 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1481,6 +1481,7 @@
"readOnly": "只读(推荐)",
"privateTrust": "在这台电脑上操作我的代码仓库",
"repoPath": "这台电脑上的仓库文件夹",
+ "repoPathPlaceholder": "~/projects/my-repo",
"privateWarning": "完整工具权限:智能体可以在这台电脑上运行命令、编辑文件,并使用网络和已登录的服务。仓库只是起始目录,不是安全边界。仅在你是唯一人类成员的邀请制 Pod 中使用。",
"privateUnavailable": "要授予完整电脑权限,请选择仅有你一位人类成员的邀请制 Pod,并填写已有的仓库文件夹。",
"loadingPods": "正在加载 Pod…",
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index 912c3af152..253422f57b 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -967,7 +967,7 @@ const V2AgentBYO: React.FC = () => {
{t('agentByo.form.repoPath')}
setRepoPath(event.target.value)}
className="v2-byo__input" data-testid="byo-repo-path"
- placeholder="~/projects/my-repo" />
+ placeholder={t('agentByo.form.repoPathPlaceholder')} />
{t('agentByo.form.privateWarning')}
)}
From 8968d38231f8d87f671dffbc92362d88e8f97380 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 24 Oct 2026 05:42:30 -0700
Subject: [PATCH 14/15] fix: exclude peer bots from private listener pods
Co-authored-by: kai
Agent-Model: codex/gpt-6.1-sol/medium
---
.../service/privateListenerTrust.test.js | 40 ++++++++++++++++++-
.../agentBinding.grantConfinement.test.js | 2 +-
backend/routes/registry/agent-config.ts | 4 +-
backend/routes/registry/install.ts | 4 +-
backend/services/privateListenerPolicy.ts | 19 +++++++--
5 files changed, 59 insertions(+), 10 deletions(-)
diff --git a/backend/__tests__/service/privateListenerTrust.test.js b/backend/__tests__/service/privateListenerTrust.test.js
index 33f9c67459..ff85ec0bb3 100644
--- a/backend/__tests__/service/privateListenerTrust.test.js
+++ b/backend/__tests__/service/privateListenerTrust.test.js
@@ -83,7 +83,7 @@ beforeEach(async () => {
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)).toBe(true);
+ 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: {},
@@ -132,6 +132,42 @@ test('a live group bridge pauses a private listener and refuses new issuance', a
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 }, {
@@ -164,6 +200,7 @@ test('install refuses private trust in a shared pod before creating a registry i
});
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' });
@@ -194,6 +231,7 @@ test('a newer private install cannot be shadowed by an older read-only installat
});
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] });
diff --git a/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js b/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js
index ba9746f9d7..10417548a2 100644
--- a/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js
+++ b/backend/__tests__/unit/routes/agentBinding.grantConfinement.test.js
@@ -53,7 +53,7 @@ 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' },
});
diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts
index 9590b1ab8f..6db62d96cc 100644
--- a/backend/routes/registry/agent-config.ts
+++ b/backend/routes/registry/agent-config.ts
@@ -182,12 +182,12 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res:
const existingConfig = normalizeConfigMap(installation.config) || {};
const runtimeType = config.runtime?.runtimeType || existingConfig.runtime?.runtimeType;
const eligible = privateListenerConfigurationValid(config.environment)
- && await privateListenerPodAllowed(podId, userId)
+ && 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 your sole-human invite-only pod and an absolute repo path',
+ error: 'Full computer access requires an invite-only pod with only you and this listener, and an absolute repo path',
});
}
}
diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts
index 98c3248844..d2205f58de 100644
--- a/backend/routes/registry/install.ts
+++ b/backend/routes/registry/install.ts
@@ -255,12 +255,12 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'),
const requestedEnvironment = normalizeConfigMap(config)?.environment;
if (isPrivateListenerEnvironment(requestedEnvironment)) {
const allowed = privateListenerConfigurationValid(requestedEnvironment)
- && await privateListenerPodAllowed(podId, userId)
+ && 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 your sole-human invite-only pod and an absolute repo path',
+ error: 'Full computer access requires an invite-only pod with only you and this listener, and an absolute repo path',
});
}
}
diff --git a/backend/services/privateListenerPolicy.ts b/backend/services/privateListenerPolicy.ts
index fe494dfacb..257a14ad79 100644
--- a/backend/services/privateListenerPolicy.ts
+++ b/backend/services/privateListenerPolicy.ts
@@ -1,6 +1,7 @@
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';
@@ -18,7 +19,9 @@ const configOf = (installation: any): Record => (
);
/** A private listener may take instructions only from its owner. */
-export const privateListenerPodAllowed = async (podId: unknown, ownerUserId: unknown): Promise => {
+export const privateListenerPodAllowed = async (
+ podId: unknown, ownerUserId: unknown, agentName?: string, instanceId = 'default',
+): Promise => {
const podIdString = id(podId);
if (!/^[0-9a-fA-F]{24}$/.test(podIdString)) return false;
const pod = await Pod.findById(new Types.ObjectId(podIdString))
@@ -32,8 +35,16 @@ export const privateListenerPodAllowed = async (podId: unknown, ownerUserId: unk
const members = await User.find({ _id: { $in: pod.members } }).select('_id isBot botType').lean();
// A missing member row has unknown authority. Refuse rather than assume bot.
if (members.length !== new Set((pod.members || []).map(id)).size) return false;
- const humans = members.filter((member: any) => !member.isBot && !member.botType);
- return humans.length === 1 && id(humans[0]._id) === id(ownerUserId);
+ const listener = agentName ? await User.findOne({
+ username: AgentIdentityService.buildAgentUsername(
+ AgentIdentityService.resolveAgentType(agentName), instanceId,
+ ),
+ isBot: true,
+ }).select('_id').lean() : null;
+ // Other bots can address and wake the full-tool listener just like people.
+ // Permit only the owner and the exact portable User row being connected.
+ return members.every((member: any) => id(member._id) === id(ownerUserId)
+ || (listener && id(member._id) === id(listener._id)));
};
/** Re-evaluate the full identity, since its runtime and token span every installation. */
@@ -54,7 +65,7 @@ export const privateListenerIdentityAllowed = async (
}
for (const installation of installations) {
// eslint-disable-next-line no-await-in-loop
- if (!await privateListenerPodAllowed(installation.podId, ownerUserId)) return false;
+ if (!await privateListenerPodAllowed(installation.podId, ownerUserId, agentName, instanceId)) return false;
}
return true;
};
From f25ed3a5025d7a5ee8e73da1ab1023626a1b6e4d Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 24 Oct 2026 05:50:51 -0700
Subject: [PATCH 15/15] fix: align private listener choices and visible safety
warning
Co-authored-by: kai
Agent-Model: codex/gpt-6.1-sol/medium
---
frontend/src/i18n/locales/en.json | 4 +--
frontend/src/i18n/locales/zh-CN.json | 4 +--
.../v2/__tests__/V2AgentBYOMachine.test.tsx | 30 ++++++++++++++++-
.../v2/__tests__/v2-layout-invariants.test.ts | 3 ++
frontend/src/v2/components/V2AgentBYO.tsx | 32 +++++++++++--------
frontend/src/v2/v2.css | 17 ++++++++++
6 files changed, 71 insertions(+), 19 deletions(-)
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 1876546929..bec3a989f5 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1422,8 +1422,8 @@
"privateTrust": "Work in my repo on this machine",
"repoPath": "Repo folder on that computer",
"repoPathPlaceholder": "~/projects/my-repo",
- "privateWarning": "Full tools: the agent can run commands, edit files, and use your network and signed-in services on this computer. The repo is its starting folder, not a security boundary. Only use this in your invite-only pod when you are its sole human member.",
- "privateUnavailable": "Choose your sole-human invite-only pod and enter an existing repo folder for full computer access.",
+ "privateWarning": "Full tools: the agent can run commands, edit files, and use your network and signed-in services on this computer. The repo is its starting folder, not a security boundary. Only use this in an invite-only pod containing only you and this agent.",
+ "privateUnavailable": "Choose an invite-only pod containing only you and this agent, and enter an existing repo folder for full computer access.",
"loadingPods": "Loading pods…",
"machineLabel": "Computer",
"adapterLabel": "Adapter",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index e784a536c1..6d868f92d1 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1482,8 +1482,8 @@
"privateTrust": "在这台电脑上操作我的代码仓库",
"repoPath": "这台电脑上的仓库文件夹",
"repoPathPlaceholder": "~/projects/my-repo",
- "privateWarning": "完整工具权限:智能体可以在这台电脑上运行命令、编辑文件,并使用网络和已登录的服务。仓库只是起始目录,不是安全边界。仅在你是唯一人类成员的邀请制 Pod 中使用。",
- "privateUnavailable": "要授予完整电脑权限,请选择仅有你一位人类成员的邀请制 Pod,并填写已有的仓库文件夹。",
+ "privateWarning": "完整工具权限:智能体可以在这台电脑上运行命令、编辑文件,并使用网络和已登录的服务。仓库只是起始目录,不是安全边界。仅在成员只有你和此智能体的邀请制 Pod 中使用。",
+ "privateUnavailable": "要授予完整电脑权限,请选择成员只有你和此智能体的邀请制 Pod,并填写已有的仓库文件夹。",
"loadingPods": "正在加载 Pod…",
"machineLabel": "电脑",
"adapterLabel": "适配器",
diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
index b2db81d6a7..90386ed922 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx
@@ -156,7 +156,7 @@ describe('BYO on-my-computer mode', () => {
test('full tools are offered only in the owner-only invite pod and carry the repo path', async () => {
mockGet({ pods: [{ _id: 'p1', name: 'Workspace', type: 'chat', joinPolicy: 'invite-only',
- createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }, { _id: 'b1', isBot: true }] }] });
+ createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }, { _id: 'b1', username: 'sam-local', isBot: true }] }] });
axios.post.mockResolvedValue({ data: {} });
renderPage();
await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
@@ -170,6 +170,34 @@ describe('BYO on-my-computer mode', () => {
} }) }), expect.anything()));
});
+ test('the listener itself remains eligible but changing identity removes private mode', async () => {
+ mockGet({ pods: [{ _id: 'p1', name: 'Workspace', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }, { _id: 'b1', username: 'sam-local', isBot: true }] }] });
+ axios.post.mockResolvedValue({ data: {} });
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('byo-mode-machine'));
+ fireEvent.click(screen.getByLabelText('Work in my repo on this machine'));
+ fireEvent.change(screen.getByTestId('byo-repo-path'), { target: { value: '~/projects/example' } });
+ fireEvent.change(screen.getByDisplayValue('sam-local'), { target: { value: 'another-listener' } });
+ expect(screen.queryByLabelText('Work in my repo on this machine')).toBeNull();
+ expect(screen.getByLabelText('Read-only (recommended)')).toBeChecked();
+ });
+
+ test.each([
+ { _id: 'b2', username: 'peer-bot', isBot: true },
+ { _id: 'b2', username: 'sam-claude-other', isBot: true },
+ 'unresolved-member',
+ ])('peer or unresolved member refuses private mode: %j', async (extraMember) => {
+ mockGet({ pods: [{ _id: 'p1', name: 'Workspace', type: 'chat', joinPolicy: 'invite-only',
+ createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }, extraMember] }] });
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('byo-mode-machine'));
+ expect(screen.queryByLabelText('Work in my repo on this machine')).toBeNull();
+ expect(screen.getByLabelText('Read-only (recommended)')).toBeChecked();
+ });
+
test('shared invite pod and open pod expose read-only only', async () => {
mockGet({ pods: [{ _id: 'p1', name: 'Shared', type: 'chat', joinPolicy: 'invite-only',
createdBy: { _id: 'u1' }, members: [{ _id: 'u1', isBot: false }, { _id: 'u2', isBot: false }] }] });
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 4b8ff883a9..d7ce6ec606 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -217,6 +217,9 @@ describe('v2 layout invariants (CSS rule presence)', () => {
});
test('BYO hints do not add paragraph margins to flex gaps', () => {
+ expect(ruleBody(v2, '.v2-byo__warning')).toContain('display: block');
+ expect(ruleBody(v2, '.v2-byo__trust')).toContain('border: 0');
+ expect(ruleBody(v2, '.v2-byo__trust')).toContain('min-inline-size: 0');
expect(ruleBody(v2, '.v2-byo__hint')).toContain('margin: 0');
});
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index 253422f57b..90a99d18a6 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -32,6 +32,7 @@ import { hostedCapNotice } from './hostedCapNotice';
import { readPodVisits } from '../lib/podRecency';
import { AgentConnection, connectionLabel } from '../utils/agentConnection';
import cursorMcpInstallLink from '../utils/cursorMcpInstallLink';
+import { buildAgentUsername } from '../utils/threadAgentIdentity';
const DEFAULT_AGENT_NAME = 'my-mcp-agent';
// The token routes refuse a name that belongs to an agent Commonly runs (the
@@ -157,18 +158,6 @@ const V2AgentBYO: React.FC = () => {
// shared literal default ("my-mcp-agent") 409s for every user after the
// first one to accept it. Seed from the username so defaults never collide.
const { currentUser } = useAuth();
- const selectedPod = pods.find((pod) => pod._id === podId);
- const privateListenerEligible = Boolean(selectedPod
- && selectedPod.joinPolicy === 'invite-only'
- && selectedPod.publicRead !== true
- && selectedPod.communityListed !== true
- && String(selectedPod.createdBy?._id) === String(currentUser?._id)
- && selectedPod.members?.filter((member) => typeof member !== 'string' && !member.isBot).length === 1
- && selectedPod.members?.some((member) => typeof member !== 'string'
- && !member.isBot && String(member._id) === String(currentUser?._id)));
- useEffect(() => {
- if (!privateListenerEligible) setListenerTrust('read-only');
- }, [privateListenerEligible]);
const userSlug = (currentUser?.username || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
// The default name follows where the agent runs (C14 rehearsal, 2026-10-20):
// "Run it here" takes `-agent`, so an own agent defaulting to the same
@@ -182,6 +171,21 @@ const V2AgentBYO: React.FC = () => {
return `${userSlug}-agent`;
};
const [name, setName] = useState(prefill.name || defaultNameFor(null, ''));
+ const selectedPod = pods.find((pod) => pod._id === podId);
+ const listenerUsername = buildAgentUsername(sanitizeAgentName(name), prefill.instanceId);
+ const privateListenerEligible = Boolean(selectedPod
+ && selectedPod.joinPolicy === 'invite-only'
+ && selectedPod.publicRead !== true
+ && selectedPod.communityListed !== true
+ && String(selectedPod.createdBy?._id) === String(currentUser?._id)
+ && selectedPod.members?.every((member) => typeof member !== 'string'
+ && ((!member.isBot && String(member._id) === String(currentUser?._id))
+ || (member.isBot === true && member.username === listenerUsername)))
+ && selectedPod.members?.some((member) => typeof member !== 'string'
+ && !member.isBot && String(member._id) === String(currentUser?._id)));
+ useEffect(() => {
+ if (!privateListenerEligible) setListenerTrust('read-only');
+ }, [privateListenerEligible]);
const [nameTouched, setNameTouched] = useState(Boolean(prefill.name));
const [namesReady, setNamesReady] = useState(false);
const [nameLookupError, setNameLookupError] = useState(false);
@@ -946,7 +950,7 @@ const V2AgentBYO: React.FC = () => {
const cursorInstallHref = cursorMcpInstallLink(apiUrl);
const trustChoice = (
-