fix(connections): keep agent access consistent through setup and permission changes (#19352)

Task-hosted tool setup kept requester-only defaults, and audience edits could leave runtime installations restricted after permission bindings were widened. Large provider catalogs could also fail before creating an access card.

Use the normal agent audience defaults for new tool setup, preserve explicit selections through verified OAuth and draft resumption, and synchronize permission bindings and runtime installations atomically. Enforce agent-edit authority and preserve existing access during additive account reuse.

Validation: 915 focused regression tests, typechecks, and builds passed. All 54 CI checks passed and Greptile Apex scored the reviewed head 5/5 with no unresolved threads. Broad local test failures, recoveries, and incomplete heartbeat coverage are documented in the PR.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-05 17:38:32 -08:00
1 parent 36643d9378
commit 8f79a32898
27 files changed
+489 -91

No files matched your search

+2 -1
View File
@@ -12,10 +12,11 @@ export const connectionIntentsApi = {
setPhase: (
interactionId: string,
phase: ConnectionIntentInteraction["payload"]["phase"],
setupAccess?: ConnectionIntentInteraction["payload"]["setupAccess"],
) =>
api.post<ConnectionIntentInteraction>(
`/connection-intents/${interactionId}/phase`,
{ phase },
{ phase, ...(setupAccess ? { setupAccess } : {}) },
),
complete: (interactionId: string, connectionId: string) =>
api.post<ConnectionIntentInteraction>(
@@ -219,7 +219,8 @@ describe("IssueThreadInteractionCard", () => {
const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
expect(labels).toContain("Connect");
expect(labels).toContain("Not now");
expect(host.textContent).toContain("Access is added only for this agent");
expect(host.textContent).toContain("New connections default to all agents");
expect(host.textContent).toContain("Reusing an account adds access for this agent");
const loadButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Connect",
);
@@ -83,6 +83,7 @@ vi.mock("@/components/ai-connections/AiProviderSetup", () => ({
vi.mock("./ConnectionSetupFlow", () => ({
ConnectionSetupFlow: (props: {
requestedAgentId?: string;
requiredAgentId?: string;
existingConnections?: ToolConnection[];
onUseExisting?: (id: string) => Promise<void>;
onComplete?: (completion: { connectionId: string } | { resolvedByCallback: true }) => void;
@@ -91,6 +92,7 @@ vi.mock("./ConnectionSetupFlow", () => ({
}) => (
<div data-testid="shared-connection-setup">
<span data-testid="requested-agent">{props.requestedAgentId}</span>
<span data-testid="required-agent">{props.requiredAgentId}</span>
<span data-testid="existing-count">
{props.existingConnections?.length ?? 0}
</span>
@@ -318,7 +320,7 @@ describe("ConnectionIntentInteractionBody dialog behavior", () => {
expect(completeMock).not.toHaveBeenCalled();
});
it("shows loading, then passes existing choices and the locked requesting agent to the shared flow", async () => {
it("passes existing choices and requires the requester without locking tool setup to that agent", async () => {
let resolveSetup!: (value: unknown) => void;
setupOptionsMock.mockReturnValue(
new Promise((resolve) => {
@@ -343,7 +345,10 @@ describe("ConnectionIntentInteractionBody dialog behavior", () => {
expect(
document.querySelector('[data-testid="requested-agent"]')?.textContent,
).toBe("agent-requesting");
).toBe("");
expect(document.querySelector('[data-testid="required-agent"]')?.textContent).toBe("agent-requesting");
expect(document.body.textContent).toContain("New connections default to all agents");
expect(document.body.textContent).not.toContain("Access is added only for this agent");
expect(
document.querySelector('[data-testid="existing-count"]')?.textContent,
).toBe("2");
@@ -238,7 +238,11 @@ export function ConnectionIntentInteractionBody({
upstreamServiceName: interaction.payload.upstreamService?.name,
serviceSlug: interaction.payload.serviceSlug.startsWith("connection:") ? undefined : interaction.payload.serviceSlug,
configuredConnection: interaction.payload.serviceSlug.startsWith("connection:") ? setupQuery.data.existingConnections[0] : undefined,
requestedAgentId: setupQuery.data.requestedAgentId,
// Tool setup uses the same audience defaults and controls as Apps.
// Runtime authentication and inboxes still belong to the requesting agent.
requestedAgentId: interaction.payload.purpose ? setupQuery.data.requestedAgentId : undefined,
requiredAgentId: interaction.payload.purpose ? undefined : setupQuery.data.requestedAgentId,
setupAccess: setupQuery.data.interaction?.payload.setupAccess,
aiConnection: setupQuery.data.aiConnection,
interactionId: interaction.id,
existingConnections: setupQuery.data.existingConnections,
@@ -481,7 +485,7 @@ export function ConnectionIntentInteractionBody({
? "This task can’t run until the agent has a valid AI connection. Connect here and the task will resume automatically."
: isChatHandoff ? "Complete the human sign-in step in the saved setup. Your task continues when the connection is ready."
: isEmail ? "Connect AgentMail to create an email address for this agent."
: "Connect your identity or reuse an eligible connection. Access is added only for this agent."}
: "New connections default to all agents; you can choose fewer during setup. Reusing an account adds access for this agent."}
</p>
</div>
</div>
@@ -568,6 +568,9 @@ export interface ConnectionSetupFlowProps {
host?: "page" | "dialog";
serviceSlug?: string;
requestedAgentId?: string;
/** Task requester must be included without restricting the audience to it. */
requiredAgentId?: string;
setupAccess?: import("@paperclipai/shared").ConnectionIntentInteraction["payload"]["setupAccess"];
interactionId?: string;
forceNewConnection?: boolean;
existingConnections?: ConnectionIntentSetupConnection[];
@@ -645,6 +648,7 @@ function StandardConnectionSetupFlow({
host = "page",
serviceSlug,
requestedAgentId,
requiredAgentId,
interactionId,
forceNewConnection = false,
existingConnections = [],
@@ -1384,6 +1388,17 @@ function StandardConnectionSetupFlow({
// not identity: an organization Notion connection must stay organization-
// scoped, while a personal one must put its token back on that user grant.
mutationFn: async (connection: ToolConnection) => {
// Resumed setup hides audience controls and promises to retain access.
// Leave the durable selection untouched instead of saving form defaults.
if (connectionIntentId && !aiConnection && !resumeConnectionId && !reconnectConnection && !resumableOAuthConnection) {
if (requiredAgentId && installChoice !== "all" && !installAgentIds.has(requiredAgentId)) {
throw new Error("Include the requesting agent or choose all agents to continue this task.");
}
await connectionIntentsApi.setPhase(connectionIntentId, "authorizing", {
connectionId: connection.id,
access: installChoice === "all" ? "all_agents" : { agentIds: [...installAgentIds] },
});
}
// Persist before navigation, including retries that reuse an existing OAuth draft.
if (instructionValue && JSON.stringify(instructionValue) !== JSON.stringify(connection.agentInstructions)) {
await toolsApi.updateConnection(connection.id, { agentInstructions: instructionValue });
@@ -1439,7 +1454,7 @@ function StandardConnectionSetupFlow({
*/
const applyAccessInstalls = async (connectionId: string) => {
// The task completion endpoint adds the requester atomically with resolution.
if (requestedAgentId) return;
if (connectionIntentId) return;
const dedicatedIdentity = (fixedGrantKind ?? grantKind) === "agent";
const installState = !dedicatedIdentity && installChoice === "all"
? { onAll: true, agentIds: new Set<string>() }
@@ -1451,6 +1466,10 @@ function StandardConnectionSetupFlow({
const effectiveGrantKind = fixedGrantKind ?? grantKind;
const connectMutation = useMutation({
mutationFn: async (entryOverride?: AppDefinition) => {
if (requiredAgentId && !resumeConnectionId && !reconnectConnectionId
&& installChoice !== "all" && !installAgentIds.has(requiredAgentId)) {
throw new Error("Include the requesting agent or choose all agents to continue this task.");
}
const connectEntry = entryOverride ?? entry;
let result: ConnectToolAppResult;
if (connectEntry) {
@@ -1919,6 +1938,9 @@ function StandardConnectionSetupFlow({
connected,
(catalogEntryId) => Boolean(enabledMap[catalogEntryId]),
);
if (requiredAgentId && installChoice !== "all" && !installAgentIds.has(requiredAgentId)) {
throw new Error("Include the requesting agent or choose all agents to continue this task.");
}
// The Access step asks one question about agent reach, so profile access
// and installs are committed to the same target set instead of drifting
// apart behind two separate wizard screens.
@@ -2212,6 +2234,7 @@ function StandardConnectionSetupFlow({
?? "Only someone who can configure this connection can give every agent access."
: null,
requestedAgentId ? "This task grants access only to the agent that asked for it." : null,
requiredAgentId ? "Include the requesting agent when choosing specific agents." : null,
].filter((reason): reason is string => Boolean(reason))}
authKind={accessStepAuthKind}
grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { ToolConnection } from "@paperclipai/shared";
import type { ConnectionSetupFlowProps } from "../ConnectionSetupFlow";
import { ApiError } from "@/api/client";
import { toolsApi } from "@/api/tools";
import { RemoteMcpProductionSetup } from "./RemoteMcpProductionSetup";
@@ -33,10 +34,10 @@ let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
function render() {
function render(props: Partial<ConnectionSetupFlowProps> & { connection?: ToolConnection } = {}) {
act(() => root.render(
<QueryClientProvider client={client}>
<RemoteMcpProductionSetup providerId="composio" connection={connection} />
<RemoteMcpProductionSetup providerId="composio" connection={connection} {...props} />
</QueryClientProvider>,
));
}
@@ -48,6 +49,15 @@ beforeEach(() => {
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
it("restores a task's saved draft audience before installations exist", async () => {
vi.mocked(toolsApi.getConnectionInstalls).mockResolvedValue({ installs: [] } as never);
render({ connection: { ...connection, status: "draft" }, interactionId: "intent", requiredAgentId: "agent-1",
setupAccess: { connectionId: connection.id, access: { agentIds: ["agent-1"] } } });
await vi.waitFor(() => expect(container.querySelector('[data-testid="remote-mcp-setup"]')?.textContent).toBe("agent-1"));
await act(async () => { await client.invalidateQueries(); });
expect(container.querySelector('[data-testid="remote-mcp-setup"]')?.textContent).toBe("agent-1");
});
afterEach(() => {
act(() => root.unmount());
client.clear();
@@ -4,7 +4,7 @@ import { ConnectionInstructionsEditor } from "../ConnectionInstructions";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { isAppAggregator, aggregatorManagementUrl } from "@paperclipai/shared/aggregator-apps";
import { REMOTE_MCP_CONNECTOR_METHODS, defaultConnectionAgentInstructions, getConnectableAppDefinition, type ToolConnection } from "@paperclipai/shared";
import { REMOTE_MCP_CONNECTOR_METHODS, defaultConnectionAgentInstructions, getConnectableAppDefinition, type ConnectionIntentSetupOptions, type ToolConnection } from "@paperclipai/shared";
import { askFirstCatalogEntryIdsFor } from "../connection-defaults";
import { RemoteMcpAccountChoice } from "./RemoteMcpAccountChoice";
import { readConnectionIntentOAuthOutcome, type ConnectionSetupFlowProps } from "../ConnectionSetupFlow";
@@ -32,7 +32,7 @@ function readAccessDraft(key: string): Partial<RemoteMcpSetupState> {
}
export function RemoteMcpProductionSetup({ providerId, connection, host = "page", interactionId,
upstreamServiceName, requestedAgentId, existingConnections = [], forceNewConnection, onUseExisting, onComplete, onCancel, onPhaseChange,
upstreamServiceName, requestedAgentId, requiredAgentId, setupAccess, existingConnections = [], forceNewConnection, onUseExisting, onComplete, onCancel, onPhaseChange,
}: ConnectionSetupFlowProps & { providerId: RemoteMcpProviderId; connection?: ToolConnection }) {
const provider = remoteMcpProviders[providerId];
const template = getConnectableAppDefinition(providerId)?.agentInstructions;
@@ -65,6 +65,9 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
const savedConnection = useRef(connection);
const busy = useRef(false);
const authorizationUrl = useRef<string | undefined>(undefined);
const savedAudience = connection?.status === "draft" && setupAccess?.connectionId === connection.id
? setupAccess.access : undefined;
const hydratedAudience = useRef<string | null>(null);
const [state, setState] = useState<RemoteMcpSetupState>(() => ({
// PAP-659 C0: there is no Access step on the way in. The resolved default
// is stated on the connect screen and changed in its Advanced disclosure;
@@ -77,6 +80,7 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
token: "", headers: [], connectStatus: oauthOutcome === "denied" ? "cancelled" : oauthOutcome === "failed" ? "oauth_failed" : "idle", connected: false,
identity: null, allAgents: true, agentIds: [], permissions: {}, tools: [], notice: connection?.authKind === "api_key" ? "Saved credentials are retained when these fields are left blank. Enter a replacement only to change them." : null, refreshing: false,
...(!connection ? readAccessDraft(accessDraftKey) : {}),
...(savedAudience ? { allAgents: savedAudience === "all_agents", agentIds: savedAudience === "all_agents" ? [] : savedAudience.agentIds } : {}),
...(requestedAgentId ? { allAgents: false, agentIds: [requestedAgentId] } : {}),
}));
const installs = useQuery({ queryKey: queryKeys.tools.connectionInstalls(connection?.id ?? "__new__"), queryFn: () => toolsApi.getConnectionInstalls(connection!.id), enabled: !!connection });
@@ -85,8 +89,18 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId,
});
useEffect(() => {
if (installs.data && !requestedAgentId) setState((s) => ({ ...s, allAgents: installs.data.installs.some((i) => i.targetType === "company"), agentIds: installs.data.installs.filter((i) => i.targetType === "agent").map((i) => i.targetId) }));
}, [installs.data, requestedAgentId]);
const savedInstalls = installs.data?.installs;
if (!savedInstalls || requestedAgentId) return;
if (savedAudience) {
const key = JSON.stringify(savedAudience);
if (hydratedAudience.current === key) return;
hydratedAudience.current = key;
}
setState((s) => ({ ...s,
allAgents: savedAudience ? savedAudience === "all_agents" : savedInstalls.some((i) => i.targetType === "company"),
agentIds: savedAudience ? savedAudience === "all_agents" ? [] : savedAudience.agentIds : savedInstalls.filter((i) => i.targetType === "agent").map((i) => i.targetId),
}));
}, [installs.data, requestedAgentId, savedAudience]);
useEffect(() => {
if (connection) return;
try { sessionStorage.setItem(accessDraftKey, JSON.stringify({ grantKind: state.grantKind, allAgents: state.allAgents, agentIds: state.agentIds })); } catch { /* Storage can be disabled. */ }
@@ -179,6 +193,10 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
}, [state.connectStatus, interactionId]);
const submit = async (saveDraft = false) => {
if (busy.current || !selectedCompanyId || !instructionsValid || (connection && !installs.data)) return;
if (!saveDraft && (!connection || connection.status === "draft") && requiredAgentId && !state.allAgents && !state.agentIds.includes(requiredAgentId)) {
edit({ notice: "Include the requesting agent or choose all agents to continue this task." });
return;
}
try {
const url = new URL(state.url.trim());
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
@@ -227,11 +245,19 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
try { sessionStorage.setItem(intentDraftKey, result.connectionId); } catch { /* Storage may be disabled. */ }
}
// Persist the Access step before leaving for OAuth. Reconnect retains its existing installs.
if (!requestedAgentId && (!prior || prior.status === "draft")) {
if (!interactionId && !requestedAgentId && (!prior || prior.status === "draft")) {
await toolsApi.putConnectionInstalls(result.connectionId, state.allAgents
? [{ targetType: "company", targetId: selectedCompanyId }]
: state.agentIds.map((targetId) => ({ targetType: "agent", targetId })));
}
if (interactionId && (!connection || connection.status === "draft") && (saveDraft || result.auth?.kind === "oauth")) {
const interaction = await connectionIntentsApi.setPhase(interactionId, saveDraft ? "requested" : "authorizing", {
connectionId: result.connectionId,
access: state.allAgents ? "all_agents" : { agentIds: state.agentIds },
});
queries.setQueryData<ConnectionIntentSetupOptions>(["connection-intent", interactionId, "setup-options"],
current => current ? { ...current, interaction } : current);
}
if (saveDraft) { if (onCancel) onCancel(); else navigate("/apps"); return; }
if (result.auth?.kind === "oauth") {
onPhaseChange?.("authorizing");
+2 -2
View File
@@ -715,7 +715,7 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
appName={appName}
agents={agents}
access={access}
install={connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? installStateFrom([]) : install}
install={connection.connectionPurpose === "ai" || connection.connectionPurpose === "tool" ? installStateFrom([]) : install}
readOnly={readOnly}
canChange={canChange}
quarantined={quarantined}
@@ -728,7 +728,7 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
? "Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls."
: undefined
}
onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? next : accessIncludingInstalls(next, install) })}
onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" || connection.connectionPurpose === "tool" ? next : accessIncludingInstalls(next, install) })}
onRefreshActions={() => refreshTools.mutate()}
onSetActionPermission={(ids, next) => apply(actionPermissionMutation(ids, next, enabledIds, askFirstIds))}
onReviewQuarantined={reviewQuarantined}
+48 -4
View File
@@ -16,7 +16,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "board-user", settled: true, failed: false }), resolveAccountUserId: async () => "board-user" }));
const intentSetupMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: { setupOptions: intentSetupMock } }));
const intentPhaseMock = vi.hoisted(() => vi.fn().mockResolvedValue({}));
vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: { setupOptions: intentSetupMock, setPhase: intentPhaseMock } }));
const listGalleryMock = vi.hoisted(() => vi.fn());
const experimentalMock = vi.hoisted(() => vi.fn());
@@ -588,6 +589,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('input[type="password"]')!, "https://provider.example/mcp"));
await act(async () => buttonByText(connectLabel)!.click());
await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledWith(connection.id, { asCurrentUser: true, interactionId: "intent-inline" }));
expect(intentPhaseMock).toHaveBeenCalledWith("intent-inline", "authorizing", {
connectionId: connection.id, access: { agentIds: ["agent-1"] },
});
expect(intentPhaseMock.mock.invocationCallOrder[0]).toBeLessThan(startOAuthMock.mock.invocationCallOrder[0]);
expect(popup.location.assign).toHaveBeenCalled();
expect(popup.opener).toBe(window);
popup.closed = true;
@@ -687,11 +692,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
it("inline aggregator saves and resumes a draft without storing credentials in browser storage", async () => {
const onCancel = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const intentKey = ["connection-intent", "intent-inline", "setup-options"];
client.setQueryData(intentKey, { interaction: { payload: { phase: "requested" } } });
intentPhaseMock.mockImplementation(async (_id, phase, setupAccess) => ({ payload: { phase, setupAccess } }));
const connection = { id: "conn-inline-draft", status: "draft", credentialPolicy: "per_user", authKind: "none", config: { url: "https://provider.example/mcp", sourceTemplateKey: "zapier", connectionMethodKey: "generated-url" } };
connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [] });
getConnectionMock.mockResolvedValue(connection);
const content = <ConnectionSetupFlow host="dialog" serviceSlug="zapier" requestedAgentId="agent-1" interactionId="intent-inline" onCancel={onCancel} />;
const root = await render(undefined, false, content);
getConnectionInstallsMock.mockResolvedValue({ installs: [] });
const content = (setupAccess?: { connectionId: string; access: { agentIds: string[] } }) => <ConnectionSetupFlow host="dialog" serviceSlug="zapier" requiredAgentId="agent-1" setupAccess={setupAccess} interactionId="intent-inline" onCancel={onCancel} />;
const root = await render(client, false, content());
await openAccessAdvanced();
await act(async () => radioContaining("Just agents I pick")!.click());
await flushReact();
await act(async () => buttonByText("Select agents")!.click());
await flushReact();
await act(async () => document.body.querySelector<HTMLElement>('[aria-label="Allow Ada"]')!.click());
await flushReact();
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('input[type="password"]')!, "https://provider.example/mcp?token=fixture-secret"));
await act(async () => buttonByText("Save & exit")!.click());
await vi.waitFor(() => expect(onCancel).toHaveBeenCalled());
@@ -700,13 +717,19 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(JSON.stringify(sessionStorage)).not.toContain("fixture-secret");
expect(finishAppMock).not.toHaveBeenCalled();
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
expect(intentPhaseMock).toHaveBeenCalledWith("intent-inline", "requested", {
connectionId: connection.id, access: { agentIds: ["agent-1"] },
});
const saved = client.getQueryData<{ interaction: { payload: { setupAccess: { connectionId: string; access: { agentIds: string[] } } } } }>(intentKey)!;
expect(saved.interaction.payload.setupAccess).toEqual({ connectionId: connection.id, access: { agentIds: ["agent-1"] } });
await act(async () => root.unmount());
mountedRoot = null;
await render(undefined, false, content);
await render(client, false, content(saved.interaction.payload.setupAccess));
await vi.waitFor(() => expect(container.textContent).toContain("MCP server URL"));
expect(getConnectionMock).toHaveBeenCalledWith(connection.id);
// Resuming lands on the same single screen it was saved from, not "step 2".
expect(container.textContent).not.toContain("Step 2 of 2");
expect(container.textContent).toContain("available to 1 selected agent");
});
it.each(["page", "dialog"] as const)("lets %s setup cancel after an invalid provider URL without trying to save it", async (host) => {
@@ -3034,11 +3057,32 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
asCurrentUser: true,
interactionId,
});
// The task already stores its restricted audience. Hidden form defaults
// must not replace it with all_agents when sign-in is resumed.
expect(intentPhaseMock.mock.calls.some(call => call[2] !== undefined)).toBe(false);
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=existing",
);
});
it("requires the task requester in a new OAuth audience before starting provider sign-in", async () => {
listGalleryMock.mockResolvedValue({ apps: [NOTION] });
connectAppMock.mockResolvedValue({ connectionId: "conn-notion", connection: { id: "conn-notion", credentialPolicy: "shared" }, auth: { kind: "oauth" } });
await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug="notion" interactionId="intent-1" requiredAgentId="agent-1" />);
await openAccessAdvanced();
await act(async () => radioContaining("Just agents I pick")!.click());
await flushReact();
await act(async () => buttonByText("Select agents")!.click());
await flushReact();
await act(async () => document.body.querySelector<HTMLElement>('[aria-label="Allow Grace"]')!.click());
await flushReact();
await passAccessStep();
await submitCuratedOAuthSetup();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(intentPhaseMock).not.toHaveBeenCalled();
});
it("resumes the exact draft selected by Finish setup", async () => {
mockSearch.value = "source=notion&resume=22222222-2222-4222-8222-222222222222";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });