mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
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
This commit is contained in:
1 parent
eb5ad4c920
commit
25f0957c24
14 files changed
+215
-142
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { z } from "zod";
|
||||
export interface AdapterLoginStartResolution<TData> {
|
||||
ownerUserId: string;
|
||||
data: TData;
|
||||
targetAgentId: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,10 +55,9 @@ export interface AdapterLoginStartSpineInput<TData extends { environmentId: stri
|
||||
*/
|
||||
guardAfterValidate?: (data: TData) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* 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<void>;
|
||||
resolveTargetAgentId: (data: TData) => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,9 +71,9 @@ export interface AdapterLoginStartSpineInput<TData extends { environmentId: stri
|
||||
* 400 and returns null on a failure.
|
||||
* 4. It runs the optional post-validation guard. It returns null when the guard
|
||||
* sends a response.
|
||||
* 5. It checks the sandbox environment.
|
||||
* 5. It checks the login environment and resolves the transport target.
|
||||
*
|
||||
* It returns the owner and the validated body when every step passes. It returns
|
||||
* It returns the owner, validated body and transport target when every step passes. It returns
|
||||
* null when a step sends a response; the caller must then stop.
|
||||
*/
|
||||
export async function runAdapterLoginStartSpine<TData extends { environmentId: string }>(
|
||||
@@ -102,6 +102,6 @@ export async function runAdapterLoginStartSpine<TData extends { environmentId: s
|
||||
if (stop) return null;
|
||||
}
|
||||
|
||||
await input.assertSandbox(data);
|
||||
return { ownerUserId, data };
|
||||
const targetAgentId = await input.resolveTargetAgentId(data);
|
||||
return { ownerUserId, data, targetAgentId };
|
||||
}
|
||||
+21
-16
@@ -1,4 +1,4 @@
|
||||
import { assertLoginEnvironmentTarget } from "../services/login-environment.js";
|
||||
import { resolveLoginEnvironmentTarget } from "../services/login-environment.js";
|
||||
import { assertProviderApiPlacementPolicy, managedCursorApiDefaultEnabled, resolveProviderApiPlacement } from "../services/native-runtime/provider-api-placement.js";
|
||||
import { isObjectJournalBusyError } from "../services/object-byte-journal.js";
|
||||
import { readTaskPanelActivations } from "../services/plugin-task-panels.js";
|
||||
@@ -745,7 +745,7 @@ export function agentRoutes(
|
||||
options.setupTokenLogin?.completeCredential ?? deferredSetupTokenSecretWriter;
|
||||
|
||||
// Re-check the environment company binding at lease acquisition. The start
|
||||
// route runs `assertSandboxLoginEnvironment` before the session begins, but
|
||||
// route runs `resolveSandboxLoginTarget` before the session begins, but
|
||||
// managed-environment reconciliation can bind the sandbox to another company
|
||||
// between that guard and the lease acquire. This wrapper re-runs the same
|
||||
// guard at acquire time and fails closed with the 403
|
||||
@@ -754,7 +754,7 @@ export function agentRoutes(
|
||||
// insert, so a bind that lands during the provider call still holds no lease.
|
||||
const guardedSetupTokenLeaseManager: SetupTokenLeaseManager = {
|
||||
async acquire(input): Promise<SetupTokenLease> {
|
||||
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<void> {
|
||||
await assertLoginEnvironmentTarget(db, { companyId, environmentId, agentId: options?.agentId, adapterType: options?.adapterType });
|
||||
options: { requireSetupTokenLoginProvider?: boolean; agentId?: string; adapterType?: string; selectComputer?: boolean },
|
||||
): Promise<string | undefined> {
|
||||
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)),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 && (
|
||||
<div className="flex flex-col items-start gap-3 px-2">
|
||||
<p role="alert" className="text-sm text-muted-foreground">Retry sign-in to get a fresh code. Your setup is saved.</p>
|
||||
<p role="alert" className="text-sm text-muted-foreground">{failureMessage ?? "Retry sign-in to get a fresh code. Your setup is saved."}</p>
|
||||
{onRetry && <Button type="button" variant="outline" onClick={onRetry}>Retry sign-in</Button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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(),
|
||||
<AdapterLoginPanel requiresAgent companyId="company-1" adapterType={adapterType} environmentId="plugin-env"
|
||||
aiConnection={aiConnection} chrome="onboarding" autoStart />));
|
||||
|
||||
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({
|
||||
|
||||
@@ -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<string, unknown> | 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
|
||||
? <AgentLoginTarget companyId={props.companyId} environmentId={props.environmentId} agentId={props.agentId}>
|
||||
{agentId => <Panel key={agentId} {...props} agentId={agentId} />}
|
||||
</AgentLoginTarget>
|
||||
: <Panel {...props} agentId={undefined} />;
|
||||
const agentId = props.requiresAgent ? props.agentId : undefined;
|
||||
return <Panel key={agentId} {...props} agentId={agentId} />;
|
||||
}
|
||||
|
||||
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<string | null>(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<string | null>(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({
|
||||
<ProviderSubscriptionCard
|
||||
loading={retryLogin.isPending || (!prompt && !failure)}
|
||||
failure={failure}
|
||||
failureMessage={automaticComputer ? startError ?? undefined : undefined}
|
||||
onRetry={!onRetryReady && retryReady ? retryAction : undefined}
|
||||
providerName={connectSourceName(adapterType)}
|
||||
authorizationUrl={prompt?.url}
|
||||
@@ -3143,6 +3146,7 @@ const CLAUDE_LOGIN_TIMED_OUT_MESSAGE = "The login timed out. Start the login aga
|
||||
// only the server `stored` state as success, and it never shows the OAuth token.
|
||||
function SubmittedBrowserCodeLoginPanel({
|
||||
active: loginActive = true,
|
||||
requiresAgent,
|
||||
companyId,
|
||||
adapterType,
|
||||
environmentId,
|
||||
@@ -3161,6 +3165,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
onRetryReady,
|
||||
}: AdapterLoginPanelProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const automaticComputer = Boolean(requiresAgent && !agentId && aiConnection);
|
||||
const cancellation = useAdapterLoginCancellation(loginActive, companyId, adapterType, true, sessionId, { environmentId, aiConnection });
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
@@ -3328,7 +3333,11 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
try {
|
||||
const active = await cancellation.resume(() => agentsApi.getActiveClaudeSetupTokenLoginSession(companyId, { 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 Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one.");
|
||||
const wrongComputer = !automaticComputer && active.agentId !== agentId;
|
||||
const wrongConnection = aiConnection
|
||||
? !matchesAdapterLoginTarget(active, { environmentId, aiConnection })
|
||||
: Boolean(active.aiConnection);
|
||||
if (wrongComputer || wrongConnection) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one.");
|
||||
return active;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
@@ -3733,6 +3742,7 @@ function SubmittedBrowserCodeLoginPanel({
|
||||
<ProviderSubscriptionCard
|
||||
loading={retryLogin.isPending || (!authorizationUrl && !failure)}
|
||||
failure={failure}
|
||||
failureMessage={automaticComputer ? startError ?? undefined : undefined}
|
||||
onRetry={!onRetryReady && retryReady ? retryAction : undefined}
|
||||
providerName={connectSourceName(adapterType)}
|
||||
authorizationUrl={authorizationUrl ?? undefined}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { AgentLoginTarget } from "./AgentLoginTarget";
|
||||
const mocks = vi.hoisted(() => ({ 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(<QueryClientProvider client={client}><AgentLoginTarget companyId="company" environmentId="environment" agentId={agentId}>{target => <div data-target={target}>Login ready</div>}</AgentLoginTarget></QueryClientProvider>)); }
|
||||
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<HTMLElement>('[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<HTMLElement>('[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);
|
||||
});
|
||||
@@ -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 <p role="status" className="text-sm text-muted-foreground">Finding sign-in computers…</p>;
|
||||
const error = views.find(view => view.kind === "error");
|
||||
if (error) return <QueryErrorState size="inline" error={error.error} action="load sign-in computers" onRetry={error.retry} retrying={error.isFetching} />;
|
||||
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 <div className="space-y-4">
|
||||
<Select value={target?.id ?? ""} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label="Sign-in computer"><SelectValue placeholder="Choose a sign-in computer" /></SelectTrigger>
|
||||
<SelectContent>{eligible.map(agent => <SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
{!eligible.length && <p className="text-sm text-muted-foreground">An agent computer must finish setup before you can sign in.</p>}
|
||||
{target && children(target.id)}
|
||||
</div>;
|
||||
}
|
||||
@@ -56,6 +56,7 @@ async function mount(
|
||||
deploymentMode: "local_trusted" | "authenticated" = "local_trusted",
|
||||
localAiLoginSupported = true,
|
||||
advancedConnection?: Parameters<typeof AgentProviderConnection>[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, {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [loginPhase, setLoginPhase] = useState<"preparing" | "ready" | "waiting" | "connecting">("preparing");
|
||||
const phaseBeforeSubmit = useRef<"ready" | "waiting">("ready");
|
||||
@@ -370,7 +374,7 @@ export function AgentProviderConnection({
|
||||
/>
|
||||
)}
|
||||
</OnboardingLoginCard>
|
||||
) : needsLogin ? (
|
||||
) : needsLogin ? !awaitingLoginRequest && (
|
||||
<AdapterLoginPanel
|
||||
requiresAgent={requiresAgent}
|
||||
agentId={agentId}
|
||||
@@ -447,7 +451,7 @@ export function AgentProviderConnection({
|
||||
opened && needsLogin
|
||||
? loginPhase === "waiting" ? adapterType === "codex_local" ? "Waiting for auth…" : "Waiting for code"
|
||||
: loginPhase === "connecting" ? "Connecting"
|
||||
: `Sign in to ${provider}`
|
||||
: automaticComputer && authorizationUrl ? `Continue to ${provider}` : `Sign in to ${provider}`
|
||||
: busy
|
||||
? "Connecting"
|
||||
: method === "subscription" &&
|
||||
@@ -468,16 +472,20 @@ export function AgentProviderConnection({
|
||||
// The saved-subscription chooser renders outside the opened section,
|
||||
// so reusing the visible choice must not wait for a tile click.
|
||||
(!opened && !(method === "subscription" && savedSubscription)) ||
|
||||
(Boolean(needsLogin) && (!authorizationUrl || loginPhase !== "ready")) ||
|
||||
(Boolean(needsLogin) && !awaitingLoginRequest && (!authorizationUrl || loginPhase !== "ready")) ||
|
||||
(method === "api" &&
|
||||
!apiKey.trim() &&
|
||||
!storedConnection &&
|
||||
!selectedKey)
|
||||
}
|
||||
loading={busy}
|
||||
primaryIcon={opened && needsLogin ? loginPhase === "ready" ? "none" : "spinner" : undefined}
|
||||
primaryIcon={opened && needsLogin && !awaitingLoginRequest ? loginPhase === "ready" ? "none" : "spinner" : undefined}
|
||||
onPrimary={() => {
|
||||
if (needsLogin) {
|
||||
if (awaitingLoginRequest) {
|
||||
setLoginRequested(true);
|
||||
return;
|
||||
}
|
||||
if (!authorizationUrl || loginPhase !== "ready") return;
|
||||
window.open(authorizationUrl, "_blank", "noreferrer,noopener");
|
||||
setLoginPhase("waiting");
|
||||
|
||||
Reference in new issue
Block a user