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') && ( +
+ {t('agentByo.form.trustLabel')} + + {privateListenerEligible && ( + + )} + {listenerTrust === 'private' && privateListenerEligible && ( + + )} +
+ )} {/* 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 = ( +
+ {t('agentByo.form.trustLabel')} + + {privateListenerEligible && ( + + )} + {listenerTrust === 'private' && privateListenerEligible && ( + + )} +
+ ); + 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') && ( -
- {t('agentByo.form.trustLabel')} - - {privateListenerEligible && ( - - )} - {listenerTrust === 'private' && privateListenerEligible && ( - - )} -
- )} + {(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 = ( -
+
{t('agentByo.form.trustLabel')} )}
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 0864a7228c..194bd9d8d8 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -6018,6 +6018,23 @@ body.modern-ui.v2-canvas { flex-direction: column; gap: 6px; } +/* Trust is a radio group, not a browser-default bordered fieldset. */ +.v2-byo__trust { + border: 0; + padding: 0; + margin: 0; + min-inline-size: 0; +} +.v2-byo__trust > legend { padding: 0; margin-bottom: 6px; } +.v2-byo__trust > label { font-size: 14px; line-height: 20px; color: var(--v2-text-primary); } +.v2-byo__trust input[type="radio"] { accent-color: var(--v2-accent); } +/* Safety copy stays visible when the repo field loses focus. */ +.v2-byo__warning { + display: block; + font-size: 12px; + line-height: 18px; + color: var(--v2-text-secondary); +} .v2-byo__label { font-size: 13px; font-weight: 600;