fix(ui): configure Grok Bot through its external connection (#16338)

## Thinking Path

Paperclip coordinates an external Grok Bot through its authenticated
agent connection and routine. The shared configuration form only
excluded OpenAI Dot from local runtime controls, so Grok Bot inherited a
model picker, local paths, an AI account picker, and a generic runtime
test. These controls cannot configure the external Bot and the generic
test is not evidence of its webhook-to-MCP connection. Extend the
existing external-provider presentation gates and retain the dedicated
connection verification workflow.

Related implementation: #16219 and #16305. Searched open Grok PRs and
issues; the remaining matches concern the separate coding runtime or
unrelated coverage.

## Linked Issues or Issue Description

**What happened?**
An existing Grok Bot agent's Runtime page shows local model and runtime
configuration alongside its actual external connection controls.

**Expected behavior**
Identify the harness as Grok Bot, show its connection and routine
controls, and use Verify connection for authenticated two-way readiness.
Omit inapplicable local-runtime controls and model catalog queries.

**Steps to reproduce**
Create a Grok Bot through Invite external agent, then open its Runtime
settings. Before this correction, the shared form renders a model
selector and Test your agent despite the Bot owning its runtime.

**Paperclip version/commit**
Master after #16219; reproduced against the shared form at
8ffc5748a2.

## What Changed

- Treat Grok Bot and Dot as external providers when resolving local
configuration and model catalog queries.
- Identify Grok Bot directly in the harness field and keep its dedicated
connection, routine, and verification controls.
- Hide the generic runtime test for Grok Bot, including compact feedback
and externally registered test actions.
- Add regressions for compact and inline configuration and production
ConfigurationTab stories for connected, disconnected, and mobile states.
- Isolate generated documentation previews in separate iframes; verified
connected and disconnected states remain distinct across polling.
- Document the Runtime management path.

## Verification

- New regression failed in both variants before the correction; the four
focused UI suites pass: 131 tests, including existing native-provider
configuration coverage.
- Repository typecheck, production build, and token gates pass.
Storybook builds successfully.
- Used the built production ConfigurationTab in the browser: connected
health, credential replacement fields, disconnected reconnect, and a
375-pixel mobile layout with no horizontal overflow. These are synthetic
connection fixtures, not live-provider qualification.
- A full local database-backed suite is unavailable because this Mac has
reached its SysV shared-memory limit. The earlier diagnostic fixture
confirmed that limitation; no unrelated databases were stopped. Full
repository CI passes on 60119f1ce1: 55
checks successful and two intentional skips.

- Fresh Greptile review is 5/5 on the final head, with the
preview-isolation finding addressed and its thread resolved.

## Risks

Low risk: shared form presentation and unnecessary model queries change;
no authorization, secret storage, Runner execution, schema, or transport
behavior changes. Existing stored configuration remains intact.
Native-provider form regressions run in the focused suite; full
repository CI passes on the final head.

## Model Used

OpenAI Codex, based on GPT-6, with repository inspection, code editing,
shell tests, and browser verification. The host does not expose a more
specific serving-model ID or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-14 17:23:18 -05:00
1 parent ebceb2a744
commit 538bbd5478
5 files changed
+135 -16

No files matched your search

+5
View File
@@ -19,6 +19,11 @@ assignment delivery and an authenticated reply before **Done** becomes available
Use **Save & exit** to preserve unfinished setup. See the official
[routine instructions](https://cursor.com/help/grok-bot/routines).
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.
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
new task from its Grok conversation through the admitted work-session tools.
@@ -73,6 +73,12 @@ vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/grokBot", () => ({
grokBotApi: {
connection: async () => ({ enabled: true, canConfigureConnection: true, binding: null }),
},
}));
vi.mock("../api/adapters", () => ({
adaptersApi: { list: async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }] },
}));
@@ -312,6 +318,7 @@ async function renderForm(
options: {
showAdapterTestEnvironmentButton?: boolean;
showAdapterTypeField?: boolean;
compactTestFeedback?: boolean;
content?: "configuration" | "secrets";
environmentVariablesPlacement?: "configuration" | "secrets";
hideInlineSave?: boolean;
@@ -335,6 +342,7 @@ async function renderForm(
agent={makeAgent(agentOverrides)}
onSave={onSave}
hidePromptTemplate
compactTestFeedback={options.compactTestFeedback}
content={options.content}
environmentVariablesPlacement={options.environmentVariablesPlacement}
hideInlineSave={options.hideInlineSave}
@@ -841,6 +849,25 @@ describe("AgentConfigForm environment selector", () => {
vi.clearAllMocks();
});
it.each([false, true])("uses Grok connection verification without native controls (compact: %s)", async compactTestFeedback => {
const result = await renderForm([], {
adapterType: "paperclip_runner",
adapterConfig: { provider: "grok_bot", cwd: "/old/local/path", model: "old-model" },
}, { showAdapterTypeField: true, showAdapterTestEnvironmentButton: true, compactTestFeedback });
roots.push(result.root);
expect(result.container.querySelector('[aria-label="Grok Bot connection"]')).not.toBeNull();
expect(result.container.textContent).toContain("Reconnect agent access");
expect(result.container.querySelector('[data-config-section="adapter"]')?.textContent).toContain("Grok Bot");
expect(result.container.querySelector('[aria-label="Model"]')).toBeNull();
expect(result.container.textContent).not.toContain("AI connection");
expect(result.container.textContent).not.toContain("Working directory");
expect(result.container.textContent).not.toContain("Test your agent");
expect(findButton(result.container, "Test")).toBeUndefined();
expect(mockAgentsApi.adapterModels).not.toHaveBeenCalled();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
});
it("promotes environment drafts through the page Save action and discards them through the page Discard action", async () => {
const dirty = vi.fn();
let save: (() => void) | null = null;
+26 -15
View File
@@ -345,9 +345,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const isCreate = mode === "create";
const cards = props.sectionLayout === "cards";
const showAdapterTypeField = props.showAdapterTypeField ?? true;
const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true;
const showInlineAdapterTestEnvironmentButton =
showAdapterTestEnvironmentButton && !props.onTestActionChange && !props.compactTestFeedback;
const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange;
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
const hideInstructionsFile = props.hideInstructionsFile ?? false;
@@ -629,8 +626,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: adapterType;
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(isCodexHarness ? harnessType : adapterType);
const isDotRunner = adapterType === "paperclip_runner" && (isCreate ? props.values.adapterSchemaValues?.provider : eff("adapterConfig", "provider", config.provider)) === "openai_dot";
const isLocal = !isDotRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt);
const runnerProvider = adapterType === "paperclip_runner"
? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex"
: eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex"))
: undefined;
const isDotRunner = runnerProvider === "openai_dot";
const isGrokBotRunner = runnerProvider === "grok_bot";
const isExternalBotRunner = isDotRunner || isGrokBotRunner;
const showAdapterTestEnvironmentButton = !isGrokBotRunner && (props.showAdapterTestEnvironmentButton ?? true);
const showInlineAdapterTestEnvironmentButton =
showAdapterTestEnvironmentButton && !props.onTestActionChange && !props.compactTestFeedback;
const isLocal = !isExternalBotRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt);
// The legacy working directory is an absolute path on the host, so the
// managed-sandbox-only policy hides it. A stored value stays untouched; it is
@@ -909,14 +915,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? "Paperclip Computer"
: "Local";
const runnerProvider = adapterType === "paperclip_runner"
? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex"
: eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex"))
: undefined;
const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse(
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection,
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent)));
const connectionModels = useConnectionModels(isExternalBotRunner ? null : selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent)));
// Fetch adapter models for the effective provider, including unsaved changes.
const modelAdapterType = isCodexHarness ? harnessType : adapterType;
const modelQueryKey = selectedCompanyId
@@ -932,7 +934,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
environmentId: currentDefaultEnvironmentId || null,
provider: modelProvider,
}),
enabled: Boolean(selectedCompanyId) && !connectionModels,
enabled: Boolean(selectedCompanyId) && !isExternalBotRunner && !connectionModels,
});
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
@@ -983,8 +985,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Resolve the effective instructions-file gate once. The instructions file
// is an absolute host path, so the managed-sandbox-only policy hides it for
// every adapter without a per-adapter edit.
hideInstructionsFile: hideInstructionsFile || hideHostPaths || isDotRunner,
managedSandboxOnly: hideHostPaths || isDotRunner,
hideInstructionsFile: hideInstructionsFile || hideHostPaths || isExternalBotRunner,
managedSandboxOnly: hideHostPaths || isExternalBotRunner,
openAiDotEnabled: experimentalSettings?.enableOpenAiDot === true,
};
@@ -1624,7 +1626,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
</div>
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{showAdapterTypeField && (
{showAdapterTypeField && isGrokBotRunner && (
<Field label="Harness" hint={help.adapterType}>
<div className="inline-flex items-center gap-1.5">
<AdapterMark type="grok_bot" className="size-4" />
<span>Grok Bot</span>
<ExperimentalBadge />
</div>
</Field>
)}
{showAdapterTypeField && !isGrokBotRunner && (
<Field label="Harness" hint={help.adapterType}>
<AdapterTypeDropdown
value={isDotRunner ? "openai_dot" : isCodexHarness ? harnessType : adapterType}
@@ -1697,7 +1708,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
)}
{!isDotRunner && !isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={aiRoutingHarness(adapterType, eff("adapterConfig", "provider", config.provider), eff("adapterConfig", "acpxAgent", config.acpxAgent))}
{!isExternalBotRunner && !isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={aiRoutingHarness(adapterType, eff("adapterConfig", "provider", config.provider), eff("adapterConfig", "acpxAgent", config.acpxAgent))}
routerAdapterType={adapterType} value={aiRuntimeConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data}
model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy
onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />}
+1 -1
View File
@@ -2021,7 +2021,7 @@ export function ConfigurationTab({
? queryKeys.agents.adapterModels(companyId, agent.adapterType, null, catalogProvider)
: ["agents", "none", "adapter-models", agent.adapterType],
queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType, { provider: catalogProvider }),
enabled: Boolean(companyId) && content === "runtime",
enabled: Boolean(companyId) && content === "runtime" && !["openai_dot", "grok_bot"].includes(catalogProvider ?? ""),
});
const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review";
@@ -0,0 +1,76 @@
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 type { AgentDetail, GrokConnection } from "@paperclipai/shared";
import { ConfigurationTab } from "@/pages/AgentDetail";
import { storybookAgents } from "../../../fixtures/paperclipData";
const agent: AgentDetail = {
...storybookAgents[0]!, id: "grok-config-agent", name: "Night Shift", urlKey: "night-shift",
adapterType: "paperclip_runner", adapterConfig: { provider: "grok_bot", allowUnmeteredProvider: true },
chainOfCommand: [], access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] },
};
const bindingId = "11111111-1111-4111-8111-111111111111";
const at = "2026-10-13T12:00:00Z";
const connection: GrokConnection = {
enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle",
agentLifecycleState: "ready", canConfigureConnection: true, recoveryNeeded: false,
binding: {
id: bindingId, providerKind: "grok_bot", status: "ready", connected: true, subscriptionVerified: false,
hasPendingChallenge: false, pairingExpiresAt: null, challengeExpiresAt: null, wakeVerified: true,
routine: { bindingId, botName: "Night Shift", revision: 1, configured: true, verified: true,
verifiedAt: at, lastContactAt: at, recoveryNeeded: false, lastExchange: {
id: "synthetic-exchange", kind: "verify", status: "acknowledged", httpStatus: 200,
sentAt: at, acknowledgedAt: at, expiresAt: "2099-01-01T00:00:00Z",
} },
},
};
const ignore = () => {};
function Configuration({ disconnected = false }: { disconnected?: boolean }) {
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }));
const [ready, setReady] = useState(false);
useEffect(() => {
const previous = window.fetch;
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 ((init?.method ?? "GET") !== "GET") return Response.json({ error: "This preview does not authorize connections." }, { status: 405 });
return Response.json(disconnected ? { ...connection, binding: null } : connection);
}
return previous(input, init);
};
window.fetch = fixture;
setReady(true);
return () => { if (window.fetch === fixture) window.fetch = previous; client.clear(); };
}, [client, disconnected]);
return <QueryClientProvider client={client}><main className="space-y-6 p-6">
<h1 className="text-xl font-semibold">Night Shift · Runtime</h1>
{ready && <ConfigurationTab agent={agent} companyId={agent.companyId} content="runtime"
onDirtyChange={ignore} onSaveActionChange={ignore} onCancelActionChange={ignore} onSavingChange={ignore}
updatePermissions={{ mutate: ignore, isPending: false }} />}
</main></QueryClientProvider>;
}
const meta = {
id: "grok-bot-configuration",
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/Grok Bot/Configuration",
component: Configuration,
tags: ["autodocs"],
parameters: { layout: "fullscreen", docs: { story: { inline: false } }, paperclip: { capabilities: ["agents/grok-bot"], status: "proposed", implementation: "production", boundary: "fixtures", notes: "Production configuration page with synthetic connection observations. No Grok or authorization calls." } },
} satisfies Meta<typeof Configuration>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ConnectedBot: Story = { play: async ({ canvasElement }) => {
const page = within(canvasElement);
await expect(await page.findByText("Agent access connected. Routine round trip verified.")).toBeVisible();
await expect(page.getByRole("button", { name: "Verify connection" })).toBeVisible();
await expect(page.queryByRole("button", { name: /^Model$/ })).not.toBeInTheDocument();
await expect(page.queryByText("Test your agent")).not.toBeInTheDocument();
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();
} };
export const Mobile: Story = { ...ConnectedBot, globals: { viewport: { value: "mobile1", isRotated: false } } };