From 3ce2308b56725a7de3e0cb2a2a49caeec5fd5de9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:08:27 -0800 Subject: [PATCH] feat(connectors): an operator-key connector takes a pasted key per agent A hosted-MCP entry with credentialMode 'operator_key' (the first is Latchkey, #3352) now shows a form on its tile: pick one of your agents, paste that agent's key, then Add key. It posts to the existing POST /connect/hosted-mcp/:entryId/operator-key, in place of the OAuth Connect, which answers unknown_entry for such an entry. - The catalogue projection carries credentialMode: 'operator_key'. It is omitted for every other entry, so those rows are unchanged. - Agents come from the pod rosters the page already reads, filtered to ones the viewer installed. An agent someone else also installed is left out, and so is an agent already keyed on this entry. When a roster can't be read, the form says the agents couldn't be read, not "no agents". - One key per agent. The form stays on the tile and in a granted entry's drawer while any of the person's agents has no key. - The key lives only in form state and is cleared on every path. Errors are fixed sentences keyed by route code; the server's message is never shown and nothing is logged. Success needs the route's { integrationId, status: 'connected' } receipt. - The input is type=password and autocomplete=new-password, with the 1Password and LastPass ignore attributes. - en and zh-CN strings, through the skill's two-pass review. - A layout presence test for the form's flex rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- .../installableCatalogService.test.js | 53 +++ .../installable/installableCatalogService.ts | 7 +- frontend/.eslintrc.js | 1 + frontend/src/i18n/locales/en.json | 24 +- frontend/src/i18n/locales/zh-CN.json | 24 +- .../ConnectorsPage.operatorKey.test.tsx | 334 ++++++++++++++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 4 + frontend/src/v2/connectors/ConnectorsPage.tsx | 4 + .../src/v2/connectors/OperatorKeyForm.tsx | 106 ++++++ frontend/src/v2/connectors/ToolPanels.tsx | 61 +++- frontend/src/v2/connectors/connectors.css | 3 + frontend/src/v2/connectors/toolModel.ts | 8 + .../src/v2/connectors/useToolConnectors.ts | 49 +++ 13 files changed, 664 insertions(+), 14 deletions(-) create mode 100644 frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx create mode 100644 frontend/src/v2/connectors/OperatorKeyForm.tsx 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,