mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
Cancel subscription sign-in requests when leaving agent setup (#19610)
Back during pending subscription sign-in could accept a late response and
reopen agent setup. Abort pending reads, release abandoned sessions, and
ignore stale responses. Wait for cleanup before starting a replacement,
and preserve sessions belonging to another account or environment.
Cover deferred resume/start/prompt responses for both providers, local
login cancellation, quick reselection, and browser request abort. Stabilize
Runner fixtures and normalization assertions exposed by integration CI.
Verified: 483 focused UI tests, six onboarding browser cases, UI typecheck
and token gates, Runner fixture checks, and the complete CI run on
221f2e3eb7. Greptile scored 5/5;
all review threads are resolved. Earlier failures and recovery evidence
remain in the PR description.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
fdda02a26e
commit
8621bcf53e
12 files changed
+439
-89
No files matched your search
@@ -41,6 +41,14 @@ the provider's API key, and Advanced. All three stay visible while the selected
|
||||
mode's form is shown below. Advanced opens the existing compatible-connection
|
||||
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. 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.
|
||||
|
||||
Storybook's simulated controllers and page annotations do not run in the app.
|
||||
|
||||
## Compatibility and selection
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PAPERCLIP_RUNNER_DEFAULT_MODELS } from "@paperclipai/adapter-utils/paperclip-runner-permissions";
|
||||
import {
|
||||
normalizePaperclipOperationalSkillPreference,
|
||||
normalizePaperclipRunnerAdapterConfig,
|
||||
@@ -16,7 +17,7 @@ describe("paperclip_runner operational skill normalization", () => {
|
||||
it("applies full-auto native runner defaults at persistence boundaries", () => {
|
||||
expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", {})).toEqual({
|
||||
provider: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
model: PAPERCLIP_RUNNER_DEFAULT_MODELS.codex,
|
||||
codexPermissionMode: "never",
|
||||
lifecycleMode: "per_turn",
|
||||
});
|
||||
@@ -24,7 +25,7 @@ describe("paperclip_runner operational skill normalization", () => {
|
||||
|
||||
it("repairs an existing blank model without replacing an explicit model", () => {
|
||||
expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", { model: "" }))
|
||||
.toMatchObject({ model: "gpt-5.6-sol" });
|
||||
.toMatchObject({ model: PAPERCLIP_RUNNER_DEFAULT_MODELS.codex });
|
||||
expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", { model: "gpt-5.5" }))
|
||||
.toMatchObject({ model: "gpt-5.5" });
|
||||
});
|
||||
|
||||
@@ -234,11 +234,12 @@ test.describe("Onboarding wizard", () => {
|
||||
// The sign-in Connect now starts. Stubbed so the card is deterministic:
|
||||
// the session start answers, and the guarded prompt read hands back an
|
||||
// authorization URL for the card's link row.
|
||||
let loginStarts = 0;
|
||||
await page.route("**/setup-token-login-sessions", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: "e2e-setup-token-session",
|
||||
sessionId: `e2e-setup-token-session-${++loginStarts}`,
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
}),
|
||||
@@ -253,11 +254,11 @@ test.describe("Onboarding wizard", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/setup-token-login-sessions/e2e-setup-token-session", (route) =>
|
||||
await page.route(/\/setup-token-login-sessions\/e2e-setup-token-session-\d+$/, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: "e2e-setup-token-session",
|
||||
sessionId: route.request().url().split("/").at(-1),
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
}),
|
||||
@@ -343,6 +344,45 @@ test.describe("Onboarding wizard", () => {
|
||||
await expect(page.getByRole("button", { name: "Use saved login" })).toHaveCount(0);
|
||||
expect(hireCalled).toBe(false);
|
||||
|
||||
let cancellations = 0;
|
||||
await page.route("**/setup-token-login-sessions/*/cancel", async route => {
|
||||
cancellations++;
|
||||
await route.fulfill({ status: 204 });
|
||||
});
|
||||
const back = page.getByRole("button", { name: "Back", exact: true });
|
||||
await back.click();
|
||||
await expect(source).toHaveAttribute("aria-checked", "false");
|
||||
await expect.poll(() => cancellations).toBe(1);
|
||||
await page.evaluate(() => {
|
||||
const state = window as typeof window & { cancelledLoginPrompts: number };
|
||||
state.cancelledLoginPrompts = 0;
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.endsWith("/prompt")) init?.signal?.addEventListener("abort", () => { state.cancelledLoginPrompts++; }, { once: true });
|
||||
return fetch(input, init);
|
||||
};
|
||||
});
|
||||
let finishPrompt!: () => void;
|
||||
let promptStarted!: () => void;
|
||||
const pendingPrompt = new Promise<void>(resolve => { finishPrompt = resolve; });
|
||||
const promptRequest = new Promise<void>(resolve => { promptStarted = resolve; });
|
||||
await page.route("**/setup-token-login-sessions/*/prompt", async route => {
|
||||
promptStarted();
|
||||
await pendingPrompt;
|
||||
await route.fulfill({ json: { authorizationUrl: "https://claude.ai/oauth/authorize?late=true", transportAdvisory: null } }).catch(() => {});
|
||||
});
|
||||
await source.click();
|
||||
await promptRequest;
|
||||
await back.click();
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & { cancelledLoginPrompts: number }).cancelledLoginPrompts)).toBe(1);
|
||||
finishPrompt();
|
||||
await expect(source).toHaveAttribute("aria-checked", "false");
|
||||
await expect.poll(() => cancellations).toBe(2);
|
||||
await expect(cardInstruction).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled();
|
||||
expect(hireCalled).toBe(false);
|
||||
|
||||
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
||||
});
|
||||
test("a reload during a login resumes the same session", async ({ page }) => {
|
||||
|
||||
@@ -391,6 +391,7 @@ describe("runner E2E report aggregation", () => {
|
||||
GITHUB_SERVER_URL: "https://github.com",
|
||||
GITHUB_REPOSITORY: "paperclipai/paperclip",
|
||||
GITHUB_RUN_ID: "123456",
|
||||
GITHUB_RUN_ATTEMPT: "1",
|
||||
PAPERCLIP_RUNNER_E2E_HISTORY_PUBLIC_BASE_URL:
|
||||
"https://reports.example.test/",
|
||||
PAPERCLIP_RUNNER_E2E_HISTORY_PREFIX: "/runner-e2e/",
|
||||
|
||||
+10
-8
@@ -338,18 +338,18 @@ export const agentsApi = {
|
||||
// The owner response repeats the live prompt on every read while the
|
||||
// session holds an active public status, so this polling call carries the
|
||||
// same no-store request option as the active-session read below.
|
||||
getAdapterAuthLoginStatus: (companyId: string, type: string, sessionId: string) =>
|
||||
getAdapterAuthLoginStatus: (companyId: string, type: string, sessionId: string, options?: { signal?: AbortSignal }) =>
|
||||
api.get<AdapterAuthSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store" },
|
||||
{ cache: "no-store", ...options },
|
||||
),
|
||||
// Reads the caller's active login session for one company and adapter, with
|
||||
// no session id, so the browser rediscovers its own session after a reload
|
||||
// with no local state. A 404 means no active session for the caller.
|
||||
getActiveAdapterAuthLoginSession: (companyId: string, type: string) =>
|
||||
getActiveAdapterAuthLoginSession: (companyId: string, type: string, options?: { signal?: AbortSignal; fresh?: boolean }) =>
|
||||
api.get<AdapterAuthSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/active`,
|
||||
{ cache: "no-store" },
|
||||
{ cache: "no-store", ...options },
|
||||
),
|
||||
cancelAdapterAuthLogin: (companyId: string, type: string, sessionId: string) =>
|
||||
api.post<AdapterAuthSessionOwnerResponse>(
|
||||
@@ -378,21 +378,23 @@ export const agentsApi = {
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`,
|
||||
{ adapterType: "claude_local", ...data },
|
||||
),
|
||||
getClaudeSetupTokenLoginStatus: (companyId: string, sessionId: string) =>
|
||||
getClaudeSetupTokenLoginStatus: (companyId: string, sessionId: string, options?: { signal?: AbortSignal }) =>
|
||||
api.get<ClaudeSetupTokenSessionResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}`,
|
||||
options,
|
||||
),
|
||||
// Reads the caller's active Claude setup-token login session, with no
|
||||
// session id, so the browser rediscovers its own session after a reload with
|
||||
// no local state. A 404 means no active session for the caller.
|
||||
getActiveClaudeSetupTokenLoginSession: (companyId: string) =>
|
||||
getActiveClaudeSetupTokenLoginSession: (companyId: string, options?: { signal?: AbortSignal; fresh?: boolean }) =>
|
||||
api.get<ClaudeSetupTokenSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/active`,
|
||||
{ cache: "no-store" },
|
||||
{ cache: "no-store", ...options },
|
||||
),
|
||||
getClaudeSetupTokenLoginPrompt: (companyId: string, sessionId: string) =>
|
||||
getClaudeSetupTokenLoginPrompt: (companyId: string, sessionId: string, options?: { signal?: AbortSignal }) =>
|
||||
api.get<ClaudeSetupTokenSessionPrompt>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/prompt`,
|
||||
options,
|
||||
),
|
||||
submitClaudeSetupTokenBrowserCode: (companyId: string, sessionId: string, browserCode: string) =>
|
||||
api.post<ClaudeSetupTokenSessionResponse>(
|
||||
|
||||
@@ -2998,6 +2998,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
"company-1",
|
||||
"codex_local",
|
||||
"resumed-session-1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(findButton(result.container, "Cancel")).toBeTruthy();
|
||||
});
|
||||
@@ -3769,6 +3770,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
expect(mockAgentsApi.getClaudeSetupTokenLoginStatus).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"resumed-claude-session-1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(findButton(result.container, "Cancel")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAdapterLoginCancellation } from "./useAdapterLoginCancellation";
|
||||
import { ACCOUNT_BOUND_MODEL_DISCOVERY_QUERY_OPTIONS, isAccountBoundAcpxAgent } from "@/lib/native-model-catalog";
|
||||
import { useConnectionModels } from "./ai-connections/useConnectionModels";
|
||||
import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, normalizeClaudeRunnerOptions, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
@@ -2519,6 +2520,7 @@ export type AdapterLoginDescriptor = {
|
||||
// correctly, and the first thing to rot would have been the timeout and
|
||||
// cleanup paths, which are the ones nobody exercises by hand.
|
||||
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
|
||||
active?: boolean;
|
||||
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
|
||||
onStored?: (storedSessionId: string) => void;
|
||||
onApplyStored?: () => void;
|
||||
@@ -2597,6 +2599,7 @@ class AdapterLoginConflictError extends Error {
|
||||
}
|
||||
|
||||
function DisplayedCodeLoginPanel({
|
||||
active: loginActive = true,
|
||||
companyId,
|
||||
adapterType,
|
||||
environmentId,
|
||||
@@ -2610,6 +2613,7 @@ function DisplayedCodeLoginPanel({
|
||||
onRetryReady,
|
||||
}: AdapterLoginPanelProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
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
|
||||
@@ -2632,8 +2636,9 @@ function DisplayedCodeLoginPanel({
|
||||
const resumedRef = useRef(false);
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection }),
|
||||
mutationFn: () => cancellation.accept(() => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection })),
|
||||
onSuccess: (session) => {
|
||||
if (!session || !cancellation.isActive()) return;
|
||||
resumedRef.current = false;
|
||||
setStartError(null);
|
||||
setLatchedPrompt(null);
|
||||
@@ -2643,6 +2648,7 @@ function DisplayedCodeLoginPanel({
|
||||
setSessionId(session.sessionId);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
@@ -2660,6 +2666,7 @@ function DisplayedCodeLoginPanel({
|
||||
mutationFn: () => agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId!),
|
||||
onSuccess: clearActiveSession,
|
||||
onError: (error) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
@@ -2669,9 +2676,9 @@ function DisplayedCodeLoginPanel({
|
||||
// 404 means no active session for the caller.
|
||||
const activeSessionQuery = useQuery({
|
||||
queryKey: ["adapter-login-active-session", companyId, adapterType],
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
const active = await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType);
|
||||
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;
|
||||
@@ -2687,6 +2694,7 @@ function DisplayedCodeLoginPanel({
|
||||
// in flight — which the per-owner cap then rejects.
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
enabled: loginActive,
|
||||
});
|
||||
const activeSessionView = useQueryView(activeSessionQuery);
|
||||
|
||||
@@ -2697,8 +2705,8 @@ function DisplayedCodeLoginPanel({
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["adapter-login-status", companyId, adapterType, sessionId],
|
||||
queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!),
|
||||
enabled: Boolean(sessionId) && !releasingResumedSession && !retrying,
|
||||
queryFn: ({ signal }) => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!, { signal }),
|
||||
enabled: loginActive && Boolean(sessionId) && !releasingResumedSession && !retrying,
|
||||
// A status 404 is unrecoverable: the server removed the row, so a retry
|
||||
// cannot bring it back. The shared retry policy never retries a 4xx, so
|
||||
// it stops at once and fails loudly; only transient errors are retried.
|
||||
@@ -2721,14 +2729,14 @@ function DisplayedCodeLoginPanel({
|
||||
const status = session?.status ?? null;
|
||||
const prompt = latchedPrompt;
|
||||
const isTerminal = status ? ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) : false;
|
||||
const isActive = Boolean(sessionId) && !isTerminal && !retrying;
|
||||
const isActive = loginActive && Boolean(sessionId) && !isTerminal && !retrying;
|
||||
// A saving bind also blocks a new Sign in: the bind is an agent-update save,
|
||||
// and a second login started while it is in flight could finish its own
|
||||
// save first — the older save would then land last and silently revert the
|
||||
// agent to the previous account while the panel reports the newer bind.
|
||||
// Serializing at the only entry point is the whole fix; the panel has no
|
||||
// other way to start a login mid-save.
|
||||
const startDisabled = startLogin.isPending || isActive || accountBindState === "saving";
|
||||
const startDisabled = !loginActive || startLogin.isPending || isActive || accountBindState === "saving";
|
||||
|
||||
// Adopt the caller's active session once, on mount. This is what makes a
|
||||
// page reload keep the session: with no local state at all, the panel would
|
||||
@@ -2736,7 +2744,7 @@ function DisplayedCodeLoginPanel({
|
||||
// active login for this owner.
|
||||
const resumeAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
if (!loginActive || resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
resumeAttemptedRef.current = true;
|
||||
const active = activeSessionQuery.data;
|
||||
if (!active) return;
|
||||
@@ -2744,7 +2752,7 @@ function DisplayedCodeLoginPanel({
|
||||
setStartError(null);
|
||||
setLatchedPrompt(active.prompt ?? null);
|
||||
setSessionId(active.sessionId);
|
||||
}, [activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
}, [loginActive, activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
|
||||
// A resumed session's status poll found the session already gone: the read
|
||||
// that discovered it and the poll that tried to use it raced, and the
|
||||
@@ -2793,7 +2801,7 @@ function DisplayedCodeLoginPanel({
|
||||
const startLoginRef = useRef(startLogin.mutate);
|
||||
startLoginRef.current = startLogin.mutate;
|
||||
useEffect(() => {
|
||||
if (!autoStart || autoStartedRef.current) return;
|
||||
if (!loginActive || !autoStart || autoStartedRef.current) return;
|
||||
// A failed lookup is not proof that no session exists: only a successful
|
||||
// lookup is. Show the failure to the user instead of starting a second
|
||||
// login the server would reject against the per-owner cap.
|
||||
@@ -2808,6 +2816,7 @@ function DisplayedCodeLoginPanel({
|
||||
startLoginRef.current();
|
||||
}, [
|
||||
autoStart,
|
||||
loginActive,
|
||||
activeSessionQuery.isSuccess,
|
||||
activeSessionView.kind,
|
||||
activeSessionQuery.data,
|
||||
@@ -2821,10 +2830,10 @@ function DisplayedCodeLoginPanel({
|
||||
const onConnectedRef = useRef(onConnected);
|
||||
onConnectedRef.current = onConnected;
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated" || connectedRef.current) return;
|
||||
if (!loginActive || status !== "authenticated" || connectedRef.current) return;
|
||||
connectedRef.current = true;
|
||||
onConnectedRef.current?.(sessionId ?? undefined);
|
||||
}, [status]);
|
||||
}, [loginActive, status]);
|
||||
|
||||
// Drive the account-binding hand-off as a visible state machine, not a
|
||||
// fire-and-forget latch. The bind saves the agent, and the status poll
|
||||
@@ -2869,8 +2878,8 @@ function DisplayedCodeLoginPanel({
|
||||
const onPromptReadyRef = useRef(onPromptReady);
|
||||
onPromptReadyRef.current = onPromptReady;
|
||||
useEffect(() => {
|
||||
onPromptReadyRef.current?.(prompt?.url ?? null);
|
||||
}, [prompt]);
|
||||
onPromptReadyRef.current?.(loginActive ? prompt?.url ?? null : null);
|
||||
}, [loginActive, prompt]);
|
||||
|
||||
const failed = Boolean(isTerminal && status !== "authenticated") || statusQuery.isError;
|
||||
const retryingRef = useRef(false);
|
||||
@@ -3130,6 +3139,7 @@ const CLAUDE_LOGIN_TIMED_OUT_MESSAGE = "The login timed out. Start the login aga
|
||||
// and submits it. The panel clears the code right after submit. The panel treats
|
||||
// only the server `stored` state as success, and it never shows the OAuth token.
|
||||
function SubmittedBrowserCodeLoginPanel({
|
||||
active: loginActive = true,
|
||||
companyId,
|
||||
adapterType,
|
||||
environmentId,
|
||||
@@ -3147,6 +3157,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
onRetryReady,
|
||||
}: AdapterLoginPanelProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
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.
|
||||
@@ -3227,7 +3238,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const storedToken = storedTokenQuery.data ?? null;
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: () => cancellation.accept(() =>
|
||||
agentsApi.startClaudeSetupTokenLogin(companyId, {
|
||||
environmentId,
|
||||
aiConnection,
|
||||
@@ -3242,13 +3253,15 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
})),
|
||||
onSuccess: (session) => {
|
||||
if (!session || !cancellation.isActive()) return;
|
||||
resumedRef.current = false;
|
||||
resetLocalState();
|
||||
setSessionId(session.sessionId);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
@@ -3306,9 +3319,9 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
// with no local state. A 404 means no active session for the caller.
|
||||
const activeSessionQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-active-session", companyId],
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
const active = await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId);
|
||||
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;
|
||||
@@ -3324,6 +3337,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
// in flight — which the per-owner cap then rejects.
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
enabled: loginActive,
|
||||
});
|
||||
const activeSessionView = useQueryView(activeSessionQuery);
|
||||
|
||||
@@ -3337,11 +3351,11 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
// status 404 also stops the polls: the server cleaned up the session, so the
|
||||
// panel enters a terminal failure state instead.
|
||||
const pollingEnabled =
|
||||
Boolean(sessionId) && !timedOut && !statusGone && !releasingResumedSession && !retrying;
|
||||
loginActive && Boolean(sessionId) && !timedOut && !statusGone && !releasingResumedSession && !retrying;
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-status", companyId, sessionId],
|
||||
queryFn: () => agentsApi.getClaudeSetupTokenLoginStatus(companyId, sessionId!),
|
||||
queryFn: ({ signal }) => agentsApi.getClaudeSetupTokenLoginStatus(companyId, sessionId!, { signal }),
|
||||
enabled: pollingEnabled,
|
||||
// A status 404 is terminal. The server removes a cleaned-up session at once,
|
||||
// so a retry cannot recover it. The shared retry policy never retries a
|
||||
@@ -3394,7 +3408,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
// active login for this owner.
|
||||
const resumeAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
if (!loginActive || resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
resumeAttemptedRef.current = true;
|
||||
const active = activeSessionQuery.data;
|
||||
if (!active) return;
|
||||
@@ -3405,7 +3419,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
setAuthorizationUrl(active.prompt.authorizationUrl);
|
||||
if (active.prompt.transportAdvisory) setTransportInsecure(true);
|
||||
}
|
||||
}, [activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
}, [loginActive, activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
|
||||
// Poll the guarded prompt route until it returns the authorization URL. The
|
||||
// route returns 404 until the URL is ready, so the panel treats a 404 as
|
||||
@@ -3413,9 +3427,9 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const promptQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-prompt", companyId, sessionId],
|
||||
enabled: pollingEnabled && !authorizationUrl,
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
return await agentsApi.getClaudeSetupTokenLoginPrompt(companyId, sessionId!);
|
||||
return await agentsApi.getClaudeSetupTokenLoginPrompt(companyId, sessionId!, { signal });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
@@ -3437,6 +3451,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
mutationFn: (code: string) =>
|
||||
agentsApi.submitClaudeSetupTokenBrowserCode(companyId, sessionId!, code),
|
||||
onError: (error) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
setStartError(describeError(error).body);
|
||||
},
|
||||
});
|
||||
@@ -3444,6 +3459,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const completeLogin = useMutation({
|
||||
mutationFn: () => agentsApi.completeClaudeSetupTokenLogin(companyId, sessionId!),
|
||||
onSuccess: (result) => {
|
||||
if (!cancellation.isActive()) return;
|
||||
// The server reached the `stored` state. Hold the non-secret claim and
|
||||
// report it to the parent. The response carries no token.
|
||||
setStoredSessionId(result.storedSessionId);
|
||||
@@ -3465,11 +3481,11 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const completeLoginRef = useRef(completeLogin.mutate);
|
||||
completeLoginRef.current = completeLogin.mutate;
|
||||
useEffect(() => {
|
||||
if (status === "authenticated" && !completionStartedRef.current) {
|
||||
if (loginActive && status === "authenticated" && !completionStartedRef.current) {
|
||||
completionStartedRef.current = true;
|
||||
completeLoginRef.current();
|
||||
}
|
||||
}, [status]);
|
||||
}, [loginActive, status]);
|
||||
|
||||
const isStored = storedSessionId !== null;
|
||||
const isFailure =
|
||||
@@ -3477,8 +3493,8 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
statusGone ||
|
||||
Boolean(status && CLAUDE_LOGIN_FAILURE_STATUSES.has(status));
|
||||
const isCompleting = status === "authenticated" && !isStored && !completionFailed;
|
||||
const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut && !retrying;
|
||||
const startDisabled = startLogin.isPending || isActive;
|
||||
const isActive = loginActive && Boolean(sessionId) && !isStored && !isFailure && !timedOut && !retrying;
|
||||
const startDisabled = !loginActive || startLogin.isPending || isActive;
|
||||
|
||||
// Cap the active login at the server deadline. The timer arms when the login
|
||||
// becomes active and clears when the login leaves the active state (a terminal
|
||||
@@ -3562,7 +3578,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const startLoginRef = useRef(startLogin.mutate);
|
||||
startLoginRef.current = startLogin.mutate;
|
||||
useEffect(() => {
|
||||
if (!autoStart || autoStartedRef.current) return;
|
||||
if (!loginActive || !autoStart || autoStartedRef.current) return;
|
||||
// A failed lookup is not proof that no session exists: only a successful
|
||||
// lookup is. Show the failure to the user instead of starting a second
|
||||
// login the server would reject against the per-owner cap.
|
||||
@@ -3577,6 +3593,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
startLoginRef.current();
|
||||
}, [
|
||||
autoStart,
|
||||
loginActive,
|
||||
activeSessionQuery.isSuccess,
|
||||
activeSessionView.kind,
|
||||
activeSessionQuery.data,
|
||||
@@ -3630,10 +3647,10 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const onConnectedRef = useRef(onConnected);
|
||||
onConnectedRef.current = onConnected;
|
||||
useEffect(() => {
|
||||
if (!isStored || connectedRef.current) return;
|
||||
if (!loginActive || !isStored || connectedRef.current) return;
|
||||
connectedRef.current = true;
|
||||
onConnectedRef.current?.(sessionId ?? undefined);
|
||||
}, [isStored]);
|
||||
}, [loginActive, isStored]);
|
||||
|
||||
// The other end of `onCodeSubmitted`. Any of these after a submit means the
|
||||
// code is not going to become a stored login, and a caller still showing
|
||||
@@ -3651,8 +3668,8 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
const onPromptReadyRef = useRef(onPromptReady);
|
||||
onPromptReadyRef.current = onPromptReady;
|
||||
useEffect(() => {
|
||||
onPromptReadyRef.current?.(authorizationUrl);
|
||||
}, [authorizationUrl]);
|
||||
onPromptReadyRef.current?.(loginActive ? authorizationUrl : null);
|
||||
}, [loginActive, authorizationUrl]);
|
||||
|
||||
const retryingRef = useRef(false);
|
||||
const retryLogin = useMutation({
|
||||
|
||||
@@ -3968,19 +3968,168 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("resumes the same claude_local session after Back, rather than starting a second", async () => {
|
||||
// This is the behaviour that makes the card's Cancel removable. Back only
|
||||
// hides the card — it deliberately does not release the session — so
|
||||
// coming back has to adopt the one already running. If it started a
|
||||
// fresh one instead, the removed Cancel would have been the only way out
|
||||
// of a login the customer could no longer reach, and the per-owner cap
|
||||
// would reject the second start.
|
||||
it.each(([
|
||||
["claude_local", "resume"], ["codex_local", "resume"],
|
||||
["claude_local", "start"], ["codex_local", "start"],
|
||||
["claude_local", "prompt"], ["codex_local", "prompt"],
|
||||
] as const).flatMap(([adapterType, stage]) => [false, true].map(afterExit => [adapterType, stage, afterExit] as const)))("keeps %s cancelled when its pending %s response arrives after Back (exit finished: %s)", async (adapterType, stage, afterExit) => {
|
||||
const claude = adapterType === "claude_local";
|
||||
const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession;
|
||||
const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin;
|
||||
const promptApi = claude ? mockAgentsApi.getClaudeSetupTokenLoginPrompt : mockAgentsApi.getAdapterAuthLoginStatus;
|
||||
const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin;
|
||||
const session = {
|
||||
sessionId: claude ? "claude-session-1" : "codex-session-1", environmentId: "env-sandbox-1",
|
||||
status: "waiting_for_user", expiresAt: null, failure: null,
|
||||
aiConnection: { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [], allAgents: true },
|
||||
panelMode: claude ? "submitted_browser_code" : "displayed_code",
|
||||
prompt: claude ? { authorizationUrl: "https://claude.ai/oauth/authorize?cancelled=true" }
|
||||
: { url: "https://auth.openai.com/codex/device", code: "CANCELLED-CODE" },
|
||||
};
|
||||
let finish!: (value: unknown) => void;
|
||||
const pending = new Promise(resolve => { finish = resolve; });
|
||||
const pendingApi = stage === "resume" ? activeApi : stage === "start" ? startApi : promptApi;
|
||||
pendingApi.mockReturnValue(pending as never);
|
||||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||||
const { root } = await openStep4({ adapterType });
|
||||
try {
|
||||
await pickSource(claude ? /Claude/ : /OpenAI/);
|
||||
expect(pendingApi).toHaveBeenCalled();
|
||||
await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
|
||||
if (afterExit) for (let i = 0; i < 12; i++) await flushReact();
|
||||
if (stage !== "start") {
|
||||
const signals = (pendingApi.mock.calls as unknown[][]).map(call => (call.at(-1) as { signal?: AbortSignal })?.signal).filter(signal => signal !== undefined);
|
||||
expect(signals.length).toBeGreaterThan(0);
|
||||
expect(signals.every(signal => signal.aborted)).toBe(true);
|
||||
}
|
||||
await act(async () => finish(stage === "prompt" && claude ? session.prompt : session));
|
||||
for (let i = 0; i < 12; i++) await flushReact();
|
||||
expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0);
|
||||
expect(document.body.textContent).not.toContain("CANCELLED-CODE");
|
||||
expect(document.querySelector('input[aria-label="Authorization code"]')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("Let's get started...");
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
if (claude) expect(cancelApi).toHaveBeenCalledWith("company-new", session.sessionId);
|
||||
else expect(cancelApi).toHaveBeenCalledWith("company-new", "codex_local", session.sessionId);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude_local", false], ["codex_local", false], ["claude_local", true], ["codex_local", true],
|
||||
] as const)("discovers and releases %s after its resume read rejects on abort (panel mounted: %s)", async (adapterType, panelMounted) => {
|
||||
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 session = { sessionId: "abandoned-session", environmentId: "env-sandbox-1", status: "waiting_for_user", expiresAt: null,
|
||||
panelMode: claude ? "submitted_browser_code" : "displayed_code",
|
||||
aiConnection: { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [], allAgents: true } };
|
||||
let serverActive = true;
|
||||
let aborts = 0;
|
||||
let finishDiscovery!: () => void;
|
||||
let finishCancellation = () => {};
|
||||
const discovery = new Promise<void>(resolve => { finishDiscovery = resolve; });
|
||||
activeApi.mockImplementation((...args: unknown[]) => {
|
||||
const options = args.at(-1) as { signal?: AbortSignal; fresh?: boolean };
|
||||
if (options?.fresh) return discovery.then(() => serverActive ? session : null);
|
||||
if (!serverActive) return Promise.resolve(null);
|
||||
return new Promise((_resolve, reject) => options?.signal?.addEventListener("abort", () => {
|
||||
aborts++;
|
||||
reject(new DOMException("The request was aborted", "AbortError"));
|
||||
}, { once: true }));
|
||||
});
|
||||
cancelApi.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { serverActive = false; resolve({}); }; }));
|
||||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||||
const { root } = await openStep4({ adapterType });
|
||||
const matchMedia = window.matchMedia;
|
||||
const restoreMotion = () => Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: matchMedia });
|
||||
try {
|
||||
if (panelMounted) await pickSource(claude ? /Claude/ : /OpenAI/);
|
||||
else {
|
||||
Object.defineProperty(window, "matchMedia", { configurable: true, writable: true,
|
||||
value: (media: string) => ({ matches: false, media, onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false }) });
|
||||
const tile = [...document.body.querySelectorAll('[role="radio"]')].find(tile => (claude ? /Claude/ : /OpenAI/).test(tile.textContent ?? ""))!;
|
||||
await act(async () => (tile as HTMLElement).click());
|
||||
expect(activeApi).toHaveBeenCalledTimes(1);
|
||||
restoreMotion();
|
||||
}
|
||||
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(aborts).toBeGreaterThan(0);
|
||||
await vi.waitFor(() => expect((activeApi.mock.calls as unknown[][]).some(call => (call.at(-1) as { fresh?: boolean })?.fresh)).toBe(true));
|
||||
expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0);
|
||||
await pickSource(claude ? /Claude/ : /OpenAI/);
|
||||
expect(startApi).not.toHaveBeenCalled();
|
||||
await act(async () => finishDiscovery());
|
||||
await vi.waitFor(() => expect(cancelApi).toHaveBeenCalled());
|
||||
expect(startApi).not.toHaveBeenCalled();
|
||||
await act(async () => finishCancellation());
|
||||
for (let i = 0; i < 12; i++) await flushReact();
|
||||
expect(serverActive).toBe(false);
|
||||
expect(startApi).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
restoreMotion();
|
||||
finishDiscovery();
|
||||
finishCancellation();
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
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",
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
};
|
||||
let started = false;
|
||||
let finishCancellation!: () => void;
|
||||
mockAgentsApi.cancelClaudeSetupTokenLogin.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { started = false; resolve({}); }; }));
|
||||
mockAgentsApi.startClaudeSetupTokenLogin.mockImplementation(async () => {
|
||||
started = true;
|
||||
return session;
|
||||
@@ -3993,8 +4142,6 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||||
|
||||
await pickSource(/Claude/);
|
||||
// The login is genuinely running: without this the assertion below holds
|
||||
// for the wrong reason.
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||||
|
||||
const back = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
@@ -4005,18 +4152,23 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
});
|
||||
for (let i = 0; i < 12; i++) await flushReact();
|
||||
|
||||
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalled();
|
||||
await pickSource(/Claude/);
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finishCancellation());
|
||||
for (let i = 0; i < 8; i++) await flushReact();
|
||||
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(2);
|
||||
expect(mockAgentsApi.getActiveClaudeSetupTokenLoginSession).toHaveBeenCalled();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("resumes the same codex_local session after Back, rather than starting a second", async () => {
|
||||
it("cancels codex_local on Back and starts a fresh sign-in when selected again", async () => {
|
||||
const session = { sessionId: "codex-session-1", status: "pending" };
|
||||
let started = false;
|
||||
let finishCancellation!: () => void;
|
||||
mockAgentsApi.cancelAdapterAuthLogin.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { started = false; resolve({}); }; }));
|
||||
mockAgentsApi.startAdapterAuthLogin.mockImplementation(async () => {
|
||||
started = true;
|
||||
return session;
|
||||
@@ -4039,10 +4191,13 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
});
|
||||
for (let i = 0; i < 12; i++) await flushReact();
|
||||
|
||||
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalled();
|
||||
await pickSource(/OpenAI/);
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finishCancellation());
|
||||
for (let i = 0; i < 8; i++) await flushReact();
|
||||
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(2);
|
||||
expect(mockAgentsApi.getActiveAdapterAuthLoginSession).toHaveBeenCalled();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -1149,25 +1151,37 @@ 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({
|
||||
queryKey: createdCompanyId
|
||||
? queryKeys.agents.activeLoginSession(createdCompanyId, adapterType)
|
||||
: ["agents", "none", "active-login-session", adapterType],
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
return adapterCaps.login?.panelMode === "submitted_browser_code"
|
||||
? await agentsApi.getActiveClaudeSetupTokenLoginSession(createdCompanyId!)
|
||||
: await agentsApi.getActiveAdapterAuthLoginSession(createdCompanyId!, adapterType);
|
||||
const session = adapterCaps.login?.panelMode === "submitted_browser_code"
|
||||
? await agentsApi.getActiveClaudeSetupTokenLoginSession(createdCompanyId!, { signal })
|
||||
: await agentsApi.getActiveAdapterAuthLoginSession(createdCompanyId!, adapterType, { signal });
|
||||
if (session && dismissedLoginResumes.current.has(loginResumeKey)) {
|
||||
if (matchesAdapterLoginTarget(session, loginTarget)) await cancelAdapterLoginSession(createdCompanyId!, adapterType,
|
||||
adapterCaps.login?.panelMode === "submitted_browser_code", session.sessionId);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled:
|
||||
Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && canShowAdapterLogin,
|
||||
Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && canShowAdapterLogin && !dismissedLoginResumes.current.has(loginResumeKey),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!activeLoginSessionQuery.data || credentialMode === "api" || savedSubscription || savedKeys.storedLogin.data) return;
|
||||
if (dismissedLoginResumes.current.has(loginResumeKey) || !activeLoginSessionQuery.data || credentialMode === "api" || savedSubscription || savedKeys.storedLogin.data) return;
|
||||
// Re-derive the row's answer along with the sequence: a resumed session
|
||||
// implies a source was already picked, and the row stays a question
|
||||
// otherwise (see `sourcePicked` above).
|
||||
@@ -1554,21 +1568,14 @@ function OnboardingWizardInner({
|
||||
// it has been answered the button has nothing to do.
|
||||
{ label: "Next", icon: "arrow", disabled: true };
|
||||
|
||||
/**
|
||||
* Back, on the connect step, unwinds the sign-in before it leaves the step.
|
||||
*
|
||||
* This hides the card; it does not cancel the login. Unmounting the panel
|
||||
* does not release the server session — the session stays reachable for a
|
||||
* later resume, the same read that restores it after a reload — so backing
|
||||
* out and returning shows the sign-in still running, not a fresh one.
|
||||
*
|
||||
* Nothing releases it explicitly any more. The card carried a Cancel that
|
||||
* did, sitting beside an instruction and directly above this step's own
|
||||
* Back, and two ways out of one screen is one too many — the button went and
|
||||
* the release went with it. What is left is the server deadline, which is
|
||||
* the same thing that collects a session abandoned by closing the tab.
|
||||
*/
|
||||
function unwindConnectStep() {
|
||||
localLogin.cancel();
|
||||
dismissedLoginResumes.current.add(loginResumeKey);
|
||||
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", loginTarget);
|
||||
}
|
||||
connectAttemptRef.current++;
|
||||
hiringAgentRef.current = null;
|
||||
setLoading(false);
|
||||
@@ -3242,11 +3249,6 @@ function OnboardingWizardInner({
|
||||
in the connect step's chrome. It owns the session; the
|
||||
step owns the sequence around it.
|
||||
|
||||
Unmounting it is not the cancel: the session stays
|
||||
reachable for a later resume, so Back and a source
|
||||
switch only hide the card, and nothing here releases
|
||||
the session early — see `unwindConnectStep`.
|
||||
|
||||
No "Use saved login" control: the hire step already
|
||||
applies a stored login on its own. */
|
||||
<AdapterLoginPanel
|
||||
@@ -3255,9 +3257,11 @@ 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) => {
|
||||
if (!connectCardLive) return;
|
||||
setConnectAuthUrl(url);
|
||||
// The prompt arriving is what ends the waiting beat.
|
||||
if (url) setConnectPhase((p) => (p === "loading" ? "ready" : p));
|
||||
|
||||
@@ -22,7 +22,7 @@ beforeEach(() => {
|
||||
afterEach(() => { flushSync(() => root.unmount()); host.remove(); });
|
||||
function Harness({ name = "Account", provider = "openai", enabled = true }: { name?: string; provider?: "anthropic" | "openai"; enabled?: boolean }) {
|
||||
const login = useLocalAiLogin("company", { provider, method: "subscription", name, ownership: "personal", agentIds: [], allAgents: true }, enabled);
|
||||
return <><LocalProviderLoginInstructions adapterType={provider === "anthropic" ? "claude_local" : "codex_local"} login={login} /><button onClick={() => void login.connect()}>Connect</button></>;
|
||||
return <><LocalProviderLoginInstructions adapterType={provider === "anthropic" ? "claude_local" : "codex_local"} login={login} /><button onClick={() => void login.connect()}>Connect</button><button onClick={login.cancel}>Back</button></>;
|
||||
}
|
||||
it("checks once under StrictMode, preserves renaming and navigation, and cancels only on explicit retry", async () => {
|
||||
flushSync(() => root.render(<StrictMode><Harness name="First name" /></StrictMode>));
|
||||
@@ -106,3 +106,21 @@ it("hides a failed device prompt and lets the user restart sign-in", async () =>
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("NEW-CODE"));
|
||||
expect(host.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["start", "check"])("cancels a local subscription with a pending %s and ignores its late result", async stage => {
|
||||
let finish!: (result: unknown) => void;
|
||||
const pending = new Promise(resolve => { finish = resolve; });
|
||||
const held = stage === "start" ? api.startLocalLogin : api.checkLocalLogin;
|
||||
held.mockReturnValueOnce(pending);
|
||||
flushSync(() => root.render(<Harness />));
|
||||
await vi.waitFor(() => expect(held).toHaveBeenCalledTimes(1));
|
||||
flushSync(() => Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Back")!.click());
|
||||
finish(stage === "start" ? { sessionId: "attempt-1", expiresAt: "2099-01-01T00:00:00Z" }
|
||||
: { status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "CANCELLED-CODE" });
|
||||
await vi.waitFor(() => expect(api.cancelLocalLogin).toHaveBeenCalledWith("company", "attempt-1"));
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(host.textContent).not.toContain("CANCELLED-CODE");
|
||||
expect(api.checkLocalLogin).toHaveBeenCalledTimes(stage === "start" ? 0 : 1);
|
||||
expect(api.connectLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -49,17 +49,17 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
|
||||
}
|
||||
const request = isolated ? current.current!.request : Promise.resolve(null);
|
||||
async function check() {
|
||||
if (checking || cancelled) return;
|
||||
if (checking || cancelled || (isolated && current.current?.request !== request)) return;
|
||||
checking = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const result = await request;
|
||||
if (cancelled) return;
|
||||
if (cancelled || (isolated && current.current?.request !== request)) return;
|
||||
setAttempt(result);
|
||||
const next = await aiConnectionsApi.checkLocalLogin(companyId!, {
|
||||
...latestIntent.current, ...(result ? { localSessionId: result.sessionId } : {}),
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (cancelled || (isolated && current.current?.request !== request)) return;
|
||||
setStatus(next.status);
|
||||
setAuthorizationUrl(next.authorizationUrl ?? null);
|
||||
setCode(next.code ?? null);
|
||||
@@ -69,7 +69,7 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
|
||||
// browser visit; sign-in never requires repeated Connect clicks.
|
||||
if (next.status === "sign_in_required") timer = setTimeout(() => void check(), 5000);
|
||||
} catch (cause) {
|
||||
if (!cancelled) setError(describeError(cause).body);
|
||||
if (!cancelled && (!isolated || current.current?.request === request)) setError(describeError(cause).body);
|
||||
} finally { checking = false; }
|
||||
}
|
||||
const onFocus = () => { if (!document.hidden) void check(); };
|
||||
@@ -94,6 +94,14 @@ export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLo
|
||||
status,
|
||||
preparing: active && !status && !error,
|
||||
error,
|
||||
cancel: () => {
|
||||
cancelCurrent();
|
||||
setAttempt(null);
|
||||
setStatus(null);
|
||||
setAuthorizationUrl(null);
|
||||
setCode(null);
|
||||
setError(null);
|
||||
},
|
||||
retry: () => { restartRequested.current = true; cancelCurrent(); setGeneration((value) => value + 1); },
|
||||
submitCode: async (browserCode: string) => {
|
||||
if (!companyId || !attempt) throw new Error("Start sign-in before submitting a code.");
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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>>();
|
||||
|
||||
export function cancelAdapterLoginSession(companyId: string, adapterType: string, submittedCode: boolean, sessionId: string) {
|
||||
const key = JSON.stringify([companyId, adapterType, sessionId]);
|
||||
const previous = sessionCancellations.get(key);
|
||||
if (previous) return previous;
|
||||
const request = (submittedCode
|
||||
? agentsApi.cancelClaudeSetupTokenLogin(companyId, sessionId)
|
||||
: agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId)).catch(() => {});
|
||||
sessionCancellations.set(key, request);
|
||||
void request.then(() => { if (sessionCancellations.get(key) === request) sessionCancellations.delete(key); });
|
||||
return request;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!discover) return;
|
||||
const session = submittedCode
|
||||
? await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId, { fresh: true })
|
||||
: await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType, { fresh: true });
|
||||
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, target: AdapterLoginTarget) {
|
||||
const queryClient = useQueryClient();
|
||||
const ownerKey = JSON.stringify([companyId, adapterType]);
|
||||
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) => {
|
||||
const previous = cancelled.current.get(id);
|
||||
if (previous) return previous;
|
||||
const request = cancelAdapterLoginSession(companyId, adapterType, submittedCode, id);
|
||||
cancelled.current.set(id, request);
|
||||
return request;
|
||||
}, [companyId, adapterType, submittedCode]);
|
||||
|
||||
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)) {
|
||||
if (!resumed || matchesAdapterLoginTarget(session, attemptTarget)) await cancel(session.sessionId);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
})();
|
||||
requests.current.add(result);
|
||||
const forget = () => { requests.current.delete(result); };
|
||||
void result.then(forget, forget);
|
||||
return result;
|
||||
}, [cancel, ownerKey, attemptTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
const keys = submittedCode
|
||||
? [["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, attemptTarget,
|
||||
[...requests.current, ...(sessionId ? [cancel(sessionId)] : [])], !sessionId);
|
||||
}, [active, companyId, adapterType, submittedCode, sessionId, queryClient, cancel, ownerKey, attemptTarget]);
|
||||
|
||||
return { accept, resume: <T extends AdapterLoginSession>(request: () => Promise<T | null>) => accept(request, true), isActive: () => scope.current.active };
|
||||
}
|
||||
Reference in new issue
Block a user