From 25f0957c24925b8063a66dc9393caac7332cd985 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 11 Nov 2026 11:42:31 -0800 Subject: [PATCH] Select the AI Connection sign-in computer automatically (#20906) ## Thinking Path > - Paperclip manages agents and their AI Connections. > - Codex and Claude subscription sign-in can run on a company agent computer. > - Core stores the credential and uses connection permissions to control access. > - The Connections page asks the user to choose a computer just to host sign-in. > - This change lets Core choose an eligible computer when sign-in starts. ## Linked Issues or Issue Description Refs #20410 and #20417. **What existing behavior does this improve?** OpenAI and Anthropic subscription sign-in from Connections with an agent computer as the sign-in environment. **Current behavior** The user must choose a sign-in computer before the provider flow can start. **Proposed behavior** Start sign-in from the provider form. Core chooses a computer in the company and sign-in environment. The session stays bound to that computer and reports when no eligible computer is available. **Reason and benefit** The saved credential and its permissions are independent of the computer that hosts sign-in, so users can proceed without picking an agent. ## What Changed - Select an eligible computer when a managed Codex or Claude login starts without an explicit target. Prefer ready computers and skip active login leases. - Remove the picker and let the browser resume the existing session by environment and connection intent. Continue to honor explicit agent targets in agent settings. Remove the unused picker fallback from the legacy form. - Update the AI Connections guide, the plugin environment contract, and the OpenAPI descriptions. ## Verification - Passed server and UI typechecks. - Passed 317 tests across the login environment, setup-token route, device-login service, and two UI suites. The Postgres test covers eligibility, inherited environments, explicit targeting, occupied computers, and the no-computer response. - Passed 45 OpenAPI route tests. - Reran the affected UI suites on the final revision. All 230 tests passed. - UI tests cover starting from the provider form and resuming both provider flows with a server-selected target. - [CI](https://github.com/paperclipai/paperclip/actions/runs/43086849302) and [Greptile 5/5](https://github.com/paperclipai/paperclip/pull/20906#issuecomment-6805135968) passed on the final commit. - Local verification used test transports. Live provider sign-in remains to be checked in a Cloud composition that contains this change. ## Risks - Two sign-ins that select a computer before either lease is recorded can contend for the same computer. The existing provider admission guard handles the conflict. A retry can select another computer once the lease is visible. - The computer selection uses existing lifecycle and lease data. Plugin admission still validates the live computer. - No schema migration or plugin protocol change is required. ## Model Used OpenAI Codex (GPT-6), with shell execution and local test tooling. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- doc/connections/AI-CONNECTIONS.md | 4 ++ doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md | 6 +- .../src/routes/adapter-login-route-spine.ts | 14 ++-- server/src/routes/agents.ts | 37 +++++----- server/src/routes/openapi.ts | 2 + server/src/services/login-environment.test.ts | 68 +++++++++++++++++-- server/src/services/login-environment.ts | 50 +++++++++++--- ui/src/components/AdapterLoginChrome.tsx | 4 +- .../AgentConfigForm.render.test.tsx | 22 ++++++ ui/src/components/AgentConfigForm.tsx | 30 +++++--- ui/src/components/AgentLoginTarget.test.tsx | 51 -------------- ui/src/components/AgentLoginTarget.tsx | 36 ---------- .../AgentProviderConnection.test.tsx | 17 +++++ .../new-agent/AgentProviderConnection.tsx | 16 +++-- 14 files changed, 215 insertions(+), 142 deletions(-) delete mode 100644 ui/src/components/AgentLoginTarget.test.tsx delete mode 100644 ui/src/components/AgentLoginTarget.tsx diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index bef4ac78c97..5d4ca80faec 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -4,6 +4,10 @@ AI accounts use the existing Apps/Connections substrate. Manage them at `/:company/apps`; select their use beside an agent's harness/model settings. Onboarding, new-agent setup, account creation/reconnect, and inline task requests reuse `AdapterLoginPanel`, its existing login controllers, and `AdapterLoginChrome`. +For Codex and Claude subscriptions on an agent computer, Connections selects an +eligible computer in the chosen company and sign-in environment when the user +starts sign-in. The session keeps that computer until it ends. Connection +ownership and permissions still come from the account setup. Onboarding and new-agent setup retain upstream's `SavedProviderKeySelect` and `useSavedProviderKeys`, including saved-key references and account-specific Codex homes. Managed default/shared accounts are additional choices in that same diff --git a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md index d2243782769..a22d20e2160 100644 --- a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md +++ b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md @@ -440,8 +440,10 @@ back to reading remote paths on the server. Existing artifact uploads are unchan A login-capable environment driver can host authorization on a saved agent's computer. Core checks the company, environment, agent lifecycle, manifest, and -live worker hooks before acquisition. The chosen agent is a transport target; -it does not change the signed-in account's owner or agent grants. +live worker hooks before acquisition. Managed AI Connection requests can omit +the agent ID so Core selects an eligible agent from the company and environment. +Core uses that ID throughout the login. The chosen agent is a transport target +and the signed-in account retains its own owner and agent grants. Core acquires this resource with `purpose: "login"`, the fixed command key, and an absolute expiry. The returned lease must attest an expiry no later than that diff --git a/server/src/routes/adapter-login-route-spine.ts b/server/src/routes/adapter-login-route-spine.ts index 1b5dc86e275..16e0cea73ec 100644 --- a/server/src/routes/adapter-login-route-spine.ts +++ b/server/src/routes/adapter-login-route-spine.ts @@ -9,6 +9,7 @@ import type { z } from "zod"; export interface AdapterLoginStartResolution { ownerUserId: string; data: TData; + targetAgentId: string | undefined; } /** @@ -54,10 +55,9 @@ export interface AdapterLoginStartSpineInput boolean | Promise; /** - * Checks the sandbox environment. It throws a mapped HTTP error for a missing, - * archived, non-sandbox, or foreign environment. + * Checks the sign-in environment and resolves its computer target, when required. */ - assertSandbox: (data: TData) => Promise; + resolveTargetAgentId: (data: TData) => Promise; } /** @@ -71,9 +71,9 @@ export interface AdapterLoginStartSpineInput( @@ -102,6 +102,6 @@ export async function runAdapterLoginStartSpine { - await assertSandboxLoginEnvironment(input.scope.companyId, input.scope.environmentId, { + await resolveSandboxLoginTarget(input.scope.companyId, input.scope.environmentId, { agentId: input.scope.agentId, requireSetupTokenLoginProvider: true, }); @@ -1527,12 +1527,12 @@ export function agentRoutes( // an environment that binds to other companies. The route caller is already // bound to the path company by `assertCompanyAccess`, and the acquired lease // records that same company, so the login stays attributed to the caller. - async function assertSandboxLoginEnvironment( + async function resolveSandboxLoginTarget( companyId: string, environmentId: string, - options?: { requireSetupTokenLoginProvider?: boolean; agentId?: string; adapterType?: string }, - ): Promise { - await assertLoginEnvironmentTarget(db, { companyId, environmentId, agentId: options?.agentId, adapterType: options?.adapterType }); + options: { requireSetupTokenLoginProvider?: boolean; agentId?: string; adapterType?: string; selectComputer?: boolean }, + ): Promise { + const target = await resolveLoginEnvironmentTarget(db, { companyId, environmentId, ...options }); // Gate the Claude setup-token login on the provider capability. Only a // sandbox provider that advertises the setup-token login capability // implements the setup-token pseudo-terminal methods. The setup-token start @@ -1540,9 +1540,10 @@ export function agentRoutes( // before the session starts. The lease guard passes it too, so a // reconciliation that rebinds the environment to an unsupported provider // still fails closed before the lease and the pseudo-terminal. - if (options?.requireSetupTokenLoginProvider) { + if (options.requireSetupTokenLoginProvider) { await assertSetupTokenLoginProviderCapability(environmentId); } + return target.agentId; } /** @@ -3718,18 +3719,21 @@ export function agentRoutes( requestSchema: startAdapterAuthSessionRequestSchema, invalidRequestError: "The device login start request is invalid.", requestOverrides: { adapterType: type }, - assertSandbox: async (data) => { + resolveTargetAgentId: async (data) => { // The device login runs on a real pseudo-terminal, so it needs a // provider that advertises the login pseudo-terminal capability. Gate // the route on the current provider capability before any session or // lease state. The lease-acquisition path re-checks it from current // runtime state before the provider lease. - await assertSandboxLoginEnvironment(companyId, data.environmentId, { agentId: data.agentId, adapterType: data.adapterType }); + const targetAgentId = await resolveSandboxLoginTarget(companyId, data.environmentId, { + agentId: data.agentId, adapterType: data.adapterType, selectComputer: Boolean(data.aiConnection), + }); await assertCodexLoginProviderCapability(data.environmentId); + return targetAgentId; }, }); if (!resolved) return; - const { ownerUserId: startedByUserId, data } = resolved; + const { ownerUserId: startedByUserId, data, targetAgentId } = resolved; if (req.actor.type === "agent" && !data.aiConnection) throw forbidden("Agent logins must save a managed AI account"); if (data.aiConnection) { @@ -3742,7 +3746,7 @@ export function agentRoutes( result = await adapterLoginService.start({ companyId, environmentId: data.environmentId, - agentId: data.agentId, + agentId: targetAgentId, adapterType: type, startedByUserId, ttlSeconds: data.ttlSeconds, @@ -6865,15 +6869,16 @@ export function agentRoutes( } return false; }, - assertSandbox: (data) => - assertSandboxLoginEnvironment(companyId, data.environmentId, { + resolveTargetAgentId: (data) => + resolveSandboxLoginTarget(companyId, data.environmentId, { requireSetupTokenLoginProvider: true, agentId: data.agentId, adapterType: data.adapterType, + selectComputer: Boolean(data.aiConnection), }), }); if (!resolved) return; - const { ownerUserId, data } = resolved; + const { ownerUserId, data, targetAgentId } = resolved; const { environmentId, adapterType } = data; if (req.actor.type === "agent" && !data.aiConnection) throw forbidden("Agent logins must save a managed AI account"); if (data.aiConnection) { @@ -6887,7 +6892,7 @@ export function agentRoutes( ownerUserId, adapterType, environmentId, - agentId: data.agentId, + agentId: targetAgentId, confirmedOverwrite, aiConnection: data.aiConnection, managementContext: continuingConnectionManagement(connectionRequestContext(req)), diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 261434dd520..87eb0032948 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4281,6 +4281,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/adapters/{type}/login-sessions", tags: ["adapters"], summary: "Start a company-scoped adapter device login", + description: "For a managed AI Connection, omit agentId to select an eligible computer in the sign-in environment.", request: { params: z.object({ companyId: z.string(), type: z.string() }), body: jsonBody(startAdapterLoginSessionSchema), @@ -8350,6 +8351,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/setup-token-login-sessions", tags: ["companies"], summary: "Start a company-and-environment Claude setup-token login session", + description: "For a managed AI Connection, omit agentId to select an eligible computer in the sign-in environment.", request: { params: z.object({ companyId: z.string() }), body: jsonBody(startClaudeSetupTokenSessionRequestSchema), diff --git a/server/src/services/login-environment.test.ts b/server/src/services/login-environment.test.ts index 13aa8f91e89..b811e8f76df 100644 --- a/server/src/services/login-environment.test.ts +++ b/server/src/services/login-environment.test.ts @@ -1,12 +1,15 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Db } from "@paperclipai/db"; -import { assertLoginEnvironmentTarget } from "./login-environment.js"; -const fixtures = vi.hoisted(() => ({ environment: { id: "environment", driver: "plugin" }, companies: ["company"], agent: { id: "agent", companyId: "company", adapterType: "paperclip_runner", lifecycleState: "verifying", defaultEnvironmentId: "environment" } as Record | undefined })); +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { agents, companies, createDb, environmentLeases, environments, type Db } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js"; +import { assertLoginEnvironmentTarget, resolveLoginEnvironmentTarget } from "./login-environment.js"; +const fixtures = vi.hoisted(() => ({ environment: { id: "environment", driver: "plugin" }, instanceDefault: "environment", companies: ["company"], agent: { id: "agent", companyId: "company", adapterType: "paperclip_runner", lifecycleState: "verifying", defaultEnvironmentId: "environment" } as Record | undefined })); vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: async () => fixtures.environment, listBoundCompanyIds: async () => fixtures.companies }) })); -vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => ({ defaultEnvironmentId: "environment" }) }) })); +vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => ({ defaultEnvironmentId: fixtures.instanceDefault }) }) })); vi.mock("./environment-selection.js", () => ({ assertEnvironmentSelectionForCompany: vi.fn(async () => {}) })); const db = { select: () => ({ from: () => ({ where: () => ({ limit: async () => fixtures.agent ? [fixtures.agent] : [] }) }) }) } as unknown as Db; -beforeEach(() => { fixtures.environment = { id: "environment", driver: "plugin" }; fixtures.companies = ["company"]; fixtures.agent = { id: "agent", companyId: "company", adapterType: "paperclip_runner", lifecycleState: "verifying", defaultEnvironmentId: "environment" }; }); +beforeEach(() => { fixtures.instanceDefault = "environment"; fixtures.environment = { id: "environment", driver: "plugin" }; fixtures.companies = ["company"]; fixtures.agent = { id: "agent", companyId: "company", adapterType: "paperclip_runner", lifecycleState: "verifying", defaultEnvironmentId: "environment" }; }); describe("login environment transport target", () => { it("permits a verifying saved agent on its own computer", async () => { await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment", agentId: "agent" })).resolves.toEqual(fixtures.environment); }); it("rejects a provider without an existing-computer credential path", async () => { await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment", agentId: "agent", adapterType: "grok_local" })).rejects.toMatchObject({ status: 400 }); }); @@ -17,3 +20,56 @@ describe("login environment transport target", () => { it("supports the inherited environment", async () => { fixtures.agent!.defaultEnvironmentId = null; await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment", agentId: "agent" })).resolves.toEqual(fixtures.environment); }); it("retains agentless temporary sandbox login", async () => { fixtures.environment.driver = "sandbox"; await expect(assertLoginEnvironmentTarget(db, { companyId: "company", environmentId: "environment" })).resolves.toEqual(fixtures.environment); }); }); + +const postgres = await getEmbeddedPostgresTestSupport(); +(postgres.supported ? describe : describe.skip)("automatic login computer selection", () => { + let database: Awaited>; + beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-login-target-"); }, 90_000); + afterAll(async () => { await database?.cleanup(); }); + + it("prefers a ready computer in the chosen company and effective environment", async () => { + const db = createDb(database.connectionString); + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const environmentId = randomUUID(); + const otherEnvironmentId = randomUUID(); + const readyId = randomUUID(); + const verifyingId = randomUUID(); + fixtures.environment = { id: environmentId, driver: "plugin" }; + fixtures.instanceDefault = environmentId; + fixtures.companies = [companyId]; + await db.insert(companies).values([ + { id: companyId, name: "Login company", issuePrefix: "LOGIN" }, + { id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" }, + ]); + await db.insert(environments).values([ + { id: environmentId, name: "Computers", driver: "plugin" }, + { id: otherEnvironmentId, name: "Other computers", driver: "plugin" }, + ]); + const agent = { companyId, name: "Computer", adapterType: "paperclip_runner", defaultEnvironmentId: environmentId } as const; + await db.insert(agents).values([ + { ...agent, id: readyId, defaultEnvironmentId: null, lifecycleState: "ready" }, + { ...agent, id: verifyingId, lifecycleState: "verifying", status: "paused" }, + { ...agent, companyId: otherCompanyId }, + { ...agent, defaultEnvironmentId: otherEnvironmentId }, + { ...agent, status: "paused" }, + { ...agent, status: "pending_approval", lifecycleState: "verifying" }, + { ...agent, adapterType: "codex_local" }, + ]); + const request = { companyId, environmentId, adapterType: "codex_local", selectComputer: true }; + await expect(resolveLoginEnvironmentTarget(db, request)).resolves.toMatchObject({ agentId: readyId }); + await expect(resolveLoginEnvironmentTarget(db, { ...request, agentId: verifyingId })).resolves.toMatchObject({ agentId: verifyingId }); + const [loginLease] = await db.insert(environmentLeases).values({ + companyId, environmentId, metadata: { purpose: "login", agentId: readyId }, + }).returning({ id: environmentLeases.id }); + await expect(resolveLoginEnvironmentTarget(db, request)).resolves.toMatchObject({ agentId: verifyingId }); + await db.update(environmentLeases).set({ status: "released" }).where(eq(environmentLeases.id, loginLease.id)); + await expect(resolveLoginEnvironmentTarget(db, request)).resolves.toMatchObject({ agentId: readyId }); + fixtures.instanceDefault = otherEnvironmentId; + await expect(resolveLoginEnvironmentTarget(db, request)).resolves.toMatchObject({ agentId: verifyingId }); + await db.delete(agents).where(eq(agents.id, verifyingId)); + await expect(resolveLoginEnvironmentTarget(db, request)).rejects.toMatchObject({ + status: 400, message: "No agent computer is available for sign-in.", + }); + }); +}); diff --git a/server/src/services/login-environment.ts b/server/src/services/login-environment.ts index b36b9c389f8..2bd02046534 100644 --- a/server/src/services/login-environment.ts +++ b/server/src/services/login-environment.ts @@ -1,14 +1,21 @@ -import { agents, type Db } from "@paperclipai/db"; -import { and, eq } from "drizzle-orm"; +import { agents, environmentLeases, type Db } from "@paperclipai/db"; +import { and, asc, eq, isNull, notExists, notInArray, or, sql } from "drizzle-orm"; import { badRequest, forbidden, notFound } from "../errors.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { environmentService } from "./environments.js"; import { instanceSettingsService } from "./instance-settings.js"; -/** Validate a login's transport target without changing credential ownership. */ -export async function assertLoginEnvironmentTarget(db: Db, input: { - companyId: string; environmentId: string; agentId?: string; adapterType?: string; -}) { +type LoginEnvironmentTarget = { companyId: string; environmentId: string; agentId?: string; adapterType?: string }; + +export async function assertLoginEnvironmentTarget(db: Db, input: LoginEnvironmentTarget) { + return (await resolveLoginEnvironmentTarget(db, input)).environment; +} + +/** Resolve the transport target once at start. Subsequent lease checks use its exact agent ID. */ +export async function resolveLoginEnvironmentTarget( + db: Db, + input: LoginEnvironmentTarget & { selectComputer?: boolean }, +) { const environments = environmentService(db); await assertEnvironmentSelectionForCompany(environments, input.companyId, input.environmentId, { allowedDrivers: ["sandbox", "plugin"], @@ -19,15 +26,40 @@ export async function assertLoginEnvironmentTarget(db: Db, input: { if (companies.length && !companies.includes(input.companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" }); if (environment.driver !== "plugin") { if (input.agentId) throw badRequest("A temporary sandbox login does not take an agent computer target."); - return environment; + return { environment, agentId: undefined }; } if (input.adapterType && !["codex_local", "claude_local"].includes(input.adapterType)) throw badRequest("This provider login is not supported on agent computers.", { code: "login_adapter_unsupported" }); - if (!input.agentId) throw badRequest("Select an agent computer for sign-in.", { code: "login_agent_required" }); + if (!input.agentId) { + if (!input.selectComputer) throw badRequest("Select an agent computer for sign-in.", { code: "login_agent_required" }); + const settings = await instanceSettingsService(db).get(); + const matchesEnvironment = eq(agents.defaultEnvironmentId, environment.id); + const activeLogin = db.select({ id: environmentLeases.id }).from(environmentLeases).where(and( + eq(environmentLeases.companyId, input.companyId), + eq(environmentLeases.environmentId, environment.id), + eq(environmentLeases.status, "active"), + sql`${environmentLeases.metadata}->>'purpose' = 'login'`, + sql`${environmentLeases.metadata}->>'agentId' = ${agents.id}::text`, + )); + const [agent] = await db.select({ id: agents.id }).from(agents).where(and( + eq(agents.companyId, input.companyId), + eq(agents.adapterType, "paperclip_runner"), + notExists(activeLogin), + settings.defaultEnvironmentId === environment.id + ? or(matchesEnvironment, isNull(agents.defaultEnvironmentId)) + : matchesEnvironment, + or( + and(eq(agents.lifecycleState, "ready"), notInArray(agents.status, ["paused", "terminated", "pending_approval"])), + and(eq(agents.lifecycleState, "verifying"), notInArray(agents.status, ["terminated", "pending_approval"])), + ), + )).orderBy(asc(agents.lifecycleState), asc(agents.id)).limit(1); + if (!agent) throw badRequest("No agent computer is available for sign-in.", { code: "login_agent_unavailable" }); + return { environment, agentId: agent.id }; + } const [agent] = await db.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))).limit(1); if (!agent || agent.companyId !== input.companyId) throw notFound("Agent not found"); const inherited = agent.defaultEnvironmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId; if (inherited !== environment.id || agent.adapterType !== "paperclip_runner" || !["ready", "verifying"].includes(agent.lifecycleState)) { throw badRequest("The selected agent computer is not available for sign-in.", { code: "login_agent_unavailable" }); } - return environment; + return { environment, agentId: agent.id }; } diff --git a/ui/src/components/AdapterLoginChrome.tsx b/ui/src/components/AdapterLoginChrome.tsx index e0e76cc7e83..a1d9031a126 100644 --- a/ui/src/components/AdapterLoginChrome.tsx +++ b/ui/src/components/AdapterLoginChrome.tsx @@ -422,6 +422,7 @@ export function ProviderSubscriptionCard({ mode, loading, failure, + failureMessage, onRetry, children, }: { @@ -429,6 +430,7 @@ export function ProviderSubscriptionCard({ authorizationUrl?: string; onOpenAuthorization?: () => void; failure?: "expired" | "failed"; + failureMessage?: string; onRetry?: () => void; mode: "submitted_code" | "displayed_code" | "browser_redirect"; loading?: boolean; @@ -461,7 +463,7 @@ export function ProviderSubscriptionCard({ > {failure && (
-

Retry sign-in to get a fresh code. Your setup is saved.

+

{failureMessage ?? "Retry sign-in to get a fresh code. Your setup is saved."}

{onRetry && }
)} diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index f530370a3a0..112339a907e 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -2502,6 +2502,28 @@ describe("AgentConfigForm environment selector", () => { expect(mockAgentsApi.getClaudeSetupTokenLoginStatus).not.toHaveBeenCalled(); }); + it.each(["codex_local", "claude_local"] as const)("resumes %s using the computer Core selected", async (adapterType) => { + const aiConnection = { + provider: adapterType === "codex_local" ? "openai" : "anthropic", + method: "subscription", name: "My subscription", ownership: "personal", allAgents: false, + agentIds: [] as string[], + } as const; + const active = { sessionId: "computer-login", agentId: "computer-a", environmentId: "plugin-env", + status: "waiting_for_user", prompt: null, aiConnection }; + mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValue(active); + mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue(active); + const container = document.createElement("div"); document.body.appendChild(container); + const root = createRoot(container); roots.push(root); + await act(async () => renderFormView(root, createFormQueryClient(), + )); + + const statusApi = adapterType === "codex_local" ? mockAgentsApi.getAdapterAuthLoginStatus : mockAgentsApi.getClaudeSetupTokenLoginStatus; + await flushUntil(() => statusApi.mock.calls.some(call => call.includes(active.sessionId))); + expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); + expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); + }); + it("recovers a previous-environment sign-in only after explicit successful cancellation", async () => { const intent = { provider: "xai", method: "subscription", name: "My Grok subscription", ownership: "personal", agentIds: [], allAgents: true } as const; mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValueOnce({ diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index d218f433a2e..e9b2a373462 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,5 @@ -import { AgentLoginTarget } from "./AgentLoginTarget"; import { environmentSupportsLoginPty } from "@paperclipai/shared"; -import { useAdapterLoginCancellation } from "./useAdapterLoginCancellation"; +import { matchesAdapterLoginTarget, useAdapterLoginCancellation } from "./useAdapterLoginCancellation"; import { ACCOUNT_BOUND_MODEL_DISCOVERY_QUERY_OPTIONS, isAccountBoundAcpxAgent } from "@/lib/native-model-catalog"; import { useConnectionModels } from "./ai-connections/useConnectionModels"; import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, normalizeClaudeRunnerOptions, type AgentRunnerChoice } from "@paperclipai/shared"; @@ -1333,7 +1332,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const showAdapterLogin = (isCreate || !((overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? runtimeConfig.aiConnection)) && adapterSupportsSandboxLogin && - (effectiveLoginEnvironment?.driver === "sandbox" || effectiveLoginEnvironment?.driver === "plugin") && + (effectiveLoginEnvironment?.driver === "sandbox" || (effectiveLoginEnvironment?.driver === "plugin" && !isCreate)) && Boolean(effectiveLoginEnvironmentId) && Boolean(selectedCompanyId) && Boolean(authMissingCheck) && @@ -2587,11 +2586,8 @@ export function AdapterLoginPanel(props: AdapterLoginPanelProps) { const getCapabilities = useAdapterCapabilities(); const Panel = getCapabilities(props.adapterType).login?.panelMode === "submitted_browser_code" ? SubmittedBrowserCodeLoginPanel : DisplayedCodeLoginPanel; - return props.requiresAgent - ? - {agentId => } - - : ; + const agentId = props.requiresAgent ? props.agentId : undefined; + return ; } class AdapterLoginConflictError extends Error { @@ -2602,6 +2598,7 @@ class AdapterLoginConflictError extends Error { function DisplayedCodeLoginPanel({ active: loginActive = true, + requiresAgent, companyId, adapterType, environmentId, @@ -2616,6 +2613,7 @@ function DisplayedCodeLoginPanel({ onRetryReady, }: AdapterLoginPanelProps) { const [sessionId, setSessionId] = useState(null); + const automaticComputer = Boolean(requiresAgent && !agentId && aiConnection); const cancellation = useAdapterLoginCancellation(loginActive, companyId, adapterType, false, sessionId, { environmentId, aiConnection }); const [retrying, setRetrying] = useState(false); const [startError, setStartError] = useState(null); @@ -2683,7 +2681,11 @@ function DisplayedCodeLoginPanel({ try { const active = await cancellation.resume(() => agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType, { signal })); if (!active) return null; - if (active.agentId !== agentId || (aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new AdapterLoginConflictError(active.sessionId); + const wrongComputer = !automaticComputer && active.agentId !== agentId; + const wrongConnection = aiConnection + ? !matchesAdapterLoginTarget(active, { environmentId, aiConnection }) + : Boolean(active.aiConnection); + if (wrongComputer || wrongConnection) throw new AdapterLoginConflictError(active.sessionId); return active; } catch (error) { if (error instanceof ApiError && error.status === 404) return null; @@ -2938,6 +2940,7 @@ function DisplayedCodeLoginPanel({ ({ list: vi.fn(), settings: vi.fn() })); -vi.mock("@/api/agents", () => ({ agentsApi: { list: mocks.list } })); -vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { get: mocks.settings } })); -let root: Root, container: HTMLDivElement, client: QueryClient; -async function settle() { for (let i = 0; i < 5; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(() => {}); } } -beforeEach(() => { - vi.clearAllMocks(); - mocks.settings.mockResolvedValue({ defaultEnvironmentId: "environment" }); - mocks.list.mockResolvedValue([ - { id: "eligible", name: "Setup computer", adapterType: "paperclip_runner", lifecycleState: "verifying", defaultEnvironmentId: null }, - { id: "foreign-env", name: "Another computer", adapterType: "paperclip_runner", lifecycleState: "ready", defaultEnvironmentId: "another" }, - { id: "pending", name: "Pending computer", adapterType: "paperclip_runner", lifecycleState: "pending", defaultEnvironmentId: "environment" }, - ]); - container = document.createElement("div"); document.body.append(container); root = createRoot(container); - client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); -}); -afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.remove(); }); -function mount(agentId?: string) { flushSync(() => root.render({target =>
Login ready
}
)); } -it("uses a contextual saved agent without listing unrelated computers", async () => { mount("context-agent"); await settle(); expect(container.querySelector('[data-target="context-agent"]')).not.toBeNull(); expect(mocks.list).not.toHaveBeenCalled(); }); -it("waits for an explicit eligible transport target before starting login", async () => { - mount(); await settle(); expect(container.querySelector("[data-target]")).toBeNull(); - const trigger = container.querySelector('[role="combobox"]')!; - flushSync(() => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); await settle(); - const options = Array.from(document.querySelectorAll('[role="option"]')); - expect(options.map(option => option.textContent)).toEqual(["Setup computer"]); - flushSync(() => options[0].click()); await settle(); - expect(container.querySelector('[data-target="eligible"]')).not.toBeNull(); -}); - -it("keeps selection work linear across a company-sized agent list", async () => { - let reads = 0; - const agents = Array.from({ length: 600 }, (_, index) => ({ - get id() { if (++reads > 50000) throw new Error("Agent selection exceeded its linear work bound"); return `computer-${index}`; }, - name: `Computer ${index}`, adapterType: "paperclip_runner", lifecycleState: "ready", defaultEnvironmentId: "environment", - })); - mocks.list.mockResolvedValue(agents); - mount(); await settle(); - flushSync(() => container.querySelector('[role="combobox"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); - await settle(); - const options = Array.from(document.querySelectorAll('[role="option"]')); - expect(options).toHaveLength(600); - flushSync(() => options[599].click()); await settle(); - expect(container.querySelector('[data-target="computer-599"]')).not.toBeNull(); - expect(reads).toBeLessThan(50000); -}); diff --git a/ui/src/components/AgentLoginTarget.tsx b/ui/src/components/AgentLoginTarget.tsx deleted file mode 100644 index d79d08312b2..00000000000 --- a/ui/src/components/AgentLoginTarget.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { ReactNode } from "react"; -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { agentsApi } from "@/api/agents"; -import { instanceSettingsApi } from "@/api/instanceSettings"; -import { queryKeys } from "@/lib/queryKeys"; -import { QueryErrorState, useQueryView } from "@/components/QueryView"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; - -/** The sign-in computer is independent of the account's ownership and grants. */ -export function AgentLoginTarget({ companyId, environmentId, agentId, children }: { - companyId: string; environmentId: string; agentId?: string; - children: (targetAgentId: string) => ReactNode; -}) { - const [selected, setSelected] = useState(""); - const agents = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: !agentId }); - const settings = useQuery({ queryKey: queryKeys.instance.settings, queryFn: instanceSettingsApi.get, enabled: !agentId }); - const views = [useQueryView(agents), useQueryView(settings)]; - if (agentId) return children(agentId); - if (views.some(view => view.kind === "loading" || view.kind === "reconnecting")) return

Finding sign-in computers…

; - const error = views.find(view => view.kind === "error"); - if (error) return ; - if (!agents.data || !settings.data) return null; - const eligible = agents.data.filter(agent => agent.adapterType === "paperclip_runner" && - (agent.lifecycleState === "ready" || agent.lifecycleState === "verifying") && - (agent.defaultEnvironmentId ?? settings.data.defaultEnvironmentId) === environmentId); - const target = eligible.find(agent => agent.id === selected); - return
- - {!eligible.length &&

An agent computer must finish setup before you can sign in.

} - {target && children(target.id)} -
; -} diff --git a/ui/src/components/new-agent/AgentProviderConnection.test.tsx b/ui/src/components/new-agent/AgentProviderConnection.test.tsx index 4057b60dd94..e5ecd90cae7 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.test.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.test.tsx @@ -56,6 +56,7 @@ async function mount( deploymentMode: "local_trusted" | "authenticated" = "local_trusted", localAiLoginSupported = true, advancedConnection?: Parameters[0]["advancedConnection"], + requiresAgent = false, ) { const key = adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; @@ -123,6 +124,7 @@ async function mount( adapterType={adapterType} environmentId="e1" canLogin={canLogin} + requiresAgent={requiresAgent} localEnvironment={localEnvironment} onBack={() => {}} testConnection={test} @@ -150,6 +152,21 @@ function openProvider() { (host.querySelector('[role="radio"]') as HTMLElement).click(), ); } +it("starts managed computer sign-in on demand without asking for an agent", async () => { + await mount("codex_local", false, true, false, false, false, { + intent: { provider: "openai", method: "subscription", name: "OpenAI", ownership: "personal", agentIds: [], allAgents: false }, + onComplete: vi.fn(), + }, false, "authenticated", true, undefined, true); + + expect(mocks.loginPanel).not.toHaveBeenCalled(); + expect(host.querySelector('[aria-label="Sign-in computer"]')).toBeNull(); + click("Sign in to OpenAI"); + expect(mocks.loginPanel).toHaveBeenCalledWith(expect.objectContaining({ + agentId: undefined, environmentId: "e1", requiresAgent: true, autoStart: true, + })); + expect(host.querySelector('[aria-label="Sign-in computer"]')).toBeNull(); +}); + describe("AgentProviderConnection reuse", () => { it("keeps all three connection tiles visible while switching forms", async () => { await mount("codex_local", false, true, true, true, false, undefined, false, "local_trusted", true, { diff --git a/ui/src/components/new-agent/AgentProviderConnection.tsx b/ui/src/components/new-agent/AgentProviderConnection.tsx index ca599f51a0c..8ecd46a4162 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.tsx @@ -93,11 +93,15 @@ export function AgentProviderConnection({ setOpened(false); setAuthorizationUrl(null); setLoginPhase("preparing"); + setLoginRequested(false); }; const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(managedAccount?.initialMethod === "api_key" ? "api" : managedAccount ? "subscription" : null); const [advanced, setAdvanced] = useState(false); // Connections already selected the provider before showing this step. const [opened, setOpened] = useState(Boolean(advancedConnection || managedAccount)); + const [loginRequested, setLoginRequested] = useState(false); + const automaticComputer = requiresAgent && !agentId; + const awaitingLoginRequest = automaticComputer && !loginRequested; const [authorizationUrl, setAuthorizationUrl] = useState(null); const [loginPhase, setLoginPhase] = useState<"preparing" | "ready" | "waiting" | "connecting">("preparing"); const phaseBeforeSubmit = useRef<"ready" | "waiting">("ready"); @@ -370,7 +374,7 @@ export function AgentProviderConnection({ /> )} - ) : needsLogin ? ( + ) : needsLogin ? !awaitingLoginRequest && ( { if (needsLogin) { + if (awaitingLoginRequest) { + setLoginRequested(true); + return; + } if (!authorizationUrl || loginPhase !== "ready") return; window.open(authorizationUrl, "_blank", "noreferrer,noopener"); setLoginPhase("waiting");