From 738d4dea0f0f8e157b9b14d336faeabf2553615d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:24:03 -0700 Subject: [PATCH 1/3] fix(connectors): name a per-user bot member by its curated label in grants The grant panel named an audience member by its username, which for a per-user Scout is the raw instance id. Name a bot member the way the server names an agent: displayName, then a non-default instanceId, then username. The pod list now projects the bot fields it needs. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: tern Agent-Model: claude/claude-haiku-5-5/default --- backend/controllers/podController.ts | 2 +- .../v2/__tests__/V2ConnectorTools.test.tsx | 23 +++++++++++++++++++ .../src/v2/components/V2ConnectorTools.tsx | 9 +++++++- frontend/src/v2/hooks/useV2Pods.ts | 2 ++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/backend/controllers/podController.ts b/backend/controllers/podController.ts index d9eb46b395..ed833b32c9 100644 --- a/backend/controllers/podController.ts +++ b/backend/controllers/podController.ts @@ -251,7 +251,7 @@ exports.getAllPods = async (req: any, res: any) => { let pods = await Pod.find(listedQuery) .populate('createdBy', 'username profilePicture') - .populate(podMemberPopulate('username profilePicture isBot')) + .populate(podMemberPopulate('username profilePicture isBot botMetadata.displayName botMetadata.instanceId')) .populate('parentPod', 'name _id') .sort({ updatedAt: -1 }); diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index cfeb6332fd..edffca38ca 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -404,6 +404,29 @@ test('TASK-050: a human member in the audience is named, not called "an agent"', expect(within(aside).getByText('agents allowed').nextElementSibling).toHaveTextContent('sam, Scout'); }); +test('TASK-624: a per-user bot member with no roster row is named by its curated label, not its instance id', async () => { + // A per-user Scout is a pod member with no AgentInstallation row in the roster, so the + // audience id resolves through the pod member. Its username is the raw instance id. + const perUserPods = [{ + _id: 'p1', name: 'Launch pod', + members: [ + { _id: 'u1', username: 'sam' }, + { _id: 'a1', username: 'uf2554d19c9', isBot: true, botMetadata: { displayName: 'Scout', instanceId: 'uf2554d19c9' } }, + ], + }]; + axios.get.mockImplementation((url) => { + if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); + if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [grantLive] } }); + if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); + if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); + return Promise.resolve({ data: {} }); + }); + renderTools({ pods: perUserPods }); + const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' }); + expect(within(live).getByText('Scout may use it')).toBeInTheDocument(); + expect(screen.queryByText(/uf2554d19c9/)).not.toBeInTheDocument(); +}); + test('Revoke is two-click and posts the revoke verb, then reloads; a dead grant offers no Revoke', async () => { renderTools(); fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 2226efca05..27937dfe4b 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -325,11 +325,18 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) || t('tools.unknownConnector', { defaultValue: 'Unknown connector' }); const podName = (podId: string): string => pods.find((pod) => String(pod._id) === podId)?.name || t('tools.aPod', { defaultValue: 'a pod' }); + // A bot member is named the way the server names an agent (resolveAgentDisplayLabel): + // its curated displayName, then its instanceId when that names an identity, then its username. const memberName = (userId: string | null): string | null => { if (!userId) return null; for (const pod of pods) { for (const member of pod.members || []) { - if (typeof member === 'object' && member && String(member._id) === userId && member.username) return member.username; + if (typeof member !== 'object' || !member || String(member._id) !== userId) continue; + const instanceId = member.botMetadata?.instanceId; + const botLabel = member.botMetadata?.displayName?.trim() + || (instanceId && instanceId !== 'default' ? instanceId : ''); + if (botLabel) return botLabel; + if (member.username) return member.username; } } return null; diff --git a/frontend/src/v2/hooks/useV2Pods.ts b/frontend/src/v2/hooks/useV2Pods.ts index 43e53d2c0c..06bb33b531 100644 --- a/frontend/src/v2/hooks/useV2Pods.ts +++ b/frontend/src/v2/hooks/useV2Pods.ts @@ -6,6 +6,8 @@ export interface V2PodMember { username?: string; profilePicture?: string | null; isBot?: boolean; + // Bot members only: the curated label and identity, projected by the pod list. + botMetadata?: { displayName?: string; instanceId?: string }; } export interface V2PodLastMessage { From ac6523a6cb3c36128b97b103d269cfa43a8cc6b5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:46:14 -0700 Subject: [PATCH 2/3] test(connectors): pin the pod-list bot projection and the memberName fallbacks A service-tier test asserts getAllPods returns a bot member's botMetadata as exactly displayName and instanceId, so a reverted or widened populate fails. Frontend tests cover a blank displayName with a non-default instanceId and with 'default'. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: tern Agent-Model: claude/claude-haiku-5-5/default --- .../service/podMemberBotProjection.test.js | 58 +++++++++++++++++++ .../v2/__tests__/V2ConnectorTools.test.tsx | 23 +++++++- .../src/v2/components/V2ConnectorTools.tsx | 5 +- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 backend/__tests__/service/podMemberBotProjection.test.js diff --git a/backend/__tests__/service/podMemberBotProjection.test.js b/backend/__tests__/service/podMemberBotProjection.test.js new file mode 100644 index 0000000000..2841b32309 --- /dev/null +++ b/backend/__tests__/service/podMemberBotProjection.test.js @@ -0,0 +1,58 @@ +// TASK-624 — service tier. The grant panel names a per-user bot member from the +// pod list, so `getAllPods` projects the bot's label fields and nothing else. +// `scope=discover` serves pods the caller does not belong to, so the projection +// is also what keeps a bot's owner, machine and runtime off that surface. The +// unit tests cannot see either property, so this pins the populated shape. +process.env.PG_HOST = ''; +const mongoose = require('mongoose'); +const Pod = require('../../models/Pod'); +const User = require('../../models/User'); +const podController = require('../../controllers/podController'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); + +const callGetAllPods = async (userId) => { + const req = { query: {}, userId: String(userId), user: {} }; + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await podController.getAllPods(req, res); + return res.json.mock.calls[0][0]; +}; + +describe('getAllPods projects a bot member to its label fields only (TASK-624)', () => { + beforeAll(setupMongoDb); + afterAll(closeMongoDb); + afterEach(clearMongoDb); + + it('returns exactly displayName and instanceId for a per-user bot, never its owner, machine or runtime', async () => { + const me = new mongoose.Types.ObjectId(); + const bot = new mongoose.Types.ObjectId(); + const owner = new mongoose.Types.ObjectId(); + await User.create([ + { _id: me, username: 'bot-projection-me', email: 'bot-projection-me@example.com', password: 'x' }, + { + _id: bot, + username: 'scout-uf2554d19c9', + email: 'scout-uf2554d19c9@example.com', + password: 'x', + isBot: true, + botType: 'agent', + botMetadata: { + displayName: 'Scout', + instanceId: 'uf2554d19c9', + agentName: 'scout', + ownerUserId: String(owner), + machineId: 'machine-1', + runtimeId: 'runtime-1', + }, + }, + ]); + await Pod.create({ name: 'Mine', type: 'team', createdBy: me, members: [me, bot] }); + + const body = await callGetAllPods(me); + const member = body[0].members.find((m) => String(m._id) === String(bot)); + + // Exact equality: a widened projection adds owner/machine/runtime and fails here. + expect(member.botMetadata).toEqual({ displayName: 'Scout', instanceId: 'uf2554d19c9' }); + expect(member.username).toBe('scout-uf2554d19c9'); + expect(member.isBot).toBe(true); + }); +}); diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index edffca38ca..22695150ab 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -411,7 +411,7 @@ test('TASK-624: a per-user bot member with no roster row is named by its curated _id: 'p1', name: 'Launch pod', members: [ { _id: 'u1', username: 'sam' }, - { _id: 'a1', username: 'uf2554d19c9', isBot: true, botMetadata: { displayName: 'Scout', instanceId: 'uf2554d19c9' } }, + { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: 'Scout', instanceId: 'uf2554d19c9' } }, ], }]; axios.get.mockImplementation((url) => { @@ -427,6 +427,27 @@ test('TASK-624: a per-user bot member with no roster row is named by its curated expect(screen.queryByText(/uf2554d19c9/)).not.toBeInTheDocument(); }); +test('TASK-624: a bot member with a blank displayName falls back to its non-default instanceId, then to the default username', async () => { + const fallbackPods = [{ + _id: 'p1', name: 'Launch pod', + members: [ + { _id: 'u1', username: 'sam' }, + { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: ' ', instanceId: 'uf2554d19c9' } }, + { _id: 'a2', username: 'guide', isBot: true, botMetadata: { displayName: '', instanceId: 'default' } }, + ], + }]; + axios.get.mockImplementation((url) => { + if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); + if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [{ ...grantLive, effectiveAudience: ['a1', 'a2'] }] } }); + if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); + if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); + return Promise.resolve({ data: {} }); + }); + renderTools({ pods: fallbackPods }); + const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' }); + expect(within(live).getByText('uf2554d19c9, guide may use it')).toBeInTheDocument(); +}); + test('Revoke is two-click and posts the revoke verb, then reloads; a dead grant offers no Revoke', async () => { renderTools(); fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 27937dfe4b..6dfbe5a669 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -325,8 +325,9 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) || t('tools.unknownConnector', { defaultValue: 'Unknown connector' }); const podName = (podId: string): string => pods.find((pod) => String(pod._id) === podId)?.name || t('tools.aPod', { defaultValue: 'a pod' }); - // A bot member is named the way the server names an agent (resolveAgentDisplayLabel): - // its curated displayName, then its instanceId when that names an identity, then its username. + // A bot member takes its curated displayName, then a non-default instanceId, then its + // username. This client does not screen leaked runtime labels the way the server's + // resolveAgentDisplayLabel does, so a bot whose displayName is a runtime name shows it. const memberName = (userId: string | null): string | null => { if (!userId) return null; for (const pod of pods) { From 520970f189481def8637fa3c619b3e02ee0b8683 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:54:04 -0700 Subject: [PATCH 3/3] test(connectors): build the bot through the identity writer in the projection test Co-Authored-By: Claude Haiku 5.5 Co-authored-by: tern Agent-Model: claude/claude-haiku-5-5/default --- .../service/podMemberBotProjection.test.js | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/backend/__tests__/service/podMemberBotProjection.test.js b/backend/__tests__/service/podMemberBotProjection.test.js index 2841b32309..e6140a49f8 100644 --- a/backend/__tests__/service/podMemberBotProjection.test.js +++ b/backend/__tests__/service/podMemberBotProjection.test.js @@ -7,6 +7,7 @@ process.env.PG_HOST = ''; const mongoose = require('mongoose'); const Pod = require('../../models/Pod'); const User = require('../../models/User'); +const AgentIdentityService = require('../../services/agentIdentityService'); const podController = require('../../controllers/podController'); const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); @@ -24,31 +25,22 @@ describe('getAllPods projects a bot member to its label fields only (TASK-624)', it('returns exactly displayName and instanceId for a per-user bot, never its owner, machine or runtime', async () => { const me = new mongoose.Types.ObjectId(); - const bot = new mongoose.Types.ObjectId(); const owner = new mongoose.Types.ObjectId(); - await User.create([ - { _id: me, username: 'bot-projection-me', email: 'bot-projection-me@example.com', password: 'x' }, - { - _id: bot, - username: 'scout-uf2554d19c9', - email: 'scout-uf2554d19c9@example.com', - password: 'x', - isBot: true, - botType: 'agent', - botMetadata: { - displayName: 'Scout', - instanceId: 'uf2554d19c9', - agentName: 'scout', - ownerUserId: String(owner), - machineId: 'machine-1', - runtimeId: 'runtime-1', - }, - }, - ]); - await Pod.create({ name: 'Mine', type: 'team', createdBy: me, members: [me, bot] }); + await User.create({ _id: me, username: 'bot-projection-me', email: 'bot-projection-me@example.com', password: 'x' }); + // The production identity writer, so the stored shape is the one a real per-user Scout carries. + const bot = await AgentIdentityService.getOrCreateAgentUser('scout', { + instanceId: 'uf2554d19c9', + displayName: 'Scout', + ownerUserId: String(owner), + runtimeId: 'runtime-1', + }); + bot.botMetadata.machineId = 'machine-1'; + await bot.save(); + expect(bot.username).toBe('scout-uf2554d19c9'); + await Pod.create({ name: 'Mine', type: 'team', createdBy: me, members: [me, bot._id] }); const body = await callGetAllPods(me); - const member = body[0].members.find((m) => String(m._id) === String(bot)); + const member = body[0].members.find((m) => String(m._id) === String(bot._id)); // Exact equality: a widened projection adds owner/machine/runtime and fails here. expect(member.botMetadata).toEqual({ displayName: 'Scout', instanceId: 'uf2554d19c9' });