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..14063b8d34 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..0c171eca39 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 () => { @@ -282,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'); @@ -294,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 () => { @@ -332,3 +359,139 @@ 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); + 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]); + // 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/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 76a9e6c9c0..a11b9ff44f 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, @@ -363,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. @@ -376,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; @@ -513,6 +526,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 +547,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => { {!draft.replaces && podIds.length > 1 && ( @@ -538,8 +555,28 @@ export const useToolPanels = (m: ToolConnectorsModel) => { {draftEntry.connections.length > 1 && ( )} @@ -557,15 +594,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 || t('tools.aSeat', { defaultValue: 'an agent' }) })} + )}
- {selectedAgentIds.length === 0 && ( + {selectedAgentIds.length === 0 && !boundAbsent && (

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

@@ -710,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/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..a561408862 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, @@ -107,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)); @@ -117,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 @@ -323,16 +327,55 @@ 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. + // 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) => { + 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: keyGone ? [] : (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,9 +401,13 @@ 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; + // 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; @@ -717,6 +764,8 @@ export const useToolConnectors = ( openGithubAppSetup, draftEntry, draftTools, + draftBoundAgent, + startingAudience, selectedAgentIds, submitDraft, fixJoinPolicy,