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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-11-06 13:14:28 -08:00
1 parent f87f874d29
commit fe8653a2a5
5 files changed
+87 -21

No files matched your search

+2 -1
View File
@@ -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.
+4 -4
View File
@@ -2613,7 +2613,7 @@ function DisplayedCodeLoginPanel({
onRetryReady,
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(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<string | null>(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<string | null>(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<string | null>(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;
@@ -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",
+9 -4
View File
@@ -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<string>());
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) => {
@@ -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<string, Promise<unknown>>();
const sessionCancellations = new Map<string, Promise<unknown>>();
@@ -17,7 +30,7 @@ export function cancelAdapterLoginSession(companyId: string, adapterType: string
return request;
}
export function cancelPendingAdapterLogin(companyId: string, adapterType: string, submittedCode: boolean, pending: Promise<unknown>[] = [], discover = true) {
export function cancelPendingAdapterLogin(companyId: string, adapterType: string, submittedCode: boolean, target: AdapterLoginTarget, pending: Promise<unknown>[] = [], 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<Promise<unknown>>());
const cancelled = useRef(new Map<string, Promise<unknown>>());
const cancel = useCallback((id: string) => {
@@ -47,14 +62,14 @@ export function useAdapterLoginCancellation(active: boolean, companyId: string,
return request;
}, [companyId, adapterType, submittedCode]);
const accept = useCallback(<T extends { sessionId: string }>(request: () => Promise<T | null>): Promise<T | null> => {
const accept = useCallback(<T extends AdapterLoginSession>(request: () => Promise<T | null>, resumed = false): Promise<T | null> => {
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: <T extends AdapterLoginSession>(request: () => Promise<T | null>) => accept(request, true), isActive: () => scope.current.active };
}