// @vitest-environment jsdom import { act, StrictMode } from "react"; import { buildClaudeLocalConfig } from "../../../packages/adapters/claude-local/src/ui/build-config"; import { buildCodexRunnerConfig } from "../adapters/codex-local/config-fields"; import type { CreateConfigValues } from "@paperclipai/adapter-utils"; import type { LocalAiLoginStatus } from "@paperclipai/shared"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --- Mocks (hoisted so vi.mock factories can close over them) ---------------- // The company list is keyed by account, so it holds until the session query // *succeeds*. A seeded entry is stale under the test client and refetches, so // the refetch has to answer too — otherwise the identity errors and the list // never runs. const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn(async (_context?: { companyId: string; environmentId?: string | null }) => [] as import("../api/adapters").AdapterInfo[]) })); vi.mock("../api/adapters", () => ({ adaptersApi: mockAdaptersApi })); function setupAdapterInfo(input: Pick & Partial): import("../api/adapters").AdapterInfo { return { label: input.type, source: "builtin", modelsCount: 0, loaded: true, disabled: false, capabilities: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsAcp: true }, ...input }; } const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() })); const localHealth = vi.hoisted(() => ({ get: vi.fn() })); vi.mock("@/api/health", () => ({ healthApi: localHealth })); const managedApi = vi.hoisted(() => ({ list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })), startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", expiresAt: "2099-01-01T00:00:00Z" })), submitLocalLoginCode: vi.fn(async () => ({ ok: true })), checkLocalLogin: vi.fn(async (): Promise => ({ status: "sign_in_required" })), cancelLocalLogin: vi.fn(async () => ({})), connectLocal: vi.fn(async () => ({ connectionId: "local-connection", grantId: "local-grant" })), create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), })); vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); vi.mock("../api/auth", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, authApi: { ...actual.authApi, getSession: mockAuthApi.getSession } }; }); const mockDialog = vi.hoisted(() => ({ onboardingOpen: true, onboardingOptions: {} as { initialStep?: number; companyId?: string }, closeOnboarding: vi.fn(), onboardingRouteDismissed: false, setOnboardingRouteDismissed: vi.fn(), })); const mockCompany = vi.hoisted(() => ({ companies: [] as Array<{ id: string; name: string; issuePrefix: string }>, setSelectedCompanyId: vi.fn(), loading: false, error: null as Error | null, })); const mockCompaniesApi = vi.hoisted(() => ({ detachInflightList: vi.fn(), create: vi.fn(), update: vi.fn(), // The gate fetches the list itself now, rather than reading the shared // cache, so ownership cases are driven from here. `mockCompany.companies` // below still feeds the *inner* wizard, which is a different question. list: vi.fn(), })); const mockGoalsApi = vi.hoisted(() => ({ create: vi.fn(), list: vi.fn(async () => []), })); const mockAgentsApi = vi.hoisted(() => ({ create: vi.fn(async (_company: string, input: Record) => ({ ...input, id: input.setupId, pauseReason: "setup" })), update: vi.fn(async (_id: string, input: Record) => input), adapterModels: vi.fn(async () => [] as Array<{ id: string; label: string }>), testEnvironment: vi.fn( async (): Promise => ({ adapterType: "claude_local", status: "pass", checks: [], testedAt: new Date().toISOString(), }), ), hire: vi.fn(async () => ({ agent: { id: "agent-1" }, approval: null })), // The hire step lists the company's agents first and adopts one that already // carries the typed name on the same source, so a wizard that reopens on the // agent step cannot hire "Ada 2". Empty by default: the company is new. list: vi.fn(async () => [] as Array<{ id: string; name: string; adapterType: string; adapterConfig?: Record }>), instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })), saveInstructionsFile: vi.fn(async () => ({})), // No default implementation: the top-level `beforeEach` sets the "no // stored value" 404 rejection, using the real `ApiError` class the code // under test checks with `instanceof`. getClaudeOAuthTokenStatus: vi.fn(), getAdapterAuthSignal: vi.fn( async (): Promise => ({ status: "present", }), ), // The sign-in routes. The connect step's Connect button starts a login rather // than hiring when the signal says the source has no credential, so the two // login shapes need enough of a server to reach the state the step is about: // a session that is running, and a prompt to show for it. startClaudeSetupTokenLogin: vi.fn(async () => ({ sessionId: "claude-session-1", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), })), getClaudeSetupTokenLoginStatus: vi.fn(async () => ({ sessionId: "claude-session-1", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), })), getClaudeSetupTokenLoginPrompt: vi.fn(async () => ({ authorizationUrl: "https://claude.ai/oauth/authorize?code=true", transportAdvisory: null, })), cancelClaudeSetupTokenLogin: vi.fn(async () => ({})), submitClaudeSetupTokenBrowserCode: vi.fn(async () => ({})), completeClaudeSetupTokenLogin: vi.fn(async () => ({ storedSessionId: "stored-1" })), startAdapterAuthLogin: vi.fn(async () => ({ sessionId: "codex-session-1", status: "pending", })), getAdapterAuthLoginStatus: vi.fn(async () => ({ sessionId: "codex-session-1", status: "pending", prompt: { url: "https://auth.openai.com/codex/device", code: "Q2RJ-E1YIF" }, })), cancelAdapterAuthLogin: vi.fn(async () => ({})), // No default implementation: the top-level `beforeEach` sets the "no active // session" 404 rejection, matching the real route. getActiveAdapterAuthLoginSession: vi.fn(), getActiveClaudeSetupTokenLoginSession: vi.fn(), })); // The adapter registry mock below always returns this function, so a test // can shape the built adapter config (e.g. a configured ANTHROPIC_API_KEY) // without a real adapter package. const mockAdapterBuild = vi.hoisted(() => ({ buildAdapterConfig: vi.fn((_values?: CreateConfigValues) => ({}) as Record), })); // The Connect path loads environment settings before probing; without these // the probe dies on "Could not load environment settings" and the hire never // runs — which reads as a mysterious 0-call assertion, not an error. const mockEnvironmentsApi = vi.hoisted(() => ({ probe: vi.fn(async (): Promise => ({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } })), list: vi.fn(async () => [] as Array>), capabilities: vi.fn( async (): Promise => (await import("@paperclipai/shared")).getEnvironmentCapabilities([]), ), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })), getExperimental: vi.fn(async (): Promise<{ enableManagedSandboxOnly: boolean; enableNativeRunner?: boolean }> => ({ enableManagedSandboxOnly: false })), })); const mockApprovalsApi = vi.hoisted(() => ({ create: vi.fn(), })); const mockSecretsApi = vi.hoisted(() => ({ list: vi.fn(), removeUserSecretDefinition: vi.fn(), listMyUserSecrets: vi.fn(), createUserSecretDefinition: vi.fn(), createMyUserSecret: vi.fn(), rotateMyUserSecret: vi.fn(), })); const mockIssuesApi = vi.hoisted(() => ({ create: vi.fn(), })); const mockProjectsApi = vi.hoisted(() => ({ create: vi.fn(), list: vi.fn(async () => []), })); // The real adapter registry eagerly imports every adapter package. The // model/harness picker internals are out of scope here, so stub the adapter // layer entirely and drive it through this knob. const mockAdapterRegistry = vi.hoisted(() => ({ list: [] as Array<{ type: string; recommended?: boolean }>, disabled: new Set(), })); const mockRouterState = vi.hoisted(() => ({ pathname: "/" })); vi.mock("@/lib/router", () => ({ useLocation: () => ({ pathname: mockRouterState.pathname, search: "", hash: "", state: null }), useNavigate: () => vi.fn(), useParams: () => ({}), })); vi.mock("../context/DialogContext", () => ({ useDialog: () => mockDialog, })); vi.mock("../context/CompanyContext", () => ({ useCompany: () => mockCompany, })); vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi })); vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi })); vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi })); vi.mock("../api/approvals", () => ({ approvalsApi: mockApprovalsApi })); vi.mock("../api/secrets", () => ({ secretsApi: mockSecretsApi })); vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi })); vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi })); vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi })); vi.mock("../adapters", () => ({ listUIAdapters: () => mockAdapterRegistry.list, getUIAdapter: () => ({ buildAdapterConfig: mockAdapterBuild.buildAdapterConfig }), })); vi.mock("../adapters/metadata", () => ({ isVisualAdapterChoice: () => true })); vi.mock("../adapters/adapter-display-registry", () => ({ getAdapterDisplay: (type: string) => ({ type, // Mirrors the real registry, where these two and only these two are // `recommended`. A blanket `false` used to be harmless because every adapter // then sat in the "Advanced settings" disclosure and was reachable anyway; // with the step down to a tile row built from this flag, it made that row // empty in every test and hid the surface under it. recommended: type === "claude_local" || type === "codex_local" || mockAdapterRegistry.list.some(entry => entry.type === type && entry.recommended), label: type, description: "", icon: () => null, }), getAdapterLabel: (type: string) => type, getAdapterLabels: () => ({}) as Record, isKnownAdapterType: () => true, })); vi.mock("../adapters/use-disabled-adapters", () => ({ useDisabledAdaptersSync: () => mockAdapterRegistry.disabled, // Added by #11371's adapter snap. A module mock replaces the whole module, // so every export the component reaches for has to be here — omitting one // makes it undefined and the call throws. useAdapterRegistryLoaded: () => true, })); // Adapters with a declared login capability, mirroring the real registry: // `claude_local` and `codex_local` both support a sandbox login, and every // other type has none, matching the real `useAdapterCapabilities` fallback for // an unlisted type. // // The panel modes are the real ones rather than one mode for both. They used to // be, back when nothing outside the panel dispatcher read them. The connect // step reads them now — the two logins end in different places, so its button // waits differently for each — and a mock that called Claude's login // `displayed_code` would have the step testing the wrong half of that. // Reconcile with KNOWN_DEFAULTS in `adapters/use-adapter-capabilities.ts`. const ADAPTER_LOGIN_MODES: Record = { claude_local: "submitted_browser_code", codex_local: "displayed_code", }; vi.mock("../adapters/use-adapter-capabilities", () => ({ useAdapterCapabilities: () => (type: string) => ({ supportsInstructionsBundle: false, supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, login: ADAPTER_LOGIN_MODES[type] ? { panelMode: ADAPTER_LOGIN_MODES[type]!, timeoutPolicy: "fixed" as const } : undefined, }), })); // Animation / canvas-ish children that add nothing to the logic under test. vi.mock("./AsciiArtAnimation", () => ({ AsciiArtAnimation: () => null })); vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null })); import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; import { ADAPTER_AUTH_MISSING_CHECK_CODE, getEnvironmentCapabilities } from "@paperclipai/shared"; import { CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model"; import { ONBOARDING_STORAGE_KEY, OnboardingWizard } from "./OnboardingWizard"; import { CONNECTED_HOLD_MS } from "./onboarding/onboarding-motion"; import { TooltipProvider } from "./ui/tooltip"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; /** React tracks input value on the DOM node; set it the way React will see. */ function setControlledValue(el: HTMLInputElement, value: string) { const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, "value", )!.set!; setter.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true })); } async function flushReact() { await act(async () => { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); }); } function renderWizardView(root: ReturnType, queryClient: QueryClient) { return act(async () => { root.render( , ); }); } const SESSION_USER_ID = "user-b"; function render(staleTime = 0) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime } }, }); // The company list is keyed by account, so it stays disabled until the // session is known. Seeding it is how these tests say "signed in as B". queryClient.setQueryData(queryKeys.auth.session, { session: { id: "session-b", userId: SESSION_USER_ID }, user: { id: SESSION_USER_ID, name: "B", email: "b@example.com", image: null }, }); return { container, root, queryClient }; } /** * Press the first model-source tile. * * By `aria-checked` rather than by label: the display registry is mocked in this * suite, so a tile reads "claude_localSubscription" rather than "Claude Code", * and a test that selected on the visible name would be asserting the mock. * * The connect step arrives with nothing chosen, so this is what opens the input * surface and lets the step advance. */ async function pickFirstSource( click: (match: (text: string) => boolean) => Promise, ): Promise { const tile = [...document.body.querySelectorAll("button[aria-checked]")][0]; const label = tile?.textContent?.trim() ?? ""; await click((text) => text === label); } /** * The arc footer's primary button, whatever this step calls it. * * Step 4 calls it "Connect", because there it starts a sign-in rather than * simply advancing; every other arc step calls it "Next". These tests are about * what the press does, not what it reads, so they match either — restating the * label at twenty call sites would make a copy change look like a behaviour * regression. The label itself is pinned once, in the step test that is about * the label. */ function isArcPrimary(text: string): boolean { return text.startsWith("Next") || text.startsWith("Connect"); } describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => { beforeEach(() => { mockAdaptersApi.list.mockReset().mockResolvedValue([]); mockEnvironmentsApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false }); localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" }); managedApi.checkLocalLogin.mockReset().mockResolvedValue({ status: "sign_in_required", authorizationUrl: "https://claude.ai/oauth/authorize?code=true", code: "TEST-CODE", }); managedApi.connectLocal.mockReset().mockResolvedValue({ connectionId: "local-connection", grantId: "local-grant" }); mockAuthApi.getSession.mockResolvedValue({ session: { id: "session-b", userId: SESSION_USER_ID }, user: { id: SESSION_USER_ID, name: "B", email: "b@example.com", image: null }, }); mockSecretsApi.list.mockResolvedValue([]); mockSecretsApi.listMyUserSecrets.mockResolvedValue([]); mockSecretsApi.removeUserSecretDefinition.mockResolvedValue({ ok: true }); window.localStorage.clear(); mockRouterState.pathname = "/"; mockDialog.onboardingOpen = true; mockDialog.onboardingOptions = {}; mockDialog.onboardingRouteDismissed = false; mockCompany.companies = []; mockCompany.loading = false; mockCompany.error = null; mockCompaniesApi.list.mockResolvedValue([]); mockAdapterRegistry.list = []; mockAdapterRegistry.disabled = new Set(); mockAdaptersApi.list.mockReset().mockResolvedValue(["claude_local", "codex_local"].map(type => setupAdapterInfo({ type, loaded: true, disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" }))); mockAdapterBuild.buildAdapterConfig.mockReset(); mockAdapterBuild.buildAdapterConfig.mockReturnValue({}); // Default: no stored Claude login for the owner. The route returns a // fixed 404 for a missing value, so the client treats a real `ApiError` // with that status as "no stored value" rather than a hard failure. mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); mockAgentsApi.getClaudeOAuthTokenStatus.mockRejectedValue( new ApiError("Not found", 404, null), ); // Default: no active login session for the caller. A resume test // overrides this with a resolved session body. mockAgentsApi.getActiveAdapterAuthLoginSession.mockReset(); mockAgentsApi.getActiveAdapterAuthLoginSession.mockRejectedValue( new ApiError("Not found", 404, null), ); mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockReset(); mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue( new ApiError("Not found", 404, null), ); // Reset to each mock's original default. `mockResolvedValue` / // `mockReturnValue` overrides a mock's implementation permanently — it // is not undone by `afterEach`'s `vi.clearAllMocks()`, which only clears // call history — so a test that customizes one of these must not leak // its override into the next test. mockAgentsApi.testEnvironment.mockReset(); mockAgentsApi.adapterModels.mockReset().mockResolvedValue([]); mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "claude_local", status: "pass" as const, checks: [], testedAt: new Date().toISOString(), }); mockAgentsApi.hire.mockReset(); mockAgentsApi.list.mockReset().mockResolvedValue([]); mockAgentsApi.create.mockReset().mockImplementation(async (_company, input) => ({ ...input, id: input.setupId, pauseReason: "setup" })); mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null }); mockCompaniesApi.create.mockResolvedValue({ id: "created", name: "Created Co", issuePrefix: "CRE", }); mockCompaniesApi.update.mockResolvedValue({ id: "c1", name: "Acme Rockets", issuePrefix: "PAP", }); mockGoalsApi.create.mockResolvedValue({ id: "goal-1" }); mockGoalsApi.list.mockResolvedValue([]); }); afterEach(() => { document.body.innerHTML = ""; vi.clearAllMocks(); }); describe("first-agent provider choices", () => { async function openProviderChoices(savedOverrides: Record = {}) { mockCompany.companies = [{ id: "c1", name: "Acme", issuePrefix: "ACM" }]; mockCompaniesApi.list.mockResolvedValue(mockCompany.companies); mockAdapterRegistry.list = [ { type: "claude_local" }, { type: "codex_local" }, { type: "paperclip_runner" }, { type: "copilot_runtime", recommended: true }, { type: "gemini_local", recommended: true }, { type: "opencode_local" }, ]; window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 4, createdCompanyId: "c1", agentName: "Ada", ...savedOverrides, })); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); return root; } it.each([true, false, undefined].flatMap(enableNativeRunner => (["paperclip", "legacy"] as const).map(defaultRunner => ({ enableNativeRunner, defaultRunner })), ))("offers the same Claude and Codex choices with $defaultRunner availability and native runner setting $enableNativeRunner", async ({ enableNativeRunner, defaultRunner }) => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false, enableNativeRunner }); mockAdaptersApi.list.mockResolvedValue(["claude_local", "codex_local"].map(type => setupAdapterInfo({ type, defaultRunner, supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"] }), )); const root = await openProviderChoices(); try { const sources = [...document.body.querySelectorAll("button[aria-checked]")]; expect(sources).toHaveLength(2); expect(sources.map(source => source.textContent)).toEqual([ expect.stringContaining("Claude"), expect.stringContaining("OpenAI"), ]); expect(document.body.textContent).not.toContain("GitHub Copilot"); expect(document.body.textContent).not.toContain("Windows"); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).runner).toBe("auto"); } finally { await act(async () => root.unmount()); } }); it("still hides disabled providers from first-agent setup", async () => { mockAdapterRegistry.disabled = new Set(["claude_local"]); const root = await openProviderChoices(); try { const sources = [...document.body.querySelectorAll("button[aria-checked]")]; expect(sources).toHaveLength(1); expect(sources[0]?.textContent).toContain("OpenAI"); } finally { await act(async () => root.unmount()); } }); it("requires a new choice when resuming a Copilot draft", async () => { const root = await openProviderChoices({ adapterType: "paperclip_runner", onboardingCopilot: true, model: "copilot-model", }); try { expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull(); expect(document.body.textContent).not.toContain("Connect GitHub Copilot"); const connect = [...document.body.querySelectorAll("button")].find(button => isArcPrimary(button.textContent?.trim() ?? "")); expect(connect?.disabled).toBe(true); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "claude_local", runner: "auto", model: "", }); } finally { await act(async () => root.unmount()); } }); it("clears a saved Gemini model when only Codex is available", async () => { mockAdapterRegistry.disabled = new Set(["claude_local"]); const root = await openProviderChoices({ adapterType: "gemini_local", model: "gemini-model" }); try { expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull(); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "codex_local", model: "", }); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); } finally { await act(async () => root.unmount()); } }); it.each([false, true])("explains unavailable onboarding providers and clears an old Copilot draft: %s", async savedCopilot => { mockAdapterRegistry.disabled = new Set(["claude_local", "codex_local"]); const root = await openProviderChoices(savedCopilot ? { adapterType: "paperclip_runner", onboardingCopilot: true, runner: "paperclip", model: "copilot-model", } : {}); try { expect(document.body.querySelectorAll("button[aria-checked]")).toHaveLength(0); expect(document.body.textContent).toContain("Claude and Codex are unavailable on this instance."); expect(document.body.textContent).toContain("choose New Agent on the Agents page"); const next = [...document.body.querySelectorAll("button")].find(button => isArcPrimary(button.textContent?.trim() ?? "")); expect(next?.disabled).toBe(true); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); const saved = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); expect(saved).toMatchObject({ adapterType: "claude_local", runner: "auto", model: "" }); expect(saved).not.toHaveProperty("onboardingCopilot"); } finally { await act(async () => root.unmount()); } }); }); describe("step 1 leads straight to the agent — there is no mission step 2", () => { // One path now: Name your organization → Name your agent → Connect → Get // started. The Build / Grow front door and both mission screens are gone, // so "Continue" on step 1 creates the organization and lands on the agent // step with no mission question in between. async function openStepOne(savedOverrides: Record = {}) { window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 1, companyName: "Initech", ...savedOverrides }), ); mockDialog.onboardingOptions = {}; mockCompany.companies = []; mockCompany.loading = false; mockCompaniesApi.list.mockResolvedValue([]); // The model step needs tiles to pick from, and this suite's default // registry is empty. mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; const { root, queryClient } = render(); const renderTree = () => renderWizardView(root, queryClient); await renderTree(); await flushReact(); return { root, renderTree }; } async function clickByText(match: (text: string) => boolean) { const el = [...document.body.querySelectorAll("button")].find((b) => match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""), )!; await act(async () => { el.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); } it("defers the native runner until the name-only setup has a chosen provider", async () => { mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); const { root } = await openStepOne({ adapterType: "codex_local", runner: "paperclip" }); try { await clickByText((text) => text.startsWith("Continue")); const field = document.body.querySelector("#onboarding-agent-name") as HTMLInputElement; await act(async () => setControlledValue(field, "Chief of Staff")); await flushReact(); await clickByText((text) => isArcPrimary(text)); expect(document.body.textContent).toContain("Connect a model"); expect(mockAgentsApi.create).toHaveBeenCalledTimes(1); const setup = mockAgentsApi.create.mock.calls[0][1]; expect(setup).toMatchObject({ adapterType: "process", metadata: { setupAdapterPending: true } }); expect(setup).not.toHaveProperty("runner"); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await clickByText((text) => /OpenAI.*Subscription/.test(text)); for (let i = 0; i < 3; i++) await flushReact(); expect(mockAgentsApi.update).toHaveBeenCalledWith(setup.setupId, expect.objectContaining({ adapterType: "codex_local", runner: "paperclip", metadata: { setupAdapterPending: false }, }), "company-new"); expect(mockAgentsApi.create).toHaveBeenCalledTimes(1); } finally { await act(async () => root.unmount()); } }); it("shows no environment-check card on the model step, and no Mission row on review", async () => { // Round-3 walk feedback: the adapter environment check still runs — // Connect probes before hiring and blocks on a fail — but its idle card // (explainer plus "Test now") is gone. And the review checklist lost its // "Mission" row: onboarding stopped asking, so the row could only ever // render unchecked. Both asserted against positive anchors so an // unrendered step cannot pass as an absence. mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); const { root } = await openStepOne(); await clickByText((t) => t.startsWith("Continue")); expect(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Initech" }); expect(document.body.textContent).not.toContain("Define your mission"); expect(document.body.textContent).not.toContain("Tell us about your team"); expect(document.body.textContent).toContain("Create your first agent"); // Step 3 → 4 needs an agent name — the one field the step has now. const agentField = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement; await act(async () => { setControlledValue(agentField, "Ada"); }); await flushReact(); await clickByText((t) => isArcPrimary(t)); expect(document.body.textContent).toContain("Connect a model"); await pickFirstSource(clickByText); expect(document.body.textContent).not.toContain("Adapter environment check"); expect(document.body.textContent).not.toContain("Test now"); // Through Connect to Review, so the Mission-row assertion runs against // the checklist that actually renders it — stopping at the model step // would let a Mission regression pass unseen. await clickByText((t) => isArcPrimary(t)); // The review step is the heading and the woken agent, nothing else: the // checklist that restated the walk in three rows is gone, and with it // the Mission row that could only render unchecked. expect(document.body.textContent).toContain("Let's get started..."); expect(document.body.textContent).toContain("Ada is ready to work!"); expect(document.body.textContent).not.toContain("Organization name"); expect(document.body.textContent).not.toContain("Model connected"); expect(document.body.textContent).not.toContain("Mission"); await act(async () => root.unmount()); }); it("adopts an agent the company already has under that name instead of hiring it twice", async () => { // The wizard can reopen on the agent step for a company that just got // its first agent — the dashboard's agentless offer on a stale list is // one way — with nothing in its state to say the hire happened. The // server numbers a repeat name, so without this the walk produced // "Ada" and "Ada 2". Same name on the same source is the same agent. mockDialog.onboardingOptions = {}; mockCompany.companies = []; mockCompany.loading = false; mockCompaniesApi.list.mockResolvedValue([]); mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); mockAgentsApi.list.mockResolvedValue([ { id: "agent-existing", name: "Ada", adapterType: "claude_local" }, ]); mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); const clickText = async (match: (t: string) => boolean) => { const el = [...document.body.querySelectorAll("button")].find((b) => match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""), )!; await act(async () => { el.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); }; const nameField = document.body.querySelector( "#onboarding-company-name", ) as HTMLInputElement | null; if (nameField) { await act(async () => { setControlledValue(nameField, "Initech"); }); await flushReact(); } else { const anyName = document.body.querySelector( 'input[placeholder="e.g. Northwind Labs"]', ) as HTMLInputElement; await act(async () => { setControlledValue(anyName, "Initech"); }); await flushReact(); } await clickText((t) => t.startsWith("Continue")); const agentField = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement; await act(async () => { setControlledValue(agentField, "ada "); }); await flushReact(); await clickText((t) => isArcPrimary(t)); await pickFirstSource(clickText); await clickText((t) => isArcPrimary(t)); expect(mockAgentsApi.list).toHaveBeenCalledWith("company-new"); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("ada is ready to work!"); await act(async () => root.unmount()); }); it.each([false, true])("shows manual CLI debugging only outside managed Cloud (managed: %s)", async managed => { localHealth.get.mockResolvedValue({ deploymentMode: "authenticated", ...(managed ? { cloud: { managed: true, managedBy: "paperclip-cloud" } } : {}) }); mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "claude_local", status: "fail", testedAt: new Date().toISOString(), checks: [{ code: "claude_hello_probe_failed", level: "error", message: "The runtime test did not finish.", detail: "The provider exited with code 1.", hint: "Exit code 1. Run `claude --print - --output-format stream-json --verbose` manually in this directory and prompt `Respond with hello` to debug." }] }); mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); const { root } = await openStepOne(); try { await clickByText(t => t.startsWith("Continue")); await act(async () => setControlledValue(document.body.querySelector("#onboarding-agent-name") as HTMLInputElement, "Ada")); await flushReact(); await clickByText(isArcPrimary); await pickFirstSource(clickByText); await clickByText(isArcPrimary); expect(document.body.textContent).toContain("The runtime test did not finish."); expect(document.body.textContent).toContain("The provider exited with code 1."); expect(document.body.textContent?.includes("manually in this directory")).toBe(!managed); expect(document.body.textContent?.includes("Manual debug")).toBe(!managed); expect(document.body.textContent?.includes("--output-format stream-json")).toBe(!managed); expect(document.body.textContent?.includes("Fix the reported checks")).toBe(!managed); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); } finally { await act(async () => root.unmount()); } }); it("hires from a legacy draft that saved an empty role", async () => { // `agentRole: ""` was this field's default before the arc stopped asking // for a role, so every draft saved by an earlier build carries it. `??` // would pass the empty string straight through to the hire's silent // return — the same no-op the default exists to prevent, arriving // through a restored draft instead of a fresh one. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 1, companyName: "Initech", agentRole: "" }), ); mockDialog.onboardingOptions = {}; mockCompany.companies = []; mockCompany.loading = false; mockCompaniesApi.list.mockResolvedValue([]); mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); // The model step needs tiles to pick from, and this suite's default // registry is empty. mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; const { root, queryClient } = render(); const renderTree = () => renderWizardView(root, queryClient); await renderTree(); await flushReact(); const clickText = async (match: (t: string) => boolean) => { const el = [...document.body.querySelectorAll("button")].find((b) => match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""), )!; await act(async () => { el.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); }; await clickText((t) => t.startsWith("Continue")); const agentField = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement; await act(async () => { setControlledValue(agentField, "Ada"); }); await flushReact(); await clickText((t) => isArcPrimary(t)); await pickFirstSource(clickText); await clickText((t) => isArcPrimary(t)); expect(mockAgentsApi.hire).toHaveBeenCalled(); // The mock is declared with no parameters, so index the call rather than // destructuring a zero-length tuple type. const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[]; expect((hireArgs[1] as { role: string }).role).toBe("general"); await act(async () => root.unmount()); }); it("hires one agent when Connect fires twice in one breath", async () => { // The Connect handler re-runs a cached failed probe now that "Test now" // is gone — so two overlapping submissions could both pass the fresh // probe and both hire. `loading` cannot stop the second caller: it is // state, unwritten while the first call is still awaiting. The ref // guard must make the second submission a no-op. let resolveHire: (v: { agent: { id: string }; approval: null }) => void = () => {}; mockAgentsApi.hire.mockReturnValue( new Promise((resolve) => { resolveHire = resolve; }), ); mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); const { root } = await openStepOne(); await clickByText((t) => t.startsWith("Continue")); const agentField = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement; await act(async () => { setControlledValue(agentField, "Ada"); }); await flushReact(); await clickByText((t) => isArcPrimary(t)); expect(document.body.textContent).toContain("Connect a model"); await pickFirstSource(clickByText); const connect = [...document.body.querySelectorAll("button")].find((b) => isArcPrimary(b.textContent?.trim() ?? ""), )!; await act(async () => { connect.dispatchEvent(new MouseEvent("click", { bubbles: true })); connect.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => resolveHire({ agent: { id: "agent-1" }, approval: null })); await flushReact(); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); await act(async () => root.unmount()); }); it("creates one company for one keystroke, modifier or not", async () => { // The name field handles Enter itself and does not check for a modifier, // so Cmd+Enter in that field reaches the field's handler *and* the // wizard's step-level one. Both would start creating. The step-level // `loading` guard cannot stop it — `setLoading(true)` has not landed // while the same event is still bubbling — so the second caller reads a // value the first has not written. Two companies, one keystroke. mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); const { root } = await openStepOne(); const nameInput = document.body.querySelector( 'input[placeholder="e.g. Northwind Labs"]', ) as HTMLInputElement; await act(async () => { nameInput.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }), ); }); await flushReact(); expect(mockCompaniesApi.create).toHaveBeenCalledTimes(1); expect(document.body.textContent).toContain("Create your first agent"); await act(async () => root.unmount()); }); it("creates one company however many times Enter repeats", async () => { // Holding Enter down fires keydown repeatedly. Each one is a separate // event, so `defaultPrevented` says nothing about the others, and neither // `loading` nor `createdCompanyId` has been written by the time the next // arrives — the first is state, the second is not set until the request // it guards resolves. Only a ref written before the request goes out is // visible to the caller behind it. let resolveCreate: (c: { id: string; issuePrefix: string }) => void = () => {}; mockCompaniesApi.create.mockReturnValue( new Promise<{ id: string; issuePrefix: string }>((resolve) => { resolveCreate = resolve; }), ); const { root } = await openStepOne(); const nameInput = document.body.querySelector( 'input[placeholder="e.g. Northwind Labs"]', ) as HTMLInputElement; await act(async () => { for (let i = 0; i < 4; i++) { nameInput.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), ); } }); expect(mockCompaniesApi.create).toHaveBeenCalledTimes(1); await act(async () => resolveCreate({ id: "company-new", issuePrefix: "INI" })); await flushReact(); expect(document.body.textContent).toContain("Create your first agent"); await act(async () => root.unmount()); }); it("sends Back to the screen the run actually came from", async () => { // A create run reached the agent step from step 1, so Back owes it step 1 — // not the mission screen it never saw. mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); const { root } = await openStepOne(); await clickByText((t) => t.startsWith("Continue")); expect(document.body.textContent).toContain("Create your first agent"); await clickByText((t) => t.includes("Back")); expect(document.body.textContent).toContain("What is the name of your organization?"); expect(document.body.textContent).not.toContain("Define your mission"); await act(async () => root.unmount()); }); }); describe("hire gate: adapter authentication (claude_local, the default onboarding adapter)", () => { /** Drives the wizard to the Connect step, agent name already filled in. */ async function openConnectStep({ useApiKeys = false } = {}) { // The tile row is built from this registry, and the suite's default is // empty. That was survivable while the step preselected a source; now that // nothing is chosen until a tile is pressed, a step with no tiles is a step // that can never advance. mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 1, companyName: "Initech" }), ); mockDialog.onboardingOptions = {}; mockCompany.companies = []; mockCompany.loading = false; mockCompaniesApi.list.mockResolvedValue([]); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); const clickByText = async (match: (text: string) => boolean) => { const el = [...document.body.querySelectorAll("button")].find((b) => match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""), )!; await act(async () => { el.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); }; await clickByText((t) => t.startsWith("Continue")); const agentField = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement; await act(async () => { setControlledValue(agentField, "Ada"); }); await flushReact(); await clickByText((t) => isArcPrimary(t)); expect(document.body.textContent).toContain("Connect a model"); // The credential mode is chosen *before* a source, because picking a // source starts the sequence and the mode link fades out with the row — // after that it is inert, and switching would mean changing the card out // from under a running sign-in. if (useApiKeys) { await clickByText((t) => t.startsWith("Use API key")); } // Pick a source. The step arrives with nothing chosen — `adapterType` // carries a value for the hire, but that is not the same as the customer // having answered — so the input surface stays closed and the step will // not advance until a tile is pressed. Every case below is about what // happens *after* that choice, so the helper makes it. await pickFirstSource(clickByText); return { root, clickByText }; } it("blocks the hire on a warn result that holds adapter_auth_missing, and shows the returned checks", async () => { mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "claude_local", status: "warn" as const, checks: [ { code: ADAPTER_AUTH_MISSING_CHECK_CODE, level: "warn" as const, message: "No stored Claude login was found for this agent.", }, ], testedAt: new Date().toISOString(), }); const { root, clickByText } = await openConnectStep(); await clickByText((t) => isArcPrimary(t)); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).toContain( "No stored Claude login was found for this agent.", ); expect(document.body.textContent).toContain("No working authentication was found"); await act(async () => root.unmount()); }); it("still hires on a warn result that carries no adapter_auth_missing check", async () => { mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "claude_local", status: "warn" as const, checks: [ { code: "claude_anthropic_api_key_overrides_subscription", level: "warn" as const, message: "ANTHROPIC_API_KEY overrides the subscription login.", }, ], testedAt: new Date().toISOString(), }); const { root, clickByText } = await openConnectStep(); await clickByText((t) => isArcPrimary(t)); expect(mockAgentsApi.hire).toHaveBeenCalled(); await act(async () => root.unmount()); }); // The Connect handler reuses a passing probe instead of re-running it, so the // effect that clears the cache has to name every input to the configuration // the probe tested. `credentialMode` and `apiKey` were missing from it, and // the gap is reachable: the probe and the hire share one try/catch, so a hire // that throws leaves the pass in state. Switching to a key and pressing // Connect again then hired against a key nothing had tested. /** * Typing a key into this step must not put the key into the agent's stored * configuration. That configuration is persisted and revisioned, so a plain * value there is a live credential at rest in every copy of it — which is * what this step did before, and what the Claude token path has always * avoided by holding a `user_secret_ref` instead. */ it.each(["personal", "organization"])("defaults to a saved %s API key and uses the same reference for probe and hire", async (scope) => { const key = "ANTHROPIC_API_KEY"; const binding = scope === "personal" ? { type: "user_secret_ref", key, version: "latest" } : { type: "secret_ref", secretId: "saved-org-key", version: "latest" }; if (scope === "personal") { mockSecretsApi.listMyUserSecrets.mockResolvedValue([{ definition: { id: "saved-key", companyId: "company-new", key, name: "Saved key", status: "active" }, secret: { companyId: "company-new", status: "active" }, }]); } else { mockSecretsApi.list.mockResolvedValue([{ id: "saved-org-key", companyId: "company-new", key, name: "Saved key", scope: "company", status: "active", }]); } const { root, clickByText } = await openConnectStep(); const picker = document.body.querySelector('select[aria-label="Saved API key"]') as HTMLSelectElement; expect(picker.value).toBe(scope === "personal" ? "user:saved-key" : "company:saved-org-key"); await clickByText((t) => isArcPrimary(t)); expect((mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[])[2]).toMatchObject({ adapterConfig: { env: { [key]: binding } } }); expect((mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1]).toMatchObject({ adapterConfig: { env: { [key]: binding } } }); expect(mockSecretsApi.createMyUserSecret).not.toHaveBeenCalled(); expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); describe("an API key typed on the step", () => { const KEY = "sk-ant-typed-by-the-customer"; // The canvas holding the key field only opens once a source is selected, // and the tile row that selects one is built from this registry. The // suite's default is empty, which leaves the step with no tiles, no // canvas, and no field to type into. beforeEach(() => { mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; // No definition and no stored value yet: the first customer to type a key. mockSecretsApi.listMyUserSecrets.mockResolvedValue([]); mockSecretsApi.createUserSecretDefinition.mockResolvedValue({ id: "def-1" }); mockSecretsApi.createMyUserSecret.mockResolvedValue({ id: "secret-abc" }); mockSecretsApi.rotateMyUserSecret.mockResolvedValue({ id: "secret-existing" }); }); async function connectWithApiKey() { const handles = await openConnectStep({ useApiKeys: true }); const field = document.body.querySelector( 'input[type="password"]', ) as HTMLInputElement; await act(async () => { setControlledValue(field, KEY); }); await flushReact(); await handles.clickByText((t) => isArcPrimary(t)); return handles; } it("is stored as a personal connection and referenced, never carried in the hire", async () => { const { root } = await connectWithApiKey(); expect(managedApi.create).toHaveBeenCalledTimes(1); expect(managedApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", ownership: "personal", apiKey: KEY })); const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { runtimeConfig: { aiConnection: unknown }; adapterConfig: { env?: Record } }; expect(hireBody.runtimeConfig.aiConnection).toEqual({ provider: "anthropic", method: "api_key", mode: "responsible_user" }); expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toBeUndefined(); // The whole payload, not just that one field: the point is that the key // is nowhere in what gets persisted, however it might be nested. expect(JSON.stringify(hireBody)).not.toContain(KEY); await act(async () => root.unmount()); }); it("creates a managed connection without rotating an existing saved key", async () => { mockSecretsApi.listMyUserSecrets.mockResolvedValue([ { definition: { id: "old-def", key: "ANTHROPIC_API_KEY" }, secret: { id: "secret-existing" } }, ]); const { root } = await connectWithApiKey(); expect(managedApi.create).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ apiKey: KEY })); expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled(); expect(managedApi.create).toHaveBeenCalledTimes(1); await act(async () => root.unmount()); }); // The one outcome that must never happen is a hire that falls back to // embedding the key because storing it failed. it("blocks the hire when the key cannot be stored", async () => { managedApi.create.mockRejectedValueOnce(new Error("vault unreachable")); const { root } = await connectWithApiKey(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("Could not store the API key"); await act(async () => root.unmount()); }); it("stores one connection when Connect is pressed twice with the same key", async () => { mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away")); const { root, clickByText } = await connectWithApiKey(); await clickByText((t) => isArcPrimary(t)); expect(managedApi.create).toHaveBeenCalledTimes(1); await act(async () => root.unmount()); }); }); it("re-probes rather than reusing a pass when the credential mode changes", async () => { mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "claude_local", status: "pass" as const, checks: [], testedAt: new Date().toISOString(), }); // The hire fails, which is what leaves the passing probe behind. mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away")); const { root, clickByText } = await openConnectStep(); await clickByText((t) => isArcPrimary(t)); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); // Switching the credential mode means backing out first: the mode link // fades away with the row once a source is chosen, and is inert after // that, so it cannot be used to change the card out from under a running // sign-in. Back unwinds to the question, and the answer is given again. await clickByText((t) => t.startsWith("Back")); await clickByText((t) => t.startsWith("Use API key")); await pickFirstSource(clickByText); const field = document.body.querySelector( 'input[type="password"]', ) as HTMLInputElement; await act(async () => { setControlledValue(field, "sk-ant-rekey"); }); await flushReact(); await clickByText((t) => isArcPrimary(t)); // A different configuration, so the passing probe from before it cannot // stand in for one against this one. expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2); await act(async () => root.unmount()); }); it("does not open the create path on a cached warn result that holds adapter_auth_missing", async () => { mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "claude_local", status: "warn" as const, checks: [ { code: ADAPTER_AUTH_MISSING_CHECK_CODE, level: "warn" as const, message: "No stored Claude login was found for this agent.", }, ], testedAt: new Date().toISOString(), }); const { root, clickByText } = await openConnectStep(); await clickByText((t) => isArcPrimary(t)); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); // A second Connect must not treat the first (cached) blocking result as // reusable — it re-probes, and the create path stays closed. await clickByText((t) => isArcPrimary(t)); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); it("sends the fixed Claude binding and applyStoredClaudeLogin when a stored login exists", async () => { mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "11111111-1111-1111-1111-111111111111", latestVersion: 1, }); const { root } = await openConnectStep(); // Selecting the provider automatically verifies the saved subscription. await flushReact(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalled(); const testArgs = mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[]; const testBody = testArgs[2] as { adapterConfig: { env?: Record } }; expect(testBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toEqual({ type: "user_secret_ref", key: CLAUDE_OAUTH_TOKEN_ENV_KEY, version: "latest", required: true, }); expect(mockAgentsApi.hire).toHaveBeenCalled(); const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[]; const hireBody = hireArgs[1] as { adapterConfig: { env?: Record }; applyStoredClaudeLogin?: boolean; }; const binding = hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY] as | { type: string } | undefined; expect(binding?.type).toBe("user_secret_ref"); expect(JSON.stringify(hireBody)).not.toContain("secretId"); expect(hireBody.applyStoredClaudeLogin).toBe(true); expect(hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toEqual({ type: "user_secret_ref", key: CLAUDE_OAUTH_TOKEN_ENV_KEY, version: "latest", required: true, }); await act(async () => root.unmount()); }); it("sends no binding and no flag when the Claude status route returns 404", async () => { // The default `beforeEach` mock already rejects with a 404 `ApiError`, // matching "no stored value" — asserted explicitly here to pin the // scenario this test is named for. const { root, clickByText } = await openConnectStep(); await clickByText((t) => isArcPrimary(t)); expect(mockAgentsApi.testEnvironment).toHaveBeenCalled(); const testArgs = mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[]; const testBody = testArgs[2] as { adapterConfig: { env?: Record } }; expect(testBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toBeUndefined(); expect(mockAgentsApi.hire).toHaveBeenCalled(); const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[]; const hireBody = hireArgs[1] as { adapterConfig: { env?: Record }; applyStoredClaudeLogin?: boolean; }; expect(hireBody.applyStoredClaudeLogin).toBeUndefined(); expect(hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toBeUndefined(); await act(async () => root.unmount()); }); it("sends no binding when the adapter configuration holds a non-empty ANTHROPIC_API_KEY", async () => { mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "11111111-1111-1111-1111-111111111111", latestVersion: 1, }); mockAdapterBuild.buildAdapterConfig.mockReturnValue({ env: { ANTHROPIC_API_KEY: { type: "plain", value: "sk-ant-configured" } }, }); const { root } = await openConnectStep(); // Selecting the provider automatically verifies the saved subscription. await flushReact(); expect(mockAgentsApi.hire).toHaveBeenCalled(); // Discovery reads saved-login metadata once; the hire does not re-read // or apply it when the configuration already has an API key. expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(1); const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[]; const hireBody = hireArgs[1] as { adapterConfig: { env?: Record }; applyStoredClaudeLogin?: boolean; }; expect(hireBody.applyStoredClaudeLogin).toBeUndefined(); expect(hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toBeUndefined(); await act(async () => root.unmount()); }); it("hires when a stored login exists, even though a probe without the binding would warn adapter_auth_missing", async () => { mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "11111111-1111-1111-1111-111111111111", latestVersion: 1, }); // Answers like the real sandbox probe: `warn` with `adapter_auth_missing` // for a configuration with no binding, `pass` once the binding is // present. This proves the wizard sends the probe the SAME configuration // it hires with — a probe still sent without the binding would warn and // block the hire below. mockAgentsApi.testEnvironment.mockImplementation( (async (...args: unknown[]) => { const request = args[2] as { adapterConfig: { env?: Record }; }; const hasBinding = Boolean( request.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY], ); return hasBinding ? { adapterType: "claude_local" as const, status: "pass" as const, checks: [], testedAt: new Date().toISOString(), } : { adapterType: "claude_local" as const, status: "warn" as const, checks: [ { code: ADAPTER_AUTH_MISSING_CHECK_CODE, level: "warn" as const, message: "No stored Claude login was found for this agent.", }, ], testedAt: new Date().toISOString(), }; }) as unknown as () => Promise< import("@paperclipai/shared").AdapterEnvironmentTestResult >, ); const { root } = await openConnectStep(); // Selecting the provider automatically verifies the saved subscription. await flushReact(); expect(mockAgentsApi.hire).toHaveBeenCalled(); await act(async () => root.unmount()); }); ; it("reads the stored-login status once for each create attempt", async () => { mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "11111111-1111-1111-1111-111111111111", latestVersion: 1, }); // Fails after the gate opens, so the button stays clickable for a // second attempt instead of advancing past step 4. mockAgentsApi.hire.mockRejectedValue(new Error("hire failed")); const { root, clickByText } = await openConnectStep(); const discoveryReads = mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length; await clickByText((t) => isArcPrimary(t)); expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(discoveryReads + 1); await clickByText((t) => isArcPrimary(t)); expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(discoveryReads + 2); await act(async () => root.unmount()); }); }); describe("restored route progress", () => { function savedReview(runner = "auto") { return { step: 5, companyName: "Saved Co", createdCompanyId: "c1", createdCompanyPrefix: "SC", createdAgentId: "saved-agent", agentName: "Saved lead", adapterType: "codex_local", runner, model: "gpt-5.6-sol", credentialMode: "api" }; } async function openSavedReview(pathname: string, runner = "auto", options: { strict?: boolean; companiesLoading?: boolean; dialogStep?: number } = {}) { const draft = savedReview(runner); window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(draft)); mockRouterState.pathname = pathname; mockDialog.onboardingOpen = options.dialogStep !== undefined; mockDialog.onboardingOptions = options.dialogStep === undefined ? {} : { initialStep: options.dialogStep, companyId: "c1" }; const companies = [{ id: "c1", name: "Saved Co", issuePrefix: "SC" }, { id: "c2", name: "Other Co", issuePrefix: "OTHER" }]; mockCompany.companies = companies; mockCompany.loading = options.companiesLoading ?? false; mockCompaniesApi.list.mockResolvedValue(companies); const handles = render(); if (options.strict) { await act(async () => handles.root.render( , )); } else await renderWizardView(handles.root, handles.queryClient); for (let tick = 0; tick < 8; tick++) await flushReact(); return { ...handles, draft }; } it.each([ ["/onboarding", "auto"], ["/SC/onboarding", "auto"], ["/onboarding", "legacy"], ["/SC/onboarding", "legacy"], ])("restores the authorized review draft after route reload %s with %s runner", async (pathname, runner) => { const { root, draft } = await openSavedReview(pathname, runner); try { expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true); expect(document.body.textContent).not.toContain("What is the name of your organization?"); const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); expect(persisted).toMatchObject(draft); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); } finally { await act(async () => root.unmount()); } }); it("keeps restored review progress when initial effects replay in StrictMode", async () => { const { root, draft } = await openSavedReview("/SC/onboarding", "auto", { strict: true }); try { expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject(draft); } finally { await act(async () => root.unmount()); } }); it("waits for route company loading before deciding the initial restored step", async () => { const { root, queryClient, draft } = await openSavedReview("/SC/onboarding", "auto", { companiesLoading: true }); try { mockCompany.loading = false; await renderWizardView(root, queryClient); for (let tick = 0; tick < 8; tick++) await flushReact(); expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject(draft); } finally { await act(async () => root.unmount()); } }); it("does not restore another company's review into a different route", async () => { const { root } = await openSavedReview("/OTHER/onboarding"); try { expect(document.querySelector("#onboarding-agent-name")).not.toBeNull(); const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); expect(persisted.createdCompanyId).toBe("c2"); expect(persisted.createdAgentId).toBeNull(); expect(persisted.step).toBe(3); } finally { await act(async () => root.unmount()); } }); it("does not bind an unmatched company prefix to the restored company", async () => { const { root } = await openSavedReview("/MISSING/onboarding"); try { const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); expect(persisted.step).toBe(1); expect(persisted.createdCompanyId).toBeNull(); expect(persisted.createdAgentId).toBeNull(); expect(persisted.companyName).toBe(""); } finally { await act(async () => root.unmount()); } }); it.each([2, 3])("honors explicit prefilled-company dialog step %s on initial mount", async initialStep => { const { root } = await openSavedReview("/SC/onboarding", "auto", { dialogStep: initialStep }); try { const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); expect(persisted.step).toBe(initialStep); expect(persisted.createdCompanyId).toBe("c1"); expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(false); } finally { await act(async () => root.unmount()); } }); it("honors an explicit dialog step instead of resuming the route draft", async () => { const { root, queryClient } = await openSavedReview("/SC/onboarding"); try { mockDialog.onboardingOpen = true; mockDialog.onboardingOptions = { companyId: "c2", initialStep: 3 }; await renderWizardView(root, queryClient); await flushReact(); expect(document.querySelector("#onboarding-agent-name")).not.toBeNull(); const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); expect(persisted.createdCompanyId).toBe("c2"); expect(persisted.createdAgentId).toBeNull(); expect(persisted.step).toBe(3); } finally { await act(async () => root.unmount()); } }); }); it("re-syncs a restored draft once companies resolve asynchronously (companies start empty/loading)", async () => { // Regression for the initializer-only restore bug: the inner wizard's // ~20 useState(saved?.x ?? default) initializers only read `saved` on // their very first render. useCompany() starts with companies=[] and // loading=true and resolves later; if the inner component mounted before // that resolution, restoreOnboardingState would see an empty companies // list and the whole draft would lock to defaults forever, even after // companies arrive. The fix defers mounting the inner wizard until // companies settle. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 3, companyName: "Saved Co", agentName: "Ops Lead", createdCompanyId: "c1", }), ); mockDialog.onboardingOptions = {}; mockCompany.companies = []; mockCompany.loading = true; // Deferred rather than swapped later: the gate's fetch fires on the first // render, so replacing the mock afterwards would never reach it. let resolveList: (companies: Array<{ id: string; name: string; issuePrefix: string }>) => void = () => {}; mockCompaniesApi.list.mockReturnValue( new Promise>((resolve) => { resolveList = resolve; }), ); const { container, root, queryClient } = render(); const renderTree = () => renderWizardView(root, queryClient); await renderTree(); await flushReact(); // Nothing mounts yet: no premature guess, and the draft is not touched. expect(container.textContent).toBe(""); expect(document.body.textContent).toBe(""); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).not.toBeNull(); // Companies resolve asynchronously, owning the saved company. mockCompany.companies = [{ id: "c1", name: "Saved Co", issuePrefix: "SC" }]; mockCompany.loading = false; await act(async () => { resolveList([{ id: "c1", name: "Saved Co", issuePrefix: "SC" }]); }); await renderTree(); await flushReact(); // The draft is restored once companies settle: step 3 (Create your first // agent) with the saved agent name in the input, not the defaults // (step 0, "Chief of staff"). expect(document.body.textContent).toContain("Create your first agent"); const nameInput = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement | null; expect(nameInput?.value).toBe("Ops Lead"); // The run entered on the agent arc, so the arc strip is the progress // indicator and counts 1-3 over the wizard's steps 3-5. Segments are // labelled by destination: the wizard has its own numbering, and two // controls both announcing "Step 1" would mean different things. const currentStep = document.body.querySelector('[aria-current="step"]'); expect(currentStep?.getAttribute("aria-label")).toBe("Create your first agent"); expect(document.body.textContent).toContain("Step 1 of 3"); await act(async () => { root.unmount(); }); }); it("keeps the live wizard mounted while a post-create company-list refetch runs", async () => { // The authorization fetch needs to delay the first mount when a draft // exists. Once the customer has typed, though, invalidating that query is // normal background work. Unmounting for the refetch remounted the wizard // from this page-load draft and made a successful create look like a reset. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 1, companyName: "", createdCompanyId: null }), ); let resolveRefetch: (companies: Array<{ id: string; name: string; issuePrefix: string }>) => void = () => {}; mockCompaniesApi.list .mockResolvedValueOnce([]) .mockImplementationOnce( () => new Promise>((resolve) => { resolveRefetch = resolve; }), ); mockCompaniesApi.create.mockResolvedValue({ id: "created", name: "Created Co", issuePrefix: "CRE", }); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); const companyNameInput = document.querySelector("input") as HTMLInputElement; setControlledValue(companyNameInput, "Created Co"); await act(async () => { [...document.body.querySelectorAll("button")] .find((button) => button.textContent?.trim() === "Continue")! .dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Created Co" }); expect(mockCompaniesApi.list).toHaveBeenCalledTimes(2); expect(document.querySelector("#onboarding-agent-name")).not.toBeNull(); await act(async () => { resolveRefetch([{ id: "created", name: "Created Co", issuePrefix: "CRE" }]); }); await act(async () => { root.unmount(); }); }); it("restores a draft after its initial ownership check fails and a retry succeeds", async () => { // A failed first request cannot authorize the draft, so the wizard opens // with safe defaults. The original gate remounted on a later successful // retry so the now-verified draft could be restored; keep that recovery // while preserving the post-create refetch fix above. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 3, companyName: "Saved Co", agentName: "Ops Lead", createdCompanyId: "c1", }), ); const company = { id: "c1", name: "Saved Co", issuePrefix: "SC" }; let resolveRetry: (companies: Array<{ id: string; name: string; issuePrefix: string }>) => void = () => {}; mockCompaniesApi.list .mockRejectedValueOnce(new Error("company list unavailable")) .mockImplementationOnce( () => new Promise>((resolve) => { resolveRetry = resolve; }), ); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); expect(document.querySelector("#onboarding-agent-name")).toBeNull(); mockCompany.companies = [company]; await act(async () => { void queryClient.invalidateQueries({ queryKey: queryKeys.companies.list(SESSION_USER_ID), }); }); await flushReact(); // The retry is intentionally still in flight. The wrapper removes the // safe-default wizard so a completed validation can mount the saved draft. expect(document.body.textContent).toBe(""); await act(async () => { resolveRetry([company]); }); await flushReact(); expect(mockCompaniesApi.list).toHaveBeenCalledTimes(2); expect(queryClient.getQueryData(queryKeys.companies.list(SESSION_USER_ID))).toEqual({ companies: [company], unauthorized: false, }); const agentNameInput = document.querySelector( "#onboarding-agent-name", ) as HTMLInputElement | null; expect(agentNameInput?.value).toBe("Ops Lead"); await act(async () => { root.unmount(); }); }); it("discards a saved draft for a company the signed-in account does not own, and wipes the stale blob", async () => { // The actual vulnerability this fix closes: localStorage is per-origin, // not per-account, so a browser that already onboarded "company-old" for // a different account hands its id straight to a brand-new session. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 4, companyName: "Someone Else's Co", createdCompanyId: "company-old", }), ); mockCompany.companies = [{ id: "company-new", name: "My Co", issuePrefix: "MC" }]; mockCompany.loading = false; mockCompaniesApi.list.mockResolvedValue([ { id: "company-new", name: "My Co", issuePrefix: "MC" }, ]); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); // Falls back to the wizard's default first step, not the stale step 4 // draft for a company this account does not own. expect(document.body.textContent).not.toContain("Someone Else's Co"); // The stale blob must not linger to confuse the next onboarding attempt. expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBeNull(); await act(async () => { root.unmount(); }); }); it("opens, and keeps the draft, when the initial company query fails", async () => { // React Query reports a failed list as `isLoading === false` with `data` // defaulted to `[]`, which is indistinguishable from "settled, and this // account owns nothing" — the verdict that wipes the blob. Deleting a // customer's onboarding because their company request timed out is the one // outcome here that cannot be undone. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 3, companyName: "Saved Co", agentName: "Ops Lead", createdCompanyId: "c1", }), ); mockCompany.companies = []; mockCompany.loading = false; mockCompany.error = new Error("company list unavailable"); mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable")); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); // It mounts: the companies query sets `retry: false`, and with no // companies the dashboard's "Get Started" button opens onboarding — a gate // that rendered nothing here would make that button dead. expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull(); // The draft is not restored, because ownership cannot be verified... const nameInput = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement | null; expect(nameInput?.value ?? "").not.toBe("Ops Lead"); // ...and not deleted either. The wizard is open in this harness, so the // persist effect does supersede it - the point is that the ownership check // did not *discard* it. The closed case below is where it survives intact. expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).not.toBeNull(); await act(async () => { root.unmount(); }); }); it("renders instead of throwing when the browser denies storage access", async () => { // Safari's private mode and blocked third-party contexts make getItem // throw outright. This runs during render, so an escaping exception takes // the whole app down rather than just losing a draft. // A browser that refuses the read refuses the write too, so deny both — // otherwise the cleanup effect's removeItem is never exercised. const deny = () => { throw new DOMException("The operation is insecure.", "SecurityError"); }; // Deny the browser boundary itself. Spying on a Storage object or // prototype is not portable: DOM implementations may return a fresh // wrapper and Node exposes a separate experimental Storage global. const localStorage = vi.spyOn(window, "localStorage", "get").mockImplementation(deny); mockCompany.companies = [{ id: "c1", name: "My Co", issuePrefix: "MC" }]; mockCompany.loading = false; const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); expect(localStorage).toHaveBeenCalled(); // It mounted: the wizard is open with no draft, rather than the render // throwing on the way in. expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull(); localStorage.mockRestore(); await act(async () => { root.unmount(); }); }); it("opens but does not restore when a refetch fails over a cached list", async () => { // The companies cache is not account-scoped and survives sign-out, so a // failed refetch after an account switch can leave the *previous* // account's companies in hand. Trusting a non-empty list there would find // the old company id in it and hand that draft to the new account — the // leak this whole change closes, through a different door. // // So: mount (no dead end), do not restore, do not delete. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 3, companyName: "Saved Co", agentName: "Ops Lead", createdCompanyId: "c1", }), ); mockCompany.companies = [{ id: "c1", name: "Saved Co", issuePrefix: "SC" }]; mockCompany.loading = false; mockCompany.error = new Error("refetch failed"); mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed")); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); // Mounted rather than blank... expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull(); // ...but the draft was not restored, because the list cannot be trusted. const nameInput = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement | null; expect(nameInput?.value ?? "").not.toBe("Ops Lead"); await act(async () => { root.unmount(); }); }); // The close-on-storage-denial case is gone with the control that reached it. // Onboarding is a gate now, not a dialog: there is no X, Escape does not // dismiss it, and nothing in the wizard calls `handleClose`. A test that // clicked Close was asserting a path a customer no longer has. // // The open side of the same hazard is still covered by "renders instead of // throwing when the browser denies storage access" directly above, which is // the half that can still happen. it("leaves the draft untouched when the company query fails and onboarding is closed", async () => { // Mounting is safe for the draft precisely because the persist effect is // gated on the wizard being open. A closed wizard writes nothing, so the // blob survives for a later load that can actually verify ownership. const draft = JSON.stringify({ step: 3, companyName: "Saved Co", agentName: "Ops Lead", createdCompanyId: "c1", }); window.localStorage.setItem(ONBOARDING_STORAGE_KEY, draft); mockDialog.onboardingOpen = false; mockDialog.onboardingRouteDismissed = true; mockCompany.companies = []; mockCompany.loading = false; mockCompany.error = new Error("company list unavailable"); mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable")); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBe(draft); await act(async () => { root.unmount(); }); }); it("does not hand one account's draft to the next when a refetch fails after a switch", async () => { // The attack path in full. Account A onboards and leaves a draft naming // its company. The account then changes without this component's company // cache being cleared, and the refetch fails, so A's list is still in // hand. A list that still contains A's company must not be read as proof // that B owns it. // // `useSignOut` now resets account-scoped caches, which closes the // sign-out-button route into this state (see its own regression test). The // gate is asserted here independently of that: it must hold for any route // that leaves a stale list behind, not only the one that has been fixed. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ // Step 3 so the agent-name input renders and can be asserted on — // step 4 has no such field, which would make this pass for the wrong // reason whether or not the draft was restored. step: 3, companyName: "Account A Co", agentName: "A's Lead", createdCompanyId: "company-a", }), ); mockCompany.companies = [{ id: "company-a", name: "Account A Co", issuePrefix: "AAC" }]; mockCompany.loading = false; mockCompany.error = new Error("refetch failed for the new account"); mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed for the new account")); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); // Account A's agent name must not appear in account B's wizard. expect(document.body.textContent).not.toContain("A's Lead"); const nameInput = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement | null; expect(nameInput?.value ?? "").not.toBe("A's Lead"); await act(async () => { root.unmount(); }); }); it("does not judge ownership against a warm cache that never refetched", async () => { // The door this whole change exists to shut, and the one the previous // version missed. `main.tsx` sets `staleTime: 30_000`, so for thirty // seconds after a sign-in the company list is served straight from cache // with no request — and `Auth.tsx` invalidates on sign-in but invalidation // keeps serving the old data while refetching. Either way account A's // companies arrive with *no loading state and no error*, so a gate keyed // on "not loading, no error" reads them as authoritative. // // Here the context reports exactly that healthy-looking stale state, and // the fetch for this session has not answered. A's draft must not be // restored on the strength of A's cached list. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 3, companyName: "Account A Co", agentName: "A's Lead", createdCompanyId: "company-a", }), ); // The shared cache still holds A's list, and looks entirely healthy. mockCompany.companies = [{ id: "company-a", name: "Account A Co", issuePrefix: "AAC" }]; mockCompany.loading = false; mockCompany.error = null; // The list fetched for *this* session answers with B's companies, which // do not include A's. Note the fetch must actually complete: leaving it // pending would keep the wizard unmounted and the assertion below would // hold for the wrong reason. mockCompaniesApi.list.mockResolvedValue([ { id: "company-b", name: "Account B Co", issuePrefix: "BBC" }, ]); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); // Mounted — so this is a real observation, not an unmounted false pass. expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull(); expect(document.body.textContent).not.toContain("A's Lead"); const nameInput = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement | null; expect(nameInput?.value ?? "").not.toBe("A's Lead"); await act(async () => { root.unmount(); }); }); it("does not delete a draft when the company list comes back unauthorized", async () => { // `companiesListQueryOptions` folds 401/403 into // `{ companies: [], unauthorized: true }` rather than throwing, so an auth // blip arrives as a successful fetch of an empty list — which reads as // "this account owns nothing" and would delete the draft. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 3, companyName: "Saved Co", createdCompanyId: "c1" }), ); mockCompaniesApi.list.mockRejectedValue( new ApiError("forbidden", 403, null), ); const { root, queryClient } = render(); await renderWizardView(root, queryClient); await flushReact(); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).not.toBeNull(); await act(async () => { root.unmount(); }); }); it("does not restore against data retained from a failed post-mount refetch", async () => { // The gate's weak point if it only asks "was there a fetch after mount, // and is there data". React Query keeps the last successful `data` when a // refetch fails — so after an account switch the retained value is the // *previous* account's list, and accepting it restores their draft. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 3, companyName: "Account A Co", agentName: "A's Lead", createdCompanyId: "company-a", }), ); const { root, queryClient } = render(); // A's list, already in the cache from their session. queryClient.setQueryData(queryKeys.companies.list(SESSION_USER_ID), { companies: [{ id: "company-a", name: "Account A Co", issuePrefix: "AAC" }], unauthorized: false, }); // B's session: the refetch fails, so A's data is retained. mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed")); await renderWizardView(root, queryClient); await flushReact(); // Mounted, so this observes the real thing rather than an empty document. expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull(); expect(document.body.textContent).not.toContain("A's Lead"); const nameInput = document.body.querySelector( "#onboarding-agent-name", ) as HTMLInputElement | null; expect(nameInput?.value ?? "").not.toBe("A's Lead"); await act(async () => { root.unmount(); }); }); it("does not mount undecided over a warm cache, which would overwrite the draft", async () => { // The customer's own draft, their own companies already cached, and a // refetch in flight. `isLoading` is false whenever retained data exists, // so a gate keyed on it would mount the wizard while ownership was still // undecidable — and with the wizard open, the persist effect writes the // wizard's state back on every change, overwriting their draft with // defaults before the answer arrives. const draft = JSON.stringify({ step: 3, companyName: "Saved Co", agentName: "Ops Lead", createdCompanyId: "c1", }); window.localStorage.setItem(ONBOARDING_STORAGE_KEY, draft); const { root, queryClient } = render(); queryClient.setQueryData(queryKeys.companies.list(SESSION_USER_ID), { companies: [{ id: "c1", name: "Saved Co", issuePrefix: "SC" }], unauthorized: false, }); mockCompaniesApi.list.mockReturnValue(new Promise(() => {})); await renderWizardView(root, queryClient); await flushReact(); // Nothing mounted, so nothing could have written over the draft. expect(document.body.textContent).toBe(""); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBe(draft); await act(async () => { root.unmount(); }); }); it("clears an unreadable draft without waiting on the company endpoint", async () => { // Junk is junk whoever owns what, so judging it must not queue behind a // request that cannot change the answer — nor issue one at all. window.localStorage.setItem(ONBOARDING_STORAGE_KEY, "{not json"); const { root, queryClient } = render(); mockCompaniesApi.list.mockReturnValue(new Promise(() => {})); await renderWizardView(root, queryClient); await flushReact(); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBeNull(); expect(mockCompaniesApi.list).not.toHaveBeenCalled(); await act(async () => { root.unmount(); }); }); describe("the adapter step login panel (cheap auth signal, no adapter test)", () => { const SANDBOX_ENVIRONMENT = { id: "env-sandbox-1", driver: "sandbox" as const, status: "active" as const, config: { provider: "daytona" }, metadata: {}, }; const LOCAL_ENVIRONMENT = { id: "env-local-1", driver: "local" as const, status: "active" as const, config: { provider: "daytona" }, metadata: { defaultForInstance: true }, }; beforeEach(() => { mockEnvironmentsApi.list.mockReset(); mockEnvironmentsApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } }); mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT]); mockEnvironmentsApi.capabilities.mockReset(); mockEnvironmentsApi.capabilities.mockResolvedValue( getEnvironmentCapabilities([], { sandboxProviders: { daytona: { supportsLoginPty: true } }, }), ); mockInstanceSettingsApi.get.mockReset(); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: "env-sandbox-1" }); mockInstanceSettingsApi.getExperimental.mockReset(); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false }); mockAgentsApi.getAdapterAuthSignal.mockReset(); mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); // The row has to actually offer the adapter these drafts name. The login // panel lives inside the input canvas, and the canvas opens for a chosen // source — a saved adapter the row cannot show is not a chosen one, so // without this the panel is absent for a reason that has nothing to do // with the auth signal these tests are about. mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; }); /** Drives the wizard to the Connect step with a company already created. */ async function openStep4(overrides: Record = {}, staleTime = 0) { mockCompany.companies = [{ id: "company-new", name: "Initech", issuePrefix: "INI" }]; mockCompany.loading = false; mockCompaniesApi.list.mockResolvedValue(mockCompany.companies); window.localStorage.setItem( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 4, companyName: "Initech", agentName: "Ada", createdCompanyId: "company-new", adapterType: "claude_local", ...overrides, }), ); mockDialog.onboardingOptions = {}; const { root, queryClient } = render(staleTime); await renderWizardView(root, queryClient); // The login-panel gate chains several dependent queries (environments, // instance settings, environment capabilities, then the auth signal // itself), each settling on its own render. One flush is not always // enough to reach the end of that chain. for (let i = 0; i < 5; i++) await flushReact(); expect(document.body.textContent).toContain("Connect a model"); // No pick needed here: the draft this helper restores already names an // adapter, which is what a run returning to this step actually carries. return { root, queryClient }; } it.each([["successful refresh", false], ["failed refresh and retry", true]] as const)("saves a custom gateway without probing, then explicitly tests and hires with its selected model: %s", async (_scenario, failRefresh) => { const gatewayAccounts: import("@paperclipai/shared").AiConnectionList = { currentUserId: "user-1", canManageConnections: true, connections: [], }; let lookupFails = false; managedApi.list.mockImplementation(async () => { if (lookupFails) throw new Error("connection lookup failed"); return structuredClone(gatewayAccounts) as never; }); managedApi.create.mockImplementationOnce(async () => { gatewayAccounts.connections = [{ id: "managed-connection", grantId: "managed-grant", companyId: "company-new", name: "My gateway.example.test", provider: "openai", method: "api_key", ownership: "personal", ownerUserId: "user-1", isDefault: false, status: "connected", routing: { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example.test/v1", models: [{ id: "gpt-5.4-mini" }] }, }]; lookupFails = failRefresh; return { connectionId: "managed-connection", grantId: "managed-grant" }; }); mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ provider: "codex", model: values?.model })); const handles = await openStep4({ adapterType: "codex_local", runner: "paperclip", credentialMode: "api" }); const click = async (label: string) => { const button = [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === label)!; expect(button).toBeTruthy(); await act(async () => button.click()); await flushReact(); }; try { await pickSource(/OpenAI.*API/); await click("Use a custom gateway"); const gatewayDialog = document.querySelector('input[aria-label="Provider URL"]')!.closest('[role="dialog"]')!; await act(async () => { setControlledValue(gatewayDialog.querySelector('input[aria-label="Provider URL"]')!, "https://gateway.example.test/v1"); setControlledValue(gatewayDialog.querySelector('input[aria-label="API key"]')!, "example-gateway-key"); }); await click("Change"); await act(async () => setControlledValue(document.querySelector('input[aria-label="Model IDs"]')!, "gpt-5.4-mini")); await act(async () => gatewayDialog.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await click("Save connection"); expect(managedApi.create).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ provider: "openai", method: "api_key", apiKey: "example-gateway-key", ownership: "personal", agentIds: [expect.any(String)], routing: expect.objectContaining({ kind: "gateway", protocol: "responses", models: [{ id: "gpt-5.4-mini" }] }), })); if (failRefresh) { await vi.waitFor(() => expect(document.body.textContent).toContain("Your saved gateway connection could not be loaded.")); expect(document.querySelector('select[aria-label="Model"]')).toBeNull(); expect([...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Connect")!.disabled).toBe(true); expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); lookupFails = false; await click("Retry loading connection"); } await vi.waitFor(() => expect(document.querySelector('select[aria-label="Model"]'), document.body.textContent ?? "").toBeTruthy()); expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("Gateway connection saved."); const connect = [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Connect")!; expect(connect.disabled).toBe(true); const select = document.querySelector('select[aria-label="Model"]') as HTMLSelectElement; expect([...select.options].map(option => option.value)).toEqual(["", "gpt-5.4-mini"]); await act(async () => { select.value = "gpt-5.4-mini"; select.dispatchEvent(new Event("change", { bubbles: true })); }); await flushReact(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(JSON.stringify(window.localStorage)).not.toContain("example-gateway-key"); await click("Connect"); const binding = { provider: "openai", method: "api_key", mode: "delegated", connectionId: "managed-connection", grantId: "managed-grant" }; expect(mockAgentsApi.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-new", "codex_local", expect.objectContaining({ environmentId: "env-sandbox-1", aiConnection: binding, adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }), })); expect(mockAgentsApi.hire).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: binding }), adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }), })); expect(JSON.stringify(mockAgentsApi.hire.mock.calls)).not.toContain("example-gateway-key"); } finally { await act(async () => handles.root.unmount()); managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [] }); } }, 15_000); it.each([["paperclip", "image"], ["legacy", "image"], ["paperclip", "snapshot"], ["legacy", "snapshot"]] as const)("qualifies managed Claude during Connect and tests/hire with measured %s configuration from %s", async (measured, source) => { mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", ...(source === "snapshot" ? { snapshot: "managed-snapshot" } : { image: `registry.example.test/runtime@sha256:${"a".repeat(64)}` }), } }]); const discovery = (runner: "paperclip" | "legacy") => [ setupAdapterInfo({ type: "claude_local", loaded: true, disabled: false, defaultRunner: runner, supportedRunners: [runner] }), setupAdapterInfo({ type: "paperclip_runner", loaded: true, disabled: false }), ]; let qualified = false; mockAdaptersApi.list.mockImplementation(async () => discovery(qualified ? measured : "legacy")); const handles = await openStep4({ adapterType: "claude_local", runner: "auto" }); await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], })); const calls: string[] = []; mockAdaptersApi.list.mockImplementation(async context => { expect(context).toEqual({ companyId: "company-new", environmentId: "env-sandbox-1" }); calls.push(qualified ? "discovery-after" : "discovery-before"); return discovery(qualified ? measured : "legacy"); }); mockEnvironmentsApi.probe.mockImplementation(async () => { calls.push("platform"); qualified = true; return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } }; }); mockAgentsApi.testEnvironment.mockImplementation(async () => { calls.push("adapter"); return { adapterType: measured === "paperclip" ? "paperclip_runner" : "claude_local", status: "pass", checks: [], testedAt: "2026-10-13T00:00:00Z" }; }); mockAgentsApi.hire.mockImplementation(async () => { calls.push("hire"); return { agent: { id: "agent-1" }, approval: null }; }); try { expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); await pickSource(/Claude/); expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter", "hire"]); expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new"); const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: Record }; const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: Record }; expect(saved).not.toHaveProperty("runner"); expect(saved.adapterConfig).toMatchObject(tested.adapterConfig); if (measured === "paperclip") expect(tested.adapterConfig).not.toHaveProperty("maxTurnsPerRun"); else expect(tested.adapterConfig.maxTurnsPerRun).toBe(1000); } finally { await act(async () => handles.root.unmount()); } }); it("stores a new API key once before managed qualification and hires with the same native configuration", async () => { const key = "example-scoped-api-key"; mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`, } }]); let qualified = false; mockAdaptersApi.list.mockImplementation(async () => [ setupAdapterInfo({ type: "claude_local", defaultRunner: qualified ? "paperclip" : "legacy", supportedRunners: qualified ? ["paperclip"] : ["legacy"] }), setupAdapterInfo({ type: "paperclip_runner" }), ]); mockEnvironmentsApi.probe.mockImplementation(async () => { qualified = true; return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } }; }); const handles = await openStep4({ adapterType: "claude_local", runner: "auto", credentialMode: "api" }); try { await pickSource(/Claude.*API/); const field = document.body.querySelector('input[type="password"]') as HTMLInputElement; await act(async () => setControlledValue(field, key)); await flushReact(); await pressArcPrimary(); expect(managedApi.create).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", apiKey: key })); expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new"); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: object }; const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: object; runtimeConfig: object }; expect(saved.adapterConfig).toMatchObject(tested.adapterConfig); expect(saved.adapterConfig).not.toHaveProperty("maxTurnsPerRun"); expect(saved.runtimeConfig).toMatchObject({ aiConnection: { provider: "anthropic", method: "api_key", mode: "responsible_user" } }); expect(JSON.stringify(saved)).not.toContain(key); } finally { await act(async () => handles.root.unmount()); } }); it.each(["back", "environment-config"])("abandons held managed Claude qualification after %s without another probe", async change => { const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", image } }]); mockAdaptersApi.list.mockResolvedValue([ setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy", supportedRunners: ["legacy"] }), setupAdapterInfo({ type: "paperclip_runner" }), ]); const handles = await openStep4({ adapterType: "claude_local", runner: "auto" }); await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], })); let finish!: (value: import("@paperclipai/shared").EnvironmentProbeResult) => void; mockEnvironmentsApi.probe.mockReturnValue(new Promise(resolve => { finish = resolve; })); try { await pickSource(/Claude/); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); await act(async () => document.querySelector('[data-testid="onboarding-wizard"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))); await flushReact(); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); if (change === "back") await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Back")!.click()); else { await act(async () => handles.queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", image: `registry.example.test/runtime@sha256:${"b".repeat(64)}`, } }])); await flushReact(); } await act(async () => finish({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-lease" } })); for (let i = 0; i < 8; i++) await flushReact(); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); } finally { await act(async () => handles.root.unmount()); } }); it("stops managed Claude onboarding at the existing platform-probe permission boundary", async () => { mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`, } }]); mockAdaptersApi.list.mockResolvedValue([ setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy", supportedRunners: ["legacy"] }), setupAdapterInfo({ type: "paperclip_runner" }), ]); const handles = await openStep4({ adapterType: "claude_local", runner: "auto" }); await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], })); mockEnvironmentsApi.probe.mockRejectedValue(new ApiError("Instance admin access required", 403, null)); try { await pickSource(/Claude/); expect(document.body.textContent).toContain("Instance admin access required"); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); } finally { await act(async () => handles.root.unmount()); } }); it.each([ ["claude_local", "anthropic", /Claude/, "claude-session-1", "claude-setup-token-status"], ["codex_local", "openai", /OpenAI/, "codex-session-1", "adapter-login-status"], ] as const)("finishes %s sign-in when its connection becomes visible before the completion poll", async (adapterType, provider, label, sessionId, statusKey) => { mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); mockAgentsApi.hire.mockRejectedValueOnce(new Error("Temporary hire failure")); const { root, queryClient } = await openStep4({ adapterType }); await pickSource(label); for (let i = 0; i < 6; i++) await flushReact(); try { // The connection activity event arrives before the login poll. It must // not replace/unmount the controller that still owns the completion. await act(async () => { queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "managed-connection", grantId: "managed-grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], }); }); for (let i = 0; i < 4; i++) await flushReact(); expect(document.body.textContent).toContain(adapterType === "claude_local" ? "authorization code" : "Q2RJ-E1YIF"); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => { queryClient.setQueryData( adapterType === "claude_local" ? [statusKey, "company-new", sessionId] : [statusKey, "company-new", adapterType, sessionId], { sessionId, status: "authenticated", expiresAt: new Date(Date.now() + 600_000).toISOString() }, ); }); for (let i = 0; i < 120 && !mockAgentsApi.hire.mock.calls.length; i++) { await act(async () => { await new Promise(resolve => setTimeout(resolve, 25)); }); } expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }) })); for (let i = 0; i < 4; i++) await flushReact(); expect(document.body.textContent).toContain("Temporary hire failure"); const retry = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Connect"); expect(retry).toBeTruthy(); expect(retry!.disabled).toBe(false); await act(async () => { retry!.click(); }); for (let i = 0; i < 6; i++) await flushReact(); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(2); expect(mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length + mockAgentsApi.startAdapterAuthLogin.mock.calls.length).toBe(1); } finally { await act(async () => root.unmount()); } }); it("names the tiles for the provider, not the adapter type", async () => { // `MODEL_SOURCE_NAMES` exists so this row says "Claude" and "OpenAI" — // which provider you are signing in to, the question the step's heading // asks — rather than the display registry's tool names, which the agent // config screens want. It was added with a long comment justifying it and // then never read, so the row went on rendering whatever the registry // supplied: "Claude Code" and "Codex" in the app, and the bare type here, // since this suite's registry mock returns `label: type`. mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "codex_local" }]; const { root } = await openStep4({ adapterType: "claude_local" }); const labels = [...document.body.querySelectorAll("button[aria-checked]")].map( (tile) => tile.textContent ?? "", ); expect(labels.length, "both recommended sources should render").toBe(2); expect(labels.some((l) => l.includes("Claude"))).toBe(true); expect(labels.some((l) => l.includes("OpenAI"))).toBe(true); // The negative half is the one that fails on the unwired version: the // registry label is the adapter type, and it must not reach the tile. expect(labels.join(" ")).not.toContain("claude_local"); expect(labels.join(" ")).not.toContain("codex_local"); await act(async () => root.unmount()); }); it("will not advance on a saved adapter the step no longer offers", async () => { // A draft can name an adapter this registry does not carry — a cloud // sandbox without claude_local, an adapter since disabled. The row hides // it, so the step shows an unanswered question: no tile filled, no input // canvas. The CTA has to agree with that. // // It did not. The gate asked `sourcePicked`, which only means "a draft // named something", so Next stayed live on a step that had visibly asked // nothing and would hire against the hidden name. Reported by Greptile on // #12726. mockAdapterRegistry.list = [{ type: "codex_local" }]; const { root } = await openStep4({ adapterType: "some_retired_adapter" }); const tiles = [...document.body.querySelectorAll("button[aria-checked]")]; expect(tiles.length, "the row should still offer what it has").toBeGreaterThan(0); expect( tiles.some((t) => t.getAttribute("aria-checked") === "true"), "no tile should read as chosen", ).toBe(false); const cta = [...document.body.querySelectorAll("button")].find( (b) => isArcPrimary(b.textContent?.trim() ?? ""), ); expect(cta, "the step should render its forward button").toBeTruthy(); expect( cta!.hasAttribute("disabled"), "Connect must not advance a question the step has not visibly asked", ).toBe(true); // And it opens again the moment the customer answers it themselves. await act(async () => { tiles[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 5; i++) await flushReact(); const ctaAfter = [...document.body.querySelectorAll("button")].find( (b) => isArcPrimary(b.textContent?.trim() ?? ""), ); expect(ctaAfter!.hasAttribute("disabled")).toBe(false); await act(async () => root.unmount()); }); it("will not advance on a saved adapter the row does not show", async () => { mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "opencode_local" }]; const { root } = await openStep4({ adapterType: "opencode_local" }); const tiles = [...document.body.querySelectorAll("button[aria-checked]")]; expect( tiles.some((t) => t.getAttribute("aria-checked") === "true"), "the saved adapter is not in this row, so nothing should read as chosen", ).toBe(false); const cta = [...document.body.querySelectorAll("button")].find( (b) => isArcPrimary(b.textContent?.trim() ?? ""), ); expect( cta!.hasAttribute("disabled"), "Connect must not hire an adapter the row never offered", ).toBe(true); await act(async () => root.unmount()); }); it("will not hire from the keyboard on a source nobody selected", async () => { // The step has two ways forward, and gating only the visible one leaves // the defect intact behind a keystroke. Cmd+Enter called // `handleGiveHeartbeat` directly with its own, older list of conditions, // so with the button correctly disabled the same screen still hired on // Cmd+Enter. Reported by Greptile on #12726 after the button was fixed. // // The saved adapter is one the registry no longer carries, so the snap // replaces it with `claude_local` and clears the pick: the row shows two // tiles, neither chosen. A keystroke that gets through hires claude_local // — a real, offerable adapter that nobody on this screen selected, which // is what makes the bypass worth a test rather than a comment. mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "codex_local" }]; const { root } = await openStep4({ adapterType: "some_retired_adapter" }); const tiles = [...document.body.querySelectorAll("button[aria-checked]")]; expect( tiles.some((t) => t.getAttribute("aria-checked") === "true"), "the snap must not leave a tile reading as chosen", ).toBe(false); const wizard = document.querySelector('[data-testid="onboarding-wizard"]'); expect(wizard, "the wizard should be mounted").not.toBeNull(); for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { await act(async () => { wizard!.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", bubbles: true, ...modifier }), ); }); for (let i = 0; i < 6; i++) await flushReact(); } expect( mockAgentsApi.hire, "no keystroke may hire a source nobody selected", ).not.toHaveBeenCalled(); // And it works once the question is answered, so this is a gate rather // than a dead shortcut. await act(async () => { tiles[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 6; i++) await flushReact(); await act(async () => { wizard!.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", bubbles: true, metaKey: true }), ); }); for (let i = 0; i < 6; i++) await flushReact(); expect(mockAgentsApi.hire).toHaveBeenCalled(); await act(async () => root.unmount()); }); it("starts no call to the test-environment route on adapter selection", async () => { const { root } = await openStep4(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); /** * Press a source tile. This is what starts the sign-in — the row is the * question, and answering it is the whole of the trigger. Nothing is * selected on arrival, including from a draft that names an adapter. */ async function pickSource(match: RegExp) { const tile = [...document.body.querySelectorAll('[role="radio"]')].find((t) => match.test(t.textContent ?? ""), ); expect(tile, "the row should offer that source").toBeTruthy(); await act(async () => { tile!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 10; i++) await flushReact(); } /** Press the step's forward button and let the sign-in queries settle. */ async function pressArcPrimary() { const cta = [...document.body.querySelectorAll("button")].find((b) => isArcPrimary(b.textContent?.trim() ?? ""), ); expect(cta, "the step should render its forward button").toBeTruthy(); await act(async () => { cta!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 8; i++) await flushReact(); } it("saves the submitted name before any provider choice or authentication", async () => { const { root } = await openStep4(); expect(mockAgentsApi.create).toHaveBeenCalledTimes(1); expect(mockAgentsApi.create.mock.calls[0][1]).toMatchObject({ name: "Ada", adapterType: "process", metadata: { setupAdapterPending: true }, runtimeConfig: { heartbeat: { enabled: false } } }); expect(document.body.querySelector('[role="radio"][aria-checked="true"]')).toBeNull(); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); expect(managedApi.startLocalLogin).not.toHaveBeenCalled(); await pickSource(/OpenAI/); expect(mockAgentsApi.create).toHaveBeenCalledTimes(1); expect(mockAgentsApi.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterType: "codex_local", metadata: { setupAdapterPending: false } }), "company-new"); await act(async () => root.unmount()); }); it("saves a provisional enabled provider when process is disabled without choosing its tile", async () => { mockAdapterRegistry.disabled = new Set(["process"]); const { root } = await openStep4(); expect(mockAgentsApi.create.mock.calls[0][1]).toMatchObject({ adapterType: "claude_local", metadata: { setupAdapterPending: true } }); expect(document.body.querySelector('[role="radio"][aria-checked="true"]')).toBeNull(); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); it("creates its own record instead of adopting another user's name-only setup", async () => { mockAgentsApi.list.mockResolvedValue([{ id: "another-user-setup", companyId: "company-new", name: "Ada", pauseReason: "setup", createdByUserId: "other-user", adapterType: "process", adapterConfig: {}, metadata: { setupAdapterPending: true } } as never]); mockAgentsApi.create.mockImplementationOnce(async (_company, input) => ({ ...input, id: input.setupId, name: "Ada 2", pauseReason: "setup" })); const { root } = await openStep4(); expect(mockAgentsApi.create).toHaveBeenCalledTimes(1); const setupId = mockAgentsApi.create.mock.calls[0][1].setupId; expect(setupId).not.toBe("another-user-setup"); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ agentName: "Ada 2", setupAgentId: setupId }); await pickSource(/OpenAI/); expect(mockAgentsApi.update.mock.calls.every(([id]) => id === setupId)).toBe(true); expect(mockAgentsApi.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ name: "Ada 2" }), "company-new"); await act(async () => root.unmount()); }); it("restores a name-only record without restoring its provisional runner or model", async () => { const setupId = "00000000-0000-4000-8000-000000000094"; mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", adapterType: "process", adapterConfig: { model: "provisional-model" }, runtimeConfig: {}, pauseReason: "setup", metadata: { setupAdapterPending: true } } as never); const { root } = await openStep4({ adapterType: "process", runner: "legacy", model: "stale-model", setupAgentId: setupId }); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ runner: "auto", model: "", setupAgentId: setupId }); expect(document.body.querySelector('[role="radio"][aria-checked="true"]')).toBeNull(); await pickSource(/Claude/); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).runner).toBe("auto"); expect(mockAgentsApi.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ runner: "auto" }), "company-new"); await act(async () => root.unmount()); }); it("creates and saves an inactive identity before starting sandbox sign-in", async () => { let finish!: (agent: { id: string; pauseReason: string }) => void; mockAgentsApi.create.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }) as never); mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); expect(mockAgentsApi.create).toHaveBeenCalledTimes(1); const input = mockAgentsApi.create.mock.calls[0][1]; expect(input).toMatchObject({ name: "Ada", runtimeConfig: { heartbeat: { enabled: false } } }); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ setupAgentId: input.setupId }); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => finish({ id: String(input.setupId), pauseReason: "setup" })); for (let i = 0; i < 10; i++) await flushReact(); expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1); await act(async () => root.unmount()); }); it("reuses the persisted setup identity on a fresh onboarding mount", async () => { const setupId = "00000000-0000-4000-8000-000000000081"; const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId }); await pickSource(/Claude/); expect(mockAgentsApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ setupId })); await act(async () => root.unmount()); }); it("saves chosen model settings on the setup record before hiring", async () => { mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ model: values?.model })); const { root } = await openStep4({ adapterType: "codex_local", model: "gpt-5.4" }); await pickSource(/OpenAI/); const setupId = mockAgentsApi.create.mock.calls[0][1].setupId; expect(mockAgentsApi.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ adapterConfig: { model: "gpt-5.4" }, runtimeConfig: expect.objectContaining({ heartbeat: expect.objectContaining({ enabled: false }) }), }), "company-new"); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); it("loads the server setup before an older local draft can autosave over it", async () => { const setupId = "00000000-0000-4000-8000-000000000082"; mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ model: values?.model })); mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", adapterType: "codex_local", adapterConfig: { model: "server-model", customSetting: "preserve" }, runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never); const { root } = await openStep4({ adapterType: "codex_local", model: "old-local-model", setupAgentId: setupId }); await pickSource(/OpenAI/); const saves = mockAgentsApi.update.mock.calls.filter(call => call[0] === setupId); expect(saves.length).toBeGreaterThan(0); for (const [, body] of saves.filter(([, body]) => body.adapterConfig)) expect(body.adapterConfig).toMatchObject({ model: "server-model", customSetting: "preserve" }); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).model).toBe("server-model"); await act(async () => root.unmount()); }); it.each([false, true])("migrates a stored Copilot setup without reusing its model or binding, with providers disabled: %s", async providersDisabled => { const setupId = "00000000-0000-4000-8000-000000000095"; if (providersDisabled) mockAdapterRegistry.disabled = new Set(["claude_local", "codex_local"]); mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "copilot-model" }, runtimeConfig: { aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } }, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never); const { root } = await openStep4({ adapterType: "paperclip_runner", onboardingCopilot: true, setupAgentId: setupId }); try { const saved = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!); expect(saved).toMatchObject({ adapterType: "claude_local", runner: "auto", model: "", setupAgentId: setupId }); expect(saved).not.toHaveProperty("onboardingCopilot"); expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); if (!providersDisabled) { await pickSource(/Claude/); const saves = mockAgentsApi.update.mock.calls.filter(([id, body]) => id === setupId && body.adapterConfig); expect(saves.length).toBeGreaterThan(0); for (const [, body] of saves) { expect(body).toMatchObject({ adapterType: "claude_local", runner: "auto" }); expect(body.adapterConfig).not.toHaveProperty("acpxAgent", "copilot"); expect(body.adapterConfig).not.toHaveProperty("model", "copilot-model"); expect(body.runtimeConfig).not.toHaveProperty("aiConnection.provider", "github"); } } } finally { await act(async () => root.unmount()); } }); it("tests and hires in the restored setup environment instead of the instance default", async () => { const setupId = "00000000-0000-4000-8000-000000000083"; mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT, { ...SANDBOX_ENVIRONMENT, id: "saved-environment" }]); mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-claude", latestVersion: 1 }); mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", adapterType: "claude_local", adapterConfig: { model: "server-model" }, runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "saved-environment" } as never); const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId }); await pickSource(/Claude/); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "claude_local", expect.objectContaining({ environmentId: "saved-environment" })); expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ defaultEnvironmentId: "saved-environment" })); await act(async () => root.unmount()); }); it("drops the restored OpenAI connection after switching to a stored Claude login", async () => { mockAgentsApi.testEnvironment.mockImplementation(async (...args: unknown[]) => ({ adapterType: args[1] as string, status: args[1] === "codex_local" ? "fail" as const : "pass" as const, checks: [], testedAt: new Date().toISOString() })); const setupId = "00000000-0000-4000-8000-000000000084"; mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-claude", latestVersion: 1 }); mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" }, runtimeConfig: { aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" }, customSetting: "preserve", }, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never); const { root } = await openStep4({ adapterType: "codex_local", setupAgentId: setupId }); await pickSource(/OpenAI/); const back = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!; await act(async () => back.click()); for (let i = 0; i < 10; i++) await flushReact(); await pickSource(/Claude/); const saves = mockAgentsApi.update.mock.calls.filter(([, body]) => body.adapterType === "claude_local"); expect(saves.length).toBeGreaterThan(0); for (const [, body] of saves) expect(body.runtimeConfig).not.toHaveProperty("aiConnection"); const hire = (mockAgentsApi.hire.mock.calls as unknown as Array<[string, { adapterType: string; runtimeConfig: Record }]>).find(([, body]) => body.adapterType === "claude_local")?.[1]; expect(hire).toBeTruthy(); expect(hire!.runtimeConfig).toMatchObject({ customSetting: "preserve" }); expect(hire!.runtimeConfig).not.toHaveProperty("aiConnection"); await act(async () => root.unmount()); }); it("tests restored redacted environment values using the setup record", async () => { const setupId = "00000000-0000-4000-8000-000000000086"; mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", adapterType: "codex_local", adapterConfig: { env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" }, OPENAI_API_KEY: { type: "secret_ref", secretId: "saved-key" } } }, runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never); const { root } = await openStep4({ adapterType: "codex_local", setupAgentId: setupId }); await pickSource(/OpenAI/); await pressArcPrimary(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "codex_local", expect.objectContaining({ agentId: setupId, adapterConfig: expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: { type: "plain", value: "***REDACTED***" } }) }), })); await act(async () => root.unmount()); }); it.each(["api", "subscription"] as const)("removes old Claude credentials when resuming with %s authentication", async mode => { let changedMode = false; mockAgentsApi.testEnvironment.mockImplementation(async () => ({ adapterType: "claude_local", status: changedMode ? "pass" as const : "fail" as const, checks: [], testedAt: new Date().toISOString() })); const setupId = "00000000-0000-4000-8000-000000000087"; const secret = { type: "secret_ref", secretId: "saved-key" }; mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-claude", latestVersion: 1 }); mockSecretsApi.list.mockResolvedValue([{ id: "saved-key", companyId: "company-new", name: "Saved API key", key: "ANTHROPIC_API_KEY", scope: "company", status: "active" }] as never); mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", adapterType: "claude_local", adapterConfig: { env: { KEEP: { type: "plain", value: "***REDACTED***" }, [mode === "api" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY"]: secret } }, runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never); const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId, credentialModeChoice: mode }); await pickSource(/Claude/); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).credentialMode).toBe(mode === "api" ? "subscription" : "api"); const back = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!; await act(async () => back.click()); for (let i = 0; i < 10; i++) await flushReact(); changedMode = true; const switcher = document.body.querySelector(`[aria-label="Use ${mode === "api" ? "API key" : "subscription"} instead"]`) as HTMLButtonElement; await act(async () => switcher.click()); await pickSource(/Claude/); if (mode === "api") await pressArcPrimary(); const call = (mockAgentsApi.testEnvironment.mock.calls as unknown[][]).at(-1); expect(call).toBeTruthy(); const config = (call![2] as { adapterConfig: { env: Record } }).adapterConfig; expect(config.env).toHaveProperty("KEEP"); expect(config.env).not.toHaveProperty(mode === "api" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY"); expect(config.env).toHaveProperty(mode === "api" ? "ANTHROPIC_API_KEY" : "CLAUDE_CODE_OAUTH_TOKEN"); await act(async () => root.unmount()); }); it("starts setup for the new company while the previous company's creation is pending", async () => { let finish!: (agent: unknown) => void; mockAgentsApi.create.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }) as never); const { root, queryClient } = await openStep4({ adapterType: "codex_local" }); await pickSource(/OpenAI/); expect(mockAgentsApi.create).toHaveBeenCalledTimes(1); mockCompany.companies.push({ id: "company-other", name: "Other", issuePrefix: "OTH" }); mockDialog.onboardingOptions = { initialStep: 4, companyId: "company-other" }; await act(async () => root.render()); for (let i = 0; i < 10; i++) await flushReact(); expect(mockAgentsApi.create).toHaveBeenCalledWith("company-other", expect.objectContaining({ setupId: expect.any(String) })); const nextId = mockAgentsApi.create.mock.calls.find(call => call[0] === "company-other")![1].setupId; await act(async () => finish({ id: "old-company-setup", pauseReason: "setup" })); for (let i = 0; i < 5; i++) await flushReact(); expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ createdCompanyId: "company-other", setupAgentId: nextId }); expect(document.body.textContent).not.toContain("Creating your agent…"); await act(async () => root.unmount()); }); it("starts the claude_local sign-in on Connect when the signal reports no ready credential", async () => { mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); // Nothing before the press. The card *is* the sign-in now, so it does not // exist until the step has been asked to start one — which is the whole // difference between this step and the one it replaced. expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); await pickSource(/Claude/); expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalled(); // Connect started a sign-in rather than hiring. The ordering is the point: // a hire here would create an agent with no credential to run on. expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).toContain( "Sign in to Claude then come back and enter authorization code", ); await act(async () => root.unmount()); }); it("collapses the row to the chosen source and walks the button through the sign-in", async () => { // The sequence, end to end, as the step actually runs it: the row is the // question, answering it starts the sign-in, and the button reports where // that has got to rather than offering an action it cannot perform. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); // Collapse is read off the row's own layout, not off how many tiles are // in the DOM: the leaving tile exits through AnimatePresence, and in // jsdom an exit never completes, so it stays mounted either way. const rowCentred = () => document.body .querySelector('[role="radiogroup"]')! .className.includes("justify-center"); const cta = () => [...document.body.querySelectorAll("button")].pop()?.textContent?.trim(); // Nothing chosen yet on a fresh arrival: the row is a question. expect(rowCentred()).toBe(false); await pickSource(/Claude/); // The row has been answered, so it now shows only the answer — leaving // the alternative up would invite a press that has to cancel a live // session to honour. expect(rowCentred()).toBe(true); expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalled(); // And the button has become the sign-in rather than a step advance. expect(cta()).toBe("Sign in to Claude"); // Back unwinds rather than leaving the step: the row is a question again. const back = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim().startsWith("Back"), ); await act(async () => { back!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 10; i++) await flushReact(); expect(rowCentred()).toBe(false); expect(cta()).toBe("Next"); expect(document.body.textContent).toContain("Connect a model"); await act(async () => root.unmount()); }); it("submits the browser code on the paste, not on the first keystroke", async () => { mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); const field = document.body.querySelector( 'input[aria-label="Authorization code"]', ) as HTMLInputElement | null; expect(field, "the Claude card should offer a code field").toBeTruthy(); // The trap this pins. `isValidBrowserCode` reads like a completeness // check and is not one — it accepts any printable ASCII from a single // character up — so an auto-submit keyed off the value fires here, on the // first character of anyone typing the code rather than pasting it, and // clears the field they are typing into. await act(async () => { setControlledValue(field!, "Q"); }); for (let i = 0; i < 4; i++) await flushReact(); expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).not.toHaveBeenCalled(); // The paste is the answer, so it goes without a press. Order matches a // real paste: the event lands before the value changes. await act(async () => { field!.dispatchEvent(new Event("paste", { bubbles: true })); setControlledValue(field!, "Q2RJ-E1YIF-authorization-code"); }); for (let i = 0; i < 4; i++) await flushReact(); expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledWith( "company-new", "claude-session-1", "Q2RJ-E1YIF-authorization-code", ); // And it stays on screen. Clearing the field on submit emptied it in the // same frame the paste landed, so the only feedback for the seconds that // followed was an input that had just gone blank — reported from staging // as the paste looking dropped, or the step looking stuck. expect(field!.value).toBe("Q2RJ-E1YIF-authorization-code"); // As dots. The code is kept so the customer can see the paste landed, // and that is all the field needs to show of it. expect(field!.type).toBe("password"); // And the button answers the paste itself. The status here never reaches // authenticated, so this is "Connecting" before any server confirmation — // waiting for that left about a second of a button still reading // "Waiting for code" after the code had gone in. expect( [...document.body.querySelectorAll("button")].pop()?.textContent?.trim(), ).toBe("Connecting"); await act(async () => root.unmount()); }); it("does not hire on the paste alone, before the login is stored", async () => { // "Connecting" appears at the paste now, ahead of the server confirming // anything. The two-second hold used to start at that same moment, so // moving one without the other would hire at the paste plus two seconds // whether or not a credential existed. The status here stays pending, so // the login is never stored. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); const field = document.body.querySelector( 'input[aria-label="Authorization code"]', ) as HTMLInputElement; await act(async () => { field.dispatchEvent(new Event("paste", { bubbles: true })); setControlledValue(field, "Q2RJ-E1YIF-authorization-code"); }); for (let i = 0; i < 4; i++) await flushReact(); const cta = () => [...document.body.querySelectorAll("button")].pop()?.textContent?.trim(); // The paste really did start Connecting; without this the assertion // below would hold for a flow that never got that far. expect(cta(), "the paste should have started Connecting").toBe("Connecting"); await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, CONNECTED_HOLD_MS + 400)); }); for (let i = 0; i < 4; i++) await flushReact(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(cta()).toBe("Connecting"); await act(async () => root.unmount()); }); it("gives the button back when the pasted code is refused", async () => { // The other half of answering the paste early: a button that says // "Connecting" before the server answers has to stop saying it when the // answer is no, or it spins on a login that is not coming. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockRejectedValueOnce( new Error("That authorization code was not accepted."), ); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); const cta = () => [...document.body.querySelectorAll("button")].pop()?.textContent?.trim(); expect(cta()).toBe("Sign in to Claude"); const field = document.body.querySelector( 'input[aria-label="Authorization code"]', ) as HTMLInputElement; await act(async () => { field.dispatchEvent(new Event("paste", { bubbles: true })); setControlledValue(field, "Q2RJ-E1YIF-authorization-code"); }); for (let i = 0; i < 8; i++) await flushReact(); expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1); expect(document.body.textContent).toContain("That authorization code was not accepted."); expect(cta()).toBe("Sign in to Claude"); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); it("does not reopen the card when a pasted code fails after Back", async () => { // The panel stays mounted through Back's exit, so its report of a failed // submit can land mid-exit. Restoring the button there reopened the card // the customer was leaving, without the address Back had cleared. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); let refuse: (error: Error) => void = () => {}; mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockImplementationOnce( () => new Promise((_resolve, reject) => { refuse = reject; }), ); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); const field = document.body.querySelector( 'input[aria-label="Authorization code"]', ) as HTMLInputElement; await act(async () => { field.dispatchEvent(new Event("paste", { bubbles: true })); setControlledValue(field, "Q2RJ-E1YIF-authorization-code"); }); for (let i = 0; i < 4; i++) await flushReact(); const cta = () => [...document.body.querySelectorAll("button")].pop()?.textContent?.trim(); expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1); expect(cta(), "the paste should have started Connecting").toBe("Connecting"); // Hold the exit open so the refusal lands inside it. Without a // `matchMedia` to ask, every beat collapses to zero and the exit would be // over before the refusal arrived — which would pass for the wrong reason. const realMatchMedia = window.matchMedia; Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, }), }); try { const back = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim().startsWith("Back"), ); await act(async () => { back!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await act(async () => { refuse(new Error("That authorization code was not accepted.")); }); for (let i = 0; i < 4; i++) await flushReact(); // Still leaving: the button shows the step's resting face, not the // sign-in it would have reopened. expect(cta()).toBe("Next"); // And the exit finishes — the row is a question again. Waited in short // slices, each its own `act`. One long `act` defers React's commits to // its end, so a beat's timer fires on time but its phase only commits // when the wait is over — and the next beat is scheduled only then. The // exit crawls one step per wait and never gets back to the question. for (let i = 0; i < 30; i++) { await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 50)); }); } expect( document.body .querySelector('[role="radiogroup"]')! .className.includes("justify-center"), ).toBe(false); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); } finally { Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: realMatchMedia, }); } await act(async () => root.unmount()); }); it("does not hire when the login finishes after Back", async () => { // The same window from the other side. A login can complete while Back's // exit is still running, and reporting that success pulled the step back // into "Connecting" and on into a hire the customer had backed away from. // No paste needed: here the server has already authenticated, and the // completion read is simply slow. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({ sessionId: "claude-session-1", status: "authenticated", expiresAt: new Date(Date.now() + 600_000).toISOString(), }); let finishCompletion: (value: { storedSessionId: string }) => void = () => {}; mockAgentsApi.completeClaudeSetupTokenLogin.mockImplementationOnce( () => new Promise((resolve) => { finishCompletion = resolve; }), ); const realMatchMedia = window.matchMedia; try { const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); for (let i = 0; i < 6; i++) await flushReact(); // The completion read is out and has not answered, and the card is up. expect(mockAgentsApi.completeClaudeSetupTokenLogin).toHaveBeenCalledTimes(1); const cta = () => [...document.body.querySelectorAll("button")].pop()?.textContent?.trim(); expect(cta()).toBe("Sign in to Claude"); // Hold the exit open, as above, so the success lands inside it. Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, }), }); const back = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim().startsWith("Back"), ); await act(async () => { back!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await act(async () => { finishCompletion({ storedSessionId: "stored-1" }); }); for (let i = 0; i < 4; i++) await flushReact(); expect(cta()).toBe("Next"); // Past the exit, and past the full hold a late success would have // started. In short slices, each its own `act`, for the reason given in // the test above — and here it matters twice: a hire scheduled by a late // "Connecting" is only scheduled once that phase commits, so one long // `act` would hide the very hire this is looking for. const slices = Math.ceil((CONNECTED_HOLD_MS + 1200) / 50); for (let i = 0; i < slices; i++) { await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 50)); }); } expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect( document.body .querySelector('[role="radiogroup"]')! .className.includes("justify-center"), ).toBe(false); await act(async () => root.unmount()); } finally { Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: realMatchMedia, }); mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({ sessionId: "claude-session-1", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), }); } }, 15_000); it("starts the sign-in on the first press, even when it changes the adapter", async () => { // The regression this is here for. Picking a source sets the phase *and* // the adapter, and a reset keyed on the adapter then put the phase // straight back — so the first press did nothing and only a second one, // which changed no adapter and so woke no effect, was allowed to stand. // // It only showed when the chosen source differed from the one the draft // carried: picking the adapter already in state is a no-op React bails // out of, so every existing case here happened to miss it. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/OpenAI/); expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1); expect( document.body .querySelector('[role="radiogroup"]')! .className.includes("justify-center"), "one press should have answered the row", ).toBe(true); await act(async () => root.unmount()); }); it("opens the OpenAI card's sign-in once the prompt lands", async () => { // The panel that shows a code owns its session and the wizard learns the // prompt's URL only by being told. When it was not told, the step waited // on a card that had already opened: the code sat on screen under a // button still reading "Next", disabled, with nothing left to wait for. // // The sibling test above stops at the login having started, which is why // this went unseen — and the harness could not see it either, since it // supplies the prompt itself rather than going through the panel. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/OpenAI/); for (let i = 0; i < 6; i++) await flushReact(); // The displayed-code panel is the one under test: if the row picked a // source the other panel serves, the rest of this proves nothing. expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalled(); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); const cta = [...document.body.querySelectorAll("button")].pop()!; expect(cta.textContent?.trim()).toBe("Sign in to OpenAI"); // The code is the card's own content, and it arrives with the URL. expect(document.body.textContent).toContain("Q2RJ-E1YIF"); // The button is the whole point: its destination is the URL the panel // reports, so an unreported prompt leaves it reading like an offer and // refusing the press. The label alone does not catch that — the phase can // reach `ready` before the signal resolves, which names the button // without enabling it. expect(cta.hasAttribute("disabled"), "the sign-in should be pressable").toBe( false, ); await act(async () => root.unmount()); }); it("does not hire after Back interrupts the hold before step 5", async () => { // "Connecting" is held for two seconds so it reads as a state rather than // a flicker, and Back stays live throughout. The hire behind that hold // knows nothing about the phase, so a timer left running took a customer // who had just backed out to Review with an agent hired anyway. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); // A session the server has already authenticated, so the panel completes // and reports success on its own. The paste that normally gets it there // is the sibling test's subject; this one is about what the success does. mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({ sessionId: "claude-session-1", status: "authenticated", expiresAt: new Date(Date.now() + 600_000).toISOString(), }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); for (let i = 0; i < 8; i++) await flushReact(); const cta = () => [...document.body.querySelectorAll("button")].pop()?.textContent?.trim(); expect(cta(), "success should have taken the button to the hold").toBe( "Connecting", ); const back = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim().startsWith("Back"), ); await act(async () => { back!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 10; i++) await flushReact(); // Past the hold: whatever it was going to do, it has had its chance. await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, CONNECTED_HOLD_MS + 400)); }); for (let i = 0; i < 6; i++) await flushReact(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("Connect a model"); await act(async () => root.unmount()); }); it("shows no probe diagnostics between the sign-in succeeding and the step advancing", async () => { // Reported from staging: a block of amber diagnostics flashed up right // after a successful sign-in. They are the identity and target INFO // checks every run reports, which make the result a `warn` without // blocking anything — so they rendered for the window between the probe // returning and the step advancing, reading as an error thrown by the // sign-in that had just succeeded. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({ sessionId: "claude-session-1", status: "authenticated", expiresAt: new Date(Date.now() + 600_000).toISOString(), }); mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "claude_local", status: "warn", checks: [ { code: "environment_identity", level: "info", message: 'Environment test identity for "Paperclip Computer".', detail: "paperclipLeaseId=ff9e58e9; provider=daytona", }, ], testedAt: new Date().toISOString(), }); // Hold the hire open, which is the window the diagnostics appeared in: // the probe has returned but `loading` is not cleared until the `finally` // that runs after the step advances. let finishHire: (v: { agent: { id: string }; approval: null }) => void = () => {}; mockAgentsApi.hire.mockReturnValue( new Promise((resolve) => { finishHire = resolve; }), ); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); for (let i = 0; i < 8; i++) await flushReact(); // Past the deliberate hold, so the hire is running and its probe is done. await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, CONNECTED_HOLD_MS + 400)); }); for (let i = 0; i < 8; i++) await flushReact(); expect(mockAgentsApi.hire).toHaveBeenCalled(); expect(document.body.textContent).not.toContain("Environment test identity"); expect(document.body.textContent).not.toContain("Warnings"); // And the button goes on saying what is happening rather than going quiet. expect( [...document.body.querySelectorAll("button")].pop()?.textContent?.trim(), ).toBe("Connecting…"); await act(async () => finishHire({ agent: { id: "agent-1" }, approval: null })); for (let i = 0; i < 6; i++) await flushReact(); await act(async () => root.unmount()); }); it("starts no login when Back interrupts the collapse", async () => { // Backing out before the card has opened has nothing to close. Unwinding // through the card beat regardless mounted the panel — which starts a // server login on mount — only for the unmount to cancel it, and a cancel // that fails holds the per-owner reservation until the server deadline, // so the retry the customer is about to make cannot start. // // The beats collapse to zero without a `matchMedia` to ask, which is why // this stubs one: the collapse has to still be running when Back lands. const realMatchMedia = window.matchMedia; Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, }), }); try { mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); // Still collapsing: the flushes above are microtasks and 0ms timers, // far inside the collapse's own duration. expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); const back = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim().startsWith("Back"), ); await act(async () => { back!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 10; i++) await flushReact(); await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 1200)); }); for (let i = 0; i < 6; i++) await flushReact(); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("Connect a model"); await act(async () => root.unmount()); } finally { Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: realMatchMedia, }); } }); it("starts the codex_local sign-in on Connect when the signal cannot decide", async () => { mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" }); const { root } = await openStep4({ adapterType: "codex_local" }); expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); await pickSource(/OpenAI/); expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); // The displayed-code login hands a code over rather than taking one back, // so both halves of it have to reach the screen. expect(document.body.textContent).toContain( "Sign in to OpenAI by providing the authorization code below", ); expect(document.body.textContent).toContain("Q2RJ-E1YIF"); // The link lives inside the sentence now rather than in a row of its own, // and points at the same address the step's button opens — it is the // path for anyone finishing the sign-in in another browser. const link = document.body.querySelector('a[href*="auth.openai.com"]'); expect(link, "the sign-in link should be in the instruction").toBeTruthy(); expect(link!.textContent).toBe("Sign in to OpenAI"); // And the code sits below the sentence carrying it. const code = [...document.body.querySelectorAll("span")].find( (el) => el.textContent?.trim() === "Q2RJ-E1YIF", ); expect(code, "the code row should render").toBeTruthy(); expect( link!.compareDocumentPosition(code!) & Node.DOCUMENT_POSITION_FOLLOWING, "the code should follow the sentence that links to the sign-in", ).toBeTruthy(); await act(async () => root.unmount()); }); it("shows the failure instead of starting a codex_local login when the active-session read fails", async () => { // A transient active-session lookup failure is not proof that no // session exists — only a successful lookup that resolves to null is. // The step must not start a second login the server would reject // against the per-owner cap; it shows the failure instead. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" }); mockAgentsApi.getActiveAdapterAuthLoginSession.mockReset(); mockAgentsApi.getActiveAdapterAuthLoginSession.mockRejectedValue( new ApiError("Service unavailable", 503, null), ); const { root } = await openStep4({ adapterType: "codex_local" }); await pickSource(/OpenAI/); expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("Service unavailable"); await act(async () => root.unmount()); }); it("shows the failure instead of starting a claude_local login when the active-session read fails", async () => { mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockReset(); mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue( new ApiError("Service unavailable", 503, null), ); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("Service unavailable"); 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. const session = { sessionId: "claude-session-1", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), }; let started = false; mockAgentsApi.startClaudeSetupTokenLogin.mockImplementation(async () => { started = true; return session; }); mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockReset(); mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockImplementation(async () => started ? session : null, ); mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); 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) => b.textContent?.trim().startsWith("Back"), ); await act(async () => { back!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 12; i++) await flushReact(); await pickSource(/Claude/); for (let i = 0; i < 8; i++) await flushReact(); expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1); expect(mockAgentsApi.getActiveClaudeSetupTokenLoginSession).toHaveBeenCalled(); await act(async () => root.unmount()); }); it("resumes the same codex_local session after Back, rather than starting a second", async () => { const session = { sessionId: "codex-session-1", status: "pending" }; let started = false; mockAgentsApi.startAdapterAuthLogin.mockImplementation(async () => { started = true; return session; }); mockAgentsApi.getActiveAdapterAuthLoginSession.mockReset(); mockAgentsApi.getActiveAdapterAuthLoginSession.mockImplementation(async () => started ? session : null, ); mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/OpenAI/); expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1); const back = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim().startsWith("Back"), ); await act(async () => { back!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 12; i++) await flushReact(); await pickSource(/OpenAI/); for (let i = 0; i < 8; i++) await flushReact(); expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1); expect(mockAgentsApi.getActiveAdapterAuthLoginSession).toHaveBeenCalled(); await act(async () => root.unmount()); }); it("starts the other source's login after backing out of the first", async () => { // The abandonment case, raised in review against removing the card's // Cancel: with no explicit release, does a source switch still get a // login? It does. The server's lease is keyed on the adapter type as // well as the company and environment, so the abandoned Claude session // does not stand in the way of a Codex one — and it is collected on its // own five-minute timer regardless (DEVICE_LOGIN_TIMEOUT_MS), with the // reaper as the restart-safe backstop. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); expect(document.body.textContent).toContain("Sign in to Claude then come back and enter authorization code"); expect(document.body.textContent).not.toContain("Use saved login"); expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1); const back = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim().startsWith("Back"), ); await act(async () => { back!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); for (let i = 0; i < 12; i++) await flushReact(); await pickSource(/OpenAI/); for (let i = 0; i < 8; i++) await flushReact(); expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1); expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1); await act(async () => root.unmount()); }); it("hires on Connect, with no sign-in, when the signal reports a ready credential", async () => { mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); const { root } = await openStep4({ adapterType: "claude_local" }); // Answer the row, then press: with a credential already in place there is // no sign-in to run, so the button goes straight to the hire. await pickSource(/Claude/); await pressArcPrimary(); // The positive half is what makes this a test: a source that is already // signed in goes straight to the hire, so Connect keeps its old meaning // wherever there is no sign-in to do. Asserting only the absence of a // card would pass just as well if the button had stopped working. expect(mockAgentsApi.hire).toHaveBeenCalled(); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); it("reads the signal again after an adapter change", async () => { mockAdapterRegistry.list = [setupAdapterInfo({ type: "claude_local" }), { type: "codex_local" }]; mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); expect(mockAgentsApi.getAdapterAuthSignal).toHaveBeenCalledWith( "company-new", "claude_local", "env-sandbox-1", ); const clickByText = async (match: (text: string) => boolean) => { const el = [...document.body.querySelectorAll("button")].find((b) => match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""), )!; await act(async () => { el.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); }; // Straight to the tile. The adapter change used to be reached through an // "Advanced settings" disclosure listing every non-recommended adapter; // the step now offers Claude and Codex as tiles and spends that line on // the credential switch instead. What is asserted below is unchanged — // changing the source re-reads the signal — only the route there is. // The tile's text is the label plus its credential tag, hence the prefix. // That label is the provider name now, not the adapter type: this row // asks which provider you are signing in to, so it reads through // `MODEL_SOURCE_NAMES` rather than the display registry. await clickByText((t) => t.startsWith("OpenAI")); expect(mockAgentsApi.getAdapterAuthSignal).toHaveBeenCalledWith( "company-new", "codex_local", "env-sandbox-1", ); await act(async () => root.unmount()); }); describe("managed Codex automatic setup qualification", () => { const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; const discovery = (defaultRunner: "paperclip" | "legacy") => [{ type: "codex_local", defaultRunner, supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"] }] as import("../api/adapters").AdapterInfo[]; async function openManaged(overrides: Record = {}, config: Record = { provider: "daytona", image }, staleTime = 0) { mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config }]); mockAdaptersApi.list.mockResolvedValue(discovery("legacy")); const handles = await openStep4({ adapterType: "codex_local", runner: "auto", ...overrides }, staleTime); await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "openai", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], })); return handles; } it.each([["paperclip", 0], ["legacy", 0], ["paperclip", 30_000], ["legacy", 30_000]] as const)("qualifies the image on Connect and honors the measured %s default with %i ms cache", async (defaultRunner, staleTime) => { const calls: string[] = []; const handles = await openManaged({}, { provider: "daytona", image }, staleTime); let qualified = false; mockAdaptersApi.list.mockImplementation(async context => { expect(context).toEqual({ companyId: "company-new", environmentId: "env-sandbox-1" }); calls.push(qualified ? "discovery-after" : "discovery-before"); return discovery(qualified ? defaultRunner : "legacy"); }); mockEnvironmentsApi.probe.mockImplementation(async () => { calls.push("platform"); qualified = true; return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } }; }); mockAgentsApi.testEnvironment.mockImplementation(async () => { calls.push("adapter"); return { adapterType: defaultRunner === "paperclip" ? "paperclip_runner" : "codex_local", status: "pass", checks: [], testedAt: "2026-10-13T00:00:00Z" }; }); mockAgentsApi.hire.mockImplementation(async () => { calls.push("hire"); return { agent: { id: "agent-1" }, approval: null }; }); try { expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); await pickSource(/OpenAI/); expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter", "hire"]); expect(handles.queryClient.getQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"))).toEqual(discovery(defaultRunner)); expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new"); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "codex_local", expect.objectContaining({ environmentId: "env-sandbox-1", aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" } })); expect((mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2]).not.toHaveProperty("runner"); expect((mockAgentsApi.hire.mock.calls[0] as unknown[])[1]).not.toHaveProperty("runner"); expect(managedApi.connectLocal).not.toHaveBeenCalled(); } finally { await act(async () => handles.root.unmount()); } }); it.each(["permission", "cleanup", "config-race", "missing-lease", "connection", "discovery"].flatMap(failure => [0, 30_000].map(staleTime => [failure, staleTime] as const)))("stops managed automatic setup after %s qualification failure with %i ms cache", async (failure, staleTime) => { const handles = await openManaged({}, { provider: "daytona", image }, staleTime); const diagnostic = failure === "permission" ? "Instance admin access required" : "Qualification could not be confirmed"; if (failure === "permission") mockEnvironmentsApi.probe.mockRejectedValue(new ApiError(diagnostic, 403, null)); else mockEnvironmentsApi.probe.mockImplementation(async () => { if (failure === "discovery") mockAdaptersApi.list.mockRejectedValue(new Error(diagnostic)); return { ok: failure !== "connection", driver: "sandbox", summary: diagnostic, details: { platformQualificationRecorded: !["cleanup", "config-race"].includes(failure), ...(failure === "missing-lease" ? {} : { leaseId: "platform-lease" }), platformQualificationDiagnostic: diagnostic } }; }); try { await pickSource(/OpenAI/); expect(document.body.textContent).toContain(diagnostic); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); } finally { await act(async () => handles.root.unmount()); } }); it.each(["legacy", "native-cache", "snapshot", "tag", "local", "saved"])("keeps the %s path free of an extra platform probe", async mode => { const handles = await openManaged(mode === "legacy" ? { runner: "legacy" } : mode === "saved" ? { createdAgentId: "existing" } : {}, mode === "snapshot" ? { provider: "daytona", snapshot: "named" } : mode === "tag" ? { provider: "daytona", image: "ubuntu:24.04" } : { provider: "daytona", image }); if (mode === "native-cache") { mockAdaptersApi.list.mockResolvedValue(discovery("paperclip")); await act(async () => handles.queryClient.setQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"), discovery("paperclip"))); } if (mode === "local") { await act(async () => handles.queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT])); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); await act(async () => handles.queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null })); } try { await pickSource(/OpenAI/); expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); } finally { await act(async () => handles.root.unmount()); } }); it.each(["missing", "invalid-default", "unsupported-default"])("does not probe or hire with %s authoritative discovery", async mode => { const handles = await openManaged(); mockAdaptersApi.list.mockResolvedValue(mode === "missing" ? [] : mode === "invalid-default" ? [{ type: "codex_local", supportedRunners: ["legacy"] }] as import("../api/adapters").AdapterInfo[] : [{ type: "codex_local", defaultRunner: "paperclip", supportedRunners: ["legacy"] }] as import("../api/adapters").AdapterInfo[]); try { await pickSource(/OpenAI/); expect(document.body.textContent).toContain("Could not confirm Codex runner availability"); expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); } finally { await act(async () => handles.root.unmount()); } }); it.each([0, 30_000])("rechecks stale native discovery and stops a changed image from becoming an unnoticed Legacy hire with %i ms cache", async staleTime => { const handles = await openManaged({}, { provider: "daytona", image }, staleTime); await act(async () => handles.queryClient.setQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"), discovery("paperclip"))); mockEnvironmentsApi.probe.mockResolvedValue({ ok: true, driver: "sandbox", summary: "Connected", details: { platformQualificationRecorded: false, leaseId: "platform-lease", platformQualificationDiagnostic: "The environment changed during the probe. Probe its current configuration again." } }); try { await pickSource(/OpenAI/); expect(mockAdaptersApi.list).toHaveBeenCalledWith({ companyId: "company-new", environmentId: "env-sandbox-1" }); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); expect(document.body.textContent).toContain("The environment changed during the probe"); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); } finally { await act(async () => handles.root.unmount()); } }); it.each(["authentication", "dependency", "model"])("keeps selected native %s setup failures actionable without retrying Legacy", async failure => { const handles = await openManaged(); mockEnvironmentsApi.probe.mockImplementation(async () => { mockAdaptersApi.list.mockResolvedValue(discovery("paperclip")); return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } }; }); mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "paperclip_runner", status: "fail", testedAt: "2026-10-13T00:00:00Z", checks: [{ code: `native_${failure}`, level: "error", message: `Repair native ${failure}` }] }); try { await pickSource(/OpenAI/); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect((mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2]).not.toHaveProperty("runner"); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).toContain(`Repair native ${failure}`); } finally { await act(async () => handles.root.unmount()); } }); it.each(["back", "environment-config"])("does not test or hire after a held probe is abandoned by %s", async change => { let finish!: (value: import("@paperclipai/shared").EnvironmentProbeResult) => void; const handles = await openManaged(); mockEnvironmentsApi.probe.mockReturnValue(new Promise(resolve => { finish = resolve; })); try { await pickSource(/OpenAI/); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); await act(async () => document.querySelector('[data-testid="onboarding-wizard"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))); await flushReact(); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); if (change === "back") await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Back")!.click()); else { await act(async () => handles.queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } }])); await flushReact(); } await act(async () => finish({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } })); for (let i = 0; i < 8; i++) await flushReact(); expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); } finally { await act(async () => handles.root.unmount()); } }); }); describe("automatic subscription verification", () => { const providers = [ ["claude_local", "anthropic", /Claude/], ["codex_local", "openai", /OpenAI/], ] as const; const passed = { adapterType: "codex_local", status: "pass" as const, checks: [], testedAt: new Date().toISOString() }; beforeEach(() => { localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" }); mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); }); async function settle() { for (let i = 0; i < 10; i++) await flushReact(); } function button(label: string) { const found = [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim() === label); expect(found, label).toBeTruthy(); return found!; } async function click(label: string) { await act(async () => button(label).click()); await settle(); } async function detected() { managedApi.checkLocalLogin.mockResolvedValue({ status: "ready" }); await act(async () => window.dispatchEvent(new Event("focus"))); await settle(); } function expectTesting() { expect(button("Testing…").disabled).toBe(true); expect(button("Testing…").querySelector("svg.animate-spin")).not.toBeNull(); expect([...document.body.querySelectorAll('[role="status"]')].some(e => e.textContent === "Testing connection…")).toBe(true); expect(document.body.querySelector('[aria-label="Saved subscription"]')).toBeNull(); expect(document.body.textContent).not.toContain("Run this in a terminal"); expect(document.body.textContent).not.toContain("Start sign-in again"); } it.each(providers.flatMap(([adapterType, provider, source]) => (["paperclip", "legacy"] as const).map(defaultRunner => ({ adapterType, provider, source, defaultRunner })), ))("waits for $adapterType discovery before verification and keeps $defaultRunner routing automatic", async ({ adapterType, provider, source, defaultRunner }) => { let finish!: (value: import("../api/adapters").AdapterInfo[]) => void; mockAdaptersApi.list.mockReturnValue(new Promise(resolve => { finish = resolve; })); mockAdapterBuild.buildAdapterConfig.mockImplementation(values => adapterType === "claude_local" ? buildClaudeLocalConfig(values!) : buildCodexRunnerConfig(values!)); const { root, queryClient } = await openStep4({ adapterType }); try { await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider, method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], })); await pickSource(source); await settle(); expect(button("Connect").disabled).toBe(true); await act(async () => button("Connect").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))); await settle(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => finish([setupAdapterInfo({ type: adapterType, defaultRunner, supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"] })])); await settle(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); const tested = mockAgentsApi.testEnvironment.mock.calls[0] as unknown as [string, string, { adapterConfig: Record }]; const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; expect(mockAdapterBuild.buildAdapterConfig).toHaveBeenCalledWith(expect.objectContaining({ adapterType, runner: "auto", resolvedRunner: defaultRunner })); expect(tested[1]).toBe(adapterType); expect(tested[2]).not.toHaveProperty("runner"); expect(hired[1]).toMatchObject({ adapterType, onboardingFirstAgent: true }); expect(hired[1]).not.toHaveProperty("runner"); expect(hired[1].adapterConfig).toEqual(tested[2].adapterConfig); if (adapterType === "claude_local") { if (defaultRunner === "paperclip") expect(hired[1].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); else expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000); } expect(hired[1].adapterConfig).not.toHaveProperty("resolvedRunner"); } finally { await act(async () => root.unmount()); } }); it.each(["environments", "settings", "policy"] as const)("does not use warm Claude discovery before %s resolves", async pending => { let finish!: (value: any) => void; const delayed = new Promise(resolve => { finish = resolve; }); if (pending === "environments") mockEnvironmentsApi.list.mockReturnValue(delayed); else if (pending === "settings") mockInstanceSettingsApi.get.mockReturnValue(delayed); else mockInstanceSettingsApi.getExperimental.mockReturnValue(delayed); mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); try { await act(async () => { if (pending === "environments") queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT]); else if (pending === "settings") queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null }); else queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false }); for (const environmentId of [null, LOCAL_ENVIRONMENT.id]) queryClient.setQueryData(queryKeys.adapters.availability("company-new", environmentId), [{ type: "claude_local", defaultRunner: "paperclip" }]); queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] }); }); await pickSource(/Claude/); await settle(); expect(mockAdapterBuild.buildAdapterConfig).not.toHaveBeenCalled(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => finish(pending === "environments" ? [LOCAL_ENVIRONMENT] : pending === "settings" ? { defaultEnvironmentId: null } : { enableManagedSandboxOnly: false })); await settle(); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000); } finally { await act(async () => root.unmount()); } }); it.each(["environments", "settings", "policy"] as const)("reports failed Claude %s resolution and retries without early verification", async failed => { if (failed === "environments") mockEnvironmentsApi.list.mockRejectedValue(new Error("Unavailable")); else if (failed === "settings") mockInstanceSettingsApi.get.mockRejectedValue(new Error("Unavailable")); else mockInstanceSettingsApi.getExperimental.mockRejectedValue(new Error("Unavailable")); const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); try { await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] })); await pickSource(/Claude/); await settle(); expect(document.body.textContent).toContain("Could not load execution environment settings"); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false }); await click("Retry setup"); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); } finally { await act(async () => root.unmount()); } }); it("does not hire from obsolete Claude discovery after the target changes during verification", async () => { mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); mockAdaptersApi.list.mockImplementation(async context => [setupAdapterInfo({ type: "claude_local", defaultRunner: context?.environmentId === "new-local-default" ? "paperclip" : "legacy" })]); const finishes: Array<(value: typeof passed) => void> = []; mockAgentsApi.testEnvironment.mockImplementation(() => new Promise(resolve => finishes.push(resolve))); const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); try { await pickSource(/Claude/); await detected(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); await act(async () => queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...LOCAL_ENVIRONMENT, id: "new-local-default" }])); await settle(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); await act(async () => finishes[0](passed)); await settle(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(document.body.textContent).toContain("Configuration changed during setup"); await click("Connect"); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2); await act(async () => finishes[1](passed)); await settle(); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); const tested = mockAgentsApi.testEnvironment.mock.calls as unknown as Array<[string, string, { environmentId: string; adapterConfig: Record }]>; expect(tested[0][2].adapterConfig.maxTurnsPerRun).toBe(1000); expect(tested[1][2].environmentId).toBe("new-local-default"); expect(tested[1][2].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; expect(hired[1].adapterConfig).toEqual(tested[1][2].adapterConfig); } finally { await act(async () => root.unmount()); } }); it("unlocks Claude setup when automatic runner availability changes during verification", async () => { mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "paperclip" })]); const finishes: Array<(value: typeof passed) => void> = []; mockAgentsApi.testEnvironment.mockImplementation(() => new Promise(resolve => finishes.push(resolve))); const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); try { await pickSource(/Claude/); await detected(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expectTesting(); mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy" })]); await act(async () => queryClient.setQueryData(queryKeys.adapters.availability("company-new", LOCAL_ENVIRONMENT.id), [setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy" })])); await settle(); expect(document.body.textContent).toContain("Configuration changed during setup"); expect(button("Connect").disabled).toBe(false); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); await act(async () => finishes[0](passed)); await settle(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await click("Connect"); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2); await act(async () => finishes[1](passed)); await settle(); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000); } finally { await act(async () => root.unmount()); } }); it("fails closed on Claude discovery rejection and retries availability before verification", async () => { mockAdaptersApi.list.mockRejectedValue(new Error("Discovery unavailable")); const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); try { await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], })); await pickSource(/Claude/); await settle(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("Could not load runner availability"); mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "paperclip" })]); await click("Retry setup"); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); } finally { await act(async () => root.unmount()); } }); it.each(providers)("automatically verifies detected %s once through connection refreshes and advances", async (adapterType, provider, label) => { let finish!: (result: typeof passed) => void; mockAgentsApi.testEnvironment.mockReturnValue(new Promise(resolve => { finish = resolve; })); const { root, queryClient } = await openStep4({ adapterType }); try { await pickSource(label); expect(document.body.textContent).toContain("Sign in to"); expect(document.body.textContent).not.toContain("Run this in a terminal"); expect(button("Connect").disabled).toBe(false); await detected(); expectTesting(); // The saved account can become visible while its hello test is pending. await act(async () => { queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "local-connection", grantId: "local-grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], }); window.dispatchEvent(new Event("focus")); }); await settle(); expectTesting(); expect(managedApi.connectLocal).toHaveBeenCalledTimes(1); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => finish(passed)); await settle(); expect(document.body.textContent).toContain("is ready to work!"); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }) })); } finally { await act(async () => root.unmount()); } }); it.each(providers)("automatically verifies a saved %s subscription only after selecting its provider", async (adapterType, provider, label) => { const { root, queryClient } = await openStep4({ adapterType }); try { await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }], })); await settle(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); await pickSource(label); await settle(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(managedApi.connectLocal).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("is ready to work!"); } finally { await act(async () => root.unmount()); } }); it.each(providers)("prefers the personal default %s subscription over an earlier shared account", async (adapterType, provider, label) => { const { root, queryClient } = await openStep4({ adapterType }); try { await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [ { id: "shared", grantId: "shared-grant", companyId: "company-new", provider, method: "subscription", name: "Shared", ownership: "shared", status: "connected" }, { id: "personal", grantId: "personal-grant", companyId: "company-new", provider, method: "subscription", name: "Personal", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }, ], })); await pickSource(label); await settle(); expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }), })); expect(managedApi.connectLocal).not.toHaveBeenCalled(); } finally { await act(async () => root.unmount()); } }); it.each(providers)("does not choose an arbitrary %s account when several shared subscriptions exist", async (adapterType, provider, label) => { const { root, queryClient } = await openStep4({ adapterType }); try { await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: ["first", "second"].map(id => ({ id, grantId: `${id}-grant`, companyId: "company-new", provider, method: "subscription", name: id, ownership: "shared", status: "connected" })), })); await pickSource(label); await settle(); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).toContain("Sign in to"); expect(button("Connect").disabled).toBe(false); } finally { await act(async () => root.unmount()); } }); it.each(providers)("keeps %s verification pending through environment query updates", async (adapterType, _provider, label) => { let finish!: (result: typeof passed) => void; mockAgentsApi.testEnvironment.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); const { root, queryClient } = await openStep4({ adapterType }); try { await pickSource(label); await detected(); expectTesting(); await act(async () => queryClient.setQueryData(queryKeys.environments.list("company-new"), [ { ...LOCAL_ENVIRONMENT, name: "Refreshed local environment" }, ])); await settle(); expectTesting(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => finish(passed)); await settle(); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); expect(document.body.textContent).toContain("is ready to work!"); } finally { await act(async () => root.unmount()); } }); it.each(["back", "unmount"] as const)("ignores an already-submitted hire response after %s", async navigation => { let finishHire!: (value: { agent: { id: string }; approval: null }) => void; mockAgentsApi.hire.mockReturnValueOnce(new Promise(resolve => { finishHire = resolve; })); const { root } = await openStep4({ adapterType: "codex_local" }); let mounted = true; try { await pickSource(/OpenAI/); await detected(); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); if (navigation === "back") await click("Back"); else { await act(async () => root.unmount()); mounted = false; } await act(async () => finishHire({ agent: { id: "agent-1" }, approval: null })); await settle(); expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); expect(document.body.textContent).not.toContain("is ready to work!"); } finally { if (mounted) await act(async () => root.unmount()); } }); it.each(providers)("offers a manual retry after failed %s verification without signing in again", async (adapterType, _provider, label) => { mockAgentsApi.testEnvironment.mockRejectedValueOnce(new Error("Provider request timed out. Try again.")); const { root } = await openStep4({ adapterType }); try { await pickSource(label); await detected(); expect(document.body.textContent).toContain("Provider request timed out. Try again."); expect(button("Connect").disabled).toBe(false); await detected(); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await click("Connect"); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2); expect(managedApi.connectLocal).toHaveBeenCalledTimes(1); expect(document.body.textContent).toContain("is ready to work!"); } finally { await act(async () => root.unmount()); } }); it.each(providers)("keeps Connect usable when %s detection has not completed", async (adapterType, _provider, label) => { const { root } = await openStep4({ adapterType }); try { await pickSource(label); expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); await click("Connect"); expect(managedApi.connectLocal).toHaveBeenCalledTimes(1); expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); expect(document.body.textContent).toContain("is ready to work!"); } finally { await act(async () => root.unmount()); } }); it("shows Connecting while saving, then Testing while verifying", async () => { let saved!: (result: { connectionId: string; grantId: string }) => void; managedApi.connectLocal.mockReturnValue(new Promise(resolve => { saved = resolve; })); mockAgentsApi.testEnvironment.mockReturnValue(new Promise(() => {})); const { root } = await openStep4({ adapterType: "codex_local" }); try { await pickSource(/OpenAI/); await detected(); expect(button("Connecting…").disabled).toBe(true); expect(document.body.textContent).not.toContain("Run this in a terminal"); await act(async () => saved({ connectionId: "local-connection", grantId: "local-grant" })); await settle(); expectTesting(); } finally { await act(async () => root.unmount()); } }); it.each(["back", "provider", "company", "unmount"] as const)("ignores verification completion after %s navigation", async navigation => { let finish!: (result: typeof passed) => void; mockAgentsApi.testEnvironment.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); const { root, queryClient } = await openStep4({ adapterType: "codex_local" }); let mounted = true; try { await pickSource(/OpenAI/); await detected(); expectTesting(); if (navigation === "company") { mockCompany.companies.push({ id: "company-other", name: "Other", issuePrefix: "OTH" }); mockDialog.onboardingOptions = { initialStep: 4, companyId: "company-other" }; managedApi.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" }); await act(async () => root.render()); await settle(); } else if (navigation === "unmount") { await act(async () => root.unmount()); mounted = false; } else { await click("Back"); if (navigation === "provider") { managedApi.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" }); await pickSource(/Claude/); } } await act(async () => finish(passed)); await settle(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); expect(document.body.textContent).not.toContain("is ready to work!"); if (navigation === "provider") expect(document.body.textContent).toContain("Preparing browser sign-in"); } finally { if (mounted) await act(async () => root.unmount()); } }); }); it("does not offer Gemini from a saved initial onboarding draft", async () => { localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" }); mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockAdapterRegistry.list.push({ type: "gemini_local", recommended: true }); const { root } = await openStep4({ adapterType: "gemini_local" }); try { const sources = [...document.body.querySelectorAll("button[aria-checked]")]; expect(sources).toHaveLength(2); expect(sources.some(source => /Gemini|Google|gemini_local/.test(source.textContent ?? ""))).toBe(false); expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull(); expect(managedApi.startLocalLogin).not.toHaveBeenCalled(); expect(managedApi.connectLocal).not.toHaveBeenCalled(); expect(document.body.textContent).not.toContain("claude auth login"); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); } finally { await act(async () => root.unmount()); } }); it("shows the Claude browser sign-in card and saves its connection before hiring", async () => { localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" }); mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); const { root } = await openStep4({ adapterType: "claude_local" }); await pickSource(/Claude/); expect(document.body.textContent).toContain("Sign in to Claude then come back and enter authorization code"); expect(document.body.textContent).not.toContain("claude auth login"); expect(document.body.textContent).not.toContain("No managed sandbox"); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); const connect = [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim().startsWith("Connect")); expect(connect).toBeTruthy(); await act(async () => connect!.click()); for (let i = 0; i < 6; i++) await flushReact(); expect(managedApi.connectLocal).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "anthropic", method: "subscription", ownership: "personal" })); expect(mockAgentsApi.hire).toHaveBeenCalled(); const hire = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { runtimeConfig: { aiConnection: unknown } }; expect(hire.runtimeConfig.aiConnection).toEqual({ provider: "anthropic", method: "subscription", mode: "responsible_user" }); await act(async () => root.unmount()); }); it("hides the panel when the resolved login environment driver is not sandbox", async () => { mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); expect(document.body.textContent).not.toContain("Sign in to Anthropic"); expect(mockAgentsApi.getAdapterAuthSignal).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); it("restores the started-login state after a reload, resuming the active session with no press", async () => { // A reload loses `connectPhase` — the draft deliberately does not carry // it, because a login is a live server session with a deadline, not // wizard state to replay blindly. The step must instead re-derive it // from the caller's active session, so a customer who reloads mid-login // sees their sign-in still running rather than the tile row again. mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue({ sessionId: "claude-session-1", environmentId: "env-sandbox-1", status: "waiting_for_user", expiresAt: null, failure: null, panelMode: "submitted_browser_code", prompt: { authorizationUrl: "https://claude.ai/oauth/authorize?code=true" }, }); const { root } = await openStep4({ adapterType: "claude_local" }); // A reload's resume now runs one layer deeper than a fresh press: the // step's own active-session read has to land before it opens the // sequence, and only then does the mounted panel run its own resume // read. Each is a further round trip `flushReact` has to catch up to. for (let i = 0; i < 10; i += 1) await flushReact(); // No press: the resumed session is discovered on load and the card // shows the login already running. expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); expect(document.body.textContent).toContain( "Sign in to Claude then come back and enter authorization code", ); await act(async () => root.unmount()); }); }); });