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

)} + {trustChoice} {error &&
{error}
} {existingConnection && (
@@ -1186,6 +1265,7 @@ const V2AgentBYO: React.FC = () => { ))} + {(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. */} 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;