From f75e415709ff1e8bc06e1f4c51dfc994a33914b6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:53:56 -0700 Subject: [PATCH 1/3] fix: retain connector grant form without a mint receipt Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- .../v2/__tests__/V2ConnectorTools.test.tsx | 32 +++++++++++++++++++ .../src/v2/components/V2ConnectorTools.tsx | 6 ++++ 2 files changed, 38 insertions(+) diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 6d75cf05da..289f52089b 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -783,3 +783,35 @@ test('a hosted connect start error uses fixed copy instead of response text', as open.mockRestore(); } }); + +test.each([400, 403])('a hosted grant HTTP %s stays open with the server refusal', async (status) => { + mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } }); + axios.post.mockRejectedValue({ response: { status, data: { + error: 'invalid_target', message: 'The selected target is unavailable.', + } } }); + renderTools(); + fireEvent.click(await screen.findByRole('button', { name: 'Add', exact: true })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + expect(await within(form).findByRole('alert')).toHaveTextContent('The selected target is unavailable.'); + expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument(); + expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ + connectionId: 'conn-2', target: { kind: 'pod', id: 'p1' }, audience: ['a1'], + }), expect.any(Object)); +}); + +test.each([undefined, {}, { error: 'invalid_target' }, 'fallback', { grantId: '' }])( + 'an unconfirmed mint response keeps the form and old grant: %p', async (body) => { + mockApi([githubEntry]); + axios.post.mockResolvedValue({ data: body }); + renderTools(); + fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); + fireEvent.click(within(await screen.findByRole('complementary', { name: 'Grant details' })) + .getByRole('button', { name: 'Change access' })); + const form = await screen.findByRole('complementary', { name: 'Change access' }); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + expect(await within(form).findByRole('alert')).toHaveTextContent('Could not grant it.'); + expect(screen.getByRole('complementary', { name: 'Change access' })).toBeInTheDocument(); + expect(axios.post.mock.calls.map(([url]) => url)).toEqual(['/api/grants']); + }, +); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 5c7d9d6eca..cd75f7d0f0 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -469,6 +469,12 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) audience: draft.audience, expiresAt, }); + // A resolved request alone is not a mint receipt (for example, an HTML + // fallback or empty response). Keep the form and the old grant intact. + if (typeof minted?.grantId !== 'string' || !minted.grantId.trim()) { + setError(t('tools.grantError', { defaultValue: 'Could not grant it.' })); + return; + } // Change access: a change is a new grant and a revoke of the old one, because `tools` is never // widened in place. The mint is kept whatever happens next; a failed revoke is retried alone // (Vera on 0368992e), never by minting a third grant. From 8402d4a1726c853d4bbe9da1f433d0a00150d2df Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:59:25 -0700 Subject: [PATCH 2/3] fix: preserve refused grant drafts across catalogue refresh Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- .../__tests__/unit/routes/grants.mint.test.js | 9 +++++ .../v2/__tests__/V2ConnectorTools.test.tsx | 40 ++++++++++++++++++- .../src/v2/components/V2ConnectorTools.tsx | 13 ++++-- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/unit/routes/grants.mint.test.js b/backend/__tests__/unit/routes/grants.mint.test.js index daca837174..3fbbdb744e 100644 --- a/backend/__tests__/unit/routes/grants.mint.test.js +++ b/backend/__tests__/unit/routes/grants.mint.test.js @@ -93,6 +93,15 @@ beforeEach(async () => { }); describe('POST /api/grants', () => { + test('an agent User ID used as a pod target returns not-found before join-policy checks', async () => { + outsiderGrantPodRefusal.mockResolvedValue('pod_joinable'); + const res = await mint({ target: { kind: 'pod', id: SEAT } }); + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ error: 'target_not_found', message: 'target pod not found' }); + expect(outsiderGrantPodRefusal).not.toHaveBeenCalled(); + expect(await RoomGrant.countDocuments({})).toBe(0); + }); + test.each([ ['pod_required', { kind: 'seat', id: SEAT }], ['pod_not_private', { kind: 'pod', id: POD }], diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 289f52089b..70b05d9b1f 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -800,7 +800,7 @@ test.each([400, 403])('a hosted grant HTTP %s stays open with the server refusal }), expect.any(Object)); }); -test.each([undefined, {}, { error: 'invalid_target' }, 'fallback', { grantId: '' }])( +test.each([undefined, {}, { error: 'invalid_target' }, 'fallback', { grantId: '' }, { grantId: ' ' }])( 'an unconfirmed mint response keeps the form and old grant: %p', async (body) => { mockApi([githubEntry]); axios.post.mockResolvedValue({ data: body }); @@ -815,3 +815,41 @@ test.each([undefined, {}, { error: 'invalid_target' }, 'fallback', expect(axios.post.mock.calls.map(([url]) => url)).toEqual(['/api/grants']); }, ); + + +test.each(['unavailable', 'removed'])('a catalogue refresh (%s) cannot hide a refused hosted grant draft', async (refresh) => { + mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } }); + const message = 'Agents can self-install into this pod. Make it invite-only to use this connector.'; + axios.post.mockRejectedValue({ response: { status: 403, data: { error: 'outsider_grant_pod_joinable', message } } }); + renderTools(); + fireEvent.click(await screen.findByRole('button', { name: 'Add' })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + expect(await within(form).findByRole('alert')).toHaveTextContent(message); + const previousGet = axios.get.getMockImplementation(); + axios.get.mockImplementation((url, ...args) => url === '/api/installables' + ? (refresh === 'unavailable' ? Promise.reject(new Error('offline')) : Promise.resolve({ data: { installables: [] } })) + : previousGet(url, ...args)); + const reads = axios.get.mock.calls.length; + await act(async () => { document.dispatchEvent(new Event('visibilitychange')); }); + await waitFor(() => expect(axios.get.mock.calls.length).toBeGreaterThan(reads)); + expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent(message); + expect(screen.getByText('0 granted')).toBeInTheDocument(); +}); + +test('hosted grant picker sends the selected pod ID and keeps the agent User ID in audience', async () => { + mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } }); + const previousGet = axios.get.getMockImplementation(); + axios.get.mockImplementation((url, ...args) => url === '/api/registry/pods/p2/agents' + ? Promise.resolve({ data: { agents: [{ name: 'reed', userId: 'a2' }] } }) + : previousGet(url, ...args)); + renderTools(); + fireEvent.click(await screen.findByRole('button', { name: 'Add' })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + fireEvent.change(within(form).getByRole('combobox', { name: 'pod' }), { target: { value: 'p2' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ + target: { kind: 'pod', id: 'p2' }, audience: ['a2'], + }), expect.anything())); +}); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index cd75f7d0f0..8992790329 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -109,7 +109,7 @@ interface Props { } interface DraftGrant { - installableId: string; + entry: ToolCatalogEntry; podId: string; connectionId: string; writeMode: GrantWriteMode; @@ -426,7 +426,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) const podId = from ? (grantPodId(from) || podIds[0] || '') : (podIds[0] || ''); const modes = entryGrantModes(entry); setDraft({ - installableId: entry.installableId, + entry, podId, connectionId: entry.connections[0]?.connectionId || '', writeMode: from && modes.includes(from.writeMode) ? from.writeMode : modes[0] || 'read', @@ -447,7 +447,12 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) setConfirmRevoke(null); setError(null); }; - const draftEntry = draft ? catalog.find((entry) => entry.installableId === draft.installableId) || null : null; + // A background catalogue read can fail or stop offering this connection. + // Keep the open draft (and any server refusal) visible in either case. The + // mint endpoint still validates the connection and current grant policy. + const draftEntry = draft + ? catalog.find((entry) => entry.installableId === draft.entry.installableId) || draft.entry + : null; const draftTools = (entry: ToolCatalogEntry | null, mode: GrantWriteMode): string[] => (entry?.tools || []) .filter((tool) => MODE_RANK[tool.requiredWriteMode] <= MODE_RANK[mode]).map((tool) => tool.name); @@ -1069,7 +1074,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) if (grants === null) { return
{t('tools.loading', { defaultValue: 'Loading tools…' })}
; } - if (grants.length === 0 && catalog.length === 0 && hiddenConnections.length === 0 && !removedHiddenNotice) return null; + if (grants.length === 0 && catalog.length === 0 && hiddenConnections.length === 0 && !removedHiddenNotice && !draft) return null; return (
From dca08ddf29ffacee51e824aa09b60c4798d07fea Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:03:55 -0700 Subject: [PATCH 3/3] docs: name the existing mint checks precisely Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- frontend/src/v2/components/V2ConnectorTools.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 8992790329..8b04d004ff 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -449,7 +449,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) }; // A background catalogue read can fail or stop offering this connection. // Keep the open draft (and any server refusal) visible in either case. The - // mint endpoint still validates the connection and current grant policy. + // mint still checks ownership, connection state, tools, audience and pod policy. const draftEntry = draft ? catalog.find((entry) => entry.installableId === draft.entry.installableId) || draft.entry : null;