diff --git a/backend/__tests__/unit/services/installableCatalogService.test.js b/backend/__tests__/unit/services/installableCatalogService.test.js index 1964226dc3..654adc6d57 100644 --- a/backend/__tests__/unit/services/installableCatalogService.test.js +++ b/backend/__tests__/unit/services/installableCatalogService.test.js @@ -433,3 +433,56 @@ it('offers a hidden hosted entry only to its configured walk user', async () => else process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = allowBefore; } }); + +// Which connect route the page calls is the entry's fact. An operator-key entry's +// OAuth start route answers 404 `unknown_entry`, so the row has to say so; an +// OAuth entry's row stays exactly as it was, with no field. +it("a hosted entry's row says when it takes a pasted key, and only then", async () => { + const keyed = { + id: 'keyed', + title: 'Keyed', + description: 'A vendor that takes a pasted key.', + connectNotice: 'Create a key with read scopes only.', + accessPage: 'https://keyed.example/keys', + resource: 'https://mcp.keyed.example/mcp', + credentialMode: 'operator_key', + revoke: { page: 'https://keyed.example/keys' }, + tools: [{ + name: 'list_things', upstreamName: 'list_things', description: 'List things', + class: 'read', inputSchema: { type: 'object' }, annotations: { readOnlyHint: true }, + }], + }; + const shipped = [...HOSTED_MCP_ENTRIES]; + HOSTED_MCP_ENTRIES.push(keyed); + try { + const linearEntry = HOSTED_MCP_ENTRIES.find((entry) => entry.id === 'linear'); + mockInstallables([], [buildHostedMcpToolInstallable(linearEntry), buildHostedMcpToolInstallable(keyed)]); + InstallableInstallation.find.mockReturnValue(lean([])); + Integration.find.mockReturnValue(lean([{ + _id: 'conn-keyed', type: 'hosted-mcp', status: 'connected', createdBy: userId, + createdAt: new Date('2026-11-01T09:00:00Z'), + config: { entryId: 'keyed', intake: 'operator_key', agentId: '6a0000000000000000000009', credentialRef: 'ref-keyed-secret' }, + }])); + + const rows = (await catalogFor(userId)).installables.filter((entry) => entry.list === 'tools'); + const keyedRow = rows.find((entry) => entry.installableId === 'keyed'); + const linear = rows.find((entry) => entry.installableId === 'linear'); + + expect(keyedRow).toMatchObject({ + connectionType: 'hosted-mcp', + entryId: 'keyed', + credentialMode: 'operator_key', + connectNotice: keyed.connectNotice, + accessPage: keyed.accessPage, + available: true, + }); + expect(keyedRow.connections).toEqual([{ + connectionId: 'conn-keyed', owner: '', repo: '', connectedAt: '2026-11-01T09:00:00.000Z', + agentId: '6a0000000000000000000009', + }]); + expect(Object.prototype.hasOwnProperty.call(linear, 'credentialMode')).toBe(false); + expect(JSON.stringify(rows)).not.toContain('ref-keyed-secret'); + } finally { + HOSTED_MCP_ENTRIES.splice(0, HOSTED_MCP_ENTRIES.length, ...shipped); + } +}); diff --git a/backend/services/installable/installableCatalogService.ts b/backend/services/installable/installableCatalogService.ts index 7f11802a4b..8ba401b4da 100644 --- a/backend/services/installable/installableCatalogService.ts +++ b/backend/services/installable/installableCatalogService.ts @@ -136,7 +136,8 @@ const publicConnection = (integration: any) => ({ * is per ENTRY (`/api/integrations/connect/hosted-mcp/:entryId/start`, §4), and * the page has no other way to learn an entry id — an Installable id is the * entry id by construction, but reading that as a URL would be a second, - * unwitnessed spelling of the same fact. + * unwitnessed spelling of the same fact. `credentialMode` rides beside them + * for the same reason: which connect route the page calls is the entry's fact. */ const toolEntriesFor = async (userId: string): Promise<{ entries: unknown[]; hiddenConnections: unknown[] }> => { const metas = toolInstallableMetas(); @@ -187,6 +188,10 @@ const toolEntriesFor = async (userId: string): Promise<{ entries: unknown[]; hid ...(hostedEntry?.connectNotice ? { connectNotice: hostedEntry.connectNotice } : {}), ...(hostedEntry?.accessPage ? { accessPage: hostedEntry.accessPage } : {}), ...(hostedEntry?.revoke?.page ? { revokePage: hostedEntry.revoke.page } : {}), + // How a person connects: absent means the OAuth start route; an + // operator-key entry takes a pasted key per agent at its own route, and + // its start route answers 404, so the page must know before it offers. + ...(hostedEntry?.credentialMode === 'operator_key' ? { credentialMode: 'operator_key' } : {}), connectionType: meta.connectionType, ...(meta.entryId ? { entryId: meta.entryId } : {}), available: readiness.available, diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 4b0d3251f7..2e76eadb8f 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -76,6 +76,7 @@ module.exports = { 'src/v2/connectors/ConnectorPopover.tsx', 'src/v2/connectors/ConnectorTile.tsx', 'src/v2/connectors/ConnectorsPage.tsx', + 'src/v2/connectors/OperatorKeyForm.tsx', 'src/v2/connectors/ToolPanels.tsx', 'src/v2/utils/localizeRelativeTime.ts', ], diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fc8d4f7ead..c69f772f6c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2794,7 +2794,29 @@ "connectionSeparator": " · ", "connectionSignedIn": "Signed in", "connectionKey": "Key", - "disconnectConnectionLabel": "Disconnect {{label}}: {{connection}}" + "disconnectConnectionLabel": "Disconnect {{label}}: {{connection}}", + "operatorKey": { + "note": "add a key for one of your agents", + "noAgents": "no agent of yours to add a key for yet", + "agent": "agent", + "key": "key", + "submit": "Add key", + "checking": "Checking…", + "createKey": "Create a key at {{label}}", + "errors": { + "invalidAgent": "Pick one of your agents.", + "agentNotOwned": "You can add a key only for an agent you alone installed.", + "unknownEntry": "This connector does not take a key here.", + "keyMissing": "Paste a key first.", + "scopeRefused": "This key has scopes Commonly does not allow. Create one without them.", + "vendorRejected": "{{label}} did not accept this key.", + "vendorUnreachable": "Could not reach {{label}} to check the key. Try again.", + "connectionChanged": "The connection changed while saving. Paste the key again.", + "keyStoreFailed": "Could not save the key. Try again.", + "generic": "Could not add the key. Try again." + }, + "agentsUnavailable": "couldn't read your agents just now · reload to try again" + } }, "agentManage": { "runtime": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 9e61879262..2aed1c3c68 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2795,7 +2795,29 @@ "connectionSeparator": " · ", "connectionSignedIn": "已登录", "connectionKey": "密钥", - "disconnectConnectionLabel": "断开 {{label}}:{{connection}}" + "disconnectConnectionLabel": "断开 {{label}}:{{connection}}", + "operatorKey": { + "note": "为你的智能体添加密钥", + "noAgents": "你还没有可以添加密钥的智能体", + "agent": "智能体", + "key": "密钥", + "submit": "添加密钥", + "checking": "验证中…", + "createKey": "去 {{label}} 创建密钥", + "errors": { + "invalidAgent": "请先选择一个智能体。", + "agentNotOwned": "只能为仅由你安装的智能体添加密钥。", + "unknownEntry": "此连接器不支持在这里添加密钥。", + "keyMissing": "请先粘贴密钥。", + "scopeRefused": "这个密钥包含 Commonly 不允许的权限范围,请新建一个不含这些权限的密钥。", + "vendorRejected": "{{label}} 拒绝了这个密钥。", + "vendorUnreachable": "无法连接 {{label}} 来验证密钥,请稍后重试。", + "connectionChanged": "保存时连接发生了变化,请重新粘贴密钥。", + "keyStoreFailed": "无法保存密钥,请重试。", + "generic": "无法添加密钥,请重试。" + }, + "agentsUnavailable": "暂时无法读取你的智能体,请刷新后重试" + } }, "agentManage": { "runtime": { diff --git a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx new file mode 100644 index 0000000000..90314143eb --- /dev/null +++ b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx @@ -0,0 +1,334 @@ +// @ts-nocheck +// Connectors page, operator-key entries (TASK-330): a hosted entry whose +// `credentialMode` is `operator_key` is connected by pasting one agent's key, not +// by the OAuth start route, which answers 404 for it. One connection per agent, +// so a person with one agent keyed can still add a key for another. The pasted +// key must never survive a submit, in the DOM or in any sentence. +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; +import i18n, { i18nReady } from '../../i18n'; +import { + axios, mockApi, renderPage, tileNode, findTile, findPopover, openDrawer, +} from '../__testkit__/connectorsKit'; + +jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory()); + +const SCOUT = '6a00000000000000000000a1'; +const REED = '6a00000000000000000000b2'; +const NOVA = '6a00000000000000000000c3'; +// A pasted key, built at runtime so no key-shaped literal sits in the source. +const KEY = ['fixture', 'pasted', 'key'].join('-'); + +const pods = [ + { _id: 'p1', name: 'Launch pod', type: 'chat', members: [{ _id: 'u1', username: 'sam' }] }, + { _id: 'p2', name: 'Ops', type: 'chat', members: [{ _id: 'u1', username: 'sam' }] }, +]; +// Scout and Reed are the viewer's (u1); Nova was installed by someone else. +const scout = { name: 'scout', displayName: 'Scout', userId: SCOUT, installedBy: 'u1' }; +const reed = { name: 'reed', displayName: 'Reed', userId: REED, installedBy: 'u1' }; +const nova = { name: 'nova', displayName: 'Nova', userId: NOVA, installedBy: 'u2' }; +const seats = { p1: [scout, nova], p2: [reed, scout] }; + +const keyedEntry = { + installableId: 'latchkey', list: 'tools', label: 'Latchkey', description: 'Secrets in Latchkey.', available: true, + connectionType: 'hosted-mcp', entryId: 'latchkey', credentialMode: 'operator_key', + connectNotice: 'Create a key with read scopes only.', + accessPage: 'https://latchkey.example/keys', + broker: { id: 'commonly-grant-broker' }, + tools: [{ name: 'latchkey.list_secrets', requiredWriteMode: 'read', irreversible: false }], + connections: [], +}; +const linearEntry = { + installableId: 'linear', list: 'tools', label: 'Linear', description: 'Issues in Linear.', available: true, + connectionType: 'hosted-mcp', entryId: 'linear', + connectNotice: 'Ask your admin to allow this domain.', + broker: { id: 'commonly-grant-broker' }, + tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }], + connections: [], +}; +const scoutKey = { connectionId: 'conn-scout', owner: '', repo: '', connectedAt: '2026-11-01T09:00:00.000Z', agentId: SCOUT }; + +const OPERATOR_KEY_URL = '/api/integrations/connect/hosted-mcp/latchkey/operator-key'; + +const settle = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); +const openToolPopover = async (name) => { + await findTile(name); + await settle(); + fireEvent.click(tileNode(name)); + return findPopover(name); +}; +const catalogueReads = () => axios.get.mock.calls.filter(([url]) => url === '/api/installables').length; +const agentOptions = (popover) => within(within(popover).getByLabelText('agent')).getAllByRole('option').map((option) => option.textContent); +const paste = (popover, value = KEY) => fireEvent.change(popover.querySelector('input[type="password"]'), { target: { value } }); +const keyGone = () => { + expect(document.body.innerHTML).not.toContain(KEY); + document.querySelectorAll('input').forEach((input) => expect(input.value).not.toContain(KEY)); +}; + +// The catalogue a later read answers: `after` once the post has gone through. +const mockCatalog = (before, after = before) => { + let posted = false; + axios.post.mockImplementation(async (url) => { + posted = true; + if (url === OPERATOR_KEY_URL) return { data: { integrationId: 'conn-new', status: 'connected', scopeCheck: 'self_report', toolCount: 1 } }; + return { data: {} }; + }); + mockApi({ + pods, + seats, + grants: [], + route: (url) => (url === '/api/installables' + ? Promise.resolve({ data: { installables: posted ? after : before } }) + : undefined), + }); +}; + +beforeEach(() => { + jest.clearAllMocks(); + window.history.replaceState({}, '', '/v2/connectors'); +}); + +afterEach(() => jest.restoreAllMocks()); + +describe('Connectors page: operator-key entries', () => { + it('shows a paste-a-key form instead of Connect, offering only the viewer\'s own agents', async () => { + mockCatalog([keyedEntry]); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + expect(within(popover).queryByRole('button', { name: 'Connect' })).toBeNull(); + expect(agentOptions(popover)).toEqual(['Reed', 'Scout']); + const input = within(popover).getByLabelText('key'); + expect(input).toHaveAttribute('type', 'password'); + // A vendor key, not this site's password: no saved-login offer, no save prompt. + expect(input).toHaveAttribute('autocomplete', 'new-password'); + expect(input).toHaveAttribute('data-1p-ignore'); + expect(input).toHaveAttribute('data-lpignore', 'true'); + expect(within(popover).getByRole('button', { name: 'Add key' })).toBeInTheDocument(); + expect(within(popover).getByText('add a key for one of your agents')).toBeInTheDocument(); + expect(within(popover).getByText('Create a key with read scopes only.')).toBeInTheDocument(); + const link = within(popover).getByRole('link', { name: 'Create a key at Latchkey' }); + expect(link).toHaveAttribute('href', 'https://latchkey.example/keys'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('leaves out an agent someone else also installed, since the route refuses it', async () => { + axios.post.mockImplementation(async () => ({ data: {} })); + mockApi({ + pods, + // Reed is the viewer's in p2 but another person's install in p1. + seats: { p1: [scout, { ...reed, installedBy: 'u2' }], p2: [reed] }, + grants: [], + route: (url) => (url === '/api/installables' ? Promise.resolve({ data: { installables: [keyedEntry] } }) : undefined), + }); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + expect(agentOptions(popover)).toEqual(['Scout']); + }); + + it('says the agent list could not be read, not "no agents", when a roster read fails', async () => { + axios.post.mockImplementation(async () => ({ data: {} })); + const notFound = Object.assign(new Error('nope'), { response: { status: 404 } }); + mockApi({ + pods, + seats: { p1: [nova] }, + grants: [], + route: (url) => { + if (url === '/api/installables') return Promise.resolve({ data: { installables: [keyedEntry] } }); + if (url === '/api/registry/pods/p2/agents') return Promise.reject(notFound); + return undefined; + }, + }); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + expect(within(popover).getByText("couldn't read your agents just now · reload to try again")).toBeInTheDocument(); + expect(within(popover).queryByText('no agent of yours to add a key for yet')).toBeNull(); + }); + + it('a granted entry still offers the key form in its drawer for an agent with no key', async () => { + axios.post.mockImplementation(async () => ({ data: {} })); + const grant = { + grantId: 'grant_lk', installationId: 'inst-lk', target: { kind: 'pod', id: 'p1' }, tools: ['latchkey.list_secrets'], + writeMode: 'read', budget: { calls: 50, windowMs: 3600000 }, effectiveAudience: [SCOUT], + expiresAt: new Date(Date.now() + 6 * 86400000).toISOString(), revokedAt: null, revokedBy: null, + parentGrantId: null, rootGrantId: null, createdAt: new Date(Date.now() - 3600000).toISOString(), grantedBy: 'u1', + }; + mockApi({ + pods, + seats, + grants: [grant], + route: (url) => (url === '/api/installables' + ? Promise.resolve({ data: { installables: [{ ...keyedEntry, connections: [scoutKey] }] } }) + : undefined), + }); + renderPage(); + + const drawer = await openDrawer('Latchkey'); + // Scout's key is granted; Reed has none yet, so the form offers Reed only. + expect(within(drawer).getByLabelText('key')).toBeInTheDocument(); + expect(agentOptions(drawer)).toEqual(['Reed']); + }); + + it('a success without the route\'s connected receipt reads as a failure, and does not refresh', async () => { + mockApi({ + pods, + seats, + grants: [], + route: (url) => (url === '/api/installables' ? Promise.resolve({ data: { installables: [keyedEntry] } }) : undefined), + }); + // A 2xx with an HTML fallback body, as a misrouted proxy would answer. + axios.post.mockImplementation(async () => ({ data: '' })); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + const readsBefore = catalogueReads(); + paste(popover); + fireEvent.click(within(popover).getByRole('button', { name: 'Add key' })); + expect(await within(popover).findByRole('alert')).toHaveTextContent('Could not add the key. Try again.'); + expect(catalogueReads()).toBe(readsBefore); + keyGone(); + }); + + it('an OAuth hosted entry keeps its Connect button and shows no key form', async () => { + mockCatalog([linearEntry]); + renderPage(); + + const popover = await openToolPopover('Linear'); + expect(within(popover).getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + expect(within(popover).getByText('connect your own Linear account')).toBeInTheDocument(); + expect(within(popover).getByText('Ask your admin to allow this domain.')).toBeInTheDocument(); + expect(within(popover).queryByLabelText('key')).toBeNull(); + expect(within(popover).queryByRole('button', { name: 'Add key' })).toBeNull(); + }); + + it('submit posts the agent and key to the operator-key route, refreshes, and clears the key', async () => { + mockCatalog([keyedEntry], [{ ...keyedEntry, connections: [{ ...scoutKey, agentId: REED, connectionId: 'conn-reed' }] }]); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + fireEvent.change(within(popover).getByLabelText('agent'), { target: { value: REED } }); + paste(popover); + // Positive control for keyGone: before the submit the key IS on the page. + expect(within(popover).getByLabelText('key')).toHaveValue(KEY); + expect(document.body.innerHTML).toContain(KEY); + const readsBefore = catalogueReads(); + fireEvent.click(within(popover).getByRole('button', { name: 'Add key' })); + + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + OPERATOR_KEY_URL, + { agentId: REED, key: KEY }, + expect.anything(), + )); + expect(axios.post).not.toHaveBeenCalledWith(expect.stringContaining('/start'), expect.anything(), expect.anything()); + await waitFor(() => expect(catalogueReads()).toBeGreaterThan(readsBefore)); + // The refreshed row names Reed's key, and Reed leaves the picker. + await waitFor(() => expect(agentOptions(popover)).toEqual(['Scout'])); + expect(within(popover).getByLabelText('key')).toHaveValue(''); + expect(within(popover).queryByRole('alert')).toBeNull(); + keyGone(); + }); + + it.each([ + [400, 'invalid_agent', 'Pick one of your agents.'], + [403, 'agent_not_owned', 'You can add a key only for an agent you alone installed.'], + [404, 'unknown_entry', 'This connector does not take a key here.'], + [400, 'not_operator_key', 'This connector does not take a key here.'], + [400, 'key_missing', 'Paste a key first.'], + [400, 'scope_refused', 'This key has scopes Commonly does not allow. Create one without them.'], + [400, 'vendor_rejected', 'Latchkey did not accept this key.'], + [502, 'vendor_unreachable', 'Could not reach Latchkey to check the key. Try again.'], + [409, 'connection_changed', 'The connection changed while saving. Paste the key again.'], + [500, 'key_store_failed', 'Could not save the key. Try again.'], + [401, 'unauthorized', 'Could not add the key. Try again.'], + ])('a %i %s reads as its sentence and the key is gone', async (status, code, sentence) => { + mockCatalog([keyedEntry]); + axios.post.mockRejectedValue({ response: { status, data: { error: code, message: `server text ${code}` } } }); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + paste(popover); + fireEvent.click(within(popover).getByRole('button', { name: 'Add key' })); + + expect(await within(popover).findByRole('alert')).toHaveTextContent(sentence); + expect(screen.queryByText(`server text ${code}`)).toBeNull(); + expect(within(popover).getByLabelText('key')).toHaveValue(''); + keyGone(); + }); + + it('a request that never reached the server reads as the generic sentence', async () => { + mockCatalog([keyedEntry]); + axios.post.mockRejectedValue(new Error(`Network Error ${KEY}`)); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + paste(popover); + fireEvent.click(within(popover).getByRole('button', { name: 'Add key' })); + + expect(await within(popover).findByRole('alert')).toHaveTextContent('Could not add the key. Try again.'); + keyGone(); + }); + + it('an empty key is refused on the page and never posted', async () => { + mockCatalog([keyedEntry]); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + paste(popover, ' '); + fireEvent.click(within(popover).getByRole('button', { name: 'Add key' })); + + expect(await within(popover).findByRole('alert')).toHaveTextContent('Paste a key first.'); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('with one agent keyed, a key can still be added for another, and the keyed one is named', async () => { + mockCatalog([{ ...keyedEntry, connections: [scoutKey] }]); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + expect(within(popover).queryByRole('button', { name: 'Connect' })).toBeNull(); + expect(agentOptions(popover)).toEqual(['Reed']); + expect(within(popover).getByRole('button', { name: 'Add' })).toBeInTheDocument(); + expect(within(popover).getByRole('button', { name: /^Disconnect Latchkey: Key · Scout · / })).toBeInTheDocument(); + + paste(popover); + fireEvent.click(within(popover).getByRole('button', { name: 'Add key' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith(OPERATOR_KEY_URL, { agentId: REED, key: KEY }, expect.anything())); + }); + + it('every own agent keyed: no form; no own agent at all: a note instead', async () => { + mockCatalog([{ ...keyedEntry, connections: [scoutKey, { ...scoutKey, connectionId: 'conn-reed', agentId: REED }] }]); + const first = renderPage(); + const keyedAll = await openToolPopover('Latchkey'); + expect(within(keyedAll).queryByLabelText('key')).toBeNull(); + first.unmount(); + + mockApi({ pods, seats: { p1: [nova] }, grants: [], installables: [keyedEntry] }); + renderPage(); + const none = await openToolPopover('Latchkey'); + expect(within(none).queryByLabelText('key')).toBeNull(); + expect(within(none).getByText('no agent of yours to add a key for yet')).toBeInTheDocument(); + expect(within(none).queryByRole('button', { name: 'Connect' })).toBeNull(); + }); + + it('reads in zh-CN', async () => { + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + mockCatalog([keyedEntry]); + axios.post.mockRejectedValue({ response: { status: 403, data: { error: 'agent_not_owned' } } }); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + expect(within(popover).getByText('为你的智能体添加密钥')).toBeInTheDocument(); + expect(within(popover).getByLabelText('智能体')).toBeInTheDocument(); + expect(within(popover).getByRole('link', { name: '去 Latchkey 创建密钥' })).toBeInTheDocument(); + paste(popover); + fireEvent.click(within(popover).getByRole('button', { name: '添加密钥' })); + expect(await within(popover).findByRole('alert')).toHaveTextContent('只能为仅由你安装的智能体添加密钥。'); + keyGone(); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 6527011da1..2d680a658d 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2958,6 +2958,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(drawer).toMatch(/inset: 0 0 calc\(72px/); expect(baseBody('.v2-cx-drawer')).toContain('position: absolute'); }); + + it('the operator-key form takes the full width of the action row', () => { + expect(cx).toMatch(/\.v2-cx-pop__acts > \.v2-cx-keyform \{[^}]*flex: 1 1 100%/); + }); }); it('connector refusal links keep the in-text link treatment in the parsed stylesheet', () => { diff --git a/frontend/src/v2/connectors/ConnectorsPage.tsx b/frontend/src/v2/connectors/ConnectorsPage.tsx index 6ce5a2548a..5a1efc08dd 100644 --- a/frontend/src/v2/connectors/ConnectorsPage.tsx +++ b/frontend/src/v2/connectors/ConnectorsPage.tsx @@ -465,6 +465,10 @@ const ConnectorsPage: React.FC = () => { {/* Ungranted, the offer below carries the note through renderEntryNote. */} {toolGranted(toolEntry) && tp.renderLinearWriteNote(toolEntry)} + {/* A granted operator-key entry still takes a key for an agent that has none. */} + {toolGranted(toolEntry) && tp.renderKeyForm(toolEntry) && ( +
{tp.renderKeyForm(toolEntry)}
+ )} {!toolGranted(toolEntry) && (

{catalogDescription(i18n, toolEntry.installableId, toolEntry.description)}

diff --git a/frontend/src/v2/connectors/OperatorKeyForm.tsx b/frontend/src/v2/connectors/OperatorKeyForm.tsx new file mode 100644 index 0000000000..81d53b9e6e --- /dev/null +++ b/frontend/src/v2/connectors/OperatorKeyForm.tsx @@ -0,0 +1,106 @@ +// The paste-a-key form of an operator-key hosted entry (TASK-330). One key is +// bound to one agent; the server proves it against the vendor before the row +// goes connected. The key lives only in this form's state and is cleared once +// the request settles, whatever the answer, so it never outlives the submit. +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ToolCatalogEntry } from './toolModel'; + +export interface KeyAgent { + userId: string; + label: string; +} + +export type OperatorKeyResult = { ok: true } | { ok: false; code: string }; + +interface Props { + entry: ToolCatalogEntry; + /** The agents a key can still be added for: the person's own, minus those already keyed here. */ + agents: KeyAgent[]; + submit: (entry: ToolCatalogEntry, agentId: string, key: string) => Promise; +} + +const OperatorKeyForm: React.FC = ({ entry, agents, submit }) => { + const { t } = useTranslation(); + const [picked, setPicked] = useState(''); + const [key, setKey] = useState(''); + const [busy, setBusy] = useState(false); + const [failure, setFailure] = useState(null); + // A keyed agent leaves the list after the refresh, so the pick falls back to + // the first agent still offered rather than naming one that is gone. + const agentId = agents.some((agent) => agent.userId === picked) ? picked : (agents[0]?.userId || ''); + + // Fixed copy per route code: the server's own message is never shown, and no + // sentence carries the key. + const sentences: Record string> = { + invalid_agent: () => t('tools.operatorKey.errors.invalidAgent'), + agent_not_owned: () => t('tools.operatorKey.errors.agentNotOwned'), + unknown_entry: () => t('tools.operatorKey.errors.unknownEntry'), + not_operator_key: () => t('tools.operatorKey.errors.unknownEntry'), + key_missing: () => t('tools.operatorKey.errors.keyMissing'), + scope_refused: () => t('tools.operatorKey.errors.scopeRefused'), + vendor_rejected: () => t('tools.operatorKey.errors.vendorRejected', { label: entry.label }), + vendor_unreachable: () => t('tools.operatorKey.errors.vendorUnreachable', { label: entry.label }), + connection_changed: () => t('tools.operatorKey.errors.connectionChanged'), + key_store_failed: () => t('tools.operatorKey.errors.keyStoreFailed'), + }; + const sentenceFor = (code: string): string => ( + Object.prototype.hasOwnProperty.call(sentences, code) + ? sentences[code]() + : t('tools.operatorKey.errors.generic') + ); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (busy || !agentId) return; + if (!key.trim()) { + setFailure(sentenceFor('key_missing')); + return; + } + setBusy(true); + setFailure(null); + try { + const result = await submit(entry, agentId, key); + if (result.ok === false) setFailure(sentenceFor(result.code)); + } finally { + setKey(''); + setBusy(false); + } + }; + + return ( +
{ void onSubmit(event); }}> + + + {failure &&

{failure}

} +
+ +
+
+ ); +}; + +export default OperatorKeyForm; diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx index 804fb8feb1..76a9e6c9c0 100644 --- a/frontend/src/v2/connectors/ToolPanels.tsx +++ b/frontend/src/v2/connectors/ToolPanels.tsx @@ -22,6 +22,7 @@ import type { ToolConnection, } from './toolModel'; import type { ToolConnectorsModel } from './useToolConnectors'; +import OperatorKeyForm from './OperatorKeyForm'; const G: React.FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -102,6 +103,9 @@ export const useToolPanels = (m: ToolConnectorsModel) => { submitDraft, fixJoinPolicy, connectHostedMcp, + keyAgents, + keyAgentsUnavailable, + submitOperatorKey, submitGithubAppSetup, retryStaleRevoke, revoke, @@ -150,7 +154,8 @@ export const useToolPanels = (m: ToolConnectorsModel) => { const renderDisconnect = (entry: ToolCatalogEntry | null, variant: 'row' | 'aside') => { const hosted = entry && entry.connectionType === 'hosted-mcp' ? entry.connections : []; if (!entry || hosted.length === 0) return null; - if (hosted.length === 1) { + // A lone key row still names its agent, so the person can see whose key it is. + if (hosted.length === 1 && !(hosted[0].agentId && connectionLabel(hosted[0]))) { const button = renderDisconnectOne(entry, hosted[0], variant, null); return variant === 'aside' ?
{button}
: button; } @@ -340,23 +345,48 @@ export const useToolPanels = (m: ToolConnectorsModel) => { // Where the person who connected a hosted entry picks what it reaches (for // GitHub, the App's install page). Shown on the offer and on the details of // their live grant, since a grant takes the entry off the offer list. + // For an operator-key entry the same page is where the key is made, and the + // key's scopes are what bound its reach. const renderAccessLink = (entry: ToolCatalogEntry) => ( - {t('tools.chooseAccess', { defaultValue: 'Choose what {{label}} can reach', label: entry.label })} + {entry.credentialMode === 'operator_key' + ? t('tools.operatorKey.createKey', { label: entry.label }) + : t('tools.chooseAccess', { defaultValue: 'Choose what {{label}} can reach', label: entry.label })} ); + // An operator-key entry holds one connection per agent (TASK-330), so it + // stays open to a key for each of the person's agents that has none yet. An + // OAuth entry is connected once, by `connections.length`. + const keylessAgents = (entry: ToolCatalogEntry) => { + const keyed = new Set(entry.connections.map((connection) => connection.agentId).filter(Boolean)); + return keyAgents.filter((agent) => !keyed.has(agent.userId)); + }; + // What a person can do with an entry that has no live grant: the verbs of the // old "Not yet" row, shared by the tile's popover. + // The paste-a-key form while any of the person's agents has no key on this + // operator-key entry. It is offered on a granted entry too: one agent's key + // being granted says nothing about the next agent's. + const renderKeyForm = (entry: ToolCatalogEntry) => { + const takesKey = entry.connectionType === 'hosted-mcp' && Boolean(entry.entryId) && entry.credentialMode === 'operator_key'; + const keyable = takesKey && entry.available ? keylessAgents(entry) : []; + return keyable.length > 0 + ? + : null; + }; + const renderEntryActions = (entry: ToolCatalogEntry) => { const canAdd = entry.available && entry.connections.length > 0 && podIds.length > 0; const canInstallGithubApp = isAdmin && entry.installableId === 'github' && entry.available && entry.connections.length === 0; // A hosted entry is connected by the person who will grant it (§1: member // mode), so the action belongs on the tile and needs no admin. const isHosted = entry.connectionType === 'hosted-mcp' && Boolean(entry.entryId); - const canConnectHosted = Boolean(isHosted) && entry.available && entry.connections.length === 0; + const takesKey = isHosted && entry.credentialMode === 'operator_key'; + const canConnectHosted = isHosted && !takesKey && entry.available && entry.connections.length === 0; return ( <> + {renderKeyForm(entry)} {!entry.available && ( {t('tools.ask', { defaultValue: 'Ask' })} @@ -395,20 +425,29 @@ export const useToolPanels = (m: ToolConnectorsModel) => { // works: connect your own account, ask an admin, or the modes it grants. const renderEntryNote = (entry: ToolCatalogEntry) => { const isHosted = entry.connectionType === 'hosted-mcp' && Boolean(entry.entryId); - const canConnectHosted = Boolean(isHosted) && entry.available && entry.connections.length === 0; + const takesKey = isHosted && entry.credentialMode === 'operator_key'; + const canConnectHosted = isHosted && !takesKey && entry.available && entry.connections.length === 0; + const keyable = takesKey && entry.available ? keylessAgents(entry) : []; + const unconnectedNote = () => { + if (takesKey) { + if (keyAgents.length === 0 && keyAgentsUnavailable) return t('tools.operatorKey.agentsUnavailable'); + return keyAgents.length === 0 + ? t('tools.operatorKey.noAgents') + : t('tools.operatorKey.note'); + } + return isHosted + ? t('tools.connectOwn', { defaultValue: 'connect your own {{label}} account', label: entry.label }) + : t('tools.noConnection', { defaultValue: 'install the GitHub App first · an admin does this once' }); + }; return ( <> {entry.available && (

- {entry.connections.length === 0 - ? (isHosted - ? t('tools.connectOwn', { defaultValue: 'connect your own {{label}} account', label: entry.label }) - : t('tools.noConnection', { defaultValue: 'install the GitHub App first · an admin does this once' })) - : entryModeCopy(entry)} + {entry.connections.length === 0 ? unconnectedNote() : entryModeCopy(entry)}

)} {renderLinearWriteNote(entry)} - {canConnectHosted && entry.connectNotice &&

{entry.connectNotice}

} + {(canConnectHosted || keyable.length > 0) && entry.connectNotice &&

{entry.connectNotice}

} {isHosted && entry.available && entry.accessPage &&

{renderAccessLink(entry)}

} {renderManualRevokeNotice(entry)} @@ -671,5 +710,5 @@ export const useToolPanels = (m: ToolConnectorsModel) => { ); }; - return { renderManualRevokeNotice, renderLinearWriteNote, renderDisconnect, renderHiddenConnection, renderRow, renderAccessLink, renderEntryActions, renderEntryNote, renderGithubAppSetup, renderDraft, renderAside }; + return { renderManualRevokeNotice, renderLinearWriteNote, renderDisconnect, renderHiddenConnection, renderRow, renderAccessLink, renderKeyForm, renderEntryActions, renderEntryNote, renderGithubAppSetup, renderDraft, renderAside }; }; diff --git a/frontend/src/v2/connectors/connectors.css b/frontend/src/v2/connectors/connectors.css index 6b51331762..f5b1fb2c51 100644 --- a/frontend/src/v2/connectors/connectors.css +++ b/frontend/src/v2/connectors/connectors.css @@ -82,6 +82,9 @@ .v2-cx-pop__acts { display: flex; gap: 8px; flex-wrap: wrap; } .v2-cx-note { font-size: 12.5px; color: var(--v2-text-muted); } .v2-cx-error { margin: 0 0 12px; font-size: 13px; color: var(--v2-danger-text); } +/* an operator-key entry's paste form takes the act row's full width; the error sits inside it */ +.v2-cx-pop__acts > .v2-cx-keyform { flex: 1 1 100%; min-width: 0; margin-top: 0; gap: 10px; } +.v2-cx-keyform .v2-cx-error { margin: 0; color: var(--v2-danger-text); } /* the grant rows inside a drawer: one stacked card each, not the old tile template */ .v2-cx-list { display: grid; gap: 10px; } diff --git a/frontend/src/v2/connectors/toolModel.ts b/frontend/src/v2/connectors/toolModel.ts index 1297460e59..4a447d2844 100644 --- a/frontend/src/v2/connectors/toolModel.ts +++ b/frontend/src/v2/connectors/toolModel.ts @@ -72,6 +72,12 @@ export interface ToolCatalogEntry { */ connectionType?: 'github-app' | 'hosted-mcp'; entryId?: string; + /** + * How a hosted entry is connected. Absent means OAuth (the start route); an + * operator-key entry takes one pasted key per agent at its own route, and its + * start route answers 404. + */ + credentialMode?: 'operator_key'; available: boolean; unavailableReason?: string; broker?: { id: string }; @@ -111,6 +117,8 @@ export interface PodSeat { name: string; instanceId?: string; internal?: boolean; + /** Who installed the seat in this pod: the roster's own field. */ + installedBy?: string | null; // Set by the pod roster: connector grants reach more than one of this seat's pods. runtimeNarrowedByMultipleGrantPods?: boolean; // Set by the pod roster: exactly one of this seat's several pods has a grant, diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts index b788ab4e79..f397b65fdc 100644 --- a/frontend/src/v2/connectors/useToolConnectors.ts +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -29,6 +29,7 @@ import type { GithubAppIntegrationResponse, HostedMcpManualRevokeNotice, } from './toolModel'; +import type { KeyAgent, OperatorKeyResult } from './OperatorKeyForm'; // Seats matching `narrowed` across every pod roster, named by display name, // once each, never by the pods their grants reach. @@ -101,6 +102,24 @@ export const useToolConnectors = ( // anywhere, or limited to one pod. const blockedSeatNames = useMemo(() => seatNamesWhere(seats, (seat) => seat.runtimeNarrowedByMultipleGrantPods), [seats]); const onePodSeatNames = useMemo(() => seatNamesWhere(seats, (seat) => seat.runtimeNarrowedToOnePod), [seats]); + // The agents this person can bind a pasted key to: the ones they installed, + // read from the pod rosters this page already loads. The route refuses an + // agent someone else installed too (the sole-installer rule), so an agent + // another person installed in any roster the viewer can read is left out. + // Rosters the viewer can't read stay invisible, and the route's 403 has the + // last word for those. + const keyAgents = useMemo(() => { + const all = Object.values(seats).flat(); + const sharedInstall = new Set(all + .filter((seat) => seat.userId && seat.installedBy && seat.installedBy !== viewerId) + .map((seat) => seat.userId as string)); + return [...new Map(all + .filter((seat) => seat.userId && viewerId && seat.installedBy === viewerId && !sharedInstall.has(seat.userId)) + .map((seat) => [seat.userId as string, { userId: seat.userId as string, label: seat.displayName || seat.name }])).values()] + .sort((a, b) => a.label.localeCompare(b.label)); + }, [seats, viewerId]); + // The agent list is unknown when a pod list or roster couldn't be read. + const keyAgentsUnavailable = podListFailed || seatLookupFailed; // An unreadable pod list or roster, or a roster whose census failed, leaves // posting status unknown. The page says so rather than implying posting works. const narrowingStatusUnavailable = podListFailed || seatLookupFailed || Object.values(seats).flat() @@ -453,6 +472,33 @@ export const useToolConnectors = ( } }; + /** + * One agent's pasted key for an operator-key entry (TASK-330). The key goes + * only into the request body: it is never logged, kept or returned, and only + * the route's code comes back, for the form to put in words. A success + * re-reads the catalogue so the new connection shows, as the OAuth return does. + */ + const submitOperatorKey = async (entry: ToolCatalogEntry, agentId: string, key: string): Promise => { + if (!entry.entryId) return { ok: false, code: 'unknown_entry' }; + let receipt: { integrationId?: unknown; status?: unknown } | null | undefined; + try { + receipt = await api.post<{ integrationId?: unknown; status?: unknown }>( + `/api/integrations/connect/hosted-mcp/${encodeURIComponent(entry.entryId)}/operator-key`, + { agentId, key }, + ); + } catch (err) { + const code = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error; + return { ok: false, code: typeof code === 'string' ? code : '' }; + } + // Success is the route's own receipt. An empty body or an HTML fallback + // from a proxy is not a connection, so it reads as a generic failure. + if (!receipt || typeof receipt.integrationId !== 'string' || receipt.status !== 'connected') { + return { ok: false, code: '' }; + } + await load(); + return { ok: true }; + }; + const submitGithubAppSetup = async () => { if (!githubAppSetup || !isAdmin) return; const installationId = githubAppSetup.installationId.trim(); @@ -675,6 +721,9 @@ export const useToolConnectors = ( submitDraft, fixJoinPolicy, connectHostedMcp, + keyAgents, + keyAgentsUnavailable, + submitOperatorKey, submitGithubAppSetup, markRevoked, retryStaleRevoke,