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");