mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 11:27:14 +01:00
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:
1 parent
f87f874d29
commit
fe8653a2a5
5 files changed
+87
-21
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user