Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions backend/__tests__/service/podMemberBotProjection.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// 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 AgentIdentityService = require('../../services/agentIdentityService');
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 owner = new mongoose.Types.ObjectId();
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._id));

// 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);
});
});
2 changes: 1 addition & 1 deletion backend/controllers/podController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
44 changes: 44 additions & 0 deletions frontend/src/v2/__tests__/V2ConnectorTools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,50 @@ 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: 'scout-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('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' }));
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/v2/components/V2ConnectorTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,11 +325,19 @@ const V2ConnectorTools: React.FC<Props> = ({ 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 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) {
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;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/v2/hooks/useV2Pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading