diff --git a/backend/__tests__/unit/services/installableCatalogService.test.js b/backend/__tests__/unit/services/installableCatalogService.test.js index a850a004f2..05d8524bb9 100644 --- a/backend/__tests__/unit/services/installableCatalogService.test.js +++ b/backend/__tests__/unit/services/installableCatalogService.test.js @@ -314,6 +314,41 @@ it("a hosted entry's tool row carries its connect descriptor and only its own co } }); +it("a hosted entry's connections each say how they differ: when, and for a key its agent", async () => { + const shipped = HOSTED_MCP_ENTRIES.slice(); + const linearEntry = shipped.find((entry) => entry.id === 'linear'); + try { + mockInstallables([], [buildHostedMcpToolInstallable(linearEntry)]); + InstallableInstallation.find.mockReturnValue(lean([])); + Integration.find.mockReturnValue(lean([ + { + _id: 'conn-oauth', type: 'hosted-mcp', status: 'connected', createdBy: userId, + createdAt: new Date('2026-10-30T10:00:00Z'), + config: { entryId: 'linear', intake: 'oauth', credentialRef: 'ref-oauth', providerSubject: 'subject-1' }, + }, + { + _id: 'conn-key', type: 'hosted-mcp', status: 'connected', createdBy: userId, + createdAt: new Date('2026-10-31T11:00:00Z'), + config: { entryId: 'linear', agentId: '6a0000000000000000000007', credentialHint: 'lin…9f', credentialRef: 'ref-key' }, + }, + ])); + + const catalog = await catalogFor(userId); + const linear = catalog.installables.find((entry) => entry.list === 'tools' && entry.installableId === 'linear'); + + expect(linear.connections).toEqual([ + { connectionId: 'conn-oauth', owner: '', repo: '', connectedAt: '2026-10-30T10:00:00.000Z' }, + { + connectionId: 'conn-key', owner: '', repo: '', connectedAt: '2026-10-31T11:00:00.000Z', + agentId: '6a0000000000000000000007', + }, + ]); + expect(JSON.stringify(linear.connections)).not.toMatch(/ref-oauth|ref-key|subject-1|lin…9f/); + } finally { + HOSTED_MCP_ENTRIES.splice(0, HOSTED_MCP_ENTRIES.length, ...shipped); + } +}); + it('hides an instance-listed hosted entry from offerings while keeping its connection removable', async () => { const prior = process.env.HOSTED_MCP_HIDDEN_ENTRIES; process.env.HOSTED_MCP_HIDDEN_ENTRIES = '["airtable"]'; diff --git a/backend/__tests__/utils/leakMatrixSuites.js b/backend/__tests__/utils/leakMatrixSuites.js index 3fbbf4e62a..a4188f7a33 100644 --- a/backend/__tests__/utils/leakMatrixSuites.js +++ b/backend/__tests__/utils/leakMatrixSuites.js @@ -261,6 +261,30 @@ const installables = { installationId: 'install-leak-1', scope: 'user', type: 'github-app', status: 'connected', createdBy: owner, isActive: true, ...withConfig(integrationSecrets(s, 'INST_GITHUBAPP'), { installationId: 'install-leak-1', owner: 'octo', repo: 'demo' }), }); + // A hosted entry with two of the owner's rows, an OAuth sign-in and an + // operator key bound to an agent: the catalog labels each row, and no ref, + // subject, hint or pending authorization reaches any caller. + // eslint-disable-next-line global-require + const { HOSTED_MCP_ENTRIES } = require('../../integrations/hostedMcp/entries'); + // eslint-disable-next-line global-require + const { buildHostedMcpToolInstallable } = require('../../services/installable/toolInstallables'); + await model.Installable().create(buildHostedMcpToolInstallable(HOSTED_MCP_ENTRIES.find((entry) => entry.id === 'linear'))); + await Integration.create({ + scope: 'user', type: 'hosted-mcp', status: 'connected', createdBy: owner, isActive: true, + config: { + entryId: 'linear', intake: 'oauth', + credentialRef: s('INST_HOSTED_OAUTH_CREDENTIALREF'), refreshTokenRef: s('INST_HOSTED_OAUTH_REFRESHTOKENREF'), + providerSubject: s('INST_HOSTED_OAUTH_PROVIDERSUBJECT'), + pendingAuth: { state: s('INST_HOSTED_PENDINGAUTH_STATE'), codeVerifier: s('INST_HOSTED_PENDINGAUTH_CODEVERIFIER') }, + }, + }); + await Integration.create({ + scope: 'user', type: 'hosted-mcp', status: 'connected', createdBy: owner, isActive: true, + config: { + entryId: 'linear', agentId: String(new mongoose.Types.ObjectId()), + credentialRef: s('INST_HOSTED_KEY_CREDENTIALREF'), credentialHint: s('INST_HOSTED_KEY_CREDENTIALHINT'), + }, + }); return { sentinels: s.all, identities: { owner: { user: owner }, stranger: { user: stranger }, admin: { user: admin } }, diff --git a/backend/services/installable/installableCatalogService.ts b/backend/services/installable/installableCatalogService.ts index 904b3a6524..7f11802a4b 100644 --- a/backend/services/installable/installableCatalogService.ts +++ b/backend/services/installable/installableCatalogService.ts @@ -110,10 +110,19 @@ const publicInstallation = (installation: any): unknown => { // A Connection the caller may grant from: the mint requires the connection's // owner, so only the caller's own rows are offered. `connectionId` is the key // the mint takes; owner/repo are the card's evidence of what the grant acts on. +// +// A hosted entry can hold several of the caller's rows, an OAuth sign-in and +// operator keys bound per agent (TASK-330), so a hosted row also carries what +// tells them apart: when it was connected, and for a key the agent (bot user +// id) it is bound to. const publicConnection = (integration: any) => ({ connectionId: String(integration.installationId || integration._id || ''), owner: String(integration.config?.owner || ''), repo: String(integration.config?.repo || ''), + ...(integration.type === 'hosted-mcp' ? { + ...(integration.createdAt ? { connectedAt: new Date(integration.createdAt).toISOString() } : {}), + ...(integration.config?.agentId ? { agentId: String(integration.config.agentId) } : {}), + } : {}), }); /**