From 32ede08918d970bf7c4e98107c78ecd3466e209a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:22:54 -0500 Subject: [PATCH] fix(grok): copy pairing codes during agent reconnect (#16373) Grok Bot reconnect showed a masked one-use code without a copy action. Add the shared Copy code control, preserve masking and copy-error feedback, and keep the code out of Bot instructions. Cover the reconnect flow and readiness wait in tests and Storybook, and update the user guide. Verified head 1346f7a9090850236c9c5e774e64175d9f98f4d4: 55 checks passed, two optional skips, full tests/build/typecheck/E2E, and Greptile 5/5 with no unresolved threads. Local typecheck, build, token gates, Storybook build and focused regressions pass. Scoped Chrome copying and a fresh built story were verified with synthetic data. Actual Grok runtime qualification remains a release gate. Co-Authored-By: Paperclip --- doc/grok-bot-agent.md | 4 +- ui/src/components/GrokBotConnection.tsx | 15 +++++-- .../new-agent/GrokBotSetup.render.test.tsx | 43 ++++++++++++++++++- .../agents/grok-bot/configuration.stories.tsx | 12 +++++- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/doc/grok-bot-agent.md b/doc/grok-bot-agent.md index dc842362b26..ee04b8a6f3c 100644 --- a/doc/grok-bot-agent.md +++ b/doc/grok-bot-agent.md @@ -22,7 +22,9 @@ Use **Save & exit** to preserve unfinished setup. See the official Manage the Bot from its agent's **Runtime** settings. This shows connection health, routine configuration, and verification. Model selection, local paths, and runtime tests are managed outside Paperclip; use **Verify connection** to -check both directions. +check both directions. Use **Reconnect agent access** to restore the same agent's +connection. Select **Copy code** beside the masked one-use pairing code, then +paste it into Paperclip's authorization page. Keep it out of Bot chat. Assign ordinary tasks to the agent. Read its progress, answer its question cards, and review its documents and files in the task thread. The Bot can initiate a diff --git a/ui/src/components/GrokBotConnection.tsx b/ui/src/components/GrokBotConnection.tsx index 0aa3fd32060..0e7e7bc2c7c 100644 --- a/ui/src/components/GrokBotConnection.tsx +++ b/ui/src/components/GrokBotConnection.tsx @@ -1,15 +1,17 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { DotPairing } from "@paperclipai/shared"; import { grokBotApi } from "@/api/grokBot"; import { buildGrokAccessPrompt, buildGrokRoutinePrompt } from "@/lib/grok-bot-setup-prompt"; import { AgentSetupPrompt } from "./AgentSetupPrompt"; -import { Button } from "./ui/button"; +import { CopyText } from "./CopyText"; +import { Button, buttonVariants } from "./ui/button"; import { Input } from "./ui/input"; import { queryKeys } from "@/lib/queryKeys"; /** Configuration writes update the authoritative binding, never an unsaved agent form. */ export function GrokBotConnection({ companyId, agentId }: { companyId?: string; agentId?: string }) { + const pairingCodeId = useId(); const cache = useQueryClient(); const key = ["grok-binding", companyId, agentId]; const state = useQuery({ queryKey: key, enabled: !!companyId && !!agentId, @@ -75,7 +77,14 @@ export function GrokBotConnection({ companyId, agentId }: { companyId?: string; {pairing && state.data?.resourceUrl && <> - +
+ +
+ e.target.select()} /> + Copy code +
+

Copy this code into Paperclip’s authorization page. Keep it out of Bot chat.

