mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
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:
1 parent
275189d8d6
commit
32ede08918
4 files changed
+67
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 } } };
|
||||
Reference in new issue
Block a user