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 1346f7a909: 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-14 22:22:54 -05:00
1 parent 275189d8d6
commit 32ede08918
4 files changed
+67 -7

No files matched your search

+3 -1
View File
@@ -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
+12 -3
View File
@@ -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;
<Button disabled={busy || !state.data?.enabled || !state.data.canConfigureConnection} onClick={() => pair.mutate()}>Reconnect agent access</Button>
{pairing && state.data?.resourceUrl && <>
<AgentSetupPrompt prompt={buildGrokAccessPrompt(state.data.resourceUrl, botName || "your Bot")} label="Copy connection instructions" title="Reconnect Grok Bot" description="Complete OAuth in the browser. Keep this code out of Bot chat." />
<label className="block space-y-2"><span>One-use pairing code</span><Input type="password" autoComplete="off" readOnly value={pairing.pairingCode} onFocus={e => e.target.select()} /></label>
<div className="space-y-2">
<label htmlFor={pairingCodeId}>One-use pairing code</label>
<div className="flex items-center gap-2">
<Input id={pairingCodeId} type="password" autoComplete="off" readOnly value={pairing.pairingCode} onFocus={e => e.target.select()} />
<CopyText text={pairing.pairingCode} ariaLabel="Copy pairing code" className={buttonVariants({ variant: "outline", size: "sm" })}>Copy code</CopyText>
</div>
<p className="text-xs text-muted-foreground">Copy this code into Paperclip’s authorization page. Keep it out of Bot chat.</p>
</div>
</>}
</>}
{binding?.assignment && <div className="space-y-2"><p>{binding.assignment.status === "offered" ? "Awaiting acknowledgement" : "Working in a Paperclip work session"}</p><Button variant="outline" disabled={busy} onClick={() => stop.mutate(binding.assignment!.runId)}>Stop Paperclip work</Button><p className="text-xs text-muted-foreground">Revokes this session’s Paperclip authority and stops future dispatch. Remote execution may continue. Open Grok Bot to inspect it.</p></div>}
@@ -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(<QueryClientProvider client={cache}><GrokBotConnection companyId="company-fixture" agentId="agent-fixture" /></QueryClientProvider>));
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<HTMLButtonElement>('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<HTMLButtonElement>('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);
});
@@ -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 } } };