+
} } {binding?.assignment &&

{binding.assignment.status === "offered" ? "Awaiting acknowledgement" : "Working in a Paperclip work session"}

Revokes this session’s Paperclip authority and stops future dispatch. Remote execution may continue. Open Grok Bot to inspect it.

} diff --git a/ui/src/components/new-agent/GrokBotSetup.render.test.tsx b/ui/src/components/new-agent/GrokBotSetup.render.test.tsx index 88228bf05ca..3a2c873eb89 100644 --- a/ui/src/components/new-agent/GrokBotSetup.render.test.tsx +++ b/ui/src/components/new-agent/GrokBotSetup.render.test.tsx @@ -35,7 +35,7 @@ it("restores the same saved agent when a failed initial resume succeeds on retry expect(retry).toBeTruthy(); flushSync(() => retry!.click()); await vi.waitFor(() => expect([...document.querySelectorAll("label")].some(label => label.textContent === "Webhook key")).toBe(true)); - expect(api.connection).toHaveBeenCalledWith(companyId, agentId, expect.any(AbortSignal)); + await vi.waitFor(() => expect(api.connection).toHaveBeenCalledWith(companyId, agentId, expect.any(AbortSignal))); expect(api.create).not.toHaveBeenCalled(); expect(api.pair).not.toHaveBeenCalled(); }); @@ -82,3 +82,44 @@ it("copies the masked pairing code and offers a retry after a clipboard failure" expect(copyTextToClipboard).toHaveBeenNthCalledWith(2, pairingCode); expect(document.body.textContent).not.toContain(pairingCode); }); + +it("copies a masked reconnect code without adding it to Bot instructions and retries clipboard failure", async () => { + const pairingCode = "synthetic_reconnect_code_12345"; + api.connection.mockResolvedValue({ enabled: true, canConfigureConnection: true, + resourceUrl: "https://paperclip.example/mcp/runner", binding: null }); + api.pair.mockResolvedValue({ bindingId: "binding-fixture", pairingCode, + expiresAt: new Date(Date.now() + 60_000).toISOString() }); + vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error("Clipboard unavailable")).mockResolvedValue(); + cache = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); + flushSync(() => root.render()); + const reconnect = await vi.waitFor(() => { + const button = [...host.querySelectorAll("button")].find(element => element.textContent === "Reconnect agent access")!; + expect(button.disabled).toBe(false); + return button; + }); + flushSync(() => reconnect.click()); + const copy = await vi.waitFor(() => { + const button = host.querySelector('button[aria-label="Copy pairing code"]'); + expect(button).toBeTruthy(); + return button!; + }); + const label = [...host.querySelectorAll("label")].find(element => element.textContent === "One-use pairing code")!; + const input = document.getElementById(label.htmlFor) as HTMLInputElement; + expect(input.type).toBe("password"); + expect(input.readOnly).toBe(true); + expect(input.value).toBe(pairingCode); + expect(host.textContent).not.toContain(pairingCode); + flushSync(() => copy.click()); + await vi.waitFor(() => expect(host.textContent).toContain("Copy failed")); + flushSync(() => copy.click()); + await vi.waitFor(() => expect(host.textContent).toContain("Copied!")); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(1, pairingCode); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(2, pairingCode); + const instructions = host.querySelector('button[aria-label="Copy connection instructions"]')!; + flushSync(() => instructions.click()); + await vi.waitFor(() => expect(copyTextToClipboard).toHaveBeenCalledTimes(3)); + expect(vi.mocked(copyTextToClipboard).mock.calls[2]![0]).toContain("https://paperclip.example/mcp/runner"); + expect(vi.mocked(copyTextToClipboard).mock.calls[2]![0]).not.toContain(pairingCode); + expect(api.pair).toHaveBeenCalledExactlyOnceWith("company-fixture", "agent-fixture", undefined); +}); diff --git a/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx b/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx index 70370e74e60..886c24cfe55 100644 --- a/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx +++ b/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import type { AgentDetail, GrokConnection } from "@paperclipai/shared"; import { ConfigurationTab } from "@/pages/AgentDetail"; import { storybookAgents } from "../../../fixtures/paperclipData"; @@ -36,6 +36,8 @@ function Configuration({ disconnected = false }: { disconnected?: boolean }) { const fixture: typeof fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); if (url.pathname === `/api/companies/${agent.companyId}/agents/${agent.id}/grok-binding`) { + if (disconnected && init?.method === "POST") return Response.json({ bindingId, + pairingCode: "synthetic-reconnect-code", expiresAt: new Date(Date.now() + 900000).toISOString() }); if ((init?.method ?? "GET") !== "GET") return Response.json({ error: "This preview does not authorize connections." }, { status: 405 }); return Response.json(disconnected ? { ...connection, binding: null } : connection); } @@ -71,6 +73,12 @@ export const ConnectedBot: Story = { play: async ({ canvasElement }) => { await expect(page.queryByText("AI connection", { exact: true })).not.toBeInTheDocument(); } }; export const DisconnectedBot: Story = { args: { disconnected: true }, play: async ({ canvasElement }) => { - await expect(await within(canvasElement).findByRole("button", { name: "Reconnect agent access" })).toBeVisible(); + const page = within(canvasElement); + const reconnect = await page.findByRole("button", { name: "Reconnect agent access" }); + await waitFor(() => expect(reconnect).toBeEnabled()); + await userEvent.click(reconnect); + await expect(await page.findByRole("button", { name: "Copy pairing code" })).toBeVisible(); + await expect(page.getByLabelText("One-use pairing code")).toHaveAttribute("type", "password"); + await expect(page.queryByText("synthetic-reconnect-code")).not.toBeInTheDocument(); } }; export const Mobile: Story = { ...ConnectedBot, globals: { viewport: { value: "mobile1", isRotated: false } } };