From 73d1768270c74dc9d99cc508a8b1906a237e72fb Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:11:19 -0800 Subject: [PATCH 1/5] fix(connectors): a key connection's grant draft starts with its own agent and names keys by agent The grant route refuses any grant on an operator-key connection unless its audience is exactly the bound agent, but the draft started with every seat in the pod, so the first Grant in a multi-agent pod was refused. The connection picker also labelled key connections "/" since they have no owner or repo. One rule, boundAgentOf(entry, connectionId), now drives the draft: it starts in a pod where a keyed agent sits (Grant again keeps its pod and the key it was minted on), the audience is [bound] intersected with the pod's seats on open, pod change and connection change, the checkbox list offers only the bound agent, and a bound agent outside the pod gets a short note with Grant off. Key connections are named by their agent in the picker, falling back to "Key". OAuth and GitHub drafts are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- frontend/src/i18n/locales/en.json | 3 + frontend/src/i18n/locales/zh-CN.json | 3 + .../ConnectorsPage.operatorKey.test.tsx | 100 ++++++++++++++++++ frontend/src/v2/connectors/ToolPanels.tsx | 49 +++++++-- frontend/src/v2/connectors/toolModel.ts | 12 +++ .../src/v2/connectors/useToolConnectors.ts | 43 +++++++- 6 files changed, 200 insertions(+), 10 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c69f772f6c..4dccb3e4c3 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2794,6 +2794,9 @@ "connectionSeparator": " · ", "connectionSignedIn": "Signed in", "connectionKey": "Key", + "keyOnlyAgent": "Only {{agent}} can use this key.", + "keyAgentNotInPod": "{{agent}} isn't in this pod.", + "keyAgentNotInPodUnnamed": "This key's agent isn't in this pod.", "disconnectConnectionLabel": "Disconnect {{label}}: {{connection}}", "operatorKey": { "note": "add a key for one of your agents", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 2aed1c3c68..df2d5ab813 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2795,6 +2795,9 @@ "connectionSeparator": " · ", "connectionSignedIn": "已登录", "connectionKey": "密钥", + "keyOnlyAgent": "只有 {{agent}} 能用这个密钥。", + "keyAgentNotInPod": "{{agent}} 不在这个 Pod 里。", + "keyAgentNotInPodUnnamed": "这个密钥的智能体不在这个 Pod 里。", "disconnectConnectionLabel": "断开 {{label}}:{{connection}}", "operatorKey": { "note": "为你的智能体添加密钥", diff --git a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx index 90314143eb..37013388b4 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx @@ -332,3 +332,103 @@ describe('Connectors page: operator-key entries', () => { } }); }); + +// The grant draft on a key connection (#3353 review). The grant route refuses any +// audience on a key connection except exactly its agent, so the draft starts +// there, offers no one else, and names each key by its agent. +describe('Connectors page: granting an operator-key connection', () => { + const reedKey = { ...scoutKey, connectionId: 'conn-reed', agentId: REED }; + const grantPost = () => axios.post.mock.calls.find(([url]) => url === '/api/grants'); + const openKeyDraft = async (connections, { add = 'Add', form = 'Add Latchkey' } = {}) => { + axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } }); + mockApi({ pods, seats, grants: [], installables: [{ ...keyedEntry, connections }] }); + renderPage(); + const popover = await openToolPopover('Latchkey'); + fireEvent.click(within(popover).getByRole('button', { name: add })); + return screen.findByRole('complementary', { name: form }); + }; + const optionTexts = (select) => within(select).getAllByRole('option').map((option) => option.textContent); + + it('in a pod with several agents, the first Grant reaches only the key\'s agent', async () => { + const form = await openKeyDraft([scoutKey, reedKey]); + // Launch pod holds Scout and Nova; only Scout's checkbox is offered. + expect(within(form).getByRole('heading', { name: 'Latchkey · Launch pod' })).toBeInTheDocument(); + expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked(); + expect(within(form).queryByRole('checkbox', { name: 'Nova' })).toBeNull(); + expect(within(form).getByText('Only Scout can use this key.')).toBeInTheDocument(); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(grantPost()).toBeDefined()); + expect(grantPost()[1]).toEqual(expect.objectContaining({ connectionId: 'conn-scout', target: { kind: 'pod', id: 'p1' }, audience: [SCOUT] })); + }); + + it('the connection picker names each key by its agent, not "/"', async () => { + const form = await openKeyDraft([scoutKey, reedKey]); + const picker = within(form).getByLabelText('connection'); + expect(optionTexts(picker)).toEqual(['Scout', 'Reed']); + expect(optionTexts(picker)).not.toContain('/'); + }); + + it('a key whose agent is unknown on this page reads "Key"', async () => { + const form = await openKeyDraft([scoutKey, { ...reedKey, agentId: '6a00000000000000000000ff' }]); + expect(optionTexts(within(form).getByLabelText('connection'))).toEqual(['Scout', 'Key']); + }); + + it('switching to Reed\'s key in a pod Reed is not in says so and keeps Grant off; in Reed\'s pod it grants [Reed]', async () => { + const form = await openKeyDraft([scoutKey, reedKey]); + fireEvent.change(within(form).getByLabelText('connection'), { target: { value: 'conn-reed' } }); + expect(within(form).getByText("Reed isn't in this pod.")).toBeInTheDocument(); + expect(within(form).queryByRole('checkbox')).toBeNull(); + expect(within(form).queryByText('Select at least one agent to grant access.')).toBeNull(); + expect(within(form).getByRole('button', { name: 'Grant' })).toBeDisabled(); + + fireEvent.change(within(form).getByLabelText('pod'), { target: { value: 'p2' } }); + expect(within(form).queryByText("Reed isn't in this pod.")).toBeNull(); + // Ops holds Reed and Scout; Reed's key offers Reed alone. + expect(within(form).getByRole('checkbox', { name: 'Reed' })).toBeChecked(); + expect(within(form).queryByRole('checkbox', { name: 'Scout' })).toBeNull(); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(grantPost()).toBeDefined()); + expect(grantPost()[1]).toEqual(expect.objectContaining({ connectionId: 'conn-reed', target: { kind: 'pod', id: 'p2' }, audience: [REED] })); + }); + + it('a lone key starts in a pod where its agent sits', async () => { + const form = await openKeyDraft([reedKey]); + expect(within(form).getByRole('heading', { name: 'Latchkey · Ops' })).toBeInTheDocument(); + expect(within(form).getByRole('checkbox', { name: 'Reed' })).toBeChecked(); + }); + + it('a key whose agent sits in no pod starts with nobody and Grant off', async () => { + const form = await openKeyDraft([{ ...reedKey, agentId: '6a00000000000000000000ff' }]); + expect(within(form).getByText("This key's agent isn't in this pod.")).toBeInTheDocument(); + expect(within(form).getByRole('button', { name: 'Grant' })).toBeDisabled(); + }); + + it('an OAuth draft still starts with every agent in the pod', async () => { + axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } }); + const signIn = { connectionId: 'conn-linear', owner: '', repo: '', connectedAt: '2026-11-01T09:00:00.000Z' }; + mockApi({ pods, seats, grants: [], installables: [{ ...linearEntry, connections: [signIn] }] }); + renderPage(); + const popover = await openToolPopover('Linear'); + fireEvent.click(within(popover).getByRole('button', { name: 'Add' })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked(); + expect(within(form).getByRole('checkbox', { name: 'Nova' })).toBeChecked(); + expect(within(form).queryByText(/can use this key/)).toBeNull(); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(grantPost()).toBeDefined()); + expect(grantPost()[1].audience).toEqual([SCOUT, NOVA]); + }); + + it('reads in zh-CN', async () => { + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + const form = await openKeyDraft([scoutKey, reedKey], { add: '添加', form: '添加 Latchkey' }); + expect(within(form).getByText('只有 Scout 能用这个密钥。')).toBeInTheDocument(); + fireEvent.change(form.querySelectorAll('select')[1], { target: { value: 'conn-reed' } }); + expect(within(form).getByText('Reed 不在这个 Pod 里。')).toBeInTheDocument(); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); +}); diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx index 76a9e6c9c0..869d00affd 100644 --- a/frontend/src/v2/connectors/ToolPanels.tsx +++ b/frontend/src/v2/connectors/ToolPanels.tsx @@ -11,6 +11,7 @@ import { shortAge, isExpired, isDead, + boundAgentOf, PER_ENTRY_REVOKE_COPY, } from './toolModel'; import type { @@ -99,6 +100,8 @@ export const useToolPanels = (m: ToolConnectorsModel) => { openGithubAppSetup, draftEntry, draftTools, + draftBoundAgent, + startingAudience, selectedAgentIds, submitDraft, fixJoinPolicy, @@ -513,6 +516,10 @@ export const useToolPanels = (m: ToolConnectorsModel) => { const renderDraft = () => { if (!draft || !draftEntry) return null; const podSeats = seats[draft.podId] || []; + // A key connection offers only its own agent: the route refuses any other. + const offeredSeats = draftBoundAgent ? podSeats.filter((seat) => seat.userId === draftBoundAgent) : podSeats; + const boundAbsent = Boolean(draftBoundAgent) && offeredSeats.length === 0; + const boundName = draftBoundAgent ? (agentNameById(draftBoundAgent) || memberName(draftBoundAgent)) : null; const tools = draftTools(draftEntry, draft.writeMode); const irreversible = irreversibleTools(draftEntry, tools); const asks = draft.writeMode === 'read' @@ -530,7 +537,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => { {!draft.replaces && podIds.length > 1 && ( @@ -538,8 +545,28 @@ export const useToolPanels = (m: ToolConnectorsModel) => { {draftEntry.connections.length > 1 && ( )} @@ -557,15 +584,25 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
{t('tools.agents', { defaultValue: 'agents' })} - {podSeats.length === 0 && {t('tools.noSeats', { defaultValue: 'no agent in this pod yet' })}} - {podSeats.map((seat) => seat.userId && ( + {!draftBoundAgent && podSeats.length === 0 && {t('tools.noSeats', { defaultValue: 'no agent in this pod yet' })}} + {boundAbsent && ( + + {boundName + ? t('tools.keyAgentNotInPod', { defaultValue: "{{agent}} isn't in this pod.", agent: boundName }) + : t('tools.keyAgentNotInPodUnnamed', { defaultValue: "This key's agent isn't in this pod." })} + + )} + {offeredSeats.map((seat) => seat.userId && ( ))} + {draftBoundAgent && !boundAbsent && ( + {t('tools.keyOnlyAgent', { defaultValue: 'Only {{agent}} can use this key.', agent: boundName || '' })} + )}
- {selectedAgentIds.length === 0 && ( + {selectedAgentIds.length === 0 && !boundAbsent && (

{t('tools.emptyAudienceNotice', { defaultValue: 'Select at least one agent to grant access.', })}

diff --git a/frontend/src/v2/connectors/toolModel.ts b/frontend/src/v2/connectors/toolModel.ts index 4a447d2844..b8ba8ba009 100644 --- a/frontend/src/v2/connectors/toolModel.ts +++ b/frontend/src/v2/connectors/toolModel.ts @@ -188,6 +188,18 @@ export const isDead = (grant: ToolGrant, now = Date.now()): boolean => Boolean(g export const entryCovers = (entry: ToolCatalogEntry, grant: ToolGrant): boolean => ( (grant.tools || []).some((name) => (entry.tools || []).some((tool) => tool.name === name)) ); +/** + * The agent an operator-key connection belongs to, or null for any other + * connection. The grant route refuses a grant on a key connection unless its + * audience is exactly this agent (operatorKeyAudienceRefusal in + * backend/services/hostedMcpOperatorKey.ts), so the draft starts there and + * offers no one else. + */ +export const boundAgentOf = (entry: ToolCatalogEntry, connectionId: string): string | null => { + if (entry.credentialMode !== 'operator_key') return null; + return entry.connections.find((connection) => connection.connectionId === connectionId)?.agentId || null; +}; + // A grant that names no tool of this entry grants nothing on it — that is what // the enforcement path does (roomGrantService refuses any tool the grant does // not list), so the page must not read such an entry as granted. diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts index f397b65fdc..fdaad1f249 100644 --- a/frontend/src/v2/connectors/useToolConnectors.ts +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -15,6 +15,7 @@ import { WRITE_MODES, isDead, entryCovers, + boundAgentOf, } from './toolModel'; import type { GrantWriteMode, @@ -323,16 +324,47 @@ export const useToolConnectors = ( const q = query.trim().toLowerCase(); const selected = selectedId ? (grants || []).find((grant) => grant.grantId === selectedId) || null : null; + const seatIdsIn = (podId: string): string[] => (seats[podId] || []) + .map((seat) => seat.userId).filter((id): id is string => Boolean(id)); + // Who a draft starts with in a pod: every seat, or only the agent a key + // connection belongs to, when that agent sits in the pod. + const startingAudience = (entry: ToolCatalogEntry, connectionId: string, podId: string): string[] => { + const bound = boundAgentOf(entry, connectionId); + const ids = seatIdsIn(podId); + return bound ? ids.filter((id) => id === bound) : ids; + }; + const openDraft = (entry: ToolCatalogEntry, from?: ToolGrant) => { - const podId = from ? (grantPodId(from) || podIds[0] || '') : (podIds[0] || ''); + let podId = from ? (grantPodId(from) || podIds[0] || '') : (podIds[0] || ''); + let connectionId = entry.connections[0]?.connectionId || ''; + // A key connection starts in a pod where its agent sits: the first pod, in + // list order, holding any keyed agent. Grant again keeps its grant's pod and + // the key it was minted on (a hosted grant's installationId is the + // connection's id). Other connections take neither branch. + if (from) { + const minted = entry.connections.find((connection) => connection.connectionId === from.installationId); + if (minted && boundAgentOf(entry, minted.connectionId)) connectionId = minted.connectionId; + } else { + const seated = podIds.flatMap((pod) => entry.connections.map((connection) => ({ pod, connectionId: connection.connectionId }))) + .find((pair) => { + const bound = boundAgentOf(entry, pair.connectionId); + return Boolean(bound) && seatIdsIn(pair.pod).includes(bound as string); + }); + if (seated) { + podId = seated.pod; + connectionId = seated.connectionId; + } + } const modes = entryGrantModes(entry); - const agentIds = (seats[podId] || []).map((seat) => seat.userId).filter((id): id is string => Boolean(id)); + const agentIds = seatIdsIn(podId); setDraft({ entry, podId, - connectionId: entry.connections[0]?.connectionId || '', + connectionId, writeMode: from && modes.includes(from.writeMode) ? from.writeMode : modes[0] || 'read', - audience: from ? agentIds.filter((id) => from.effectiveAudience.includes(id)) : agentIds, + audience: from && !boundAgentOf(entry, connectionId) + ? agentIds.filter((id) => from.effectiveAudience.includes(id)) + : startingAudience(entry, connectionId, podId), expiryDays: 7, replaces: from && !isDead(from) ? from.grantId : null, }); @@ -358,6 +390,7 @@ export const useToolConnectors = ( const draftTools = (entry: ToolCatalogEntry | null, mode: GrantWriteMode): string[] => (entry?.tools || []) .filter((tool) => MODE_RANK[tool.requiredWriteMode] <= MODE_RANK[mode]).map((tool) => tool.name); + const draftBoundAgent = draft && draftEntry ? boundAgentOf(draftEntry, draft.connectionId) : null; const selectedAgentIds = (draft ? seats[draft.podId] || [] : []) .map((seat) => seat.userId) .filter((id): id is string => typeof id === 'string' && Boolean(id) && draft?.audience.includes(id) === true); @@ -717,6 +750,8 @@ export const useToolConnectors = ( openGithubAppSetup, draftEntry, draftTools, + draftBoundAgent, + startingAudience, selectedAgentIds, submitDraft, fixJoinPolicy, From 252134e870c321b63102f5ad8a9e6aa5b219a1d2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:13:13 -0800 Subject: [PATCH 2/5] fix(i18n): grant-draft key strings after the two-pass review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjudicated from the voice and terminology passes: - 可以使用 matches mayUse and nobodyMayUse. - Pod 中 matches mentionNotInPod and noSeats. - 密钥所属的智能体 replaces the calque 这个密钥的智能体. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- frontend/src/i18n/locales/zh-CN.json | 6 +++--- .../src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index df2d5ab813..14063b8d34 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2795,9 +2795,9 @@ "connectionSeparator": " · ", "connectionSignedIn": "已登录", "connectionKey": "密钥", - "keyOnlyAgent": "只有 {{agent}} 能用这个密钥。", - "keyAgentNotInPod": "{{agent}} 不在这个 Pod 里。", - "keyAgentNotInPodUnnamed": "这个密钥的智能体不在这个 Pod 里。", + "keyOnlyAgent": "只有 {{agent}} 可以使用这个密钥。", + "keyAgentNotInPod": "{{agent}} 不在这个 Pod 中。", + "keyAgentNotInPodUnnamed": "密钥所属的智能体不在这个 Pod 中。", "disconnectConnectionLabel": "断开 {{label}}:{{connection}}", "operatorKey": { "note": "为你的智能体添加密钥", diff --git a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx index 37013388b4..27d6dd22bf 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx @@ -424,9 +424,9 @@ describe('Connectors page: granting an operator-key connection', () => { await act(async () => { await i18n.changeLanguage('zh-CN'); }); try { const form = await openKeyDraft([scoutKey, reedKey], { add: '添加', form: '添加 Latchkey' }); - expect(within(form).getByText('只有 Scout 能用这个密钥。')).toBeInTheDocument(); + expect(within(form).getByText('只有 Scout 可以使用这个密钥。')).toBeInTheDocument(); fireEvent.change(form.querySelectorAll('select')[1], { target: { value: 'conn-reed' } }); - expect(within(form).getByText('Reed 不在这个 Pod 里。')).toBeInTheDocument(); + expect(within(form).getByText('Reed 不在这个 Pod 中。')).toBeInTheDocument(); } finally { await act(async () => { await i18n.changeLanguage('en'); }); } From ec09b740576823e416f8c015c8bc984b5f069519 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:17:13 -0800 Subject: [PATCH 3/5] fix(connectors): key guidance in a granted drawer; an agent-dm install is not ownership From CodeRabbit review 5759512235 on #3353: - A granted entry's drawer shows the key form without the ungranted offer, so it lost connectNotice and the access-page link. A new renderKeyGuidance puts both beside the form. The drawer also calls renderKeyForm once, not twice. - runtimeIdentityOwnership drops agent-dm installs, so the picker now ignores agent-dm seats on both sides. An agent the viewer installed only in an agent-dm pod isn't offered, and another person's agent-dm install doesn't hide an agent. - Moved a stale comment back to renderEntryActions. The drawer test also asserts that the grant row still renders. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- .../ConnectorsPage.operatorKey.test.tsx | 22 +++++++++++++++++++ frontend/src/v2/connectors/ConnectorsPage.tsx | 8 +++++-- frontend/src/v2/connectors/ToolPanels.tsx | 16 +++++++++++--- .../src/v2/connectors/useToolConnectors.ts | 9 +++++--- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx index 27d6dd22bf..a37900b73a 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx @@ -168,6 +168,28 @@ describe('Connectors page: operator-key entries', () => { // 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']); + // Where to make the key sits beside the form here too. + expect(within(drawer).getByText('Create a key with read scopes only.')).toBeInTheDocument(); + expect(within(drawer).getByRole('link', { name: 'Create a key at Latchkey' })).toHaveAttribute('href', 'https://latchkey.example/keys'); + // The grant row still renders beside the form. + expect(drawer.querySelector('.v2-cx-list article')).not.toBeNull(); + }); + + it('an agent installed only in an agent-dm pod is not offered, and an agent-dm install by someone else does not block', async () => { + axios.post.mockImplementation(async () => ({ data: {} })); + const dmPods = [...pods, { _id: 'dm1', name: 'Scout ↔ Nova', type: 'agent-dm', members: [{ _id: 'u1', username: 'sam' }] }]; + mockApi({ + pods: dmPods, + // Nova is the viewer's install only in the agent-dm pod; Reed is the + // viewer's in p2, and someone else's only in the agent-dm pod. + seats: { p1: [scout], p2: [reed], dm1: [{ ...nova, installedBy: 'u1' }, { ...reed, installedBy: 'u2' }] }, + grants: [], + route: (url) => (url === '/api/installables' ? Promise.resolve({ data: { installables: [keyedEntry] } }) : undefined), + }); + renderPage(); + + const popover = await openToolPopover('Latchkey'); + expect(agentOptions(popover)).toEqual(['Reed', 'Scout']); }); it('a success without the route\'s connected receipt reads as a failure, and does not refresh', async () => { diff --git a/frontend/src/v2/connectors/ConnectorsPage.tsx b/frontend/src/v2/connectors/ConnectorsPage.tsx index 5a1efc08dd..917cc57163 100644 --- a/frontend/src/v2/connectors/ConnectorsPage.tsx +++ b/frontend/src/v2/connectors/ConnectorsPage.tsx @@ -461,13 +461,17 @@ const ConnectorsPage: React.FC = () => { const entryGrants = grantsOf(toolEntry); const liveGrants = entryGrants.filter((grant) => !isDead(grant, m.now)); const deadGrants = entryGrants.filter((grant) => isDead(grant, m.now)); + const grantedKeyForm = toolGranted(toolEntry) ? tp.renderKeyForm(toolEntry) : null; return ( {/* 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)}
+ {grantedKeyForm && ( + <> + {tp.renderKeyGuidance(toolEntry)} +
{grantedKeyForm}
+ )} {!toolGranted(toolEntry) && (
diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx index 869d00affd..b9377cb615 100644 --- a/frontend/src/v2/connectors/ToolPanels.tsx +++ b/frontend/src/v2/connectors/ToolPanels.tsx @@ -366,8 +366,6 @@ export const useToolPanels = (m: ToolConnectorsModel) => { 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. @@ -379,6 +377,18 @@ export const useToolPanels = (m: ToolConnectorsModel) => { : null; }; + // Where to make the key, for a granted entry's drawer, which skips the + // ungranted offer and its renderEntryNote. Ungranted, renderEntryNote + // carries the same two lines. + const renderKeyGuidance = (entry: ToolCatalogEntry) => ( + <> + {entry.connectNotice &&

{entry.connectNotice}

} + {entry.accessPage &&

{renderAccessLink(entry)}

} + + ); + + // 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. 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; @@ -747,5 +757,5 @@ export const useToolPanels = (m: ToolConnectorsModel) => { ); }; - return { renderManualRevokeNotice, renderLinearWriteNote, renderDisconnect, renderHiddenConnection, renderRow, renderAccessLink, renderKeyForm, renderEntryActions, renderEntryNote, renderGithubAppSetup, renderDraft, renderAside }; + return { renderManualRevokeNotice, renderLinearWriteNote, renderDisconnect, renderHiddenConnection, renderRow, renderAccessLink, renderKeyForm, renderKeyGuidance, renderEntryActions, renderEntryNote, renderGithubAppSetup, renderDraft, renderAside }; }; diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts index fdaad1f249..986c31b45b 100644 --- a/frontend/src/v2/connectors/useToolConnectors.ts +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -108,9 +108,12 @@ export const useToolConnectors = ( // 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. + // last word for those. An agent-dm install is not ownership on the server + // (runtimeIdentityOwnership drops them), so agent-dm seats count on neither + // side. const keyAgents = useMemo(() => { - const all = Object.values(seats).flat(); + const agentDmPodIds = new Set(pods.filter((pod) => pod.type === 'agent-dm').map((pod) => String(pod._id))); + const all = Object.entries(seats).filter(([podId]) => !agentDmPodIds.has(podId)).flatMap(([, rows]) => rows); const sharedInstall = new Set(all .filter((seat) => seat.userId && seat.installedBy && seat.installedBy !== viewerId) .map((seat) => seat.userId as string)); @@ -118,7 +121,7 @@ export const useToolConnectors = ( .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]); + }, [seats, viewerId, pods]); // 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 From 28f4364173469307cebc3bfafcd9957ee522cc2a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:26:00 -0800 Subject: [PATCH 4/5] test(connectors): the second-agent test proves Reed's key shows after refresh From CodeRabbit review 5759653715 on #3353. The refreshed catalogue now holds both keys. The test asserts both Disconnect labels and that no agent is left to key. With an unchanged catalogue after submit, it fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- .../src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx index a37900b73a..e56119946c 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx @@ -304,7 +304,8 @@ describe('Connectors page: operator-key entries', () => { }); it('with one agent keyed, a key can still be added for another, and the keyed one is named', async () => { - mockCatalog([{ ...keyedEntry, connections: [scoutKey] }]); + const reedKey = { ...scoutKey, connectionId: 'conn-reed', agentId: REED }; + mockCatalog([{ ...keyedEntry, connections: [scoutKey] }], [{ ...keyedEntry, connections: [scoutKey, reedKey] }]); renderPage(); const popover = await openToolPopover('Latchkey'); @@ -316,6 +317,10 @@ describe('Connectors page: operator-key entries', () => { 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())); + // After the refresh both keys show, and no agent is left to key. + expect(await screen.findByRole('button', { name: /^Disconnect Latchkey: Key · Reed · / })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Disconnect Latchkey: Key · Scout · / })).toBeInTheDocument(); + expect(screen.queryByLabelText('key')).toBeNull(); }); it('every own agent keyed: no form; no own agent at all: a note instead', async () => { From 7a69772a06eacaa7e46332d91d3239a85fa4f5b7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:33:05 -0800 Subject: [PATCH 5/5] fix(connectors): Grant again on a key whose key is gone starts with nobody From #3354's review: - When a key grant's key is no longer in the catalogue, Grant again or Change access used to fall back to the first connection, which could be another agent's key. That would mint for that agent and, for a live grant, revoke this one. The draft now starts with no connection and an empty audience, so Grant stays disabled. - On a key connection, selectedAgentIds now also intersects with the bound agent, as defence in depth beside the reset paths. - The "Only {{agent}} can use this key" hint falls back to "an agent" when the seat has no name. - New tests: Grant again keeps the key it was minted on when that isn't the first key, and a gone key starts with nobody. Each fails without its fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- .../ConnectorsPage.operatorKey.test.tsx | 36 +++++++++++++++++++ frontend/src/v2/connectors/ToolPanels.tsx | 2 +- .../src/v2/connectors/useToolConnectors.ts | 17 +++++++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx index e56119946c..0c171eca39 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.operatorKey.test.tsx @@ -375,6 +375,42 @@ describe('Connectors page: granting an operator-key connection', () => { return screen.findByRole('complementary', { name: form }); }; const optionTexts = (select) => within(select).getAllByRole('option').map((option) => option.textContent); + const keyGrant = (over) => ({ + grantId: 'grant_k', installationId: 'conn-scout', 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', + ...over, + }); + // A live grant on Scout's key keeps the tile granted, so it opens the drawer; + // `dead` is the revoked grant whose Grant again is under test. + const openGrantAgain = async (connections, dead) => { + axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } }); + mockApi({ pods, seats, grants: [keyGrant({}), dead], installables: [{ ...keyedEntry, connections }] }); + renderPage(); + const drawer = await openDrawer('Latchkey'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' })); + return screen.findByRole('complementary', { name: 'Add Latchkey' }); + }; + const revoked = { revokedAt: new Date(Date.now() - 600000).toISOString(), revokedBy: 'u1' }; + + it('Grant again on a key grant keeps the key it was minted on, even when it is not the first key', async () => { + const dead = keyGrant({ grantId: 'grant_reed', installationId: 'conn-reed', target: { kind: 'pod', id: 'p2' }, effectiveAudience: [REED], ...revoked }); + const form = await openGrantAgain([scoutKey, reedKey], dead); + expect(within(form).getByRole('heading', { name: 'Latchkey · Ops' })).toBeInTheDocument(); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(grantPost()).toBeDefined()); + expect(grantPost()[1]).toEqual(expect.objectContaining({ connectionId: 'conn-reed', target: { kind: 'pod', id: 'p2' }, audience: [REED] })); + }); + + it('Grant again on a key grant whose key is gone starts with nobody, never another agent\'s key', async () => { + const dead = keyGrant({ grantId: 'grant_gone', installationId: 'conn-deleted', target: { kind: 'pod', id: 'p2' }, effectiveAudience: [REED], ...revoked }); + const form = await openGrantAgain([scoutKey, reedKey], dead); + expect(within(form).queryAllByRole('checkbox', { checked: true })).toHaveLength(0); + expect(within(form).getByRole('button', { name: 'Grant' })).toBeDisabled(); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + expect(grantPost()).toBeUndefined(); + }); it('in a pod with several agents, the first Grant reaches only the key\'s agent', async () => { const form = await openKeyDraft([scoutKey, reedKey]); diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx index b9377cb615..a11b9ff44f 100644 --- a/frontend/src/v2/connectors/ToolPanels.tsx +++ b/frontend/src/v2/connectors/ToolPanels.tsx @@ -609,7 +609,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => { ))} {draftBoundAgent && !boundAbsent && ( - {t('tools.keyOnlyAgent', { defaultValue: 'Only {{agent}} can use this key.', agent: boundName || '' })} + {t('tools.keyOnlyAgent', { defaultValue: 'Only {{agent}} can use this key.', agent: boundName || t('tools.aSeat', { defaultValue: 'an agent' }) })} )} {selectedAgentIds.length === 0 && !boundAbsent && ( diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts index 986c31b45b..a561408862 100644 --- a/frontend/src/v2/connectors/useToolConnectors.ts +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -344,9 +344,17 @@ export const useToolConnectors = ( // list order, holding any keyed agent. Grant again keeps its grant's pod and // the key it was minted on (a hosted grant's installationId is the // connection's id). Other connections take neither branch. + // A key grant whose key is gone starts with no connection and nobody, never + // with another agent's key: that would mint for that agent and, for a live + // grant, revoke this one. + let keyGone = false; if (from) { const minted = entry.connections.find((connection) => connection.connectionId === from.installationId); if (minted && boundAgentOf(entry, minted.connectionId)) connectionId = minted.connectionId; + else if (!minted && entry.credentialMode === 'operator_key') { + connectionId = ''; + keyGone = true; + } } else { const seated = podIds.flatMap((pod) => entry.connections.map((connection) => ({ pod, connectionId: connection.connectionId }))) .find((pair) => { @@ -365,9 +373,9 @@ export const useToolConnectors = ( podId, connectionId, writeMode: from && modes.includes(from.writeMode) ? from.writeMode : modes[0] || 'read', - audience: from && !boundAgentOf(entry, connectionId) + audience: keyGone ? [] : (from && !boundAgentOf(entry, connectionId) ? agentIds.filter((id) => from.effectiveAudience.includes(id)) - : startingAudience(entry, connectionId, podId), + : startingAudience(entry, connectionId, podId)), expiryDays: 7, replaces: from && !isDead(from) ? from.grantId : null, }); @@ -394,9 +402,12 @@ export const useToolConnectors = ( .filter((tool) => MODE_RANK[tool.requiredWriteMode] <= MODE_RANK[mode]).map((tool) => tool.name); const draftBoundAgent = draft && draftEntry ? boundAgentOf(draftEntry, draft.connectionId) : null; + // On a key connection only its own agent can be submitted, whatever the + // audience holds; the server refuses anyone else (operatorKeyAudienceRefusal). const selectedAgentIds = (draft ? seats[draft.podId] || [] : []) .map((seat) => seat.userId) - .filter((id): id is string => typeof id === 'string' && Boolean(id) && draft?.audience.includes(id) === true); + .filter((id): id is string => typeof id === 'string' && Boolean(id) && draft?.audience.includes(id) === true) + .filter((id) => !draftBoundAgent || id === draftBoundAgent); const submitDraft = async () => { if (!draft || !draftEntry || selectedAgentIds.length === 0) return;