From fe8653a2a5a987d3f7270d7edbaafed76cb3a9ab Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 6 Nov 2026 13:14:28 -0800 Subject: [PATCH] Scope subscription cleanup to the abandoned sign-in target Match the environment and account intent before canceling a discovered or late resumed session. Preserve sessions from another connection setup, including different ownership and agent access. Sessions created by the abandoned request still release by their exact returned ID. Fourteen regressions reproduced cancellation of conflicting settings sessions before this fix. Validate their preservation alongside matching session cleanup, real aborts, and fresh replacement sign-ins. Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTIONS.md | 3 +- ui/src/components/AgentConfigForm.tsx | 8 ++-- ui/src/components/OnboardingWizard.test.tsx | 45 +++++++++++++++++++ ui/src/components/OnboardingWizard.tsx | 13 ++++-- .../components/useAdapterLoginCancellation.ts | 39 +++++++++++----- 5 files changed, 87 insertions(+), 21 deletions(-) diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 2ea4f268145..0372f939330 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -44,7 +44,8 @@ picker and provider setup. Execution environment selection lives in Configure. Back from subscription sign-in cancels that attempt and pending prompt or status reads. A session returned after Back is released instead of reopening sign-in. If an aborted read hides the session ID, a separate owner lookup releases the attempt -before a replacement can start. +before a replacement can start. Cleanup checks the abandoned environment and +account intent, so a conflicting settings sign-in stays active. Selecting a subscription again starts a fresh attempt. Reloading the page can still resume a sign-in that was not cancelled. diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 358fb9e7f16..97dd7c52241 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -2613,7 +2613,7 @@ function DisplayedCodeLoginPanel({ onRetryReady, }: AdapterLoginPanelProps) { const [sessionId, setSessionId] = useState(null); - const cancellation = useAdapterLoginCancellation(loginActive, companyId, adapterType, false, sessionId); + const cancellation = useAdapterLoginCancellation(loginActive, companyId, adapterType, false, sessionId, { environmentId, aiConnection }); const [retrying, setRetrying] = useState(false); const [startError, setStartError] = useState(null); // The server delivers the one-time prompt on the first owner read only. Latch @@ -2678,7 +2678,7 @@ function DisplayedCodeLoginPanel({ queryKey: ["adapter-login-active-session", companyId, adapterType], queryFn: async ({ signal }) => { try { - const active = await cancellation.accept(() => agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType, { signal })); + const active = await cancellation.resume(() => agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType, { signal })); if (!active) return null; if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new AdapterLoginConflictError(active.sessionId); return active; @@ -3157,7 +3157,7 @@ function SubmittedBrowserCodeLoginPanel({ onRetryReady, }: AdapterLoginPanelProps) { const [sessionId, setSessionId] = useState(null); - const cancellation = useAdapterLoginCancellation(loginActive, companyId, adapterType, true, sessionId); + const cancellation = useAdapterLoginCancellation(loginActive, companyId, adapterType, true, sessionId, { environmentId, aiConnection }); const [retrying, setRetrying] = useState(false); const [startError, setStartError] = useState(null); // The server delivers the authorization URL on the guarded prompt read only. @@ -3321,7 +3321,7 @@ function SubmittedBrowserCodeLoginPanel({ queryKey: ["claude-setup-token-active-session", companyId], queryFn: async ({ signal }) => { try { - const active = await cancellation.accept(() => agentsApi.getActiveClaudeSetupTokenLoginSession(companyId, { signal })); + const active = await cancellation.resume(() => agentsApi.getActiveClaudeSetupTokenLoginSession(companyId, { signal })); if (!active) return null; if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one."); return active; diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index a8cd96d090f..2e6e9993e94 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -4076,6 +4076,51 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( } }); + it.each((["claude_local", "codex_local"] as const).flatMap(adapterType => + (["environment", "legacy", "connection", "ownership", "agents", "allAgents"] as const).map(difference => [adapterType, difference] as const)))("preserves another %s sign-in after Back when %s differs", async (adapterType, difference) => { + const claude = adapterType === "claude_local"; + const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession; + const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin; + const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin; + const intent = { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [] as string[], allAgents: true }; + const session = { sessionId: "settings-sign-in", environmentId: difference === "environment" ? "other-environment" : "env-sandbox-1", + status: "waiting_for_user", expiresAt: null, aiConnection: difference === "legacy" ? undefined : { ...intent, + ...(difference === "connection" ? { connectionId: "other-connection" } : {}), + ...(difference === "ownership" ? { ownership: "organization" } : {}), + ...(difference === "agents" ? { agentIds: ["other-agent"] } : {}), + ...(difference === "allAgents" ? { allAgents: false } : {}) } }; + activeApi.mockResolvedValue(session); + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType }); + try { + for (let i = 0; i < 12; i++) await flushReact(); + expect(document.body.textContent).toContain("Sign-in didn’t finish"); + await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click()); + for (let i = 0; i < 12; i++) await flushReact(); + expect(cancelApi).not.toHaveBeenCalled(); + expect(startApi).not.toHaveBeenCalled(); + expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0); + } finally { await act(async () => root.unmount()); } + }); + + it.each(["claude_local", "codex_local"] as const)("preserves a conflicting %s resume response received after Back", async adapterType => { + const claude = adapterType === "claude_local"; + const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession; + const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin; + let finish!: (session: unknown) => void; + activeApi.mockReturnValue(new Promise(resolve => { finish = resolve; })); + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType }); + try { + await pickSource(claude ? /Claude/ : /OpenAI/); + await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click()); + await act(async () => finish({ sessionId: "settings-sign-in", environmentId: "another-environment", status: "waiting_for_user" })); + for (let i = 0; i < 12; i++) await flushReact(); + expect(cancelApi).not.toHaveBeenCalled(); + expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0); + } finally { await act(async () => root.unmount()); } + }); + it("cancels claude_local on Back and starts a fresh sign-in when selected again", async () => { const session = { sessionId: "claude-session-1", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 312e373a16a..c2ba778a69e 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,4 +1,5 @@ -import { cancelAdapterLoginSession, cancelPendingAdapterLogin } from "./useAdapterLoginCancellation"; +import type { AiConnectionLoginIntent } from "@paperclipai/shared"; +import { cancelAdapterLoginSession, cancelPendingAdapterLogin, matchesAdapterLoginTarget } from "./useAdapterLoginCancellation"; import { useQueryView } from "@/components/QueryView"; import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared"; import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup"; @@ -1150,6 +1151,10 @@ function OnboardingWizardInner({ * panel unmounted and the resumed session unreachable from this step. A 404 * means no active session for the caller. */ + const managedSubscriptionIntent: AiConnectionLoginIntent | undefined = managedSubscriptionProvider + ? { provider: managedSubscriptionProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } + : undefined; + const loginTarget = { environmentId: resolvedLoginEnvironmentId ?? "", aiConnection: managedSubscriptionIntent }; const dismissedLoginResumes = useRef(new Set()); const loginResumeKey = JSON.stringify([createdCompanyId, adapterType]); const activeLoginSessionQuery = useQuery({ @@ -1162,7 +1167,7 @@ function OnboardingWizardInner({ ? await agentsApi.getActiveClaudeSetupTokenLoginSession(createdCompanyId!, { signal }) : await agentsApi.getActiveAdapterAuthLoginSession(createdCompanyId!, adapterType, { signal }); if (session && dismissedLoginResumes.current.has(loginResumeKey)) { - await cancelAdapterLoginSession(createdCompanyId!, adapterType, + if (matchesAdapterLoginTarget(session, loginTarget)) await cancelAdapterLoginSession(createdCompanyId!, adapterType, adapterCaps.login?.panelMode === "submitted_browser_code", session.sessionId); return null; } @@ -1569,7 +1574,7 @@ function OnboardingWizardInner({ if (createdCompanyId) { void queryClient.cancelQueries({ queryKey: queryKeys.agents.activeLoginSession(createdCompanyId, adapterType) }); if (canShowAdapterLogin && credentialMode !== "api") void cancelPendingAdapterLogin(createdCompanyId, adapterType, - adapterCaps.login?.panelMode === "submitted_browser_code"); + adapterCaps.login?.panelMode === "submitted_browser_code", loginTarget); } connectAttemptRef.current++; hiringAgentRef.current = null; @@ -3252,7 +3257,7 @@ function OnboardingWizardInner({ adapterType={adapterType} environmentId={resolvedLoginEnvironmentId} chrome="onboarding" - aiConnection={managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined} + aiConnection={managedSubscriptionIntent} autoStart active={connectCardLive} onPromptReady={(url) => { diff --git a/ui/src/components/useAdapterLoginCancellation.ts b/ui/src/components/useAdapterLoginCancellation.ts index 90286a6932b..ea077cc662a 100644 --- a/ui/src/components/useAdapterLoginCancellation.ts +++ b/ui/src/components/useAdapterLoginCancellation.ts @@ -1,7 +1,20 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import type { AiConnectionLoginIntent } from "@paperclipai/shared"; import { agentsApi } from "../api/agents"; +type AdapterLoginTarget = { environmentId: string; aiConnection?: AiConnectionLoginIntent }; +type AdapterLoginSession = { sessionId: string; environmentId?: string; aiConnection?: AiConnectionLoginIntent }; + +function connectionTargetKey(intent?: AiConnectionLoginIntent) { + return intent ? JSON.stringify([intent.provider, intent.method, intent.connectionId, intent.ownership, intent.allAgents, intent.agentIds]) : null; +} + +export function matchesAdapterLoginTarget(session: AdapterLoginSession, target: AdapterLoginTarget) { + return Boolean(target.environmentId) && session.environmentId === target.environmentId + && connectionTargetKey(session.aiConnection) === connectionTargetKey(target.aiConnection); +} + const pendingCancellations = new Map>(); const sessionCancellations = new Map>(); @@ -17,7 +30,7 @@ export function cancelAdapterLoginSession(companyId: string, adapterType: string return request; } -export function cancelPendingAdapterLogin(companyId: string, adapterType: string, submittedCode: boolean, pending: Promise[] = [], discover = true) { +export function cancelPendingAdapterLogin(companyId: string, adapterType: string, submittedCode: boolean, target: AdapterLoginTarget, pending: Promise[] = [], discover = true) { const key = JSON.stringify([companyId, adapterType]); const previous = pendingCancellations.get(key); const cleanup = Promise.allSettled([...(previous ? [previous] : []), ...pending]).then(async () => { @@ -25,18 +38,20 @@ export function cancelPendingAdapterLogin(companyId: string, adapterType: string const session = submittedCode ? await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId, { fresh: true }) : await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType, { fresh: true }); - if (session) await cancelAdapterLoginSession(companyId, adapterType, submittedCode, session.sessionId); + if (session && matchesAdapterLoginTarget(session, target)) await cancelAdapterLoginSession(companyId, adapterType, submittedCode, session.sessionId); }).catch(() => {}); pendingCancellations.set(key, cleanup); void cleanup.then(() => { if (pendingCancellations.get(key) === cleanup) pendingCancellations.delete(key); }); return cleanup; } -export function useAdapterLoginCancellation(active: boolean, companyId: string, adapterType: string, submittedCode: boolean, sessionId: string | null) { +export function useAdapterLoginCancellation(active: boolean, companyId: string, adapterType: string, submittedCode: boolean, sessionId: string | null, target: AdapterLoginTarget) { const queryClient = useQueryClient(); const ownerKey = JSON.stringify([companyId, adapterType]); - const scope = useRef({ active, generation: 0 }); - if (scope.current.active !== active) scope.current = { active, generation: scope.current.generation + 1 }; + const targetKey = JSON.stringify([companyId, adapterType, target.environmentId, connectionTargetKey(target.aiConnection)]); + const attemptTarget = useMemo(() => target, [targetKey]); + const scope = useRef({ active, generation: 0, targetKey }); + if (scope.current.active !== active || scope.current.targetKey !== targetKey) scope.current = { active, generation: scope.current.generation + 1, targetKey }; const requests = useRef(new Set>()); const cancelled = useRef(new Map>()); const cancel = useCallback((id: string) => { @@ -47,14 +62,14 @@ export function useAdapterLoginCancellation(active: boolean, companyId: string, return request; }, [companyId, adapterType, submittedCode]); - const accept = useCallback((request: () => Promise): Promise => { + const accept = useCallback((request: () => Promise, resumed = false): Promise => { const generation = scope.current.generation; const result = (async () => { await pendingCancellations.get(ownerKey); if (!scope.current.active || scope.current.generation !== generation) return null; const session = await request(); if (session && (!scope.current.active || scope.current.generation !== generation)) { - await cancel(session.sessionId); + if (!resumed || matchesAdapterLoginTarget(session, attemptTarget)) await cancel(session.sessionId); return null; } return session; @@ -63,7 +78,7 @@ export function useAdapterLoginCancellation(active: boolean, companyId: string, const forget = () => { requests.current.delete(result); }; void result.then(forget, forget); return result; - }, [cancel, ownerKey]); + }, [cancel, ownerKey, attemptTarget]); useEffect(() => { if (active) return; @@ -71,9 +86,9 @@ export function useAdapterLoginCancellation(active: boolean, companyId: string, ? [["claude-setup-token-active-session", companyId], ["claude-setup-token-status", companyId], ["claude-setup-token-prompt", companyId]] : [["adapter-login-active-session", companyId, adapterType], ["adapter-login-status", companyId, adapterType]]; for (const queryKey of keys) void queryClient.cancelQueries({ queryKey }); - void cancelPendingAdapterLogin(companyId, adapterType, submittedCode, + void cancelPendingAdapterLogin(companyId, adapterType, submittedCode, attemptTarget, [...requests.current, ...(sessionId ? [cancel(sessionId)] : [])], !sessionId); - }, [active, companyId, adapterType, submittedCode, sessionId, queryClient, cancel, ownerKey]); + }, [active, companyId, adapterType, submittedCode, sessionId, queryClient, cancel, ownerKey, attemptTarget]); - return { accept, isActive: () => scope.current.active }; + return { accept, resume: (request: () => Promise) => accept(request, true), isActive: () => scope.current.active }; }