mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Users connect provider accounts during onboarding and agent setup. > - They should reuse and manage those accounts through the existing Connectors interface. > - A second login wizard would diverge from the established provider workflows. > - This pull request composes the existing sign-in components into Connections and agent configuration. > - Users can select accounts without changing their agent's harness or model. ## Linked Issues or Issue Description **Problem or motivation** AI credentials are configured separately from Connections. Agents cannot consistently reuse a responsible user's account or a permitted shared account. **Proposed solution** Manage AI accounts with the existing Connections grants and permissions. Keep model and harness selection independent from credential selection. Preserve legacy authentication until validated adoption. **Alternatives considered** A separate credential registry would duplicate ownership and access policy. Automatic fallback would risk using the wrong account. **Roadmap alignment** This extends the shipped Apps, multi-user, secrets, and agent-runtime capabilities. The maintainer requested the feature and reviewed the UI. Related groundwork: #11899 (connection permissions), #10910 (connection wizard), #11692 (Claude subscription profiles), and #11854 (Codex account rotation). ## What Changed - Add compact AI-account management to the existing Connectors pages. - Reuse AgentProviderConnection, AdapterLoginPanel, AdapterLoginChrome, and authentication controllers. - Add the shared connection picker to agent setup/settings and task requests. - Preserve onboarding's sequence and reuse existing accounts. - Add local-login recovery, retry, cancellation, and React StrictMode handling. - Add interactive Storybook scenarios, design-guide examples, and app acceptance checks. This is part 2 of the AI Connections change. The runtime foundation in #13247 is merged. This PR now targets master. ## Verification - Updated against master `47ded8bf9`, including the landed runtime foundation and upstream task-search changes. - Full workspace typecheck, production build, Storybook build, and token gates passed on the integrated branch. Final local-login changes passed 59 focused tests; new-agent and inbox regression suites passed 63 tests. - Browser checks verified automatic local Claude account detection, resumable Codex login commands, retry, focus restoration, and desktop/phone layouts. Commands create their isolated directory before invoking the CLI. - All CI test, browser, build, packaging, and runner jobs passed on final head `dd17d3211931dd70aaa6ea619d83a7f9966dd18e`. The fresh Greptile review is 5/5, the security scan passed, and there are no unresolved review threads. The final CI aggregate gates passed. - Local general-server coverage passed 11,804 tests; three port-collision failures passed in an isolated 25-test rerun. All 6,111 UI tests passed. CLI coverage passed 484 tests; its remaining doctor test requires port 3199, which is occupied by an unrelated report server on this Mac. The complete CLI suite passed in CI. - Live browser testing verified Codex API-key reconnect inside a task card on desktop and phone. Real provider runs resumed and completed with unchanged connection/grant identity and agent routing. - Tested opening, cancelling, reopening, and completing connection creation. A regression confirms Connect another account cannot submit the new-agent form or copy provider keys into agent settings. - Added shared inline repair, automatic local sign-in checks, and responsive connection dialogs. Standalone Daytona installation ignores workspace configuration and suppresses dependency scripts. Its standalone build also passed with CI's exact pnpm 9.15.4. - Destructive live tests are excluded by default. Explicit opt-in, local deployment checks, and matching disposable fixture identities are required before any mutation. ## Risks - Local Codex/Grok creation requires the connection-specific terminal login command. - Browser sign-in uses the existing supported-environment controllers. - This update verifies live local Claude detection and Codex API-key task repair. New subscription authorization/refresh and independent-human/native-runner isolation were not reverified in this update. - No agent automatically adopts managed Connections. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser testing. The exact runtime model identifier and context-window size are not exposed in this session. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
227 lines
16 KiB
TypeScript
227 lines
16 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
||
|
||
// Opt in against an isolated test drive; never seed or modify a production account.
|
||
const companyId = process.env.AI_CONNECTIONS_TEST_COMPANY_ID;
|
||
test.skip(!companyId, "Provide the isolated test-drive company ID");
|
||
let prefix: string;
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
const response = await request.get(`/api/companies/${companyId}`);
|
||
expect(response.ok()).toBe(true);
|
||
prefix = (await response.json()).issuePrefix;
|
||
});
|
||
|
||
test("existing Connections lists AI providers and keeps account management compact", async ({ page, request }, testInfo) => {
|
||
await page.goto(`/${prefix}/apps`);
|
||
for (const provider of ["Anthropic", "OpenAI", "OpenRouter", "Grok"]) {
|
||
await expect(page.getByRole("button", { name: new RegExp(`^(Add account|Connect) ${provider}$`) })).toBeVisible();
|
||
}
|
||
const { connections } = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json();
|
||
const account = connections.find((entry: { ownership: string }) => entry.ownership === "personal");
|
||
expect(account, "The isolated drive should include an imported personal account").toBeTruthy();
|
||
await page.goto(`/${prefix}/apps/${account.id}/permissions`);
|
||
await expect(page.getByRole("heading", { name: account.name, exact: true })).toBeVisible();
|
||
await expect(page.getByRole("heading", { name: "Personal default", exact: true })).toBeVisible();
|
||
await expect(page.getByRole("heading", { name: "Agent usage", exact: true })).toHaveCount(0);
|
||
await expect(page.getByRole("button", { name: "Back to Connectors", exact: true })).toHaveCount(0);
|
||
await expect(page.getByRole("heading", { name: "Which humans can use this credential?" })).toBeVisible();
|
||
await expect(page.getByRole("heading", { name: "Which agents can use this connection?" })).toBeVisible();
|
||
await expect(page.getByText("Authorized use for other users’ tasks", { exact: true })).toHaveCount(0);
|
||
await expect(page.getByRole("combobox", { name: "Authorize an agent" })).toHaveCount(0);
|
||
await page.screenshot({ path: testInfo.outputPath("account-details.png"), fullPage: true });
|
||
await page.getByRole("button", { name: "Reconnect", exact: true }).last().click();
|
||
await expect(page.getByText("Step 1 of 1", { exact: true })).toBeVisible();
|
||
await expect(page.getByLabel("Connection name")).toBeDisabled();
|
||
await expect(page.getByRole("heading", { name: "Which humans can use this credential?" })).toHaveCount(0);
|
||
await page.getByRole("button", { name: "Cancel", exact: true }).last().click();
|
||
});
|
||
|
||
test("rejected API credentials do not create a connection, and cancellation returns to Connections", async ({ page, request }, testInfo) => {
|
||
const before = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json();
|
||
await page.goto(`/${prefix}/apps/connect?source=openrouter&method=ai-api_key`);
|
||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||
await page.getByLabel("Connection name").fill("Rejected browser test account");
|
||
await page.getByRole("textbox", { name: "API key", exact: true }).fill("invalid-ai-connection-browser-test");
|
||
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
||
await expect(page.getByRole("alert")).toContainText(/rejected|Could not verify|could not verify/);
|
||
await expect(page.getByRole("textbox", { name: "API key", exact: true })).toHaveValue("");
|
||
const after = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json();
|
||
expect(after.connections.map((entry: { id: string }) => entry.id).sort()).toEqual(before.connections.map((entry: { id: string }) => entry.id).sort());
|
||
await page.getByRole("button", { name: "Cancel", exact: true }).last().click();
|
||
await expect(page).toHaveURL(new RegExp(`/${prefix}/apps$`));
|
||
});
|
||
|
||
test("legacy adoption and inline account cancellation preserve the agent configuration", async ({ page, request }, testInfo) => {
|
||
const agents = await (await request.get(`/api/companies/${companyId}/agents`)).json();
|
||
const agent = agents.find((entry: { adapterType: string; runtimeConfig: { aiConnection?: unknown } }) => ["claude_local", "codex_local", "grok_local"].includes(entry.adapterType) && !entry.runtimeConfig.aiConnection);
|
||
expect(agent, "The drive should include a legacy agent for adoption review").toBeTruthy();
|
||
await page.goto(`/${prefix}/agents/${agent.urlKey ?? agent.id}/runtime`);
|
||
await page.getByRole("button", { name: "Choose a managed connection", exact: true }).click();
|
||
await expect(page.getByRole("region", { name: "AI connection", exact: true })).toBeVisible();
|
||
await expect(page.getByText("Your personal accounts", { exact: true })).toHaveCount(0);
|
||
await page.getByRole("button", { name: "Connect another account", exact: true }).click();
|
||
await expect(page.getByRole("dialog")).toBeVisible();
|
||
await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click();
|
||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||
await expect(page.getByRole("button", { name: "Connect another account", exact: true })).toBeFocused();
|
||
await page.screenshot({ path: testInfo.outputPath("agent-ai-connection.png"), fullPage: true });
|
||
await page.getByRole("button", { name: /Responsible user’s connection/ }).click();
|
||
await expect(page.getByRole("dialog")).toContainText(`Adopt Connections for ${agent.name}`);
|
||
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
|
||
const after = await (await request.get(`/api/agents/${agent.id}`)).json();
|
||
expect(after.adapterType).toBe(agent.adapterType);
|
||
expect(after.adapterConfig).toEqual(agent.adapterConfig);
|
||
expect(after.runtimeConfig).toEqual(agent.runtimeConfig);
|
||
});
|
||
|
||
|
||
test("connection setup waits for provider details before enabling Continue", async ({ page }) => {
|
||
let release!: () => void;
|
||
const held = new Promise<void>(resolve => { release = resolve; });
|
||
await page.route(`**/api/companies/${companyId}/tools/gallery`, async route => {
|
||
await held;
|
||
await route.continue();
|
||
});
|
||
await page.goto(`/${prefix}/apps/connect?source=openrouter&method=ai-api_key`);
|
||
const next = page.getByRole("button", { name: /^(Save and continue|Continue)$/ });
|
||
await expect(next).toBeDisabled();
|
||
release();
|
||
await next.click();
|
||
await expect(page.getByLabel("Connection name")).toBeVisible();
|
||
await expect(page).toHaveURL(/stage=setup/);
|
||
});
|
||
|
||
test("new OpenRouter agents use the visible binding and provider model catalog", async ({ page }) => {
|
||
let tested: { aiConnection?: unknown; adapterConfig?: { model?: string } } | undefined;
|
||
await page.route(`**/api/companies/${companyId}/adapters/opencode_local/models*`, async route => {
|
||
expect(new URL(route.request().url()).searchParams.get("provider")).toBe("openrouter");
|
||
await route.fulfill({ json: [{ id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet 4.5" }] });
|
||
});
|
||
await page.route(`**/api/companies/${companyId}/adapters/opencode_local/test-environment`, async route => {
|
||
tested = route.request().postDataJSON();
|
||
await route.fulfill({ json: { status: "pass", checks: [], testedAt: new Date().toISOString() } });
|
||
});
|
||
await page.goto(`/${prefix}/agents/new?name=OpenRouter+binding+regression&adapterType=opencode_local`);
|
||
await expect(page.getByText("Existing authentication — not managed by Connections", { exact: true })).toHaveCount(0);
|
||
await expect(page.getByRole("button", { name: /Responsible user’s connection/ })).toHaveAttribute("aria-pressed", "true");
|
||
await page.getByRole("button", { name: "Select model (required)", exact: true }).click();
|
||
await page.getByRole("button", { name: "anthropic/claude-sonnet-4.5", exact: true }).click();
|
||
await page.getByRole("button", { name: "Run test", exact: true }).click();
|
||
await expect.poll(() => tested).toBeTruthy();
|
||
expect(tested?.aiConnection).toEqual({ provider: "openrouter", method: "api_key", mode: "responsible_user" });
|
||
expect(tested?.adapterConfig?.model).toBe("openrouter/anthropic/claude-sonnet-4.5");
|
||
});
|
||
|
||
test("ordinary Anthropic setup keeps the existing tool method available", async ({ page }) => {
|
||
await page.goto(`/${prefix}/apps/connect?source=anthropic`);
|
||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||
await expect(page.getByText("How do you want to connect?", { exact: true })).toBeVisible();
|
||
await page.getByRole("radio", { name: "Use an API key", exact: true }).click();
|
||
await expect(page.getByLabel("Your Anthropic key", { exact: true })).toBeVisible();
|
||
await expect(page.getByRole("radiogroup", { name: "Connect your model provider" })).toHaveCount(0);
|
||
await page.getByRole("button", { name: "Cancel", exact: true }).first().click();
|
||
await expect(page).toHaveURL(new RegExp(`/${prefix}/apps$`));
|
||
});
|
||
|
||
test("explicit OpenAI API method survives continuing and reloading", async ({ page }) => {
|
||
await page.goto(`/${prefix}/apps/connect?source=openai&method=ai-api_key`);
|
||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||
await expect(page).toHaveURL(/method=ai-api_key/);
|
||
await page.reload();
|
||
await page.getByRole("radio", { name: /OpenAI/ }).click();
|
||
await expect(page.getByLabel("API key", { exact: true })).toBeVisible();
|
||
await expect(page.getByText(/CODEX_HOME=/)).toHaveCount(0);
|
||
await page.getByRole("button", { name: "Cancel", exact: true }).first().click();
|
||
});
|
||
|
||
for (const [provider, label] of [["anthropic", "Claude"], ["openai", "OpenAI"]]) {
|
||
test(`Connections reuses the agent provider step for ${label}`, async ({ page }, testInfo) => {
|
||
await page.goto(`/${prefix}/apps/connect?source=${provider}&method=ai-subscription`);
|
||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||
await expect(page.getByRole("radiogroup", { name: "Connect your model provider" })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Connect for tool access instead" })).toHaveCount(0);
|
||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||
if (provider === "anthropic") {
|
||
await expect(page.getByText(/Connect uses your local/)).toBeVisible();
|
||
await expect(page.getByText("claude auth login", { exact: true })).toBeVisible();
|
||
} else {
|
||
await expect(page.getByText(/Your existing terminal login stays separate/)).toBeVisible();
|
||
const command = page.getByText(/^CODEX_HOME=.* codex login$/);
|
||
await expect(command).toBeVisible();
|
||
const preparedCommand = await command.textContent();
|
||
await page.reload();
|
||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||
await expect(command).toHaveText(preparedCommand!);
|
||
}
|
||
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled();
|
||
// Let the shared tile-collapse and card-enter animations settle for visual review.
|
||
await page.waitForTimeout(1000);
|
||
await page.screenshot({ path: testInfo.outputPath(`${provider}-shared-provider-step.png`), fullPage: true });
|
||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||
await page.getByRole("button", { name: "Use API key instead", exact: true }).click();
|
||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||
await expect(page.getByLabel("API key", { exact: true })).toBeVisible();
|
||
await expect(page.getByText(/Provide your .* API key to connect/)).toBeVisible();
|
||
await page.getByRole("button", { name: "Cancel", exact: true }).first().click();
|
||
await expect(page).toHaveURL(new RegExp(`/${prefix}/apps$`));
|
||
});
|
||
}
|
||
|
||
// Exercise the real onboarding login controllers; only provider/server replies
|
||
// are simulated. No credentials, sandbox leases or accounts are created here.
|
||
for (const [provider, label, adapter] of [["anthropic", "Claude", "claude_local"], ["openai", "OpenAI", "codex_local"]]) {
|
||
test(`Connections uses onboarding browser sign-in for ${label}`, async ({ page }, testInfo) => {
|
||
const environmentId = "11111111-1111-4111-8111-111111111111";
|
||
const sessionId = "22222222-2222-4222-8222-222222222222";
|
||
const base = `/api/companies/${companyId}`;
|
||
const sessions = provider === "anthropic" ? `${base}/setup-token-login-sessions` : `${base}/adapters/${adapter}/login-sessions`;
|
||
let starts = 0;
|
||
let cancels = 0;
|
||
let intent: Record<string, unknown> | undefined;
|
||
const session = () => ({ sessionId, environmentId, adapterType: adapter, status: provider === "anthropic" ? "awaiting_code" : "awaiting_user", expiresAt: new Date(Date.now() + 300000).toISOString(), aiConnection: intent, prompt: provider === "anthropic" ? { authorizationUrl: "https://provider.example/authorize" } : { url: "https://provider.example/authorize", code: "ABCD-EFGH" } });
|
||
await page.route(`**${base}/environments`, route => route.fulfill({ json: [{ id: environmentId, name: "Browser sign-in test sandbox", driver: "sandbox", status: "active", config: { provider: "browser-test" } }] }));
|
||
await page.route(`**${base}/environments/capabilities`, route => route.fulfill({ json: { sandboxProviders: { "browser-test": { supportsLoginPty: true } } } }));
|
||
await page.route(`**${sessions}**`, async route => {
|
||
const path = new URL(route.request().url()).pathname;
|
||
if (path.endsWith("/active")) return route.fulfill(starts ? { json: session() } : { status: 404, json: { error: "Not found" } });
|
||
if (path.endsWith("/cancel")) { cancels++; return route.fulfill({ json: {} }); }
|
||
if (path.endsWith("/prompt")) return route.fulfill({ json: { authorizationUrl: "https://provider.example/authorize" } });
|
||
if (path === sessions && route.request().method() === "POST") {
|
||
starts++;
|
||
const payload = route.request().postDataJSON();
|
||
expect(payload.environmentId).toBe(environmentId);
|
||
expect(payload.aiConnection.provider).toBe(provider);
|
||
intent = payload.aiConnection;
|
||
}
|
||
return route.fulfill({ json: session() });
|
||
});
|
||
await page.addInitScript(() => { window.open = (url) => { (window as unknown as { loginDestination: string }).loginDestination = String(url); return null; }; });
|
||
await page.goto(`/${prefix}/apps/connect?source=${provider}&method=ai-subscription`);
|
||
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
|
||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||
const signIn = page.getByRole("button", { name: `Sign in to ${label}`, exact: true });
|
||
await expect(signIn).toBeEnabled();
|
||
await expect(page.getByText(/login on this machine/)).toHaveCount(0);
|
||
if (provider === "anthropic") await expect(page.locator('input[type="password"]')).toBeVisible();
|
||
else await expect(page.getByText("ABCD-EFGH", { exact: true })).toBeVisible();
|
||
await signIn.click();
|
||
expect(await page.evaluate(() => (window as unknown as { loginDestination: string }).loginDestination)).toBe("https://provider.example/authorize");
|
||
await expect(page.getByRole("button", { name: "Waiting for code", exact: true })).toBeDisabled();
|
||
// The shared footer label animates; capture after it has settled.
|
||
await page.waitForTimeout(600);
|
||
await page.screenshot({ path: testInfo.outputPath(`${provider}-onboarding-browser-login.png`), fullPage: true });
|
||
if (provider === "anthropic") {
|
||
const submitted = page.waitForRequest(request => request.url().endsWith(`${sessionId}/code`) && request.method() === "POST");
|
||
await page.locator('input[type="password"]').fill("storybook-fixture-code");
|
||
await page.locator('input[type="password"]').press("Enter");
|
||
await submitted;
|
||
await expect(page.getByRole("button", { name: "Connecting", exact: true })).toBeDisabled();
|
||
}
|
||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||
await page.getByRole("radio", { name: new RegExp(label) }).click();
|
||
await expect(page.getByRole("button", { name: `Sign in to ${label}`, exact: true })).toBeEnabled();
|
||
expect(starts).toBe(1);
|
||
expect(cancels).toBe(0);
|
||
});
|
||
}
|