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 }; }