mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +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>
172 lines
6.8 KiB
TypeScript
172 lines
6.8 KiB
TypeScript
import { test, expect, type Page } from "@playwright/test";
|
|
import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection";
|
|
import {
|
|
expectLandsOnFirstTaskWithoutDashboardBounce,
|
|
instrumentNavLog,
|
|
} from "./helpers/onboarding-landing";
|
|
|
|
/**
|
|
* E2E: post-wizard onboarding launch.
|
|
*
|
|
* Completing the onboarding wizard now creates the first assigned task and
|
|
* drops the user straight onto that task's detail page (not the dashboard),
|
|
* so they land in the conversation the agent will start in. The chat intro
|
|
* still has unit coverage in BoardChat tests.
|
|
*
|
|
* PAP-404: onboarding used to intermittently bounce to the company dashboard.
|
|
* The bounce only reproduces when the instance already has ≥1 company (the
|
|
* board's test ports), so the second test seeds a company first to exercise
|
|
* exactly that failing condition.
|
|
*/
|
|
|
|
const FIRST_TASK_TITLE = "Paperclip onboarding";
|
|
|
|
/**
|
|
* Intercept authentication, environment checks, and hiring so no real CLI check
|
|
* runs and no real agent process spawns (the hire still happens server-side
|
|
* with an inert http adapter).
|
|
*/
|
|
async function installLaunchIntercepts(page: Page, baseURL?: string) {
|
|
await mockOnboardingLocalAiConnection(page);
|
|
await page.route("**/test-environment", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ status: "pass", checks: [] }),
|
|
}),
|
|
);
|
|
|
|
await page.route("**/agent-hires", async (route) => {
|
|
const req = route.request();
|
|
const body = JSON.parse(req.postData() || "{}");
|
|
const auth = req.headers().authorization;
|
|
const real = await fetch(new URL(req.url(), baseURL).toString(), {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(auth ? { Authorization: auth } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
name: body.name,
|
|
role: body.role,
|
|
adapterType: "http",
|
|
adapterConfig: { url: "http://127.0.0.1:1/dead" },
|
|
runtimeConfig: { heartbeat: { enabled: false } },
|
|
}),
|
|
});
|
|
await route.fulfill({
|
|
status: real.status,
|
|
contentType: "application/json",
|
|
body: await real.text(),
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Drive the wizard from the /onboarding route through to "Get started". */
|
|
async function runOnboardingWizard(page: Page, companyName: string) {
|
|
await page.goto("/onboarding");
|
|
|
|
// Launcher card path (existing companies) — enter the wizard if the
|
|
// route shows a launcher instead of opening the wizard directly.
|
|
const startBtn = page.getByRole("button", { name: /Start Onboarding/i });
|
|
if (await startBtn.count()) await startBtn.first().click();
|
|
|
|
// Step 0: front door (skipped when the wizard opens on the create path).
|
|
const frontDoor = page.getByText("Build a new organization");
|
|
if (await frontDoor.count()) await frontDoor.first().click();
|
|
|
|
// Step 1: company name.
|
|
await page.getByPlaceholder("e.g. Northwind Labs").fill(companyName);
|
|
await page.getByRole("button", { name: /^Continue/ }).click();
|
|
|
|
// Step 1's "Next" creates the company; the mission step no longer runs.
|
|
|
|
// Step 3: name the agent. The role picker is gone — the arc asks for a
|
|
// name and hires under the neutral `general` role.
|
|
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
|
|
await page.locator("#onboarding-agent-name").fill("Ada");
|
|
await page.getByRole("button", { name: /^Next$/ }).click();
|
|
|
|
// Step 4: pick a model source, then advance. Nothing is selected on arrival
|
|
// — the row is a question, not a confirmation — so the CTA is disabled until
|
|
// a tile is pressed. By role rather than by label: which adapters the tiles
|
|
// offer depends on the registry this environment reports.
|
|
const source = page.getByRole("radio").first();
|
|
await source.waitFor({ timeout: 30_000 });
|
|
await source.click();
|
|
|
|
// "Connect", not "Next": this step's button starts the sign-in where there
|
|
// is one to start, so it is named for what it does. This test simulates
|
|
// successful local account connection before the environment check and hire.
|
|
//
|
|
// Waited on for enabled rather than for visible: it is already on screen,
|
|
// disabled, and clicking a disabled button raises nothing and does nothing.
|
|
const connectNext = page.getByRole("button", { name: /^Connect$/ });
|
|
await expect(connectNext).toBeEnabled({ timeout: 30_000 });
|
|
await connectNext.click();
|
|
|
|
// Step 5: review → Get started creates the first task and opens its
|
|
// detail page.
|
|
const getStarted = page.getByRole("button", { name: /Get started/ });
|
|
await getStarted.waitFor({ timeout: 20_000 });
|
|
await getStarted.click();
|
|
}
|
|
|
|
async function assertFirstTaskExists(page: Page, companyName: string) {
|
|
const companiesRes = await page.request.get("/api/companies");
|
|
expect(companiesRes.ok()).toBe(true);
|
|
const companies = await companiesRes.json();
|
|
const company = companies.find(
|
|
(candidate: { name: string }) => candidate.name === companyName,
|
|
);
|
|
expect(company).toBeTruthy();
|
|
|
|
const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`);
|
|
expect(issuesRes.ok()).toBe(true);
|
|
const issues = await issuesRes.json();
|
|
const firstTask = issues.find(
|
|
(candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE,
|
|
);
|
|
expect(firstTask).toBeTruthy();
|
|
await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
}
|
|
|
|
test.describe("First-task launch after onboarding wizard", () => {
|
|
test("creates the first task and opens its detail page", async ({ page, baseURL }) => {
|
|
await instrumentNavLog(page);
|
|
await installLaunchIntercepts(page, baseURL);
|
|
|
|
const companyName = `E2E-TypingIntro-${Date.now()}`;
|
|
await runOnboardingWizard(page, companyName);
|
|
|
|
await expectLandsOnFirstTaskWithoutDashboardBounce(page);
|
|
await assertFirstTaskExists(page, companyName);
|
|
});
|
|
|
|
// PAP-404 regression: the dashboard bounce only fires when the instance
|
|
// already has a company for the route-sync effect to reset selection to.
|
|
// Seed one first, then onboard a brand-new company and assert we still land
|
|
// on the first task without a dashboard bounce.
|
|
test("lands on the first task even when a company already exists", async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
await instrumentNavLog(page);
|
|
await installLaunchIntercepts(page, baseURL);
|
|
|
|
// Seed a pre-existing company so the companies list is non-empty when the
|
|
// wizard launches — the exact condition that reproduced the bounce.
|
|
const seedRes = await page.request.post("/api/companies", {
|
|
data: { name: `E2E-Seed-${Date.now()}` },
|
|
});
|
|
expect(seedRes.ok()).toBe(true);
|
|
|
|
const companyName = `E2E-TypingIntro-Existing-${Date.now()}`;
|
|
await runOnboardingWizard(page, companyName);
|
|
|
|
await expectLandsOnFirstTaskWithoutDashboardBounce(page);
|
|
await assertFirstTaskExists(page, companyName);
|
|
});
|
|
});
|