diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index d8a0369f72..9515797d8b 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -209,3 +209,16 @@ does not delete a directory referenced by a copied command. **Start sign-in agai explicitly cancels the old attempt; abandoned attempts expire after 30 minutes. Commands create their directory if necessary, and completed/expired attempts are cleaned up through the existing lifecycle. + +### Disposable live inline-repair test + +The normal app test configuration excludes `*.live.spec.ts`. To run the destructive +inline-repair scenario, set `AI_REPAIR_TEST_ALLOW_DESTRUCTIVE=1` and use a separate +loopback `local_trusted` instance. Set `AI_REPAIR_TEST_DISPOSABLE_MARKER` to a fresh +32-character lowercase hexadecimal value. The company, single Codex agent, single +personal OpenAI API connection, and issue must all be named `AI Repair QA ` +(the issue uses that title). Supply their IDs with `AI_CONNECTIONS_TEST_COMPANY_ID`, +`AI_REPAIR_TEST_CONNECTION_ID`, and `AI_REPAIR_TEST_ISSUE_ID`, and the disposable +provider key with `AI_REPAIR_TEST_KEY`. The test verifies these boundaries before +revoking credentials or submitting work. Delete the disposable instance and revoke +its provider key after the test; failed tests may leave a paused task for inspection. diff --git a/doc/plans/2026-09-10-ai-connections-review.md b/doc/plans/2026-09-10-ai-connections-review.md new file mode 100644 index 0000000000..af0df72fd4 --- /dev/null +++ b/doc/plans/2026-09-10-ai-connections-review.md @@ -0,0 +1,81 @@ +# AI Connections — Storybook review milestone + +Status: UI review approved. The app integration is implemented; see [AI Connections](../connections/AI-CONNECTIONS.md) for contracts, runtime selection, adoption, and verification. + +## Start here + +- [Existing Connectors page with AI accounts](http://localhost:6116/?path=/story/ai-connections-review--provider-catalog) +- [Account details in the existing page](http://localhost:6116/?path=/story/ai-connections-review--management) +- [Add account through the existing setup flow](http://localhost:6116/?path=/story/ai-connections-review--connect-from-existing-catalog) +- [Existing inline task connection host](http://localhost:6116/?path=/story/ai-connections-review--inline-task-connection) +- [Review index](http://localhost:6116/?path=/story/ai-connections-review--review-index) + +The old `provider-catalog` URL is retained so links still work. It now renders the real `Browse` page, with AI accounts alongside GitHub and Gmail. It is not another product screen or another provider catalog. + +Build and serve with `pnpm build-storybook` and `node scripts/serve-storybook-static.mjs --port 6116`. Alternatively run `pnpm storybook` on its normal development port. + +The agent picker omits the personal-account inventory and its default/authorization actions. It retains the responsible-user default preview and compatible shared or already-authorized selections. **Change Personal Default** now exercises the existing account detail page; **Authorize Personal** isolates the owner-consent dialog. + +Account details are deliberately compact: a personal-default row with a colored star/check when active, plus credential identity and reconnect/revoke actions. Agent usage and the redundant back button are removed. The preview includes the existing BreadcrumbBar for navigation. Revocation details remain in the confirmation dialog. + +## Reading the story frames + +Every AI review story has a **Storybook only · Review guide** above the preview. It identifies the intended app location, existing app components, proposed components, and simulated wrapper/state. Dashed boundaries mark review annotations, not product UI. + +Agent preview headings, harness/model values, form buttons, and provider simulation controls are labeled **Storybook only**. The picker/authentication composition has its own marked component boundary. Real Connectors pages identify the new AI-only section inside the existing page; task stories mark the existing request component. These annotations live exclusively under `ui/storybook/` and do not appear in production. + +## Existing components investigated and reused + +The current `/:company/apps` route renders **Browse**, not the older Connections page. Its actual provider groups, account rows, search, Add account buttons, status icons, owner identities, and management menus are mounted in the stories. A small optional account-detail slot adds AI sign-in method, personal/shared identity, default, and delegation metadata to its existing rows. + +| Existing component | Reuse in this milestone | +| --- | --- | +| `pages/apps/Browse.tsx` | Real Connectors list; existing provider groups and account rows. No standalone AI list. | +| `pages/apps/AppDetail.tsx` | Existing header, naming, identities, permission loading and account status. | +| `app-detail/IdentitiesSection.tsx` | Existing personal/company ownership display, member audience selection, and revoke confirmation dialog. | +| `app-detail/PermissionsPanel.tsx` | Existing agent access radio cards and agent selector. Only the irrelevant tool-action section is replaced with AI account/default controls. | +| `app-detail/AdvancedPanel.tsx` | Existing reconnect banner with an optional provider-auth callback, behind its existing permission check. | +| `features/connections/ConnectionSetupFlow.tsx` | Existing branded setup shell, human/agent access step, navigation, cancellation and reuse flow. Provider login is composed in a credential-content slot. | +| `features/connections/ConnectionIntentInteractionBody.tsx` | Real task card, modal, existing-account choice, completion and return-focus lifecycle. | +| `ConnectionChoiceList` | Extracted from the existing setup flow's account-reuse rows. Both that flow and the AI agent picker render this component. | +| `pages/apps/AppLogo.tsx` | Existing branding component in AI identity summaries; handles local and dark assets. | +| `AdapterLoginChrome`, `AgentConfigForm`, `AgentProviderConnection` | Existing subscription card/code/input presentation extracted into shared wrappers; live lifecycle hooks remain owned by the existing hosts. | +| `OnboardingWizard`, `ModelSourceTiles`, `CredentialModeLink` | Existing onboarding provider/method controls and API credential card remain shared. | + +The separate `AiProviderPicker`, `AiConnectionRow`, and standalone AI management form have been removed. New AI-specific presentation is limited to agent binding selection, personal defaults/delegation, AI account controls, and controlled auth states. The design guide explains these boundaries and shows the shared picker and credential presentation. + +## Fixture boundaries + +`AiConnectorPages` mounts real route components against an isolated in-memory API. `AiTaskConnectionReview` mounts the real connection-request host. They use the production provider catalog, including the existing `anthropic` entry, and the `ai` / `runtime_auth` discriminator. Accounts and provider responses remain fixtures. + +`AiConnectionsReview` covers proposed agent binding/default behavior with deterministic configuration data. Its agent/onboarding hosts are composition previews, not production route integration. The new-agent and onboarding login presentation uses the extracted existing authentication components. + +No fixture contains credential material. Input values are cleared after submission; provider completion is simulated. No story signs in to a provider or grants real access. Personal-default resolution and delegation checks in `model.ts` are presentation validation, not server authorization. + +## Review and verification + +Review the Connectors list first, then open an account, add one, reconnect, reuse it in a task, and exercise the AI binding picker. Theme and viewport controls cover desktop/narrow and light/dark. Keyboard coverage includes the shared chooser buttons, dialogs, and return focus after cancellation/completion. Harness/model values are asserted unchanged by connection selection. + +```sh +pnpm --filter @paperclipai/ui typecheck +pnpm check:token-gates +pnpm --filter @paperclipai/ui exec vitest run src/pages/apps/Browse.test.tsx src/pages/apps/AppDetail.test.tsx src/pages/apps/AppsConnect.test.tsx src/features/connections/ConnectionIntentInteractionBody.test.tsx src/components/ai-connections src/components/AdapterLoginChrome.test.tsx src/components/OnboardingWizard.adapters.test.tsx src/components/OnboardingWizard.test.tsx +pnpm build-storybook +pnpm exec playwright test --config tests/ai-connections-review/playwright.config.ts +``` + +Verified: 277 focused Vitest checks, 53 browser checks across 48 stories, UI typecheck, token gates, and Storybook build. Desktop and narrow screenshots were inspected in light/dark themes. + +Browser checks load every AI review story, await its interaction assertions, reject rendering/play errors, verify review-index links, test keyboard selection, and capture light/dark layouts at desktop and narrow widths while checking overflow. Screenshots remain ignored local test artifacts. + +The counts above record the original UI review. Integration verification is recorded in the implementation handoff; live provider verification requires valid accounts and a supported sign-in environment. + +## Agreed implementation after review + +- Extend existing applications/connections/grants/installations/delegations with an AI purpose and runtime-auth transport. Reuse encrypted secret storage and company access checks; keep tool/channel execution separate. +- Add typed agent bindings and personal defaults keyed by company, user, provider, and sign-in method. First successful personal connection becomes default only when none exists. Additional defaults require explicit selection; revocation never chooses a replacement. +- Resolve personal defaults from the run's responsible user. Shared and dedicated personal bindings are explicit. Only the owner can authorize their personal account across responsible users. +- Resolve the chosen grant before local, sandbox, native-runner, and test execution. Prevent inherited credentials or cached homes from overriding it; partition session/auth reuse by grant identity, preserve refresh ownership, and report actionable missing-access blockers. +- Keep provider, method, harness, and model routing fixed during connection selection. Start with Claude, OpenAI/ChatGPT, OpenRouter, and Grok/xAI, using only supported existing authentication methods and harness integrations. +- Preserve legacy execution until adoption. Index only credentials with reliable ownership; never infer ownership from account-home paths. Test and explicitly save the replacement binding; do not restore legacy fallback after adoption. +- Schema/API, runtime integration, adoption, and production UI wiring are implemented after approval. There is no separate AI Connections feature flag. diff --git a/packages/plugins/sandbox-providers/daytona/pnpm-workspace.yaml b/packages/plugins/sandbox-providers/daytona/pnpm-workspace.yaml new file mode 100644 index 0000000000..7a2a114c0b --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - '.' + +# The SDK ships generated protobuf code; its optional install script is not needed. +allowBuilds: + protobufjs: false diff --git a/server/src/__tests__/plugin-install-autobuild.test.ts b/server/src/__tests__/plugin-install-autobuild.test.ts index 158bef7aaa..d9d3a59961 100644 --- a/server/src/__tests__/plugin-install-autobuild.test.ts +++ b/server/src/__tests__/plugin-install-autobuild.test.ts @@ -213,12 +213,18 @@ describe("ensureLocalPluginBuilt", () => { expect(execStub).not.toHaveBeenCalled(); }); - it("bootstraps standalone bundled plugins before building them", async () => { + it.each([ + undefined, + "allowBuilds:\n protobufjs: false\n", + "packages:\n - ../untrusted\ndangerouslyAllowAllBuilds: true\n", + ])("bootstraps standalone plugins without trusting workspace policy: %s", async (localPolicy) => { const fixture = await createBundledPluginFixture("standalone", { rootDir: standaloneRepoPluginRoot }); cleanupPaths.add(fixture.packageRoot); + if (localPolicy) await writeFile(path.join(fixture.packageRoot, "pnpm-workspace.yaml"), localPolicy); + const installArgs = ["install", "--ignore-workspace", ...(localPolicy ? ["--ignore-scripts"] : []), "--no-lockfile"]; const execStub = vi.fn(async (_file: string, args: readonly string[]) => { - if (args.join(" ") === "install --ignore-workspace --no-lockfile") { + if (args.join(" ") === installArgs.join(" ")) { await mkdir(path.join(fixture.packageRoot, "node_modules", "@paperclipai", "plugin-sdk"), { recursive: true }); } if (args.join(" ") === "build") { @@ -239,7 +245,7 @@ describe("ensureLocalPluginBuilt", () => { expect(execStub).toHaveBeenNthCalledWith( 1, "pnpm", - ["install", "--ignore-workspace", "--no-lockfile"], + installArgs, { cwd: fixture.packageRoot, timeout: 120_000 }, ); expect(execStub).toHaveBeenNthCalledWith( diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 52d7843031..a72e5db2ef 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -793,9 +793,11 @@ function buildStandaloneBundledPluginInstallArgs( packageRoot: string, ): string[] { const packageLockfilePath = path.join(packageRoot, "pnpm-lock.yaml"); - return existsSync(packageLockfilePath) - ? ["install", "--ignore-workspace", "--frozen-lockfile"] - : ["install", "--ignore-workspace", "--no-lockfile"]; + // Never let plugin-supplied workspace settings broaden dependency resolution + // or script execution. When a plugin declares a local install policy, disable + // dependency lifecycle scripts instead of loading that workspace configuration. + const scriptArgs = existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) ? ["--ignore-scripts"] : []; + return ["install", "--ignore-workspace", ...scriptArgs, existsSync(packageLockfilePath) ? "--frozen-lockfile" : "--no-lockfile"]; } function buildStandaloneBundledPluginInstallCommand( diff --git a/tests/ai-connections-app/app.spec.ts b/tests/ai-connections-app/app.spec.ts new file mode 100644 index 0000000000..1f1036e572 --- /dev/null +++ b/tests/ai-connections-app/app.spec.ts @@ -0,0 +1,226 @@ +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(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 | 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); + }); +} diff --git a/tests/ai-connections-app/inline-repair.live.spec.ts b/tests/ai-connections-app/inline-repair.live.spec.ts new file mode 100644 index 0000000000..95bea5bf75 --- /dev/null +++ b/tests/ai-connections-app/inline-repair.live.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from "@playwright/test"; + +// Explicitly opt in with a disposable task/account and a real provider key. +// All writes use the UI. API reads only verify identity and execution outcomes. +const disposableMarker = process.env.AI_REPAIR_TEST_DISPOSABLE_MARKER; +const destructiveOptIn = process.env.AI_REPAIR_TEST_ALLOW_DESTRUCTIVE === "1"; +const companyId = process.env.AI_CONNECTIONS_TEST_COMPANY_ID; +const issueId = process.env.AI_REPAIR_TEST_ISSUE_ID; +const connectionId = process.env.AI_REPAIR_TEST_CONNECTION_ID; +const providerKey = process.env.AI_REPAIR_TEST_KEY; +test.use({ trace: "off", video: "off" }); +test.skip(!destructiveOptIn || !disposableMarker || !companyId || !issueId || !connectionId || !providerKey, "Live repair requires explicit disposable fixtures and a provider key"); + +test("repair the selected AI account inside the task and continue without another message", async ({ page, request }, testInfo) => { + test.setTimeout(240_000); + if (process.env.AI_REPAIR_TEST_NARROW === "1") await page.setViewportSize({ width: 390, height: 844 }); + // Fail before any mutation unless every target belongs to the same explicitly + // marked disposable fixture. Never run this scenario against a remote host. + const origin = new URL(testInfo.project.use.baseURL!); + expect(origin.protocol).toBe("http:"); + expect(["127.0.0.1", "[::1]"]).toContain(origin.hostname); + expect(disposableMarker).toMatch(/^[a-f0-9]{32}$/); + const fixtureName = `AI Repair QA ${disposableMarker}`; + const health = await (await request.get("/api/health")).json(); + expect(health.deploymentMode).toBe("local_trusted"); + const companies = await (await request.get("/api/companies")).json(); + const company = companies.find((company: { id: string }) => company.id === companyId); + expect(company).toMatchObject({ id: companyId, name: fixtureName }); + const prefix = company.issuePrefix; + const taskBefore = await (await request.get(`/api/issues/${issueId}`)).json(); + const agentBefore = await (await request.get(`/api/agents/${taskBefore.assigneeAgentId}`)).json(); + expect(taskBefore).toMatchObject({ companyId, title: fixtureName }); + expect(agentBefore).toMatchObject({ companyId, name: fixtureName, adapterType: "codex_local" }); + const agents = await (await request.get(`/api/companies/${companyId}/agents`)).json(); + expect(agents.map((agent: { id: string }) => agent.id)).toEqual([agentBefore.id]); + const list = async () => (await (await request.get(`/api/companies/${companyId}/ai-connections`)).json()).connections; + const before = await list(); + const accountBefore = before.find((connection: { id: string }) => connection.id === connectionId); + expect(before).toHaveLength(1); + expect(accountBefore).toMatchObject({ companyId, name: fixtureName, provider: "openai", method: "api_key", ownership: "personal" }); + expect(accountBefore.isDefault).toBe(true); + expect(["connected", "revoked"]).toContain(accountBefore.status); + + await page.goto(`/${prefix}/apps/${connectionId}/permissions`); + if (accountBefore.status === "connected") { + await page.getByRole("button", { name: "Revoke identity", exact: true }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Revoke identity", exact: true }).click(); + } + await expect(page.locator("header").getByText("Revoked", { exact: true })).toBeVisible(); + await page.goto(`/${prefix}/issues/${issueId}`); + let proof = `QA-IN-CARD-REPAIR-${Date.now()}: 1147`; + const pending = (await (await request.get(`/api/issues/${issueId}/interactions`)).json()).some((interaction: {kind: string; status: string; payload: {purpose?: string}}) => interaction.kind === "connection_intent" && interaction.status === "pending" && interaction.payload.purpose === "ai"); + if (pending) { + const comments = await (await request.get(`/api/issues/${issueId}/comments`)).json(); + proof = comments.map((comment: {body: string}) => comment.body.match(/QA-IN-CARD-REPAIR-\d+: 1147/)?.[0]).filter(Boolean).at(-1); + expect(proof).toBeTruthy(); + } else { + await page.getByRole("textbox", { name: "editable markdown" }).fill(`Inline repair acceptance: calculate 31 * 37. Post exactly ${proof}, then mark Done. This first attempt should block on my revoked default; I will reconnect it inside this task. Do not change configuration, create subtasks, or modify files.`); + await page.getByRole("button", { name: "Send", exact: true }).click(); + } + const fix = page.getByRole("button", { name: "Fix connection", exact: true }); + await expect(fix).toBeVisible({ timeout: 60_000 }); + await fix.click(); + const inline = page.getByTestId("ai-connection-inline-repair"); + await expect(inline.getByLabel("Connection name")).toHaveValue(accountBefore.name); + await expect(inline.getByLabel("Connection name")).toBeDisabled(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await inline.getByRole("button", { name: /^(Back|Cancel)$/ }).click(); + await expect(page.getByTestId("ai-connection-inline-repair")).toHaveCount(0); + await expect(page.getByTestId("connection-intent-focus-target").filter({ has: fix })).toBeFocused(); + await fix.click(); + await inline.getByRole("radio", { name: "OpenAI API", exact: true }).click(); + const keyField = inline.getByPlaceholder("Enter API key here"); + await expect(keyField).toBeVisible(); + await inline.screenshot({ path: testInfo.outputPath("inline-repair-before.png") }); + // Never include credentials in a failed action's error/trace output. + try { await keyField.fill(providerKey!); } catch { throw new Error("Could not fill the private credential field"); } + await inline.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.getByTestId("ai-connection-inline-repair")).toHaveCount(0, { timeout: 60_000 }); + await expect(page.getByText(proof, { exact: true })).toBeVisible({ timeout: 120_000 }); + await expect.poll(async () => (await (await request.get(`/api/issues/${issueId}`)).json()).status).toBe("done"); + const after = await list(); + expect(after).toHaveLength(before.length); + expect(after.find((connection: { id: string }) => connection.id === connectionId)).toMatchObject({ id: connectionId, grantId: accountBefore.grantId, isDefault: true, status: "connected" }); + const agentAfter = await (await request.get(`/api/agents/${taskBefore.assigneeAgentId}`)).json(); + expect(agentAfter.adapterType).toBe(agentBefore.adapterType); + expect(agentAfter.adapterConfig).toEqual(agentBefore.adapterConfig); + expect(agentAfter.runtimeConfig).toEqual(agentBefore.runtimeConfig); + await page.screenshot({ path: testInfo.outputPath("inline-repair-completed.png"), fullPage: true }); +}); diff --git a/tests/ai-connections-app/playwright.config.ts b/tests/ai-connections-app/playwright.config.ts new file mode 100644 index 0000000000..4d2c474814 --- /dev/null +++ b/tests/ai-connections-app/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + testIgnore: process.env.AI_REPAIR_TEST_ALLOW_DESTRUCTIVE === "1" ? [] : ["**/*.live.spec.ts"], + outputDir: "./test-results", + timeout: 45_000, + workers: 1, + reporter: "list", + use: { + baseURL: process.env.AI_CONNECTIONS_TEST_URL ?? "http://127.0.0.1:3100", + browserName: "chromium", + reducedMotion: "reduce", + screenshot: "only-on-failure", + }, +}); diff --git a/tests/ai-connections-review/playwright.config.ts b/tests/ai-connections-review/playwright.config.ts new file mode 100644 index 0000000000..75b89da5f8 --- /dev/null +++ b/tests/ai-connections-review/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + outputDir: "./test-results", + timeout: 30_000, + workers: 2, + reporter: "list", + use: { + baseURL: "http://localhost:6116", + browserName: "chromium", + reducedMotion: "reduce", + }, + webServer: { + command: "node ../../scripts/serve-storybook-static.mjs --port 6116", + url: "http://localhost:6116/index.json", + reuseExistingServer: true, + }, +}); diff --git a/tests/ai-connections-review/review.spec.ts b/tests/ai-connections-review/review.spec.ts new file mode 100644 index 0000000000..026c6d4634 --- /dev/null +++ b/tests/ai-connections-review/review.spec.ts @@ -0,0 +1,157 @@ +import { readFileSync } from "node:fs"; +import { test, expect } from "@playwright/test"; + +const index = JSON.parse( + readFileSync( + new URL("../../ui/storybook-static/index.json", import.meta.url), + "utf8", + ), +); +const stories = Object.keys(index.entries).filter((id) => + id.startsWith("ai-connections-review--"), +); + +for (const id of stories) { + test(id, async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if ( + message.type() === "error" && + !message.text().startsWith("Failed to load resource") + ) + errors.push(message.text()); + }); + await page.route("**/*", (route) => + new URL(route.request().url()).hostname === "localhost" + ? route.continue() + : route.abort(), + ); + await page.goto(`/iframe.html?id=${id}&viewMode=story`); + // Completing follows the awaited play function, including its assertions. + await page.waitForFunction(() => { + const preview = ( + window as unknown as { + __STORYBOOK_PREVIEW__?: { currentRender?: { phase: string } }; + } + ).__STORYBOOK_PREVIEW__; + return ["completing", "completed", "finished", "errored"].includes( + preview?.currentRender?.phase ?? "", + ); + }); + await expect + .poll(() => + page.evaluate(() => + Boolean( + document.querySelector("#storybook-root")?.textContent || + document.querySelector('[role="dialog"]')?.textContent, + ), + ), + ) + .toBe(true); + expect(errors, `Story/play errors for ${id}`).toEqual([]); + await expect(page.getByTestId("ai-review-frame")).toContainText("Already in the app"); + await expect(page.getByTestId("ai-review-frame")).toContainText("Storybook simulation"); + if (id.endsWith("responsible-user")) { + await expect(page.getByTestId("ai-review-preview")).toContainText("Example page context · Storybook only"); + await expect(page.getByTestId("ai-component-boundary")).toContainText("App component: AiConnectionPicker"); + await expect(page.getByText("Your personal accounts", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: /Make default|Authorize for/ })).toHaveCount(0); + await expect(page.getByText("For you: My Claude subscription", { exact: true })).toBeVisible(); + } + if (id.endsWith("review-index")) { + const links = await page + .locator("#storybook-root a") + .evaluateAll((anchors) => + anchors.map((anchor) => (anchor as HTMLAnchorElement).href), + ); + for (const href of links) { + const url = new URL(href); + expect(url.pathname).toBe("/"); + expect( + index.entries[url.searchParams.get("path")!.replace("/story/", "")], + ).toBeTruthy(); + } + } + }); +} + +for (const theme of ["light", "dark"]) { + for (const width of [390, 1200]) { + test(`layout ${theme} ${width}`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 960 }); + for (const story of [ + "responsible-user", + "claude-subscription", + "identity-matrix", + "management", + ]) { + await page.goto( + `/iframe.html?id=ai-connections-review--${story}&viewMode=story&globals=theme:${theme}`, + ); + await expect + .poll(() => + page.evaluate(() => + Boolean( + document.querySelector("#storybook-root")?.textContent || + document.querySelector('[role="dialog"]')?.textContent, + ), + ), + ) + .toBe(true); + if (story === "management") await expect(page.getByLabel("AI account settings")).toBeVisible(); + if (story === "identity-matrix") await expect(page.getByRole("button", { name: "Add account Anthropic" })).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ) + .toBe(true); + await page.screenshot({ + path: testInfo.outputPath(`${story}-${theme}-${width}.png`), + fullPage: true, + animations: "disabled", + }); + } + }); + } +} + +test("shared connection chooser keyboard navigation preserves runtime", async ({ page }) => { + await page.goto( + "/iframe.html?id=ai-connections-review--responsible-user&viewMode=story", + ); + const first = page.getByRole("button", { name: "Responsible user’s connection", exact: true }); + await first.focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Enter"); + await expect( + page.getByRole("button", { name: "Engineering Claude", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + page.getByRole("button", { name: "Engineering Claude", exact: true }), + ).toBeFocused(); + await expect(page.getByTestId("ai-harness")).toHaveText("Claude Code"); + await expect(page.getByTestId("ai-model")).toHaveText( + "Configured Claude model", + ); +}); + +// Keep upstream's real saved-account and login workflow in this integration's +// review gate as well as the AI-specific component stories above. +for (const id of Object.keys(index.entries).filter((entry) => entry.startsWith("onboarding-saved-connections--"))) { + test(`upstream ${id}`, async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(`/iframe.html?id=${id}&viewMode=story`); + await page.waitForFunction(() => { + const preview = (window as unknown as { __STORYBOOK_PREVIEW__?: { currentRender?: { phase: string } } }).__STORYBOOK_PREVIEW__; + return ["completed", "finished", "errored"].includes(preview?.currentRender?.phase ?? ""); + }); + await expect(page.locator("#storybook-root")).not.toBeEmpty(); + expect(errors).toEqual([]); + expect(await page.evaluate(() => (window as unknown as { __STORYBOOK_PREVIEW__?: { currentRender?: { phase: string } } }).__STORYBOOK_PREVIEW__?.currentRender?.phase)).not.toBe("errored"); + }); +} diff --git a/tests/e2e/conference-room-typing-intro.spec.ts b/tests/e2e/conference-room-typing-intro.spec.ts index 294aa4b0d1..b423537efe 100644 --- a/tests/e2e/conference-room-typing-intro.spec.ts +++ b/tests/e2e/conference-room-typing-intro.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from "@playwright/test"; +import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection"; import { expectLandsOnFirstTaskWithoutDashboardBounce, instrumentNavLog, @@ -21,11 +22,12 @@ import { const FIRST_TASK_TITLE = "Paperclip onboarding"; /** - * Intercept the two side-effecting calls the wizard makes so no real CLI check + * 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", @@ -93,9 +95,8 @@ async function runOnboardingWizard(page: Page, companyName: string) { 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. Here there is none — - // this instance has no sandbox environment, so the step has no login to - // offer and Connect goes straight to the hire. + // 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. diff --git a/tests/e2e/helpers/onboarding-ai-connection.ts b/tests/e2e/helpers/onboarding-ai-connection.ts new file mode 100644 index 0000000000..34bd690e5d --- /dev/null +++ b/tests/e2e/helpers/onboarding-ai-connection.ts @@ -0,0 +1,14 @@ +import { randomUUID } from "node:crypto"; +import { expect, type Page } from "@playwright/test"; + +/** First-task tests exercise the real wizard and hire/task APIs with an inert + * adapter. Simulate successful provider authentication without using host auth. */ +export async function mockOnboardingLocalAiConnection(page: Page) { + const connectionId = randomUUID(); + const grantId = randomUUID(); + await page.route("**/ai-connections/local", async (route) => { + expect(route.request().method()).toBe("POST"); + expect(route.request().postDataJSON()).toMatchObject({ method: "subscription", ownership: "personal" }); + await route.fulfill({ json: { connectionId, grantId } }); + }); +} diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index b425051524..24d54928c9 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection"; /** * E2E: Onboarding wizard flow (NUX Phase 2 expanded wizard). @@ -389,15 +390,19 @@ test.describe("Onboarding wizard", () => { // route, and keeping the pre-Connect part of this test the same as the // ordinary sign-in test above. let sessionStarted = false; + let aiConnection: Record | undefined; await page.route("**/setup-token-login-sessions", (route) => { if (route.request().method() === "POST") { startCalls += 1; sessionStarted = true; + aiConnection = route.request().postDataJSON().aiConnection; } return route.fulfill({ contentType: "application/json", body: JSON.stringify({ sessionId: SESSION_ID, + environmentId: FAKE_SANDBOX_ENVIRONMENT_ID, + aiConnection, status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), }), @@ -418,6 +423,8 @@ test.describe("Onboarding wizard", () => { contentType: "application/json", body: JSON.stringify({ sessionId: SESSION_ID, + environmentId: FAKE_SANDBOX_ENVIRONMENT_ID, + aiConnection, status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), }), @@ -438,6 +445,8 @@ test.describe("Onboarding wizard", () => { contentType: "application/json", body: JSON.stringify({ sessionId: SESSION_ID, + environmentId: FAKE_SANDBOX_ENVIRONMENT_ID, + aiConnection, status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), panelMode: "submitted_browser_code", @@ -512,14 +521,12 @@ test.describe("Onboarding wizard", () => { expect(pageErrors, pageErrors.join("\n")).toHaveLength(0); }); - test("connect step blocks the hire when the environment probe fails and no sign-in is needed", async ({ + test("connect step blocks the hire when the environment probe fails after account connection", async ({ page, }) => { - // The other half of what the test above used to cover. The two claims are - // different situations now: there, an absent credential makes Connect start - // a sign-in; here there is no sandbox to sign in against — this throwaway - // instance only auto-creates the local environment, and nothing below adds - // one — so Connect goes straight to the probe, and the probe is the gate. + // A successful local account connection still requires a passing + // environment probe before the wizard may hire the agent. + await mockOnboardingLocalAiConnection(page); const pageErrors: string[] = []; page.on("pageerror", (err) => pageErrors.push(err.message)); diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index c51a94f834..ea53f5fc74 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection"; import { expectLandsOnFirstTaskWithoutDashboardBounce, instrumentNavLog, @@ -35,6 +36,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { const screenshotDir = "test-results/planning-mode"; await instrumentNavLog(page); + await mockOnboardingLocalAiConnection(page); await page.route("**/test-environment", (route) => route.fulfill({ @@ -92,8 +94,8 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { // The connect step arrives with no source selected — the tile row is a // question, not a confirmation — so its CTA stays disabled until one is // pressed. It reads "Connect", not "Next": the button starts the sign-in - // where there is one to start. This instance has no sandbox environment, so - // there is none, and Connect goes straight to the hire. + // where there is one to start. The test simulates successful local account + // connection, then exercises the real first-task creation flow. // // Waited on for enabled rather than visible: it is already on screen, and // clicking a disabled button raises nothing and does nothing. diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index c55fe7c625..c25bbabd91 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -223,6 +223,7 @@ export const agentsApi = { type: string, data: { adapterConfig: Record; + aiConnection?: import("@paperclipai/shared").AiConnectionBinding; agentId?: string; testCredentials?: Record; environmentId?: string | null; @@ -274,7 +275,7 @@ export const agentsApi = { startAdapterAuthLogin: ( companyId: string, type: string, - data: { environmentId: string; ttlSeconds?: number }, + data: { environmentId: string; ttlSeconds?: number; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent }, ) => api.post( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions`, @@ -317,7 +318,7 @@ export const agentsApi = { ), startClaudeSetupTokenLogin: ( companyId: string, - data: { environmentId: string; overwrite?: ClaudeSetupTokenOverwrite }, + data: { environmentId: string; overwrite?: ClaudeSetupTokenOverwrite; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent }, ) => api.post( `/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`, diff --git a/ui/src/api/ai-connections.ts b/ui/src/api/ai-connections.ts new file mode 100644 index 0000000000..c9793205c2 --- /dev/null +++ b/ui/src/api/ai-connections.ts @@ -0,0 +1,13 @@ +import type { AiManagedConnectionSummary, CreateAiConnection, AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared"; +import { api } from "./client"; +export const aiConnectionsApi = { + startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post(`/companies/${companyId}/ai-connections/local/attempts`, input), + checkLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post(`/companies/${companyId}/ai-connections/local/check`, input), + cancelLocalLogin: (companyId: string, sessionId: string) => api.delete(`/companies/${companyId}/ai-connections/local/attempts/${sessionId}`), + connectLocal: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections/local`, input), + activeRuns: (companyId: string, connectionId: string) => api.get>(`/companies/${companyId}/ai-connections/${connectionId}/active-runs`), + list: (companyId: string, agentId?: string) => api.get<{ currentUserId: string; connections: AiManagedConnectionSummary[] }>(`/companies/${companyId}/ai-connections${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""}`), + create: (companyId: string, input: CreateAiConnection) => api.post<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections`, input), + setDefault: (companyId: string, grantId: string) => api.put(`/companies/${companyId}/ai-connections/default`, { grantId }), + loginResult: (companyId: string, sessionId: string) => api.get<{ connectionId: string; grantId: string }>(`/companies/${companyId}/ai-connections/login/${encodeURIComponent(sessionId)}`), +}; diff --git a/ui/src/components/AdapterLoginChrome.tsx b/ui/src/components/AdapterLoginChrome.tsx index a8028cf950..0eadf5bb97 100644 --- a/ui/src/components/AdapterLoginChrome.tsx +++ b/ui/src/components/AdapterLoginChrome.tsx @@ -49,6 +49,7 @@ export type AdapterLoginChrome = "panel" | "onboarding"; export const CONNECT_SOURCE_NAMES: Record = { claude_local: "Claude", codex_local: "OpenAI", + grok_local: "Grok", }; /** The provider name for a source, falling back to the type when unlisted. */ @@ -170,9 +171,12 @@ function LoginCardCopyButton({ const [copied, setCopied] = useState(false); const timeoutRef = useRef | null>(null); - useEffect(() => () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }, []); + useEffect( + () => () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }, + [], + ); return ( } + :

{isolated ? `Sign in to ${provider} for this connection on the machine running Paperclip. Your existing terminal login stays separate.` : `Connect uses your local ${provider} account on the machine running Paperclip.`}

} + {(!ready || showCommand) && !login?.error && <> +

Run this in a terminal on that machine and finish signing in in your browser. We’ll check automatically when you return.

+ {command &&
+
{command}
+ +
} + } + {login?.error &&

{login.error}

} + {login && !login.preparing && (isolated || login.error) && } + ; +} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 91534ffb16..fb4e0b4d12 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,3 +1,5 @@ +import { AiConnectionField } from "./ai-connections/AiConnectionField"; +import { aiConnectionBindingSchema } from "@paperclipai/shared"; import { testAgentSetup } from "@/lib/test-agent-setup"; import { RuntimeTestCard } from "./RuntimeTestCard"; import { useState, useEffect, useRef, useMemo, useCallback, Children, isValidElement, type ReactNode } from "react"; @@ -44,7 +46,7 @@ import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils"; import { copyTextToClipboard } from "../lib/clipboard"; import { connectSourceName, - OnboardingLoginCard, + ProviderSubscriptionCard, OnboardingCardField, OnboardingLoginCodeRow, type AdapterLoginChrome, @@ -888,9 +890,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex" : eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex")) : undefined; + const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse( + (overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? runtimeConfig.aiConnection, + ).data?.provider === "openrouter" ? "openrouter" : runnerProvider; // Fetch adapter models for the effective provider, including unsaved changes. const modelQueryKey = selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, runnerProvider) + ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelProvider) : ["agents", "none", "adapter-models", adapterType]; const { data: fetchedModels, @@ -899,7 +904,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { queryKey: modelQueryKey, queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, { environmentId: currentDefaultEnvironmentId || null, - provider: runnerProvider, + provider: modelProvider, }), enabled: Boolean(selectedCompanyId), }); @@ -1055,15 +1060,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }); const adapterConfig = buildAdapterConfigForTest(adapterConfigPatch); const agentId = isCreate ? undefined : props.agent.id; + const aiConnection = isCreate ? undefined : aiConnectionBindingSchema.safeParse( + (overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? props.agent.runtimeConfig.aiConnection, + ).data; if (props.compactTestFeedback) { const providerAdapter = adapterType === "paperclip_runner" ? adapterConfig.provider === "codex" ? "codex_local" : adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local" : adapterType : adapterType; - return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, environmentId }); + return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId }); } - return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, environmentId }); + return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId }); }, }); const [testActionPending, setTestActionPending] = useState(false); @@ -1139,6 +1147,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true; const loginNeedsPty = adapterCaps.login != null; const showAdapterLogin = + (isCreate || !((overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? runtimeConfig.aiConnection)) && adapterSupportsSandboxLogin && effectiveLoginEnvironment?.driver === "sandbox" && Boolean(effectiveLoginEnvironmentId) && @@ -1243,7 +1252,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setRefreshingModels(true); setRefreshModelsError(null); try { - const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: runnerProvider }); + const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider }); queryClient.setQueryData(modelQueryKey, refreshed); } catch (error) { setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models."); @@ -1641,6 +1650,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} + {!isCreate && selectedCompanyId && | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data} + model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy + onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />} + {showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && (testActionError || testEnvironment.error) && (
{testActionError @@ -2243,6 +2257,7 @@ export type AdapterLoginDescriptor = { // correctly, and the first thing to rot would have been the timeout and // cleanup paths, which are the ones nobody exercises by hand. export type AdapterLoginPanelProps = AdapterLoginDescriptor & { + aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent; onStored?: (storedSessionId: string) => void; onApplyStored?: () => void; // Applies the non-secret Codex account-binding claim from an authenticated @@ -2261,7 +2276,7 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & { // The login reached its success state. Onboarding advances on this, which is // why the `onboarding` chrome draws no success state of its own — the screen // it would appear on is already gone. - onConnected?: () => void; + onConnected?: (sessionId?: string) => void; // The pasted code went to the server. Fires as the submit starts rather than // when the login finishes, so a caller can show the work the moment the // customer has done their part: the round trip to `onConnected` is a poll @@ -2326,6 +2341,7 @@ function DisplayedCodeLoginPanel({ onConnected, onAccountBinding, chrome = "panel", + aiConnection, onPromptReady, }: AdapterLoginPanelProps) { const [sessionId, setSessionId] = useState(null); @@ -2350,7 +2366,7 @@ function DisplayedCodeLoginPanel({ const resumedRef = useRef(false); const startLogin = useMutation({ - mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId }), + mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection }), onSuccess: (session) => { resumedRef.current = false; setStartError(null); @@ -2389,7 +2405,10 @@ function DisplayedCodeLoginPanel({ queryKey: ["adapter-login-active-session", companyId, adapterType], queryFn: async () => { try { - return await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType); + const active = await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType); + if (!active) return null; + if ((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."); + return active; } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; @@ -2531,7 +2550,7 @@ function DisplayedCodeLoginPanel({ useEffect(() => { if (status !== "authenticated" || connectedRef.current) return; connectedRef.current = true; - onConnectedRef.current?.(); + onConnectedRef.current?.(sessionId ?? undefined); }, [status]); // Drive the account-binding hand-off as a visible state machine, not a @@ -2583,24 +2602,11 @@ function DisplayedCodeLoginPanel({ if (chrome === "onboarding") { const failed = isTerminal && status && status !== "authenticated"; return ( - - {/* The same destination as the step's own button. Two ways to one - link: the button for the customer following the flow, the anchor - for anyone finishing in another browser. */} - - Sign in to {connectSourceName(adapterType)} - - {" by providing the authorization code below"} - - } + providerName={connectSourceName(adapterType)} + authorizationUrl={prompt?.url} + mode="displayed_code" > {startError ? (

@@ -2617,7 +2623,7 @@ function DisplayedCodeLoginPanel({ ) : ( )} - + ); } @@ -2817,6 +2823,7 @@ function SubmittedBrowserCodeLoginPanel({ onCodeSubmitted, onSubmitFailed, chrome = "panel", + aiConnection, onPromptReady, }: AdapterLoginPanelProps) { const [sessionId, setSessionId] = useState(null); @@ -2904,10 +2911,11 @@ function SubmittedBrowserCodeLoginPanel({ mutationFn: () => agentsApi.startClaudeSetupTokenLogin(companyId, { environmentId, + aiConnection, // When the owner already has a stored token, the login rotates it under // the captured version, so a replacement login never conflicts with an // existing value. Without a stored token the login is a first write. - ...(storedToken + ...(storedToken && !aiConnection ? { overwrite: { expectedSecretId: storedToken.secretId, @@ -2981,7 +2989,10 @@ function SubmittedBrowserCodeLoginPanel({ queryKey: ["claude-setup-token-active-session", companyId], queryFn: async () => { try { - return await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId); + const active = await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId); + if (!active) return null; + if ((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."); + return active; } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; @@ -3301,7 +3312,7 @@ function SubmittedBrowserCodeLoginPanel({ useEffect(() => { if (!isStored || connectedRef.current) return; connectedRef.current = true; - onConnectedRef.current?.(); + onConnectedRef.current?.(sessionId ?? undefined); }, [isStored]); // The other end of `onCodeSubmitted`. Any of these after a submit means the @@ -3325,21 +3336,11 @@ function SubmittedBrowserCodeLoginPanel({ if (chrome === "onboarding") { const failedNow = isFailure || timedOut; return ( - - - Sign in to {connectSourceName(adapterType)} - - {" then come back and enter authorization code"} - - } + providerName={connectSourceName(adapterType)} + authorizationUrl={authorizationUrl ?? undefined} + mode="submitted_code" > {/* The plain-HTTP advisory survives the redesign. It is the one thing on this card not about getting the login done, and dropping it to keep @@ -3375,7 +3376,7 @@ function SubmittedBrowserCodeLoginPanel({ disabled={submitCode.isPending || isCompleting || codeSubmitted} /> )} - + ); } diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 01ef763576..234f2c2f10 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -12,6 +12,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // the refetch has to answer too — otherwise the identity errors and the list // never runs. const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() })); +const localHealth = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("@/api/health", () => ({ healthApi: localHealth })); +const managedApi = vi.hoisted(() => ({ + list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })), + startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", command: "CODEX_HOME='/fixture/login' codex login", expiresAt: "2026-09-11T20:00:00Z" })), + checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as const })), + cancelLocalLogin: vi.fn(async () => ({})), + connectLocal: vi.fn(async () => ({ connectionId: "local-connection", grantId: "local-grant" })), + create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), +})); +vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); vi.mock("../api/auth", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, authApi: { ...actual.authApi, getSession: mockAuthApi.getSession } }; @@ -312,6 +323,7 @@ function isArcPrimary(text: string): boolean { describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => { beforeEach(() => { + localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" }); mockAuthApi.getSession.mockResolvedValue({ session: { id: "session-b", userId: SESSION_USER_ID }, user: { id: SESSION_USER_ID, name: "B", email: "b@example.com", image: null }, @@ -915,26 +927,14 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( return handles; } - it("is stored as the user's own secret and referenced, never carried in the hire", async () => { + it("is stored as a personal connection and referenced, never carried in the hire", async () => { const { root } = await connectWithApiKey(); - expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1); - const [, createBody] = mockSecretsApi.createMyUserSecret.mock.calls.at(-1) as [ - string, - { definitionKey: string; value: string }, - ]; - expect(createBody.definitionKey).toMatch(/^ANTHROPIC_API_KEY\.setup\./); - expect(createBody.value).toBe(KEY); - - const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { - adapterConfig: { env?: Record }; - }; - // The same binding kind the subscription half of this step produces. - expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toEqual({ - type: "user_secret_ref", - key: createBody.definitionKey, - version: "latest", - }); + expect(managedApi.create).toHaveBeenCalledTimes(1); + expect(managedApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", ownership: "personal", apiKey: KEY })); + const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { runtimeConfig: { aiConnection: unknown }; adapterConfig: { env?: Record } }; + expect(hireBody.runtimeConfig.aiConnection).toEqual({ provider: "anthropic", method: "api_key", mode: "responsible_user" }); + expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toBeUndefined(); // The whole payload, not just that one field: the point is that the key // is nowhere in what gets persisted, however it might be nested. expect(JSON.stringify(hireBody)).not.toContain(KEY); @@ -942,23 +942,21 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await act(async () => root.unmount()); }); - it("creates a distinct definition instead of rotating an existing key", async () => { + it("creates a managed connection without rotating an existing saved key", async () => { mockSecretsApi.listMyUserSecrets.mockResolvedValue([ { definition: { id: "old-def", key: "ANTHROPIC_API_KEY" }, secret: { id: "secret-existing" } }, ]); const { root } = await connectWithApiKey(); - expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledWith( - expect.any(String), expect.objectContaining({ key: expect.stringMatching(/^ANTHROPIC_API_KEY\.setup\./) }), - ); + expect(managedApi.create).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ apiKey: KEY })); expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled(); - expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1); + expect(managedApi.create).toHaveBeenCalledTimes(1); await act(async () => root.unmount()); }); // The one outcome that must never happen is a hire that falls back to // embedding the key because storing it failed. it("blocks the hire when the key cannot be stored", async () => { - mockSecretsApi.createMyUserSecret.mockRejectedValue(new Error("vault unreachable")); + managedApi.create.mockRejectedValueOnce(new Error("vault unreachable")); const { root } = await connectWithApiKey(); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); @@ -967,13 +965,13 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await act(async () => root.unmount()); }); - it("stores one secret when Connect is pressed twice with the same key", async () => { + it("stores one connection when Connect is pressed twice with the same key", async () => { mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away")); const { root, clickByText } = await connectWithApiKey(); await clickByText((t) => isArcPrimary(t)); - expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1); + expect(managedApi.create).toHaveBeenCalledTimes(1); await act(async () => root.unmount()); }); @@ -3045,6 +3043,27 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await act(async () => root.unmount()); }); + it("shows local Claude instructions and saves its connection before hiring", async () => { + localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" }); + mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); + mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); + const { root } = await openStep4({ adapterType: "claude_local" }); + await pickSource(/Claude/); + expect(document.body.textContent).toContain("claude auth login"); + expect(document.body.textContent).toContain("machine running Paperclip"); + expect(document.body.textContent).not.toContain("No managed sandbox"); + expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); + const connect = [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim().startsWith("Connect")); + expect(connect).toBeTruthy(); + await act(async () => connect!.click()); + for (let i = 0; i < 6; i++) await flushReact(); + expect(managedApi.connectLocal).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "anthropic", method: "subscription", ownership: "personal" })); + expect(mockAgentsApi.hire).toHaveBeenCalled(); + const hire = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { runtimeConfig: { aiConnection: unknown } }; + expect(hire.runtimeConfig.aiConnection).toEqual({ provider: "anthropic", method: "subscription", mode: "responsible_user" }); + await act(async () => root.unmount()); + }); + it("hides the panel when the resolved login environment driver is not sandbox", async () => { mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 6df9ace26d..87a829f936 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,3 +1,9 @@ +import { healthApi } from "@/api/health"; +import { LocalProviderLoginInstructions } from "./AdapterLoginChrome"; +import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin"; +import { aiConnectionsApi } from "@/api/ai-connections"; +import { aiProviderForAdapter } from "./ai-connections/AiConnectionField"; +import type { AiConnectionBinding } from "@paperclipai/shared"; import { storeProviderApiKey } from "../lib/provider-credential"; import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect"; import { useEffect, useState, useMemo, useRef } from "react"; @@ -671,7 +677,7 @@ function OnboardingWizardInner({ effectiveOnboardingOpen && step === 4, ); const [subscriptionId, setSubscriptionId] = useState<{ companyId: string; id: string } | null>(null); - const savedSubscription = adapterType === "codex_local" + const savedSubscription = savedKeys.subscriptions.length > 0 ? savedKeys.subscriptions.find((option) => option.id === ( subscriptionId?.companyId === createdCompanyId ? subscriptionId.id @@ -684,8 +690,8 @@ function OnboardingWizardInner({ : savedKeys.options[0]?.id; const selectedApiKey = savedKeys.options.find((option) => option.id === selectedApiKeyId); const credentialMode = credentialModeChoice ?? ( - (adapterType === "claude_local" ? savedKeys.storedLogin.data : adapterType === "codex_local" && savedKeys.subscriptions.length) - ? "subscription" : savedKeys.options.length ? "api" : "subscription" + (savedKeys.subscriptions.length > 0 || (adapterType === "claude_local" && savedKeys.storedLogin.data)) + ? "subscription" : savedKeys.options.length || adapterType === "opencode_local" ? "api" : "subscription" ); const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState< string | null @@ -732,7 +738,15 @@ function OnboardingWizardInner({ * customer on the step to try again — and without this each press would store * another copy of the same credential. */ - const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding: Awaited>["binding"] } | null>(null); + const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding?: Awaited>["binding"]; aiConnection?: AiConnectionBinding } | null>(null); + const managedSubscriptionRef = useRef<{ companyId: string; binding: AiConnectionBinding } | null>(null); + const managedProvider = aiProviderForAdapter(adapterType); + function managedBindingForStep(): AiConnectionBinding | undefined { + if (credentialMode === "api") return selectedApiKey?.aiConnection ?? ( + !selectedApiKey && apiKeySecretRef.current?.companyId === createdCompanyId && apiKeySecretRef.current.envKey === apiKeyEnvKeyFor(adapterType) + ? apiKeySecretRef.current.aiConnection : undefined); + return savedSubscription?.aiConnection ?? (managedSubscriptionRef.current?.companyId === createdCompanyId && managedSubscriptionRef.current.binding.provider === managedProvider ? managedSubscriptionRef.current.binding : undefined); + } createdCompanyIdRef.current = createdCompanyId; // The step the request wants, mirrored for the same reason. `initialStep` is @@ -977,6 +991,14 @@ function OnboardingWizardInner({ // full adapter test result. The cheap auth signal below stands in for that // input here, so this gate alone only decides whether the login mechanism // could ever apply to the current adapter and environment. + const localLoginHealth = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get }); + const canUseLocalLogin = resolvedLoginEnvironment?.driver === "local" && localLoginHealth.data?.deploymentMode === "local_trusted"; + const localLogin = useLocalAiLogin(createdCompanyId, { + provider: managedProvider ?? "anthropic", method: "subscription", + name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, + ownership: "personal", agentIds: [], allAgents: true, + }, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" && + Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep()); const canShowAdapterLogin = Boolean( adapterCaps.login != null && resolvedLoginEnvironment?.driver === "sandbox" && @@ -1123,8 +1145,7 @@ function OnboardingWizardInner({ * The same four conditions the card itself renders on, named once so the * footer button and the card cannot disagree about whether a login is * happening. When it is false — an API key, a source already signed in on the - * sandbox, no sandbox to sign in against — Connect goes straight to the hire, - * exactly as it did before. + * sandbox, or a local CLI account — Connect verifies credentials before the hire. */ const connectStepNeedsLogin = Boolean( credentialMode !== "api" && @@ -1149,17 +1170,7 @@ function OnboardingWizardInner({ const loginSubmitsBrowserCode = adapterCaps.login?.panelMode === "submitted_browser_code"; - /** - * The one thing that can be wrong here before anything is pressed: there is - * no sandbox to sign in against, so Connect cannot get anywhere. Worth saying - * on arrival rather than after a press that goes nowhere. - * - * Its two neighbours in the old canvas are not worth the same. "Checking this - * source's credentials…" narrated a request nothing was waiting on, and "this - * source is already signed in" answered a question the customer had not asked - * yet — both were written for a canvas that opened on selection, and the - * press is what opens it now. - */ + /** Without browser login, show instructions for the selected execution environment. */ const connectStepHasNoSandbox = credentialMode !== "api" && !canShowAdapterLogin && !authSignalUndecided; @@ -1720,6 +1731,11 @@ function OnboardingWizardInner({ const envKey = apiKeyEnvKeyFor(adapterType); if (apiKeySecretRef.current?.key === key && apiKeySecretRef.current.companyId === companyId && apiKeySecretRef.current.envKey === envKey) return true; try { + if (managedProvider) { + await aiConnectionsApi.create(companyId, { provider: managedProvider, method: "api_key", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} API`, ownership: "personal", apiKey: key, agentIds: [], allAgents: true }); + apiKeySecretRef.current = { key, companyId, envKey, aiConnection: { provider: managedProvider, method: "api_key", mode: "responsible_user" } }; + return true; + } const stored = await storeProviderApiKey(companyId, envKey, key); apiKeySecretRef.current = { key, companyId, envKey, binding: stored.binding }; return true; @@ -1785,7 +1801,7 @@ function OnboardingWizardInner({ // present. If storing failed this stays false, and the right outcome is a // configuration with no credential — which the hire then blocks on — rather // than one that quietly falls back to embedding the value. - if (credentialMode === "api" && (bindApiKey || selectedApiKey)) { + if (!managedBindingForStep() && credentialMode === "api" && (bindApiKey || selectedApiKey)) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? { ...(config.env as Record) } @@ -1793,7 +1809,7 @@ function OnboardingWizardInner({ env[apiKeyEnvKeyFor(adapterType)] = selectedApiKey?.binding ?? apiKeySecretRef.current?.binding; config.env = env; } - if (credentialMode === "subscription" && savedSubscription) { + if (credentialMode === "subscription" && savedSubscription?.binding) { config.env = { ...((config.env as object) ?? {}), CODEX_HOME: savedSubscription.binding }; } return config; @@ -1866,6 +1882,7 @@ function OnboardingWizardInner({ adapterType, { adapterConfig: adapterConfigOverride ?? buildAdapterConfig(), + ...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}), environmentId, } ); @@ -2002,10 +2019,15 @@ function OnboardingWizardInner({ apiKeyStored = await storeApiKeyUserSecret(createdCompanyId); if (!apiKeyStored) return; } + if (credentialMode !== "api" && canUseLocalLogin && managedProvider && !managedBindingForStep() && !savedSubscription && !savedKeys.storedLogin.data) { + await localLogin.connect(); + managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } }; + } + const managedBinding = managedBindingForStep(); const baseAdapterConfig = buildAdapterConfig(apiKeyStored); let storedClaudeLogin: ClaudeOAuthTokenStatusResponse | null = null; if ( - adapterType === "claude_local" && + !managedBinding && adapterType === "claude_local" && !adapterConfigHasAnthropicApiKey(baseAdapterConfig) ) { try { @@ -2102,7 +2124,7 @@ function OnboardingWizardInner({ // the chief-of-staff persona over the agent's entry instruction file. // The wizard no longer composes or overwrites it. onboardingFirstAgent: true, - runtimeConfig: buildNewAgentRuntimeConfig() + runtimeConfig: { ...buildNewAgentRuntimeConfig(), ...(managedBinding ? { aiConnection: managedBinding } : {}) } }); if (hire.approval) { await approvalsApi.approve( @@ -2554,7 +2576,7 @@ function OnboardingWizardInner({ }} /> - {credentialMode === "subscription" && adapterType === "codex_local" && savedKeys.subscriptions.length > 0 && ( + {credentialMode === "subscription" && savedKeys.subscriptions.length > 0 && (

{ setConnectAuthUrl(url); @@ -2715,6 +2738,7 @@ function OnboardingWizardInner({ ); }} onConnected={() => { + if (managedProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } }; // Not into a card the customer has left. The panel is // still mounted through Back's exit, and a login that // finished there pulled the step back into "Connecting" @@ -2754,12 +2778,9 @@ function OnboardingWizardInner({ ) : adapterType === "claude_local" && savedKeys.storedLogin.data ? (

Use your saved Claude subscription for this agent.

) : connectStepHasNoSandbox ? ( - /* The one thing that can be wrong here before anything is - pressed, and the one worth saying out loud: without a - sandbox there is nothing to sign in against. */ -

- No managed sandbox is available to sign in against yet. -

+ resolvedLoginEnvironment?.driver === "local" && managedProvider ? ( + { setError(null); localLogin.retry(); } }} /> + ) :

This environment does not support browser sign-in. Choose another sign-in environment or connect with an API key.

) : null} diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 45e95fe937..6c31d7db28 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -96,7 +96,7 @@ import { cn } from "@/lib/utils"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; -import { latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages"; +import { isRedundantAiRecoveryNotice, latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages"; import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds"; import { resolveTaskChatBlockers, @@ -762,6 +762,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { const projectedComments = useMemo( () => comments.flatMap((comment) => { + if (isRedundantAiRecoveryNotice(comment, interactions)) return []; if (comment.body !== LEGACY_WITHHELD_RUN_COMMENT || !comment.runId) return [comment]; const resultJson = linkedRunMetaById.get(comment.runId)?.resultJson; @@ -774,7 +775,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { const summary = acceptedSemanticResultSummary(resultJson); return [summary ? { ...comment, body: summary } : comment]; }), - [comments, linkedRunMetaById], + [comments, interactions, linkedRunMetaById], ); const commentItems = useMemo( @@ -1632,8 +1633,12 @@ export function TaskChatThread(props: TaskChatThreadProps) { const retryDetail = meta?.scheduledRetryAt ? "Retry scheduled automatically." : "You can retry this message now."; - const detail = - source.status === "cancelled" + const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id); + const detail = aiRequest + ? aiRequest.status === "pending" + ? "The selected AI account is unavailable. Fix it in the connection card." + : "This run stopped because its AI account was unavailable." + : source.status === "cancelled" ? code === "execution_reconciliation_required" ? "The previous execution must be checked before this task can continue. Your message is preserved. View the stopped run for details." : "Execution was stopped before returning an answer." @@ -1970,6 +1975,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { }; }, [ orderedEntries, + interactions, runs, liveRun, transcriptByRun, @@ -2858,7 +2864,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { : (liveRun && liveRun.id === tailRunId ? liveRun.currentStatusMessage : null) || - "Waiting for transcript..." + (tailStatus === "failed" + ? "This run stopped before a response was available. Review the task’s connection or recovery action below." + : "Waiting for transcript...") } /> diff --git a/ui/src/components/ai-connections/AiConnectionAccountControls.tsx b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx new file mode 100644 index 0000000000..f4bd58bbe9 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx @@ -0,0 +1,69 @@ +import { useState, type ReactNode } from "react"; +import { CheckCircle2, RefreshCw, Star, TriangleAlert, Unplug } from "lucide-react"; +import type { ConnectionGrant } from "@paperclipai/shared"; +import { RevokeGrantDialog } from "@/pages/apps/app-detail/IdentitiesSection"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { aiMethodLabel, type AiConnectionSummary } from "./model"; + +/** AI-only account controls; identity, access and navigation belong to AppDetail. */ +export function AiConnectionAccountControls({ + account, grant, currentUserId, readOnly, onMakeDefault, onReconnect, onRevoke, revocationDetails, +}: { + account: AiConnectionSummary; + grant: ConnectionGrant; + currentUserId: string; + readOnly?: boolean; + onMakeDefault: () => void; + onReconnect: () => void; + onRevoke: () => void | Promise; + revocationDetails?: ReactNode; +}) { + const [revoking, setRevoking] = useState(false); + const [revokePending, setRevokePending] = useState(false); + const [revokeError, setRevokeError] = useState(); + const ownPersonal = account.ownership === "personal" && account.ownerUserId === currentUserId; + const available = account.status === "connected"; + const activeDefault = account.isDefault && available; + return ( +
+ {ownPersonal && ( +
+
+ +
+

Personal default

+

{aiMethodLabel(account.provider, account.method)}

+
+
+ {account.isDefault ? ( + + {available ? : } + {available ? "Your default" : "Default unavailable"} + + ) : !readOnly ? ( + + ) : Not your default} +
+ )} +
+
+

{aiMethodLabel(account.provider, account.method)}

+ {account.accountLabel &&

{account.accountLabel}

} +
+ {!readOnly && grant.capabilities?.canRevoke && ( +
+ {} + {account.status !== "revoked" && } +
+ )} +
+ {revoking && setRevoking(false)} onConfirm={async () => { setRevokePending(true); setRevokeError(undefined); try { await onRevoke(); setRevoking(false); } catch (error) { setRevokeError(error instanceof Error ? error.message : "Could not revoke this account. Retry."); } finally { setRevokePending(false); } }}>{revokeError &&

{revokeError}

}{revocationDetails}
} +
+ ); +} diff --git a/ui/src/components/ai-connections/AiConnectionAuth.test.tsx b/ui/src/components/ai-connections/AiConnectionAuth.test.tsx new file mode 100644 index 0000000000..7657f588c1 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionAuth.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import React from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + AiConnectionAuth, + type AiConnectionAuthProps, +} from "./AiConnectionAuth"; + +let root: Root | undefined; +afterEach(() => { + if (root) flushSync(() => root?.unmount()); + root = undefined; + document.body.innerHTML = ""; +}); +function mount(overrides: Partial = {}) { + const props: AiConnectionAuthProps = { + provider: "openai", + method: "api_key", + state: { phase: "idle" }, + onStart: vi.fn(), + onSubmit: vi.fn(), + onCancel: vi.fn(), + onDone: vi.fn(), + ...overrides, + }; + const container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const render = (next: Partial) => + flushSync(() => root!.render()); + render({}); + return { props, container, render }; +} +function typeInput(input: HTMLInputElement, value: string) { + flushSync(() => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +describe("AI connection authentication presentation", () => { + it("masks keys, hands them only to the injected action, and clears after submission", () => { + const { container, props } = mount(); + const input = container.querySelector("input")!; + expect(input.type).toBe("password"); + typeInput(input, "example-only-key"); + flushSync(() => + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ), + ); + expect(props.onSubmit).toHaveBeenCalledWith("example-only-key"); + expect(input.value).toBe(""); + expect(container.textContent).not.toContain("example-only-key"); + }); + it("drops private input when the provider or lifecycle phase changes", () => { + const { container, render } = mount(); + typeInput(container.querySelector("input")!, "example-only-key"); + render({ provider: "anthropic" }); + expect(container.querySelector("input")!.value).toBe(""); + typeInput(container.querySelector("input")!, "retry-key"); + render({ state: { phase: "error", message: "Rejected" } }); + expect(container.querySelector("input")!.value).toBe(""); + }); + it("cancellation clears input and invokes only cancellation", () => { + const { container, props } = mount(); + typeInput(container.querySelector("input")!, "example-only-key"); + flushSync(() => + [...container.querySelectorAll("button")] + .find((button) => button.textContent === "Cancel")! + .click(), + ); + expect(props.onCancel).toHaveBeenCalledOnce(); + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(container.querySelector("input")!.value).toBe(""); + }); + it("never offers an OpenRouter subscription or starts a provider call on render", () => { + const { container, props } = mount({ + provider: "openrouter", + method: "subscription", + }); + expect(container.textContent).toContain("does not offer a subscription"); + expect(container.querySelector("input")).toBeNull(); + expect(props.onStart).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/ai-connections/AiConnectionAuth.tsx b/ui/src/components/ai-connections/AiConnectionAuth.tsx new file mode 100644 index 0000000000..3e03b4e634 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionAuth.tsx @@ -0,0 +1,190 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + OnboardingCardField, + OnboardingLoginCodeRow, + ProviderApiKeyCard, + ProviderSubscriptionCard, +} from "@/components/AdapterLoginChrome"; +import { + AI_PROVIDERS, + aiMethodLabel, + type AiAuthMethod, + type AiProvider, +} from "./model"; + +/** Redacted view of the existing login lifecycle, supplied by the host. */ +export type AiAuthState = + | { phase: "idle" | "starting" | "submitting" | "connected" | "cancelled" } + | { phase: "waiting"; authorizationUrl: string; code?: string } + | { phase: "error" | "expired" | "unsupported"; message: string }; + +export interface AiConnectionAuthProps { + provider: AiProvider; + method: AiAuthMethod; + state: AiAuthState; + onStart: () => void; + onSubmit: (value: string) => void; + onCancel: () => void; + onDone: () => void; +} + +/** No provider calls or polling here: live hosts keep the existing login controllers. */ +export function AiConnectionAuth(props: AiConnectionAuthProps) { + // Remount private input state when the provider, method, or attempt changes phase. + return ( + + ); +} + +function AuthAttempt({ + provider, + method, + state, + onStart, + onSubmit, + onCancel, + onDone, +}: AiConnectionAuthProps) { + const [value, setValue] = useState(""); + const info = AI_PROVIDERS[provider]; + const busy = state.phase === "starting" || state.phase === "submitting"; + const unsupported = + state.phase === "unsupported" || + (method === "subscription" && !info.subscriptionName); + const submit = () => { + if (!value.trim() || busy) return; + const submitted = value.trim(); + setValue(""); + onSubmit(submitted); + }; + return ( +
+
+

Connect {info.name}

+

+ {aiMethodLabel(provider, method)} +

+
+ {state.phase === "connected" ? ( + <> +

+ Connected. This account is saved in Connections and can be reused. +

+ + + ) : ( + <> + {unsupported ? ( +

+ {state.phase === "unsupported" + ? state.message + : "This provider does not offer a subscription connection."} +

+ ) : ( + <> + {(state.phase === "error" || state.phase === "expired") && ( +

+ {state.message} +

+ )} + {state.phase === "cancelled" && ( +

+ Sign-in cancelled. No connection was created. +

+ )} + {method === "api_key" ? ( + + ) : busy ? ( + + + + ) : state.phase === "waiting" ? ( + + {provider === "anthropic" ? ( + + ) : ( + + )} + + ) : ( +

+ Sign in with your {info.subscriptionName}. +

+ )} + + )} +
+ + {!unsupported && + (method === "api_key" ? ( + + ) : state.phase === "waiting" ? ( + provider === "anthropic" ? ( + + ) : ( + + Waiting for sign-in… + + ) + ) : ( + + ))} +
+ + )} +
+ ); +} diff --git a/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx new file mode 100644 index 0000000000..ceaada992f --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionCredentialStep.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { type AiProvider, type AiAuthMethod, type AiConnectionLoginIntent } from "@paperclipai/shared"; +import { AgentProviderConnection } from "@/components/new-agent/AgentProviderConnection"; +import { ProviderApiKeyCard } from "@/components/AdapterLoginChrome"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { aiConnectionsApi } from "@/api/ai-connections"; +import { environmentsApi } from "@/api/environments"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; +import { resolveAdapterTestEnvironmentId, resolveLocalDefaultEnvironmentId, resolveManagedSandboxEnvironmentId } from "@/lib/adapter-test-environment"; +import { resolveForcedKubernetesEnvironment } from "@/lib/forced-kubernetes-environment"; + +type Props = { + companyId: string; + provider: AiProvider; + initialMethod?: AiAuthMethod; + fixedMethod?: boolean; + connectionId?: string; + name: string; + ownership: "personal" | "shared"; + agentIds: string[]; + allAgents: boolean; + environmentId?: string; + onComplete: (result: { connectionId: string; grantId: string; method: AiAuthMethod }) => void; + onCancel: () => void; +}; + +/** Connections hosts the same provider step as agent setup, with its own save intent. */ +export function AiConnectionCredentialStep(props: Props) { + if (props.provider === "openrouter") return ; + return ; +} + +function SubscriptionConnectionStep({ companyId, provider, initialMethod, fixedMethod, connectionId, name: initialName, ownership, agentIds, allAgents, environmentId: suppliedEnvironmentId, onComplete, onCancel }: Props) { + const [name, setName] = useState(initialName); + const [chosenEnvironment, setChosenEnvironment] = useState(); + const client = useQueryClient(); + const envs = useQuery({ queryKey: queryKeys.environments.list(companyId), queryFn: () => environmentsApi.list(companyId) }); + const caps = useQuery({ queryKey: queryKeys.environments.capabilities(companyId), queryFn: () => environmentsApi.capabilities(companyId) }); + const settings = useQuery({ queryKey: queryKeys.instance.settings, queryFn: instanceSettingsApi.get }); + const experimental = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: instanceSettingsApi.getExperimental }); + const general = useQuery({ queryKey: queryKeys.instance.generalSettings, queryFn: instanceSettingsApi.getGeneral }); + const forced = resolveForcedKubernetesEnvironment(general.data?.executionMode, envs.data ?? []); + let environmentId: string | null = null; + let environmentError: string | undefined; + try { + environmentId = forced.forced ? forced.kubernetesEnvironment?.id ?? null : resolveAdapterTestEnvironmentId({ + agentDefaultEnvironmentId: suppliedEnvironmentId ?? chosenEnvironment, + instanceDefaultEnvironmentId: settings.data?.defaultEnvironmentId, + localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(envs.data), + managedSandboxOnly: experimental.data?.enableManagedSandboxOnly, + managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(envs.data), + visibleEnvironmentIds: envs.data?.map((env) => env.id), + }); + } catch (error) { environmentError = error instanceof Error ? error.message : "Could not resolve the sign-in environment."; } + const loginEnvironments = (envs.data ?? []).filter((env) => + env.status === "active" && (env.driver === "local" || (env.driver === "sandbox" && + typeof env.config.provider === "string" && + caps.data?.sandboxProviders?.[env.config.provider]?.supportsLoginPty === true)), + ); + // Signing in may use a different environment from later agent execution. + // Prefer a supported login environment without changing any agent routing. + if (!forced.forced && !suppliedEnvironmentId && !chosenEnvironment && + !loginEnvironments.some((env) => env.id === environmentId)) { + environmentId = loginEnvironments[0]?.id ?? null; + } + const environment = envs.data?.find((env) => env.id === environmentId); + const sandboxProvider = typeof environment?.config.provider === "string" ? environment.config.provider : ""; + const canLogin = environment?.driver === "sandbox" && caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true; + const loading = [envs, caps, settings, experimental, general].some((query) => query.isPending); + const error = environmentError ?? [envs, caps, settings, experimental, general].find((query) => query.error)?.error?.message; + const intent: AiConnectionLoginIntent = { provider, method: "subscription", name, ownership, agentIds, allAgents, connectionId }; + return
+ + {!suppliedEnvironmentId && !forced.forced && loginEnvironments.length > 1 && } + {error &&

{error}

} + {loading ?

Preparing sign-in…

: {}} + testConnection={async () => false} + managedAccount={{ intent, initialMethod, fixedMethod: fixedMethod || Boolean(connectionId), disabled: loading || Boolean(error) || !name.trim(), onComplete: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); onComplete(result); } }} + />} +
; +} + +function ApiKeyConnectionStep({ companyId, provider, connectionId, name: initialName, ownership, agentIds, allAgents, onComplete, onCancel }: Props) { + const [name, setName] = useState(initialName); + const [apiKey, setApiKey] = useState(""); + const client = useQueryClient(); + const save = useMutation({ + mutationFn: () => aiConnectionsApi.create(companyId, { provider, method: "api_key", name, ownership, agentIds, allAgents, connectionId, apiKey }), + onSuccess: (result) => { void client.invalidateQueries({ queryKey: ["ai-connections", companyId] }); onComplete({ ...result, method: "api_key" }); }, + onSettled: () => setApiKey(""), + }); + return
+ + {save.error &&

{save.error.message}

} + save.mutate()} disabled={save.isPending} placeholder="Enter API key here" autoFocus /> +
+
; +} diff --git a/ui/src/components/ai-connections/AiConnectionDesignExamples.tsx b/ui/src/components/ai-connections/AiConnectionDesignExamples.tsx new file mode 100644 index 0000000000..059494cb18 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionDesignExamples.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { AiConnectionPicker } from "./AiConnectionPicker"; +import { ProviderApiKeyCard } from "@/components/AdapterLoginChrome"; +import type { + AiConnectionBinding, + AiConnectionRequirement, + AiConnectionSummary, +} from "./model"; + +const requirement: AiConnectionRequirement = { + companyId: "design-example", + provider: "anthropic", + method: "subscription", +}; +const account: AiConnectionSummary = { + ...requirement, + id: "example", + grantId: "example-grant", + name: "My Claude subscription", + ownership: "personal", + ownerUserId: "example-user", + ownerName: "You", + status: "connected", + isDefault: true, +}; + +export function AiConnectionDesignExamples() { + const [binding, setBinding] = useState({ + provider: "anthropic", + method: "subscription", + mode: "responsible_user", + }); + return ( +
+

+ Shared AI connection identity, account selection, and existing + authentication chrome. The full interactive state matrix lives in + Storybook under AI Connections / Review. Example controls below do not + connect accounts. +

+

Provider lists and account management use Browse and AppDetail from the Connectors interface. The picker below uses ConnectionChoiceList, also used by ConnectionSetupFlow.

+ {}} + /> + {}} + onSubmit={() => {}} + placeholder="Enter API key here" + /> +
+ ); +} diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx new file mode 100644 index 0000000000..cf3a721a34 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionField.tsx @@ -0,0 +1,180 @@ +import { useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + aiConnectionBindingSchema, + isAiConnectionCompatible, + type AiConnectionBinding, + type AiAuthMethod, + type AiProvider, +} from "@paperclipai/shared"; +import { aiConnectionsApi } from "@/api/ai-connections"; +import { AiConnectionPicker } from "./AiConnectionPicker"; +import { AiConnectionLegacyNotice } from "./AiConnectionManagement"; +import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; + +export function aiProviderForAdapter( + adapterType: string, +): AiProvider | undefined { + return ( + { + claude_local: "anthropic", + codex_local: "openai", + opencode_local: "openrouter", + grok_local: "xai", + } as Record + )[adapterType]; +} +export function AiConnectionField({ + companyId, + agentId, + agentName, + adapterType, + model, + value, + onChange, + environmentId, + legacy = false, + readOnly = false, +}: { + companyId: string; + agentId?: string; + agentName: string; + adapterType: string; + model?: string; + value?: AiConnectionBinding; + onChange: (binding: AiConnectionBinding) => void; + environmentId?: string; + legacy?: boolean; + readOnly?: boolean; +}) { + const provider = aiProviderForAdapter(adapterType); + const returnFocus = useRef(null); + const restoreFocus = (event: Event) => { event.preventDefault(); returnFocus.current?.focus(); }; + const [adopting, setAdopting] = useState(false); + const [pendingAdoption, setPendingAdoption] = useState(); + const [connecting, setConnecting] = useState(false); + const method: AiAuthMethod = + value?.method ?? (provider === "openrouter" ? "api_key" : "subscription"); + const changeBinding = (next: AiConnectionBinding) => { + if (legacy && !value) { if (!connecting) returnFocus.current = document.activeElement as HTMLElement; setPendingAdoption(next); } + else onChange(next); + }; + const client = useQueryClient(); + const accounts = useQuery({ + queryKey: ["ai-connections", companyId, agentId], + queryFn: () => aiConnectionsApi.list(companyId, agentId), + enabled: Boolean(provider), + }); + if (!provider) return null; + if (legacy && !value && !adopting) + return ( + setAdopting(true)} + /> + ); + return ( +
+ {value && (adapterType !== "opencode_local" || Boolean(model)) && !isAiConnectionCompatible(value, adapterType, model) && ( +

+ This connection does not support the current harness and model. Choose + a compatible connection before saving. +

+ )} + + changeBinding(aiConnectionBindingSchema.parse(binding)) + } + onConnect={() => { returnFocus.current = document.activeElement as HTMLElement; setConnecting(true); }} + onRetry={() => void accounts.refetch()} + /> + { + if (!open) setPendingAdoption(undefined); + }} + > + + + Adopt Connections for {agentName} + + Saving tests this account in {agentName}’s environment before + replacing its existing authentication. Other agents keep their + current configuration. + + +

+ {pendingAdoption?.mode === "responsible_user" + ? `Responsible user’s default. For you: ${accounts.data?.connections.find((account) => account.isDefault && account.provider === provider && account.method === pendingAdoption.method)?.name ?? "Not connected"}. Other users use their own default.` + : accounts.data?.connections.find( + (account) => account.id === pendingAdoption?.connectionId, + )?.name} +

+

+ After adoption, missing credentials block execution. Previous + authentication will not be used as a fallback. +

+ + + + +
+
+ + + + Connect account + + setConnecting(false)} + onComplete={({ method: connectedMethod }) => { + void client.invalidateQueries({ + queryKey: ["ai-connections", companyId], + }); + setConnecting(false); + changeBinding({ provider, method: connectedMethod, mode: "responsible_user" }); + }} + /> + + +
+ ); +} diff --git a/ui/src/components/ai-connections/AiConnectionIdentity.tsx b/ui/src/components/ai-connections/AiConnectionIdentity.tsx new file mode 100644 index 0000000000..b35aebbbfd --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionIdentity.tsx @@ -0,0 +1,37 @@ +import { Building2, UserRound } from "lucide-react"; +import { AppLogo } from "@/pages/apps/AppLogo"; +import { + AI_PROVIDERS, + aiMethodLabel, + type AiConnectionSummary, +} from "./model"; + +export function AiConnectionIdentity({ + connection, +}: { + connection: AiConnectionSummary; +}) { + const provider = AI_PROVIDERS[connection.provider]; + const Icon = connection.ownership === "shared" ? Building2 : UserRound; + return ( +
+ +
+ + {connection.name} + + + {provider.name} ·{" "} + {aiMethodLabel(connection.provider, connection.method)} + {connection.accountLabel ? ` · ${connection.accountLabel}` : ""} + + + + {connection.ownership === "shared" + ? "Company shared" + : `Personal · ${connection.ownerName ?? "Account owner"}`} + +
+
+ ); +} diff --git a/ui/src/components/ai-connections/AiConnectionManagement.tsx b/ui/src/components/ai-connections/AiConnectionManagement.tsx new file mode 100644 index 0000000000..f45ab1c680 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionManagement.tsx @@ -0,0 +1,27 @@ +import { Button } from "@/components/ui/button"; + +export function AiConnectionLegacyNotice({ + onAdopt, + readOnly = false, +}: { + onAdopt: () => void; + readOnly?: boolean; +}) { + return ( +
+

+ Existing authentication — not managed by Connections +

+

+ This agent keeps its current authentication until you choose and test a + managed connection. Confirm the account and who may use it before + adopting. +

+ {!readOnly && ( + + )} +
+ ); +} diff --git a/ui/src/components/ai-connections/AiConnectionPicker.tsx b/ui/src/components/ai-connections/AiConnectionPicker.tsx new file mode 100644 index 0000000000..f45d813225 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionPicker.tsx @@ -0,0 +1,144 @@ +import { AppLogo } from "@/pages/apps/AppLogo"; +import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + AI_PROVIDERS, + aiConnectionProblem, + aiMethodLabel, + bindingProblem, + matchesAiRequirement, + personalAiDefault, + type AiConnectionBinding, + type AiConnectionRequirement, + type AiConnectionSummary, +} from "./model"; + +export interface AiConnectionPickerProps { + requirement: AiConnectionRequirement; + connections: AiConnectionSummary[]; + value?: AiConnectionBinding; + currentUserId: string; + agentId: string; + agentName: string; + loading?: boolean; + error?: string; + readOnly?: boolean; + onChange: (binding: AiConnectionBinding) => void; + onConnect: () => void; + onRetry?: () => void; +} + +export function AiConnectionPicker({ + requirement, + connections, + value, + currentUserId, + agentId, + loading, + error, + readOnly, + onChange, + onConnect, + onRetry, +}: AiConnectionPickerProps) { + const compatible = connections.filter((connection) => + matchesAiRequirement(connection, requirement), + ); + const personalDefault = personalAiDefault( + compatible, + requirement, + currentUserId, + ); + const problem = value ? bindingProblem( + value, + requirement, + compatible, + currentUserId, + agentId, + ) : undefined; + const select = ( + mode: "shared", + connection: AiConnectionSummary, + ) => + onChange({ + provider: requirement.provider, + method: requirement.method, + mode, + connectionId: connection.id, + grantId: connection.grantId, + }); + return ( +
+
+ +
+

AI connection

+

+ {AI_PROVIDERS[requirement.provider].name} ·{" "} + {aiMethodLabel(requirement.provider, requirement.method)} +

+
+
+ {loading ? ( +
+ +
+ ) : error ? ( +
+

+ {error} +

+ {onRetry && ( + + )} +
+ ) : ( + <> + + For you: {personalDefault?.name ?? "Not connected"} + Other users’ tasks use their own {requirement.method === "api_key" ? `${AI_PROVIDERS[requirement.provider].name} API key` : aiMethodLabel(requirement.provider, requirement.method)}. + }, + ...compatible.filter((connection) => connection.ownership === "shared").map((connection) => ({ + id: connection.id, name: connection.name, + disabled: Boolean(aiConnectionProblem(connection)), + description: <>Company shared{connection.accountLabel ? ` · ${connection.accountLabel}` : ""}{aiConnectionProblem(connection) ? ` · ${aiConnectionProblem(connection)}` : ""}, + })), + ]} + onSelect={(id) => { + if (id === "responsible_user") onChange({provider: requirement.provider, method: requirement.method, mode: "responsible_user"}); + else { const connection = compatible.find((item) => item.id === id)!; select("shared", connection); } + }} + /> + {problem && ( +

+ {problem} +

+ )} + {!readOnly && ( + + )} + + )} +
+ ); +} diff --git a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx new file mode 100644 index 0000000000..ede259e7b7 --- /dev/null +++ b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx @@ -0,0 +1,134 @@ +import { heartbeatsApi } from "@/api/heartbeats"; +import { Button } from "@/components/ui/button"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { aiConnectionsApi } from "@/api/ai-connections"; +import { toolsApi } from "@/api/tools"; +import { useNavigate } from "@/lib/router"; +import { AiConnectionAccountControls } from "./AiConnectionAccountControls"; +import type { ToolConnection } from "@paperclipai/shared"; +import { aiMethodLabel } from "./model"; + +export function ManagedAiConnectionRow({ + connection, +}: { + connection: ToolConnection; +}) { + const metadata = connection.config?.ai as + | { + provider: "anthropic" | "openai" | "openrouter" | "xai"; + method: "subscription" | "api_key"; + } + | undefined; + if (!metadata) return null; + return ( +

+ {aiMethodLabel(metadata.provider, metadata.method)} ·{" "} + {connection.credentialPolicy === "per_user" + ? "Personal" + : "Company shared"} +

+ ); +} +export function ManagedAiConnectionDetails({ + connection, +}: { + connection: ToolConnection; +}) { + const client = useQueryClient(); + const navigate = useNavigate(); + const runs = useQuery({ + queryKey: ["ai-connection-active-runs", connection.id], + queryFn: () => + aiConnectionsApi.activeRuns(connection.companyId, connection.id), + }); + const accounts = useQuery({ + queryKey: ["ai-connections", connection.companyId], + queryFn: () => aiConnectionsApi.list(connection.companyId), + }); + const grants = useQuery({ + queryKey: ["ai-connection-grants", connection.id], + queryFn: () => toolsApi.listConnectionGrants(connection.id), + }); + const refresh = () => client.invalidateQueries(); + const makeDefault = useMutation({ + mutationFn: (id: string) => + aiConnectionsApi.setDefault(connection.companyId, id), + onSuccess: refresh, + }); + const revoke = useMutation({ + mutationFn: (id: string) => + toolsApi.revokeConnectionGrant(connection.id, id), + onSuccess: refresh, + }); + const stop = useMutation({ + mutationFn: (id: string) => heartbeatsApi.cancel(id), + onSuccess: refresh, + }); + const account = accounts.data?.connections.find( + (a) => a.id === connection.id, + ); + const grant = grants.data?.grants.find((g) => g.id === account?.grantId); + const error = + accounts.error ?? + grants.error ?? + makeDefault.error ?? + stop.error; + if (error) + return ( +

+ {error.message} +

+ ); + if (!account || !grant) + return ( +

+ {accounts.isPending || grants.isPending + ? "Loading AI account…" + : "This account is not available to you."} +

+ ); + return ( +
+ makeDefault.mutate(grant.id)} + onRevoke={() => revoke.mutateAsync(grant.id).then(() => undefined)} + revocationDetails={ +
+ {runs.error && ( +

+ Could not load active runs. Retry before revoking. +

+ )} + {runs.data?.map((run) => ( +
+ + {run.agentName} · {run.status} + + +
+ ))} +
+ } + onReconnect={() => + navigate( + `/apps/connect?source=${account.provider}&reconnect=${connection.id}&method=ai-${account.method}`, + ) + } + /> + +
+ ); +} diff --git a/ui/src/components/ai-connections/model.test.ts b/ui/src/components/ai-connections/model.test.ts new file mode 100644 index 0000000000..0141f0aeff --- /dev/null +++ b/ui/src/components/ai-connections/model.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + aiConnectionProblem, + bindingProblem, + matchesAiRequirement, + personalAiDefault, + type AiConnectionSummary, + type AiConnectionRequirement, + type AiConnectionBinding, +} from "./model"; + +const requirement: AiConnectionRequirement = { + companyId: "company", + provider: "anthropic", + method: "subscription", +}; +const account: AiConnectionSummary = { + ...requirement, + id: "connection", + grantId: "grant", + name: "Personal Claude", + ownership: "personal", + ownerUserId: "alice", + status: "connected", + isDefault: true, +}; +const binding: AiConnectionBinding = { + provider: "anthropic", + method: "subscription", + mode: "responsible_user", +}; + +describe("AI connection selection presentation", () => { + it("scopes personal defaults to company, user, provider and method", () => { + for (const change of [ + { companyId: "other" }, + { provider: "openai" as const }, + { method: "api_key" as const }, + { ownerUserId: "bob" }, + { ownership: "shared" as const }, + ]) { + expect( + personalAiDefault([{ ...account, ...change }], requirement, "alice"), + ).toBeUndefined(); + } + expect(personalAiDefault([account], requirement, "alice")).toBe(account); + }); + it("retains a revoked default instead of falling back to a healthy account", () => { + const revoked = { ...account, status: "revoked" as const }; + const alternate = { ...account, id: "alternate", isDefault: false }; + expect(personalAiDefault([alternate, revoked], requirement, "alice")).toBe( + revoked, + ); + expect( + bindingProblem( + binding, + requirement, + [alternate, revoked], + "alice", + "agent", + ), + ).toContain("Revoked"); + }); + it("does not select another user’s account", () => { + expect( + bindingProblem(binding, requirement, [account], "bob", "agent"), + ).toContain("No connection"); + }); + it("does not infer a default from the first compatible connection", () => { + expect( + personalAiDefault( + [{ ...account, isDefault: false }], + requirement, + "alice", + ), + ).toBeUndefined(); + }); + it("rejects incompatible bindings without modifying the requirement", () => { + const original = { ...requirement }; + expect( + bindingProblem( + { ...binding, provider: "openai" }, + requirement, + [account], + "alice", + "agent", + ), + ).toContain("compatible"); + expect(requirement).toEqual(original); + expect( + matchesAiRequirement({ ...account, method: "api_key" }, requirement), + ).toBe(false); + }); + it("requires exact grant identity and human access for a legacy personal selection", () => { + const delegated = { + provider: "anthropic", + method: "subscription", + mode: "delegated", + connectionId: account.id, + grantId: account.grantId, + } as const; + expect( + bindingProblem(delegated, requirement, [account], "bob", "agent"), + ).toContain("not shared with you"); + expect( + bindingProblem( + delegated, + requirement, + [account], + "alice", + "agent", + ), + ).toBeNull(); + expect( + bindingProblem( + { ...delegated, grantId: "different" }, + requirement, + [account], + "alice", + "agent", + ), + ).toContain("no longer available"); + }); + it("does not mistake a personal account for shared", () => { + expect( + bindingProblem( + { + ...binding, + mode: "shared", + connectionId: account.id, + grantId: account.grantId, + }, + requirement, + [account], + "alice", + "agent", + ), + ).toContain("company-shared"); + }); + it("preserves server-projected eligibility denials", () => { + expect( + aiConnectionProblem({ + ...account, + unavailableReason: "Not in the shared audience", + }), + ).toBe("Not in the shared audience"); + }); +}); diff --git a/ui/src/components/ai-connections/model.ts b/ui/src/components/ai-connections/model.ts new file mode 100644 index 0000000000..e017cc3f71 --- /dev/null +++ b/ui/src/components/ai-connections/model.ts @@ -0,0 +1,119 @@ +/** Redacted presentation contracts shared with the production API. */ +import type { AiProvider, AiAuthMethod, AiManagedConnectionSummary, AiConnectionBinding } from "@paperclipai/shared"; +export type { AiProvider, AiAuthMethod, AiConnectionBinding } from "@paperclipai/shared"; +export type AiConnectionStatus = AiManagedConnectionSummary["status"]; + +export const AI_PROVIDERS: Record< + AiProvider, + { name: string; subscriptionName?: string; logo?: string } +> = { + anthropic: { + name: "Claude", + subscriptionName: "Claude subscription", + logo: "/brands/claude-color.svg", + }, + openai: { + name: "OpenAI", + subscriptionName: "ChatGPT subscription", + logo: "/brands/codex-color.svg", + }, + openrouter: { name: "OpenRouter", logo: "/brands/apps/openrouter.svg" }, + xai: { + name: "Grok", + subscriptionName: "Grok subscription", + logo: "/brands/adapters/grok.svg", + }, +}; + +export type AiConnectionSummary = Omit & { isDefault?: boolean }; + +export interface AiConnectionRequirement { + companyId: string; + provider: AiProvider; + method: AiAuthMethod; +} + +export const AI_CONNECTION_STATUS: Record = { + connected: "Connected", + needs_attention: "Needs attention", + expired: "Expired", + revoked: "Revoked", +}; + +export function aiMethodLabel(provider: AiProvider, method: AiAuthMethod) { + return method === "subscription" + ? (AI_PROVIDERS[provider].subscriptionName ?? "Subscription unavailable") + : "API key"; +} + +export function matchesAiRequirement( + connection: AiConnectionSummary, + requirement: AiConnectionRequirement, +) { + return ( + connection.companyId === requirement.companyId && + connection.provider === requirement.provider && + connection.method === requirement.method + ); +} + +export function personalAiDefault( + connections: AiConnectionSummary[], + requirement: AiConnectionRequirement, + userId: string, +) { + // Never choose another account because the declared default is unhealthy. + return connections.find( + (connection) => + matchesAiRequirement(connection, requirement) && + connection.ownership === "personal" && + connection.ownerUserId === userId && + connection.isDefault, + ); +} + +export function aiConnectionProblem(connection?: AiConnectionSummary) { + if (!connection) + return "No connection selected. Connect an account to continue."; + return ( + connection.unavailableReason ?? + (connection.status === "connected" + ? null + : `${AI_CONNECTION_STATUS[connection.status]}. Reconnect this account to continue.`) + ); +} + +export function bindingProblem( + binding: AiConnectionBinding, + requirement: AiConnectionRequirement, + connections: AiConnectionSummary[], + userId: string, + _agentId: string, +) { + if ( + binding.provider !== requirement.provider || + binding.method !== requirement.method + ) + return "Choose a connection compatible with this provider and sign-in method."; + if (binding.mode === "responsible_user") + return aiConnectionProblem( + personalAiDefault(connections, requirement, userId), + ); + const connection = connections.find( + (item) => + item.id === binding.connectionId && + item.grantId === binding.grantId && + matchesAiRequirement(item, requirement), + ); + if (!connection) + return "This connection is no longer available for this agent. Choose another connection."; + if (binding.mode === "shared" && connection.ownership !== "shared") + return "Choose a company-shared connection."; + if ( + binding.mode === "delegated" && + (connection.ownership !== "personal" || + connection.ownerUserId !== userId) + ) + return "This credential is not shared with you. Choose a connection you can use."; + return aiConnectionProblem(connection); +} diff --git a/ui/src/components/ai-connections/useLocalAiLogin.test.tsx b/ui/src/components/ai-connections/useLocalAiLogin.test.tsx new file mode 100644 index 0000000000..67d28d6169 --- /dev/null +++ b/ui/src/components/ai-connections/useLocalAiLogin.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { StrictMode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { useLocalAiLogin } from "./useLocalAiLogin"; +import { LocalProviderLoginInstructions } from "../AdapterLoginChrome"; + +const api = vi.hoisted(() => ({ startLocalLogin: vi.fn(), checkLocalLogin: vi.fn(), cancelLocalLogin: vi.fn(), connectLocal: vi.fn() })); +vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: api })); +let root: ReturnType; +let host: HTMLDivElement; +beforeEach(() => { + vi.resetAllMocks(); + api.startLocalLogin.mockImplementation(async () => ({ sessionId: "attempt-1", command: "isolated codex login", expiresAt: "2099-01-01T00:00:00Z" })); + api.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" }); + api.cancelLocalLogin.mockResolvedValue({}); + api.connectLocal.mockResolvedValue({ connectionId: "connection", grantId: "grant" }); + host = document.createElement("div"); document.body.append(host); root = createRoot(host); +}); +afterEach(() => { flushSync(() => root.unmount()); host.remove(); }); +function Harness({ name = "Account", provider = "openai", enabled = true }: { name?: string; provider?: "anthropic" | "openai"; enabled?: boolean }) { + const login = useLocalAiLogin("company", { provider, method: "subscription", name, ownership: "personal", agentIds: [], allAgents: true }, enabled); + return <>; +} +it("checks once under StrictMode, preserves renaming and navigation, and cancels only on explicit retry", async () => { + flushSync(() => root.render()); + await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login")); + expect(api.startLocalLogin).toHaveBeenCalledTimes(1); + expect(api.checkLocalLogin).toHaveBeenCalledTimes(1); + expect(api.cancelLocalLogin).not.toHaveBeenCalled(); + flushSync(() => root.render()); + expect(api.startLocalLogin).toHaveBeenCalledTimes(1); + flushSync(() => root.render()); + flushSync(() => root.render()); + await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login")); + expect(api.cancelLocalLogin).not.toHaveBeenCalled(); + flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Connect')!.click()); + await vi.waitFor(() => expect(api.connectLocal).toHaveBeenCalledWith("company", expect.objectContaining({ name: "Renamed", localSessionId: "attempt-1" }))); + flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Start sign-in again')!.click()); + await vi.waitFor(() => expect(api.startLocalLogin).toHaveBeenCalledTimes(2)); + expect(api.cancelLocalLogin).toHaveBeenCalledTimes(1); + expect(api.cancelLocalLogin.mock.invocationCallOrder[0]).toBeLessThan(api.startLocalLogin.mock.invocationCallOrder[1]); +}); +it.each(["anthropic", "openai"] as const)("detects an already-signed-in %s account before showing instructions, and does not save it until Connect", async provider => { + api.checkLocalLogin.mockResolvedValue({ status: "ready" }); + flushSync(() => root.render()); + expect(host.textContent).toContain("Checking local"); + await vi.waitFor(() => expect(host.textContent).toContain("is signed in")); + expect(host.textContent).not.toContain("Run this in a terminal"); + expect(api.connectLocal).not.toHaveBeenCalled(); + if (provider === "anthropic") expect(api.startLocalLogin).not.toHaveBeenCalled(); +}); +it("detects terminal completion on focus without needing a Connect attempt", async () => { + flushSync(() => root.render()); + await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login")); + api.checkLocalLogin.mockResolvedValue({ status: "ready" }); + window.dispatchEvent(new Event('focus')); + await vi.waitFor(() => expect(host.textContent).toContain("is signed in")); + expect(host.textContent).not.toContain("isolated codex login"); + expect(api.connectLocal).not.toHaveBeenCalled(); +}); +it("keeps a copied command's attempt alive after leaving the page", async () => { + flushSync(() => root.render()); + await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login")); + flushSync(() => root.render(
Another page
)); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(api.cancelLocalLogin).not.toHaveBeenCalled(); + const checks = api.checkLocalLogin.mock.calls.length; + window.dispatchEvent(new Event('focus')); + expect(api.checkLocalLogin).toHaveBeenCalledTimes(checks); +}); + +it("explicit retry can replace an attempt opened in another authentication host", async () => { + api.startLocalLogin.mockRejectedValueOnce(new Error("Another sign-in is still open.")); + flushSync(() => root.render()); + await vi.waitFor(() => expect(host.textContent).toContain("Another sign-in")); + flushSync(() => Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Start sign-in again')!.click()); + await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login")); + expect(api.startLocalLogin).toHaveBeenLastCalledWith("company", expect.objectContaining({ restart: true })); +}); diff --git a/ui/src/components/ai-connections/useLocalAiLogin.ts b/ui/src/components/ai-connections/useLocalAiLogin.ts new file mode 100644 index 0000000000..e4e12afd47 --- /dev/null +++ b/ui/src/components/ai-connections/useLocalAiLogin.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import type { AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared"; +import { aiConnectionsApi } from "@/api/ai-connections"; + +/** Every authentication host uses the same local credential check and login lifecycle. */ +export function useLocalAiLogin(companyId: string | null, intent: AiConnectionLoginIntent, enabled: boolean) { + const isolated = intent.provider === "openai" || intent.provider === "xai"; + const active = Boolean(companyId && enabled); + const [attempt, setAttempt] = useState(null); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + const latestIntent = useRef(intent); + const restartRequested = useRef(false); + const pending = useRef>(Promise.resolve()); + const current = useRef<{ key: string; companyId: string; request: Promise } | null>(null); + function cancelCurrent() { + const previous = current.current; + current.current = null; + if (previous) pending.current = previous.request + .then((result) => aiConnectionsApi.cancelLocalLogin(previous.companyId, result.sessionId)).catch(() => {}); + } + latestIntent.current = intent; + // Renaming the account does not restart sign-in; access/target changes do. + const target = JSON.stringify({ ...intent, name: undefined }); + useEffect(() => { + setAttempt(null); + setError(null); + setStatus(null); + if (!active || !companyId) return; + let cancelled = false; + let checking = false; + let timer: ReturnType | undefined; + const key = JSON.stringify([companyId, target, generation]); + if (isolated && current.current?.key !== key) { + cancelCurrent(); + const input = { ...latestIntent.current, ...(restartRequested.current ? { restart: true } : {}) }; + restartRequested.current = false; + const request = pending.current.then(() => aiConnectionsApi.startLocalLogin(companyId, input)); + current.current = { key, companyId, request }; + pending.current = request.catch(() => {}); + } + const request = isolated ? current.current!.request : Promise.resolve(null); + async function check() { + if (checking || cancelled) return; + checking = true; + clearTimeout(timer); + try { + const result = await request; + if (cancelled) return; + setAttempt(result); + const next = await aiConnectionsApi.checkLocalLogin(companyId!, { + ...latestIntent.current, ...(result ? { localSessionId: result.sessionId } : {}), + }); + if (cancelled) return; + setStatus(next.status); + setError(next.status === "expired" ? "This sign-in attempt expired. Start sign-in again." : null); + // Stop polling a verified account. Focus still rechecks after a terminal + // visit; awaiting terminal login never requires repeated Connect clicks. + if (next.status === "sign_in_required") timer = setTimeout(() => void check(), 5000); + } catch (cause) { + if (!cancelled) setError(cause instanceof Error ? cause.message : "Could not check local sign-in."); + } finally { checking = false; } + } + const onFocus = () => { if (!document.hidden) void check(); }; + void check(); + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onFocus); + return () => { + cancelled = true; + clearTimeout(timer); + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onFocus); + // Navigation is not cancellation. The server resumes this bounded attempt + // when the user returns and reaps abandoned attempts after expiry. Deleting + // here made copied CODEX_HOME commands point at nonexistent directories. + }; + }, [companyId, active, isolated, target, generation]); + return { + command: attempt?.command, + status, + preparing: active && !status && !error, + error, + retry: () => { restartRequested.current = true; cancelCurrent(); setGeneration((value) => value + 1); }, + connect: (input = intent) => { + if (!companyId) throw new Error("Choose a company before connecting."); + if (isolated && !attempt) throw new Error("Prepare local sign-in before connecting."); + return aiConnectionsApi.connectLocal(companyId, { ...input, ...(attempt ? { localSessionId: attempt.sessionId } : {}) }); + }, + }; +} diff --git a/ui/src/components/new-agent/AgentProviderConnection.test.tsx b/ui/src/components/new-agent/AgentProviderConnection.test.tsx index 2116e0e6cc..a0fdd6bc71 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.test.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.test.tsx @@ -10,7 +10,18 @@ const mocks = vi.hoisted(() => ({ login: vi.fn(), personal: vi.fn(), organization: vi.fn(), + loginPanel: vi.fn(), })); +const managedApi = vi.hoisted(() => ({ + list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })), + loginResult: vi.fn(async () => ({ connectionId: "login-account", grantId: "login-grant" })), + connectLocal: vi.fn(async () => ({ connectionId: "local-account", grantId: "local-grant" })), + startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", command: "CODEX_HOME='/fixture/isolated-login' codex login", expiresAt: "2026-09-11T20:00:00Z" })), + checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as const })), + cancelLocalLogin: vi.fn(async () => ({})), + create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), +})); +vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); vi.mock("@/api/agents", () => ({ agentsApi: { getAdapterAuthSignal: mocks.auth, @@ -21,7 +32,7 @@ vi.mock("@/api/secrets", () => ({ secretsApi: { listMyUserSecrets: mocks.personal, list: mocks.organization }, })); vi.mock("../AgentConfigForm", () => ({ - AdapterLoginPanel: () =>
New subscription login
, + AdapterLoginPanel: (props: unknown) => { mocks.loginPanel(props); return
New subscription login
; }, })); let root: Root; let host: HTMLDivElement; @@ -39,6 +50,8 @@ async function mount( codexSubscriptions = false, savedApiKeys = true, cachedClaudeLogin = false, + managedAccount?: Parameters[0]["managedAccount"], + localEnvironment = false, ) { const key = adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; @@ -91,6 +104,8 @@ async function mount( client.setQueryData(["claude-oauth-token-status", "c1"], { secretId: "cached-claude", latestVersion: 1 }); mocks.auth.mockResolvedValue({ status: "absent" }); } + client.setQueryData(["health"], { deploymentMode: "local_trusted" }); + client.setQueryDefaults(["health"], { staleTime: Infinity }); host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); @@ -104,16 +119,18 @@ async function mount( adapterType={adapterType} environmentId="e1" canLogin={canLogin} + localEnvironment={localEnvironment} onBack={() => {}} testConnection={test} onConnected={connected} + managedAccount={managedAccount} /> , ), ); await vi.waitFor(() => expect(mocks.personal).toHaveBeenCalled()); await vi.waitFor(() => expect(client.isFetching()).toBe(0)); - if (savedApiKeys) await vi.waitFor(() => expect(host.textContent).toContain("2 saved API keys")); + if (savedApiKeys && !managedAccount) await vi.waitFor(() => expect(host.textContent).toContain("2 saved API keys")); return { test, connected, key }; } function click(text: string) { @@ -129,6 +146,96 @@ function openProvider() { ); } describe("AgentProviderConnection reuse", () => { + it.each(["claude_local", "codex_local"] as const)("connects a local subscription without a sandbox and supports retry: %s", async (adapterType) => { + const onComplete = vi.fn(); + const intent = { provider: adapterType === "claude_local" ? "anthropic" as const : "openai" as const, method: "subscription" as const, name: "My account", ownership: "personal" as const, agentIds: [], allAgents: false }; + await mount(adapterType, false, false, false, false, false, { intent, onComplete }, true); + openProvider(); + await vi.waitFor(() => expect(host.textContent).toContain(adapterType === "claude_local" ? "claude auth login" : "codex login")); + expect(host.textContent).toContain("machine running Paperclip"); + expect(host.textContent).not.toContain("sandbox"); + managedApi.connectLocal.mockRejectedValueOnce(new Error("Run local login and try again")); + click("Connect"); + await vi.waitFor(() => expect(host.textContent).toContain("Run local login and try again")); + expect(onComplete).not.toHaveBeenCalled(); + if (adapterType === "codex_local") { + click("Start sign-in again"); + await vi.waitFor(() => expect(host.textContent).not.toContain("Run local login and try again")); + await vi.waitFor(() => expect(managedApi.cancelLocalLogin).toHaveBeenCalledWith("c1", "local-attempt")); + await vi.waitFor(() => expect(host.textContent).toContain("codex login")); + } + click("Connect"); + await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "local-account", grantId: "local-grant", method: "subscription" })); + expect(managedApi.connectLocal).toHaveBeenCalledWith("c1", adapterType === "codex_local" ? { ...intent, localSessionId: "local-attempt" } : intent); + expect(mocks.loginPanel).not.toHaveBeenCalled(); + }); + it("leaves a completed local account saved when its host is cancelled", async () => { + let finish!: (result: { connectionId: string; grantId: string }) => void; + managedApi.connectLocal.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const onComplete = vi.fn(); + await mount("claude_local", false, false, false, false, false, { intent: { provider: "anthropic", method: "subscription", name: "My account", ownership: "personal", agentIds: [], allAgents: false }, onComplete }, true); + openProvider(); click("Connect"); flushSync(() => root.unmount()); + finish({ connectionId: "saved", grantId: "saved-grant" }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(onComplete).not.toHaveBeenCalled(); + }); + it.each(["claude_local", "codex_local"] as const)("does not import local credentials for an unsupported remote environment: %s", async (adapterType) => { + const onComplete = vi.fn(); + const intent = { provider: adapterType === "claude_local" ? "anthropic" as const : "openai" as const, method: "subscription" as const, name: "Engineering subscription", ownership: "shared" as const, agentIds: ["nova"], allAgents: false }; + const { test } = await mount(adapterType, false, false, false, true, false, { intent, onComplete }); + openProvider(); + expect(host.textContent).toContain("This environment does not support browser sign-in"); + expect(host.textContent).not.toContain("login on this machine"); + click("Connect"); + expect(onComplete).not.toHaveBeenCalled(); + expect(managedApi.create).not.toHaveBeenCalled(); + expect(test).not.toHaveBeenCalled(); + }); + + it.each(["claude_local", "codex_local"] as const)("drives onboarding's provider redirect and completion: %s", async (adapterType) => { + const onComplete = vi.fn(); + const intent = { provider: adapterType === "claude_local" ? "anthropic" as const : "openai" as const, method: "subscription" as const, name: "My account", ownership: "personal" as const, agentIds: [], allAgents: false }; + await mount(adapterType, false, true, false, false, false, { intent, onComplete }); + openProvider(); + const panel = () => mocks.loginPanel.mock.calls.at(-1)![0]; + expect(panel().chrome).toBe("onboarding"); + expect(panel().autoStart).toBe(true); + expect(panel().aiConnection).toEqual(intent); + const open = vi.spyOn(window, "open").mockReturnValue(null); + try { + flushSync(() => panel().onPromptReady("https://provider.example/authorize")); + click(adapterType === "claude_local" ? "Sign in to Claude" : "Sign in to OpenAI"); + expect(open).toHaveBeenCalledWith("https://provider.example/authorize", "_blank", "noreferrer,noopener"); + expect(host.textContent).toContain("Waiting for code"); + flushSync(() => panel().onCodeSubmitted()); + expect(host.textContent).toContain("Connecting"); + flushSync(() => panel().onSubmitFailed()); + expect(host.textContent).toContain("Waiting for code"); + flushSync(() => panel().onConnected("session-1")); + await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "login-account", grantId: "login-grant", method: "subscription" })); + expect(managedApi.loginResult).toHaveBeenCalledWith("c1", "session-1"); + } finally { open.mockRestore(); } + }); + + it("does not advance after Back while the saved login result is loading", async () => { + let finish!: (result: { connectionId: string; grantId: string }) => void; + managedApi.loginResult.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const onComplete = vi.fn(); + await mount("claude_local", false, true, false, false, false, { intent: { provider: "anthropic", method: "subscription", name: "My account", ownership: "personal", agentIds: [], allAgents: false }, onComplete }); + openProvider(); + flushSync(() => mocks.loginPanel.mock.calls.at(-1)![0].onConnected("session-1")); + click("Back"); + finish({ connectionId: "saved", grantId: "grant" }); + await Promise.resolve(); + expect(onComplete).not.toHaveBeenCalled(); + }); + it("starts the existing browser login when adding an account even if the environment is authenticated", async () => { + await mount("claude_local", true, true, false, true, false, { intent: { provider: "anthropic", method: "subscription", name: "My second account", ownership: "personal", agentIds: [], allAgents: false }, onComplete: vi.fn() }); + openProvider(); + expect(host.textContent).toContain("New subscription login"); + expect(host.textContent).not.toContain("Use saved subscription"); + }); + it("defaults to subscription when no saved credentials exist", async () => { await mount("claude_local", false, true, false, false); expect(host.textContent).toContain("Use API key instead"); @@ -241,4 +348,36 @@ describe("AgentProviderConnection reuse", () => { click("Connect"); await vi.waitFor(() => expect(test).toHaveBeenCalledWith({ env: {} })); }); + it.each(["claude_local", "codex_local"] as const)("uses a managed subscription through the upstream chooser for %s", async (adapterType) => { + const provider = adapterType === "claude_local" ? "anthropic" : "openai"; + managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{ + id: "account", grantId: "grant", companyId: "c1", provider, + method: "subscription", name: "My subscription", ownership: "personal", + ownerUserId: "user-1", isDefault: true, status: "connected", + }] } as never); + const { connected } = await mount(adapterType, false, true, false, false); + openProvider(); + expect(host.querySelector('select[aria-label="Saved subscription"]')?.textContent).toContain("My subscription (Your default)"); + click("Use saved subscription"); + await vi.waitFor(() => expect(connected).toHaveBeenCalledWith({ env: {}, aiConnection: { provider, method: "subscription", mode: "responsible_user" } })); + expect(managedApi.create).not.toHaveBeenCalled(); + flushSync(() => { + const select = host.querySelector("select")!; + select.value = ""; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(host.textContent).toContain("New subscription login"); + await client.invalidateQueries(); + await vi.waitFor(() => expect(client.isFetching()).toBe(0)); + expect(host.querySelector("select")!.value).toBe(""); + expect(host.textContent).toContain("New subscription login"); + }); + it("never offers a saved Codex home in Claude's subscription chooser", async () => { + await mount("claude_local", false, true, true); + click("Use subscription instead"); + openProvider(); + expect(host.querySelector('select[aria-label="Saved subscription"]')).toBeNull(); + expect(host.textContent).not.toContain("ChatGPT account"); + }); + }); diff --git a/ui/src/components/new-agent/AgentProviderConnection.tsx b/ui/src/components/new-agent/AgentProviderConnection.tsx index 6faa67a3e7..0de551fb5b 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.tsx @@ -1,3 +1,7 @@ +import { healthApi } from "@/api/health"; +import { aiConnectionsApi } from "@/api/ai-connections"; +import { useLocalAiLogin } from "../ai-connections/useLocalAiLogin"; +import type { AiConnectionBinding, AiConnectionLoginIntent } from "@paperclipai/shared"; import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { motion } from "motion/react"; @@ -9,6 +13,7 @@ import { agentsApi } from "@/api/agents"; import { queryKeys } from "@/lib/queryKeys"; import { AdapterLoginPanel } from "../AgentConfigForm"; import { + LocalProviderLoginInstructions, OnboardingCardField, OnboardingLoginCard, } from "../AdapterLoginChrome"; @@ -21,6 +26,7 @@ import type { EnvBinding } from "@paperclipai/shared"; export type ProviderConnection = { env: Record; + aiConnection?: AiConnectionBinding; /** Kept in memory until the user finishes setup. */ credentials?: Record; storedSessionId?: string; @@ -31,20 +37,33 @@ export function AgentProviderConnection({ adapterType, environmentId, canLogin, + localEnvironment = false, onConnected, onBack, testConnection, testError, + managedAccount, }: { companyId: string; - adapterType: "claude_local" | "codex_local"; + adapterType: "claude_local" | "codex_local" | "grok_local"; environmentId: string | null; canLogin: boolean; + localEnvironment?: boolean; onConnected: (connection: ProviderConnection) => void; onBack: () => void; testConnection: (connection: ProviderConnection) => Promise; testError?: string | null; + /** Connections supplies its access intent; presentation and login controllers stay shared. */ + managedAccount?: { + intent: AiConnectionLoginIntent; + initialMethod?: "subscription" | "api_key"; + fixedMethod?: boolean; + disabled?: boolean; + onComplete: (result: { connectionId: string; grantId: string; method: "subscription" | "api_key" }) => void; + }; }) { + const health = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get, enabled: localEnvironment }); + const canUseLocalLogin = localEnvironment && health.data?.deploymentMode === "local_trusted"; const epoch = useRef(0); useEffect( () => () => { @@ -56,21 +75,32 @@ export function AgentProviderConnection({ epoch.current++; setBusy(false); setOpened(false); + setAuthorizationUrl(null); + setLoginPhase("preparing"); }; - const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(null); + const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(managedAccount?.initialMethod === "api_key" ? "api" : managedAccount ? "subscription" : null); const [opened, setOpened] = useState(false); + const [authorizationUrl, setAuthorizationUrl] = useState(null); + const [loginPhase, setLoginPhase] = useState<"preparing" | "ready" | "waiting" | "connecting">("preparing"); + const phaseBeforeSubmit = useRef<"ready" | "waiting">("ready"); const [apiKey, setApiKey] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [storedConnection, setStoredConnection] = useState(null); - const provider = adapterType === "claude_local" ? "Claude" : "OpenAI"; + const provider = adapterType === "claude_local" ? "Claude" : adapterType === "grok_local" ? "Grok" : "OpenAI"; const envKey = - adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; - const savedKeys = useSavedProviderKeys(companyId, envKey); + adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : adapterType === "grok_local" ? "XAI_API_KEY" : "OPENAI_API_KEY"; + const aiProvider = adapterType === "claude_local" ? "anthropic" : adapterType === "grok_local" ? "xai" : "openai"; + const availableKeys = useSavedProviderKeys(companyId, envKey); + // Add/reconnect creates the requested account, never copies a saved account's + // credential or silently changes its ownership. Agent setup retains reuse. + const savedKeys = managedAccount + ? { ...availableKeys, options: [], subscriptions: [], loading: false } + : availableKeys; const [subscriptionId, setSubscriptionId] = useState(null); const savedSubscription = - adapterType === "codex_local" + savedKeys.subscriptions.length ? savedKeys.subscriptions.find( (option) => option.id === (subscriptionId ?? savedKeys.subscriptions[0]?.id), @@ -80,11 +110,18 @@ export function AgentProviderConnection({ const selectedKey = savedKeys.options.find( (option) => option.id === (selectedKeyId ?? savedKeys.options[0]?.id), ); - const storedLogin = savedKeys.storedLogin; + const storedLogin = managedAccount + ? { ...savedKeys.storedLogin, data: undefined, isPending: false, isError: false } + : savedKeys.storedLogin; + const savedManagedAccount = useRef<{ connectionId: string; grantId: string } | null>(null); const method = methodChoice ?? ( - (adapterType === "claude_local" ? storedLogin.data : savedKeys.subscriptions.length) + (savedKeys.subscriptions.length > 0 || (adapterType === "claude_local" && !savedSubscription && storedLogin.data)) ? "subscription" : savedKeys.options.length ? "api" : "subscription" ); + const localLogin = useLocalAiLogin(companyId, managedAccount?.intent ?? { + provider: aiProvider, method: "subscription", name: `My ${provider} subscription`, + ownership: "personal", agentIds: [], allAgents: true, + }, canUseLocalLogin && method === "subscription" && !savedSubscription && !storedLogin.data); const auth = useQuery({ queryKey: queryKeys.agents.authSignal( companyId, @@ -98,32 +135,52 @@ export function AgentProviderConnection({ environmentId ?? undefined, ), retry: false, + enabled: !managedAccount, }); async function connect() { - if (busy) return; + if (busy || managedAccount?.disabled) return; const run = ++epoch.current; setBusy(true); setError(null); try { - const connection = + if (managedAccount) { + if (method === "subscription" && !canUseLocalLogin) return; + const result = savedManagedAccount.current ?? await (method === "api" + ? aiConnectionsApi.create(companyId, { ...managedAccount.intent, method: "api_key", apiKey: apiKey.trim() }) + : localLogin.connect(managedAccount.intent)); + savedManagedAccount.current = result; + setApiKey(""); + if (run === epoch.current) managedAccount.onComplete({ ...result, method: method === "api" ? "api_key" : "subscription" }); + return; + } + let connection: ProviderConnection = method === "api" ? selectedKey - ? { env: { [envKey]: selectedKey.binding } } + ? selectedKey.aiConnection ? { env: {}, aiConnection: selectedKey.aiConnection } : { env: { [envKey]: selectedKey.binding } } : (storedConnection ?? { env: {}, credentials: { [envKey]: apiKey.trim() }, }) : { - env: savedSubscription + ...(savedSubscription?.aiConnection ? { aiConnection: savedSubscription.aiConnection } : {}), + env: savedSubscription?.binding ? { CODEX_HOME: savedSubscription.binding } : {}, - ...(adapterType === "claude_local" && storedLogin.data + ...(adapterType === "claude_local" && !savedSubscription && storedLogin.data ? { env: buildFixedClaudeOAuthBinding(), applyStoredClaudeLogin: true, } : {}), }; + if (method === "subscription" && canUseLocalLogin && !savedSubscription && !storedLogin.data) { + savedManagedAccount.current ??= await localLogin.connect(); + connection = { env: {}, aiConnection: { provider: aiProvider, method: "subscription", mode: "responsible_user" } }; + } + if (connection.credentials) { + await aiConnectionsApi.create(companyId, { provider: aiProvider, method: "api_key", name: `My ${provider} API`, ownership: "personal", apiKey: connection.credentials[envKey], agentIds: [], allAgents: true }); + connection = { env: {}, aiConnection: { provider: aiProvider, method: "api_key", mode: "responsible_user" } }; + } if (run !== epoch.current) return; if (method === "api") { setApiKey(""); @@ -138,6 +195,7 @@ export function AgentProviderConnection({ ); } catch (cause) { if (run !== epoch.current) return; + if (managedAccount) setApiKey(""); setError( cause instanceof Error ? cause.message @@ -154,9 +212,9 @@ export function AgentProviderConnection({ !savedSubscription && !savedKeys.loading && !storedLogin.data && - (auth.data?.status !== "present" || subscriptionId === ""); + (Boolean(managedAccount) || auth.data?.status !== "present" || subscriptionId === ""); return ( -
+
@@ -175,13 +233,14 @@ export function AgentProviderConnection({ mode={method} selectedId={opened ? adapterType : null} collapsed={opened} - onSelect={() => setOpened(true)} + onSelect={() => { if (!managedAccount?.disabled) setOpened(true); }} /> - {!opened && ( + {!opened && !managedAccount?.fixedMethod && (
{ + savedManagedAccount.current = null; setMethod(next); setError(null); }} @@ -195,7 +254,6 @@ export function AgentProviderConnection({

)} {method === "subscription" && - adapterType === "codex_local" && savedKeys.subscriptions.length > 0 && ( { - const connection = { - env: buildFixedClaudeOAuthBinding(), - storedSessionId, - }; + onStored={() => {}} + onPromptReady={(url) => { + setAuthorizationUrl(url); + setLoginPhase((phase) => url ? (phase === "preparing" ? "ready" : phase) : "preparing"); + }} + onCodeSubmitted={() => { + phaseBeforeSubmit.current = loginPhase === "waiting" ? "waiting" : "ready"; + setLoginPhase("connecting"); + }} + onSubmitFailed={() => { + setLoginPhase((phase) => phase === "connecting" ? phaseBeforeSubmit.current : phase); + }} + onConnected={(sessionId) => { + if (managedAccount) { + if (!sessionId) { setError("The login did not return a saved connection. Try again."); return; } + const run = epoch.current; + setLoginPhase("connecting"); + void aiConnectionsApi.loginResult(companyId, sessionId).then((result) => { + if (run === epoch.current) managedAccount.onComplete({ ...result, method: "subscription" }); + }).catch(() => { + if (run !== epoch.current) return; + setLoginPhase("ready"); + setError("Could not retrieve the saved connection. Go back and retry."); + }); + return; + } + const connection: ProviderConnection = { env: {}, aiConnection: { provider: aiProvider, method: "subscription", mode: "responsible_user" } }; setStoredConnection(connection); onConnected(connection); }} - onConnected={() => { - if (adapterType === "codex_local") onConnected({ env: {} }); - }} /> - ) : savedSubscription ? null : ( + ) : savedSubscription ? null : localEnvironment && !storedLogin.data ? ( + { setError(null); localLogin.retry(); } }} /> + ) : (

{storedLogin.data ? "Use your saved Claude subscription for this agent." : canLogin ? "Use the existing provider connection for this environment." - : `Use the ${provider} login on this machine. If you haven’t signed in yet, run ${adapterType === "claude_local" ? "claude auth login" : "codex login"} in your terminal, then connect.`} + : "This environment does not support browser sign-in. Choose a sign-in environment or connect with an API key."}

)}
@@ -302,7 +382,11 @@ export function AgentProviderConnection({ else onBack(); }} primaryLabel={ - busy + opened && needsLogin + ? loginPhase === "waiting" ? "Waiting for code" + : loginPhase === "connecting" ? "Connecting" + : `Sign in to ${provider}` + : busy ? "Connecting" : method === "subscription" && (storedLogin.data || savedSubscription) @@ -312,18 +396,28 @@ export function AgentProviderConnection({ : "Connect" } primaryDisabled={ - auth.isPending || + managedAccount?.disabled || + (Boolean(managedAccount) && method === "subscription" && !canLogin && !canUseLocalLogin) || + (localEnvironment && health.isPending) || localLogin.preparing || Boolean(localLogin.error) || + (!managedAccount && auth.isPending) || savedKeys.loading || (adapterType === "claude_local" && storedLogin.isPending) || !opened || - Boolean(needsLogin) || + (Boolean(needsLogin) && (!authorizationUrl || loginPhase !== "ready")) || (method === "api" && !apiKey.trim() && !storedConnection && !selectedKey) } loading={busy} - onPrimary={() => void connect()} + primaryIcon={opened && needsLogin ? loginPhase === "ready" ? "none" : "spinner" : undefined} + onPrimary={() => { + if (needsLogin) { + if (!authorizationUrl || loginPhase !== "ready") return; + window.open(authorizationUrl, "_blank", "noreferrer,noopener"); + setLoginPhase("waiting"); + } else void connect(); + }} />
); diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 9c3aaa8999..6ca7939772 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -1,3 +1,5 @@ +import { AiConnectionField, aiProviderForAdapter } from "../ai-connections/AiConnectionField"; +import type { AiConnectionBinding } from "@paperclipai/shared"; import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; import { SETUP_CREDENTIAL_KEYS, @@ -114,7 +116,7 @@ function Setup({ : "codex_local" : adapterType; const connectionAdapter = - brandType === "claude_local" || brandType === "codex_local" + brandType === "claude_local" || brandType === "codex_local" || brandType === "grok_local" ? brandType : null; const multiProvider = @@ -141,7 +143,13 @@ function Setup({ const [providerBinding, setProviderBinding] = useState( null, ); + const [runtimeAiBinding, setRuntimeAiBinding] = useState(() => + brandType === "opencode_local" + ? { provider: "openrouter", method: "api_key", mode: "responsible_user" } + : undefined, + ); const [connection, setConnection] = useState(null); + const aiBinding = runtimeAiBinding ?? connection?.aiConnection; const [repository, setRepository] = useState(""); const [branch, setBranch] = useState(""); const [createdInSession, setCreated] = useState(null); @@ -203,8 +211,8 @@ function Setup({ queryFn: () => environmentsApi.capabilities(companyId), }); const models = useQuery({ - queryKey: queryKeys.agents.adapterModels(companyId, brandType), - queryFn: () => agentsApi.adapterModels(companyId, brandType), + queryKey: queryKeys.agents.adapterModels(companyId, brandType, null, aiBinding?.provider), + queryFn: () => agentsApi.adapterModels(companyId, brandType, { provider: aiBinding?.provider }), enabled: Boolean(brandType) && showModel, retry: false, }); @@ -343,7 +351,7 @@ function Setup({ ...(runnerProvider === "claude" ? { acpxAgent: "claude" } : {}), ...(model ? { model } : {}), }); - if (hasCredentialField && binding) { + if (!aiBinding && !nextConnection?.aiConnection && hasCredentialField && binding) { if (adapterType === "hermes_gateway") config.apiKey = binding; else config.env = { ...((config.env as object) ?? {}), [envKey]: binding }; @@ -401,6 +409,7 @@ function Setup({ return buildConfig(nextConnection); } function pendingCredentials(nextConnection = connection) { + if (aiBinding || nextConnection?.aiConnection) return {}; return { ...nextConnection?.credentials, ...(hasCredentialField && apiKey.trim() @@ -423,6 +432,7 @@ function Setup({ providerAdapter: brandType, adapterConfig: config, testCredentials: pendingCredentials(nextConnection), + aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection, environmentId, }); if (run !== generation.current) return false; @@ -494,7 +504,7 @@ function Setup({ defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null), - runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }), + runtimeConfig: { ...buildNewAgentRuntimeConfig({ heartbeatEnabled: false }), ...(aiBinding ? { aiConnection: aiBinding } : {}) }, budgetMonthlyCents: 0, ...(connection?.storedSessionId ? { storedSessionId: connection.storedSessionId } @@ -700,7 +710,7 @@ function Setup({
@@ -710,6 +720,7 @@ function Setup({ adapterType={connectionAdapter} environmentId={environmentId} canLogin={canLogin} + localEnvironment={environment?.driver === "local"} onBack={() => navigate("/agents/all")} testConnection={runTest} testError={ @@ -758,7 +769,7 @@ function Setup({

{created.status === "pending_approval" ? "An organization administrator must approve this agent before it can work." - : "Your agent has not started running."} + : "Assign a task when you’re ready for this agent to work."}

@@ -797,6 +808,9 @@ function Setup({

Runtime

+ {aiProviderForAdapter(brandType) && { setRuntimeAiBinding(binding); resetTest(); }} />} + {models.error &&

Could not load models. Retry or enter a model ID manually.

} {((showModel && !usingKimiApi) || efforts.length > 0) && (
@@ -866,7 +880,7 @@ function Setup({ manually.

)} - {hasCredentialField && ( + {hasCredentialField && !aiBinding && (
{chooseProvider && ( diff --git a/ui/src/components/onboarding/SavedProviderKeySelect.tsx b/ui/src/components/onboarding/SavedProviderKeySelect.tsx index 4ee6fa265f..0cc500c158 100644 --- a/ui/src/components/onboarding/SavedProviderKeySelect.tsx +++ b/ui/src/components/onboarding/SavedProviderKeySelect.tsx @@ -1,3 +1,5 @@ +import { aiConnectionsApi } from "@/api/ai-connections"; +import type { AiProvider } from "@paperclipai/shared"; import { useQuery } from "@tanstack/react-query"; import { agentsApi } from "@/api/agents"; import { ApiError } from "@/api/client"; @@ -5,6 +7,7 @@ import { secretsApi } from "@/api/secrets"; import { queryKeys } from "@/lib/queryKeys"; import { savedProviderKeys, + savedManagedProviderAccounts, savedCodexSubscriptions, type SavedProviderKey, } from "@/lib/saved-provider-credentials"; @@ -14,6 +17,14 @@ export function useSavedProviderKeys( envKey: string, enabled = true, ) { + const provider = ({ ANTHROPIC_API_KEY: "anthropic", OPENAI_API_KEY: "openai", OPENROUTER_API_KEY: "openrouter", XAI_API_KEY: "xai" } as Record)[envKey]; + const managed = useQuery({ + queryKey: ["ai-connections", companyId], + queryFn: () => aiConnectionsApi.list(companyId!), + enabled: Boolean(companyId && provider) && enabled, + retry: false, + }); + const managedAccounts = provider && managed.data ? savedManagedProviderAccounts(companyId!, provider, managed.data.currentUserId, managed.data.connections) : []; const personal = useQuery({ queryKey: queryKeys.secrets.myUserSecrets(companyId ?? ""), queryFn: () => secretsApi.listMyUserSecrets(companyId!), @@ -43,19 +54,19 @@ export function useSavedProviderKeys( }); return { storedLogin, - options: savedProviderKeys( + options: [...managedAccounts.filter(account => account.aiConnection?.method === "api_key"), ...savedProviderKeys( companyId ?? "", envKey, personal.data ?? [], organization.data ?? [], - ), - subscriptions: savedCodexSubscriptions( + )], + subscriptions: [...managedAccounts.filter(account => account.aiConnection?.method === "subscription"), ...(provider === "openai" ? savedCodexSubscriptions( companyId ?? "", organization.data ?? [], - ), + ) : [])], // Background refreshes must not unmount an active login panel sharing this query. - loading: personal.isLoading || organization.isLoading || storedLogin.isLoading, - error: personal.isError || organization.isError, + loading: personal.isLoading || organization.isLoading || storedLogin.isLoading || managed.isLoading, + error: personal.isError || organization.isError || managed.isError, }; } diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 9000a2483f..2cf6bea4b8 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -44,6 +44,18 @@ describe("LiveUpdatesProvider issue invalidation", () => { expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments("chat-1") }); client.clear(); }); + it.each(["ai_connection.default_changed", "ai_connection.reconnected", "connection_grant.revoked"])( + "refreshes company AI account previews after %s", (action) => { + const invalidateQueries = vi.fn(); + __liveUpdatesTestUtils.invalidateActivityQueries( + { invalidateQueries, getQueryData: () => undefined } as never, + "company-1", { entityType: "connection_grant", entityId: "grant-1", action }, + { userId: "owner", agentId: null }, + ); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["ai-connections", "company-1"] }); + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ["ai-connections"] }); + }, + ); it("refreshes touched inbox queries and only the changed issue data for issue updates", () => { const invalidations: unknown[] = []; diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 636fac7c10..26598d2574 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1264,6 +1264,10 @@ function invalidateActivityQueries( !!currentActor.agentId && actorId === currentActor.agentId); + if (action?.startsWith("ai_connection.") || action?.startsWith("connection_grant.")) { + queryClient.invalidateQueries({ queryKey: ["ai-connections", companyId] }); + } + if (action?.startsWith("resource_membership.")) { const targetUserId = readString(details?.userId); if (!targetUserId || targetUserId === currentActor.userId) { diff --git a/ui/src/features/connections/ConnectionChoiceList.tsx b/ui/src/features/connections/ConnectionChoiceList.tsx new file mode 100644 index 0000000000..577ba868de --- /dev/null +++ b/ui/src/features/connections/ConnectionChoiceList.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { Check, ChevronRight, Loader2 } from "lucide-react"; + +/** The account-reuse rows shared by connection setup and agent bindings. */ +export function ConnectionChoiceList({ choices, selectedId, pendingId, disabled, onSelect }: { + choices: { id: string; name: string; description: ReactNode; disabled?: boolean }[]; + selectedId?: string; + pendingId?: string | null; + disabled?: boolean; + onSelect: (id: string) => void; +}) { + return
+ {choices.map((choice) => )} +
; +} diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx index 0335b8bfa1..e64baea43c 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx @@ -16,6 +16,7 @@ import { } from "@/fixtures/issueThreadInteractionFixtures"; import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody"; +const credentialRender = vi.hoisted(() => vi.fn()); const setupOptionsMock = vi.hoisted(() => vi.fn()); const completeMock = vi.hoisted(() => vi.fn()); const declineMock = vi.hoisted(() => vi.fn()); @@ -30,6 +31,14 @@ vi.mock("@/api/connection-intents", () => ({ }, })); +vi.mock("@/components/ai-connections/AiConnectionCredentialStep", () => ({ + AiConnectionCredentialStep: (props: { connectionId?: string; name: string; fixedMethod?: boolean; onComplete: (result: {connectionId: string; grantId: string; method: "api_key"}) => void; onCancel: () => void }) => { credentialRender(props); return
+ {props.name}{String(props.fixedMethod)} + + +
; }, +})); + vi.mock("./ConnectionSetupFlow", () => ({ ConnectionSetupFlow: (props: { requestedAgentId?: string; @@ -402,3 +411,54 @@ describe("ConnectionIntentInteractionBody dialog behavior", () => { ); }); }); + + +describe("AI repair inside the card", () => { + const interaction: ConnectionIntentInteraction = { ...pendingConnectionIntentInteraction, payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" } }; + const connection = { id: "selected-account", name: "My Codex account", provider: "openai", method: "api_key", ownership: "personal", ownerName: "Dotta", status: "revoked" }; + it("reuses authentication inline, preserves the selected account, cancels with focus, and completes", async () => { + setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection, canReconnect: true } }); + completeMock.mockResolvedValue({ ...interaction, status: "accepted" }); + renderBody(interaction); + await flush(); + await act(() => button("Fix connection")!.click()); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.querySelector('[data-testid="ai-connection-inline-repair"]')?.textContent).toContain("My Codex account"); + await act(() => button("Cancel repair")!.click()); + await waitForAssertion(() => expect(document.activeElement?.getAttribute("data-testid")).toBe("connection-intent-focus-target")); + expect(completeMock).not.toHaveBeenCalled(); + await act(() => button("Fix connection")!.click()); + await act(() => button("Reconnect selected account")!.click()); + expect(completeMock).toHaveBeenCalledWith(interaction.id, "selected-account"); + }); + it("keeps a late credential save after cancellation from accepting the request", async () => { + setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection, canReconnect: true } }); + renderBody(interaction); await flush(); + await act(() => button("Fix connection")!.click()); + const abandoned = credentialRender.mock.lastCall![0]; + await act(() => button("Cancel repair")!.click()); + await act(() => button("Fix connection")!.click()); + await act(() => abandoned.onComplete({ connectionId: connection.id, grantId: "grant", method: "api_key" })); + expect(completeMock).not.toHaveBeenCalled(); + }); + it("offers continuation for the restored account without another login", async () => { + setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [connection], aiRepair: { connection, canReconnect: true } }); + renderBody(interaction); await flush(); + await act(() => button("Fix connection")!.click()); + expect(document.querySelector('[data-testid="shared-ai-credentials"]')).toBeNull(); + await act(() => button("Continue task")!.click()); + expect(completeMock).toHaveBeenCalledWith(interaction.id, connection.id); + }); + it("does not let another user reconnect the owner's account", async () => { + setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection, canReconnect: false } }); + renderBody(interaction); await flush(); + await act(() => button("Fix connection")!.click()); + expect(document.body.textContent).toContain("Dotta must reconnect My Codex account"); + expect(document.querySelector('[data-testid="shared-ai-credentials"]')).toBeNull(); + }); + it("does not promise to run without credentials when declined", () => { + renderBody({ ...interaction, status: "rejected" }); + expect(document.body.textContent).toContain("The task still needs a working AI connection"); + expect(document.body.textContent).not.toContain("can continue without it"); + }); +}); diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx index 69601efa62..7fd9e5254a 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, @@ -10,6 +10,7 @@ import { } from "lucide-react"; import type { ConnectionIntentInteraction } from "@paperclipai/shared"; import { connectionIntentsApi } from "@/api/connection-intents"; +import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep"; import { AppLogo } from "@/pages/apps/AppLogo"; import { Button } from "@/components/ui/button"; import { @@ -23,26 +24,36 @@ import { import { ConnectionSetupFlow, type ConnectionSetupCompletion, + type ConnectionSetupFlowProps, } from "./ConnectionSetupFlow"; export interface ConnectionIntentInteractionBodyProps { interaction: ConnectionIntentInteraction; currentUserId?: string | null; addresseeLabel: string; + renderSetup?: (props: ConnectionSetupFlowProps) => ReactNode; } export function ConnectionIntentInteractionBody({ interaction, currentUserId, addresseeLabel, + renderSetup, }: ConnectionIntentInteractionBodyProps) { const [open, setOpen] = useState(false); const focusTargetRef = useRef(null); + const setupGeneration = useRef(0); + const generation = setupGeneration.current; + const closeSetup = () => { + setupGeneration.current += 1; + setOpen(false); + }; const queryClient = useQueryClient(); const isAddressee = Boolean( currentUserId && interaction.addresseeUserId === currentUserId, ); const isPending = interaction.status === "pending"; + const isAi = interaction.payload.purpose === "ai"; const focusTargetId = `connection-intent-focus-target-${interaction.id}`; const invalidateTask = async ( @@ -131,6 +142,12 @@ export function ConnectionIntentInteractionBody({ ); const finishNewConnection = async (completion: ConnectionSetupCompletion) => { + // A completed credential save survives cancellation, but an abandoned form + // must not accept the task request (even if a new form has since opened). + if (isAi && generation !== setupGeneration.current) { + await setupQuery.refetch(); + return; + } if (completion.resolvedByCallback) { // A browser message cannot establish authorization. Read the durable result. const verified = await setupQuery.refetch(); @@ -143,19 +160,34 @@ export function ConnectionIntentInteractionBody({ completeMutation.mutate(completion.connectionId); }; + const setupProps: ConnectionSetupFlowProps | null = setupQuery.data ? { + host: "dialog", + serviceSlug: interaction.payload.serviceSlug.startsWith("connection:") ? undefined : interaction.payload.serviceSlug, + configuredConnection: interaction.payload.serviceSlug.startsWith("connection:") ? setupQuery.data.existingConnections[0] : undefined, + requestedAgentId: setupQuery.data.requestedAgentId, + aiConnection: setupQuery.data.aiConnection, + interactionId: interaction.id, + existingConnections: setupQuery.data.existingConnections, + onUseExisting: async (connectionId) => { await completeMutation.mutateAsync(connectionId); }, + onComplete: (completion) => { void finishNewConnection(completion); }, + onOAuthDeclined: () => declineMutation.mutate(), + onPhaseChange: handlePhaseChange, + onCancel: () => { closeSetup(); returnFocusToCard(); }, + } : null; + const resultOutcome = interaction.result?.outcome; const status = interaction.status === "accepted" ? { icon: CheckCircle2, title: `${interaction.payload.serviceName} connected`, - body: `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`, + body: isAi ? "The connection was restored for this request." : `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`, } : interaction.status === "rejected" ? { icon: XCircle, title: "Connection declined", - body: `${interaction.payload.requestingAgentName} was notified and can continue without it.`, + body: isAi ? "The task still needs a working AI connection before it can run." : `${interaction.payload.requestingAgentName} was notified and can continue without it.`, } : interaction.status === "expired" ? { @@ -224,6 +256,61 @@ export function ConnectionIntentInteractionBody({ const needsRetry = interaction.payload.phase === "needs_retry"; const authorizing = interaction.payload.phase === "authorizing"; + const repair = setupQuery.data?.aiRepair; + const selectedReady = repair && setupQuery.data?.existingConnections.some((connection) => connection.id === repair.connection.id); + const setupContent = setupQuery.isLoading ? ( +
+ Loading + connection options… +
+ ) : setupQuery.isError ? ( +
+

+ Couldn’t load connection setup +

+

+ {setupQuery.error instanceof Error + ? setupQuery.error.message + : "Try again."} +

+ +
+ ) : setupProps ? ( + renderSetup ? renderSetup(setupProps) : + ) : null; + const inlineContent = setupQuery.isLoading || setupQuery.isError ? setupContent + : selectedReady ?
+

{repair.connection.name} is ready.

+ +
+ : repair ? repair.canReconnect ? { void finishNewConnection(result); }} + onCancel={() => { closeSetup(); returnFocusToCard(); }} + /> :

+ {repair.connection.ownership === "personal" ? `${repair.connection.ownerName ?? "The account owner"} must reconnect ${repair.connection.name}.` : `The account owner must reconnect ${repair.connection.name}.`} + {" "}You can continue here once it is restored. +

+ : setupQuery.data?.aiConnection && setupQuery.data.aiConnection.mode !== "responsible_user" + ?

The selected account is no longer available to you. Ask its owner to restore access, or choose an available AI connection in the agent’s settings.

+ : setupContent; + return (

- {interaction.payload.requestingAgentName} needs{" "} - {interaction.payload.serviceName} + {isAi ? "AI connection needs attention" : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`}

- Connect your identity or reuse an eligible connection. Access is - added only for this agent. + {interaction.payload.purpose === "ai" + ? "Restore the agent’s selected AI account, then continue this task." + : "Connect your identity or reuse an eligible connection. Access is added only for this agent."}

@@ -260,15 +347,17 @@ export function ConnectionIntentInteractionBody({ ) : null}
- - + } + {isAi ? : -
- ) : setupQuery.data ? ( - { - await completeMutation.mutateAsync(connectionId); - }} - onComplete={(completion) => { - void finishNewConnection(completion); - }} - onOAuthDeclined={() => declineMutation.mutate()} - onPhaseChange={handlePhaseChange} - onCancel={() => setOpen(false)} - /> - ) : null} + {setupContent} - + }
+ {isAi && open ?
{inlineContent}
: null} {completeMutation.isError || declineMutation.isError || diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 3a5b298536..babbf0523a 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep"; +import { ConnectionChoiceList } from "./ConnectionChoiceList"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowUpRight, @@ -32,6 +34,7 @@ import type { ToolOAuthStartResult, } from "@paperclipai/shared"; import { + aiConnectionMetadataSchema, connectionMethodAcceptsCustomerOAuthClient, connectionMethodRequiresConfiguration, connectionMethodSupportsAutomaticOAuth, @@ -276,6 +279,7 @@ function appConnectHref( resumeConnectionId?: string | null; reconnectConnectionId?: string | null; interactionId?: string | null; + connectionMethodKey?: string | null; }, ): string { const stage = ROUTE_STAGE_BY_STEP[step] ?? "setup"; @@ -283,6 +287,7 @@ function appConnectHref( if (existing?.resumeConnectionId) params.set("resume", existing.resumeConnectionId); if (existing?.reconnectConnectionId) params.set("reconnect", existing.reconnectConnectionId); if (existing?.interactionId) params.set("intent", existing.interactionId); + if (existing?.connectionMethodKey) params.set("method", existing.connectionMethodKey); const path = credentialSource === "vercel_connect" ? "/apps/vercel-connect" : "/apps/connect"; return `${path}?${params.toString()}`; } @@ -364,7 +369,7 @@ function availableToolConnectionMethods( entry: AppDefinition, ): ConnectionMethodDef[] { return getAvailableConnectionMethods(entry).filter( - (method) => (method.purpose ?? "tool") === "tool", + (method) => (method.purpose ?? "tool") !== "channel", ); } @@ -495,6 +500,9 @@ export function readConnectionIntentOAuthOutcome( } export interface ConnectionSetupFlowProps { + aiConnection?: import("@paperclipai/shared").AiConnectionBinding; + /** Provider-specific authentication inside the existing access/setup shell. Undefined retains the standard credential form. */ + renderCredentialStep?: (context: { app: AppDefinition; name: string; grantKind: ConnectionGrantKind; agentIds: string[]; allAgents: boolean; onBack: () => void }) => ReactNode; byoOnly?: boolean; credentialSource?: ToolConnectionCredentialSource; host?: "page" | "dialog"; @@ -530,8 +538,10 @@ export function ConnectionSetupFlow({ onUseExisting, onComplete, onOAuthDeclined, + aiConnection, onPhaseChange, onCancel, + renderCredentialStep, }: ConnectionSetupFlowProps = {}) { const routeNavigate = useNavigate(); const navigate = useCallback((to: string, options?: { replace?: boolean }) => { @@ -552,6 +562,7 @@ export function ConnectionSetupFlow({ const sourceSlug = searchParams.get("source")?.trim() || null; const createNewConnection = forceNewConnection || searchParams.get("new") === "1"; const routeStage = searchParams.get("stage")?.trim() || null; + const requestedMethodKey = searchParams.get("method")?.trim() || null; const resumeConnectionId = searchParams.get("resume")?.trim() || null; const oauthCallbackOutcome = searchParams.get("oauth"); const oauthCallbackCode = searchParams.get("code"); @@ -621,7 +632,7 @@ export function ConnectionSetupFlow({ const [curatedOAuthClientId, setCuratedOAuthClientId] = useState(""); const [curatedOAuthClientSecret, setCuratedOAuthClientSecret] = useState(""); const [vercelConnector, setVercelConnector] = useState(""); - const [connectionMethodKey, setConnectionMethodKey] = useState(""); + const [connectionMethodKey, setConnectionMethodKey] = useState(aiConnection ? `ai-${aiConnection.method}` : ""); const [configValues, setConfigValues] = useState>({}); const [googleSheetsLinks, setGoogleSheetsLinks] = useState(""); const [googleSheetsError, setGoogleSheetsError] = useState(null); @@ -906,6 +917,10 @@ export function ConnectionSetupFlow({ const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listGallery(selectedCompanyId!), + select: useCallback((data: Awaited>) => connectionIntentId ? { + ...data, + apps: data.apps.map(app => ({ ...app, methods: app.methods.filter(method => aiConnection ? method.ai?.provider === aiConnection.provider && method.ai.method === aiConnection.method : method.transport !== "runtime_auth") })).filter(app => app.methods.length > 0), + } : data, [connectionIntentId, aiConnection?.provider, aiConnection?.method]), enabled: !!selectedCompanyId, }); // Use the same visible catalog for cards and every branded URL shortcut. @@ -1136,6 +1151,7 @@ export function ConnectionSetupFlow({ setStep(nextStep); if (entry) { navigate(appConnectHref(entry.slug, nextStep, credentialSource, { + connectionMethodKey: entry.methods.find(method => method.key === connectionMethodKey)?.ai ? connectionMethodKey : undefined, resumeConnectionId, reconnectConnectionId, interactionId: connectionIntentId, @@ -1402,7 +1418,13 @@ export function ConnectionSetupFlow({ && connectorEnrollmentQuery.isLoading ) return; const methods = connectionMethodsForCredentialSource(requestedEntry, credentialSource); - const initialMethod = ( + const requestedAi = aiConnection ?? (reconnectConnection?.connectionPurpose === "ai" + ? aiConnectionMetadataSchema.safeParse(reconnectConnection.config?.ai).data + : undefined); + const explicitMethod = methods.find(candidate => requestedAi + ? candidate.ai?.provider === requestedAi.provider && candidate.ai.method === requestedAi.method + : candidate.key === requestedMethodKey); + const initialMethod = explicitMethod ?? ( requestedDefinitionUsesManagedConnector && !requestedEntryAdvertisesManagedConnector ? recommendedManagedConnectorMethod(fullRequestedDefinition) @@ -1502,6 +1524,8 @@ export function ConnectionSetupFlow({ return; } }, [ + aiConnection, + requestedMethodKey, applicationsQuery.isError, applicationsQuery.isFetchedAfterMount, applicationsQuery.data, @@ -1529,6 +1553,10 @@ export function ConnectionSetupFlow({ zapierSource, ]); + useEffect(() => { + if (reconnectConnection?.connectionPurpose === "ai" && step === "access") setStep("key"); + }, [reconnectConnection?.connectionPurpose, step]); + // Resume the exact method and non-secret provider configuration that the // interrupted draft already chose. Secrets are intentionally never read back // into the browser; credential-based methods ask for a replacement value. @@ -1806,38 +1834,22 @@ export function ConnectionSetupFlow({ Reuse a connection without changing who already has access, or connect a new one.

-
- {existingConnections.map((connection) => ( - - ))} -
+ ({ + id: connection.id, name: connection.name, + description: connection.status === "active" && connection.enabled ? "Ready to use" : "Setup needs attention", + }))} + pendingId={existingConnectionPendingId} + onSelect={async (id) => { + setExistingConnectionPendingId(id); + setExistingConnectionError(null); + try { await onUseExisting(id); } + catch (error) { + setExistingConnectionError(error instanceof Error ? error.message : "Couldn’t use this connection."); + setExistingConnectionPendingId(null); + } + }} + /> {existingConnectionError ? ( {existingConnectionError} ) : null} @@ -2023,7 +2035,23 @@ export function ConnectionSetupFlow({ const zapierEntry = zapierSource ? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null : null; - const stepLabels = zapierSource + const reconnectAiMethod = reconnectConnection?.connectionPurpose === "ai" + ? aiConnectionMetadataSchema.safeParse(reconnectConnection.config?.ai).data + : undefined; + const aiMethod = reconnectAiMethod ?? entry?.methods.find(method => method.key === connectionMethodKey)?.ai + ?? (!connectionMethodKey && entry?.methods.every(method => method.ai) ? entry.methods[0]?.ai : undefined); + const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => setAppStep("access") }) ?? (aiMethod && selectedCompanyId ? <> onCancel ? onCancel() : navigate("/apps")} + onComplete={result => { onComplete?.({ connectionId: result.connectionId }); if (!onComplete) navigate(`/apps/${result.connectionId}/permissions`); }} + /> : undefined) : undefined; + const stepLabels = reconnectConnection?.connectionPurpose === "ai" ? ["Reconnect account"] : credentialStep !== undefined + ? ["Access", "Connect account"] + : zapierSource ? ZAPIER_STEP_LABELS : entry && setupCredentialSourceMethods.length > 1 ? ["Access", "Choose connection"] @@ -2055,7 +2083,7 @@ export function ConnectionSetupFlow({ ? `Continue to ${entry?.name ?? "sign-in"}` : accessStepAuthKind === "oauth" ? "Continue" : "Save and continue"; - const stepIndex = (zapierSource || entry) && step !== "gallery" && step !== "success" + const stepIndex = reconnectConnection?.connectionPurpose === "ai" ? 0 : (zapierSource || entry) && step !== "gallery" && step !== "success" ? SELECTED_APP_STEP_INDEX[step] : step === "success" ? stepLabels.length @@ -2188,7 +2216,7 @@ export function ConnectionSetupFlow({
- ) : step === "key" && entry ? ( + ) : step === "key" && entry && credentialStep !== undefined ? credentialStep : step === "key" && entry ? ( { if (directOAuthEntry) { diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 3d325d7e3f..20f9041677 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -4,6 +4,7 @@ import { buildAssistantPartsFromTranscript, buildIssueChatMessages, isCoTSegmentActive, + isRedundantAiRecoveryNotice, preserveReadableStreamingRetraction, stabilizeThreadMessages, type IssueChatComment, @@ -1792,3 +1793,18 @@ describe("stabilizeThreadMessages", () => { expect(secondStable.messages).toBe(firstStable.messages); }); }); + + +describe("AI recovery presentation", () => { + it.each(["pending", "accepted", "rejected", "expired"] as const)("replaces diagnostic notices with the same-run %s connection card", (status) => { + const interaction: ConnectionIntentInteraction = { ...pendingConnectionIntentInteraction, status, sourceRunId: "failed-run", payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" } }; + const notice = createComment({ authorType: "system", presentation: { kind: "system_notice", title: "AI connection needs attention", tone: "danger", detailsDefaultOpen: false }, metadata: { version: 1, sourceRunId: "failed-run", sections: [] } }); + expect(isRedundantAiRecoveryNotice(notice, [interaction])).toBe(true); + expect(isRedundantAiRecoveryNotice(notice, [{ ...interaction, sourceRunId: "other-run" }])).toBe(false); + expect(isRedundantAiRecoveryNotice(notice, [])).toBe(false); + expect(isRedundantAiRecoveryNotice(notice, [{ ...interaction, payload: { ...interaction.payload, purpose: undefined } }])).toBe(false); + const messages = buildIssueChatMessages({ comments: [notice], interactions: [interaction], timelineEvents: [], linkedRuns: [], liveRuns: [] }); + expect(messages).toHaveLength(1); + expect(messages[0]?.metadata.custom).toMatchObject({ kind: "interaction" }); + }); +}); diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index b2048c38b9..bc6e7dfef1 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -1141,6 +1141,20 @@ function createLiveRunMessage(args: { return message; } +/** The durable AI interaction owns repair and its receipt; don't also show the + * escalation's diagnostic card for that same failure. Keep unmatched notices. */ +export function isRedundantAiRecoveryNotice( + comment: IssueChatComment, + interactions: readonly IssueThreadInteraction[] = [], +): boolean { + return comment.presentation?.kind === "system_notice" + && ["AI connection needs attention", "Configuration incomplete"].includes(comment.presentation.title ?? "") + && Boolean(comment.metadata?.sourceRunId) + && interactions.some((interaction) => interaction.kind === "connection_intent" + && interaction.payload.purpose === "ai" + && interaction.sourceRunId === comment.metadata?.sourceRunId); +} + export function buildIssueChatMessages(args: { comments: readonly IssueChatComment[]; interactions?: readonly IssueThreadInteraction[]; @@ -1181,6 +1195,7 @@ export function buildIssueChatMessages(args: { const orderedMessages: MessageWithOrder[] = []; for (const comment of sortByCreated(comments)) { + if (isRedundantAiRecoveryNotice(comment, interactions)) continue; orderedMessages.push({ createdAtMs: toTimestamp(comment.createdAt), order: 1, diff --git a/ui/src/lib/saved-provider-credentials.ts b/ui/src/lib/saved-provider-credentials.ts index 35abf6ee55..1433d5f670 100644 --- a/ui/src/lib/saved-provider-credentials.ts +++ b/ui/src/lib/saved-provider-credentials.ts @@ -1,10 +1,25 @@ -import type { CompanySecret, EnvBinding } from "@paperclipai/shared"; +import type { AiConnectionBinding, AiManagedConnectionSummary, AiProvider, CompanySecret, EnvBinding } from "@paperclipai/shared"; import type { MyUserSecretEntry } from "../api/secrets"; -export interface SavedProviderKey { - id: string; - label: string; - binding: EnvBinding; +export type SavedProviderKey = { id: string; label: string } & ( + | { binding: EnvBinding; aiConnection?: never } + | { binding?: never; aiConnection: AiConnectionBinding } +); + +export function savedManagedProviderAccounts( + companyId: string, provider: AiProvider, currentUserId: string, + connections: AiManagedConnectionSummary[], +): SavedProviderKey[] { + return connections.flatMap((account) => { + if (account.companyId !== companyId || account.provider !== provider || account.status !== "connected") return []; + if (account.ownership === "personal" && account.ownerUserId === currentUserId && account.isDefault) { + return [{ id: `ai:${account.grantId}`, label: `${account.name} (Your default)`, aiConnection: { provider, method: account.method, mode: "responsible_user" as const } }]; + } + if (account.ownership === "shared") { + return [{ id: `ai:${account.grantId}`, label: `${account.name} (Company shared)`, aiConnection: { provider, method: account.method, mode: "shared" as const, connectionId: account.id, grantId: account.grantId } }]; + } + return []; + }); } /** Match the canonical onboarding key and distinct keys created by agent setup. */ diff --git a/ui/src/lib/test-agent-setup.ts b/ui/src/lib/test-agent-setup.ts index 8d0f2dd2e2..ab77d57f77 100644 --- a/ui/src/lib/test-agent-setup.ts +++ b/ui/src/lib/test-agent-setup.ts @@ -6,16 +6,18 @@ import { agentsApi } from "../api/agents"; * the adapter's existing read-only CLI hello probe before calling setup connected. */ export async function testAgentSetup(input: { companyId: string; + agentId?: string; adapterType: string; providerAdapter: string; adapterConfig: Record; - agentId?: string; + aiConnection?: import("@paperclipai/shared").AiConnectionBinding; testCredentials?: Record; environmentId: string | null; }): Promise { const payload = { - adapterConfig: input.adapterConfig, ...(input.agentId ? { agentId: input.agentId } : {}), + ...(input.aiConnection ? { aiConnection: input.aiConnection } : {}), + adapterConfig: input.adapterConfig, ...(input.testCredentials ? { testCredentials: input.testCredentials } : {}), environmentId: input.environmentId, }; diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 961bd98df6..bafdf35e8f 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -1,5 +1,6 @@ import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard"; import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel"; +import { AiConnectionDesignExamples } from "@/components/ai-connections/AiConnectionDesignExamples"; import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect"; import { RepositoryEditor } from "@/components/RepositoryEditor"; import { TaskChatRunnerActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup"; @@ -2343,6 +2344,10 @@ export function DesignGuide() { +
+ +
+

A derived lifecycle chip (amber) for attention states. The lifecycle chip is separate from diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 8f1de89d01..d71f1ef6a7 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -39,6 +39,11 @@ const state = vi.hoisted(() => ({ navigate: vi.fn(), openNewIssue: vi.fn(), })); +const managedApi = vi.hoisted(() => ({ + list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })), + create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), +})); +vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); vi.mock("@/api/agents", () => ({ agentsApi: api })); vi.mock("@/api/environments", () => ({ environmentsApi: envApi })); vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings })); @@ -287,9 +292,9 @@ describe("New agent setup", () => { expect(api.hire.mock.calls[0][1].adapterConfig.apiKey).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" }); expect(JSON.stringify(api.hire.mock.calls)).not.toContain("hermes-test-key"); }); - it("shows Grok login guidance and hides ignored Kimi and OpenCode effort controls", async () => { + it("uses the shared Grok connection flow and hides ignored Kimi and OpenCode effort controls", async () => { await render("grok_local"); - expect(container.textContent).toContain("grok login"); + expect(container.textContent).toContain("Connect Atlas to Grok"); await render("opencode_local"); expect(container.querySelector('[aria-label="Thinking effort"]')).toBeNull(); }); @@ -374,16 +379,20 @@ describe("New agent setup", () => { ["codex_local", "codex", "OpenAI", "OPENAI_API_KEY"], ["paperclip_runner", "claude", "Claude", "ANTHROPIC_API_KEY"], ["paperclip_runner", "codex", "OpenAI", "OPENAI_API_KEY"], - ])("stores %s %s API credentials only when finishing", async (adapter, runner, provider, key) => { + ])("stores %s %s as a reusable connection before hiring", async (adapter, runner, provider, key) => { await render(adapter, runner); await click("Use API key insteadUse subscription insteadUse API key instead"); await click(provider + "API"); await fill("API key", "connection-key"); await click("Connect"); - expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({ [key]: "connection-key" }); + const binding = { provider: key === "ANTHROPIC_API_KEY" ? "anthropic" : "openai", method: "api_key", mode: "responsible_user" }; + expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ apiKey: "connection-key", provider: binding.provider })); + expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({}); + expect(api.testEnvironment.mock.calls[0][2].aiConnection).toEqual(binding); expect(secrets.createUserSecretDefinition).not.toHaveBeenCalled(); await click("Finish setup"); - expect(api.hire.mock.calls[0][1].adapterConfig.env[key].type).toBe("user_secret_ref"); + expect(api.hire.mock.calls[0][1].runtimeConfig.aiConnection).toEqual(binding); + expect(managedApi.create).toHaveBeenCalledTimes(1); expect(JSON.stringify(api.hire.mock.calls)).not.toContain("connection-key"); }); it.each([ @@ -409,7 +418,7 @@ describe("New agent setup", () => { expect(secrets.createMyUserSecret).not.toHaveBeenCalled(); expect(secrets.rotateMyUserSecret).not.toHaveBeenCalled(); }); - it.each(["opencode_local", "pi_local"])( + it.each(["pi_local"])( "persists %s OpenRouter credentials only as a secret reference", async (adapter) => { await render(adapter); @@ -434,6 +443,46 @@ describe("New agent setup", () => { expect(secrets.create).toHaveBeenCalledTimes(1); }, ); + it("connects OpenRouter before testing and hiring OpenCode without copying credentials into the agent", async () => { + await render("opencode_local"); + const model = "openrouter/anthropic/claude-sonnet-4.6"; + await fill("Model", model); + await click("Connect another account"); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog).toBeTruthy(); + expect(api.hire).not.toHaveBeenCalled(); + expect(api.testEnvironment).not.toHaveBeenCalled(); + const input = dialog.querySelector('[aria-label="API key"]') as HTMLInputElement; + expect(input).toBeTruthy(); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "example-test-secret"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + const connectButton = [...dialog.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Connect")!; + expect(connectButton.disabled).toBe(false); + await act(async () => connectButton.click()); + await settle(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + provider: "openrouter", method: "api_key", apiKey: "example-test-secret", + })); + const binding = { provider: "openrouter", method: "api_key", mode: "responsible_user" }; + await click("Run test"); + expect(api.testEnvironment.mock.calls[0][2]).toEqual(expect.objectContaining({ + aiConnection: binding, testCredentials: {}, + adapterConfig: expect.objectContaining({ model }), + })); + await click("Finish setup"); + expect(api.hire.mock.calls[0][1]).toEqual(expect.objectContaining({ + adapterType: "opencode_local", + runtimeConfig: expect.objectContaining({ aiConnection: binding }), + adapterConfig: expect.objectContaining({ model }), + })); + expect(managedApi.create).toHaveBeenCalledTimes(1); + expect(secrets.create).not.toHaveBeenCalled(); + expect(JSON.stringify(api.testEnvironment.mock.calls)).not.toContain("example-test-secret"); + expect(JSON.stringify(api.hire.mock.calls)).not.toContain("example-test-secret"); + }); it.each(["codex", "claude", "opencode"])( "uses the correct native %s runner", async (runner) => { diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 2dc27c6dfd..c44df98cb9 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -1337,6 +1337,14 @@ describe("AppDetail", () => { .find((button) => button.textContent?.trim() === label); } + it("does not label a revoked AI credential as Connected", async () => { + getConnectionMock.mockResolvedValue(connection({ connectionPurpose: "ai", transport: "runtime_auth", healthStatus: "ok", config: { provider: "openai", method: "api_key" } })); + listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1" }, grants: [organizationGrant({ status: "revoked" })], capabilities: fullCapabilities(), currentUserId: "user-1", members: [] }); + await renderAppDetail(); + expect(container.textContent).toContain("Revoked"); + expect(container.textContent).not.toContain("Connected"); + }); + it("keeps the app header concise on every tab", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(perUserConnection()); diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 826fb7693d..45328fbefe 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -1,6 +1,7 @@ +import { ManagedAiConnectionDetails } from "@/components/ai-connections/ManagedAiConnectionDetails"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { EmailConnectionAccess } from "@/components/EmailConnectionAccess"; import { EmailConnectionInboxes } from "./chat/EmailEndpointSetup"; -import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, Pencil } from "lucide-react"; import type { @@ -12,6 +13,7 @@ import type { import { connectionDisplaySecondaryHint, humanizeConnectionDisplayName, + aiSubscriptionNeedsIsolatedLogin, isToolConnectionAttentionHealth as isAttentionHealthStatus, } from "@paperclipai/shared"; import { Navigate, useParams, useNavigate, useSearchParams } from "@/lib/router"; @@ -60,7 +62,10 @@ import { export { connectionAddress, connectionTransportLabel }; -export function AppDetail() { +export function AppDetail({ renderActions, onReconnect }: { + renderActions?: (connection: ToolConnection) => ReactNode; + onReconnect?: (connection: ToolConnection) => void; +} = {}) { const { connectionId = "", tab } = useParams<{ connectionId: string; tab?: string }>(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -235,17 +240,18 @@ export function AppDetail() { () => installStateFrom(installsQuery.data?.installs ?? connection?.installs), [connection?.installs, installsQuery.data?.installs], ); - const access = useMemo(() => accessFrom(profile, install), [profile, install]); + const access = useMemo(() => accessFrom(connection?.connectionPurpose === "ai" ? undefined : profile, install), [connection?.connectionPurpose, profile, install]); const agents = agentsQuery.data ?? []; const [pending, setPending] = useState(false); const persist = useMutation({ - mutationFn: (next: { + mutationFn: async (next: { enabled: Set; askFirst: Set; access: AccessDraft; reviewed?: Set; - }) => - toolsApi.finishApp(selectedCompanyId!, connectionId, { + }) => connection?.connectionPurpose === "ai" + ? toolsApi.putConnectionInstalls(connectionId, next.access.mode === "all" ? [{ targetType: "company", targetId: selectedCompanyId! }] : [...next.access.agentIds].map(targetId => ({ targetType: "agent" as const, targetId }))) + : toolsApi.finishApp(selectedCompanyId!, connectionId, { enabledCatalogEntryIds: [...next.enabled], askFirstCatalogEntryIds: [...next.askFirst].filter((id) => next.enabled.has(id)), ...(next.reviewed ? { reviewedCatalogEntryIds: [...next.reviewed] } : {}), @@ -253,6 +259,7 @@ export function AppDetail() { }), onMutate: () => setPending(true), onSuccess: () => { + void installsQuery.refetch(); queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) }); @@ -477,14 +484,17 @@ export function AppDetail() { ); } - const status = statusFor(connection); + const aiGrantRevoked = connection.connectionPurpose === "ai" + && grantRows.length > 0 && grantRows.every((grant) => grant.status === "revoked"); + const status: StatusInfo = aiGrantRevoked ? { label: "Revoked", tone: "attention" } : statusFor(connection); const needsReconnect = connection.requiresReauthorization ?? (status.tone === "attention" && connection.healthStatus !== "unknown"); const quarantined = catalog.filter((e) => e.status === "quarantined"); const active = catalog.filter((e) => e.status === "active"); const readOnly = active.filter((e) => e.isReadOnly); const canChange = active.filter((e) => !e.isReadOnly); - const actionCount = catalogQuery.data ? active.length : null; + const actionsContent = renderActions?.(connection) ?? (connection.connectionPurpose === "ai" ? : undefined); + const actionCount = actionsContent !== undefined ? null : catalogQuery.data ? active.length : null; const reviewLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading; const permissionsLoading = reviewLoading || installsQuery.isLoading || agentsQuery.isLoading; const reviewFailed = catalogQuery.isError || profilesQuery.isError || policiesQuery.isError; @@ -529,6 +539,7 @@ export function AppDetail() { galleryEntry={logoEntry} canReconnect={canReconnect} reconnectUnavailableMessage={reconnectUnavailableMessage} + onReconnect={onReconnect ? () => onReconnect(connection) : connection.connectionPurpose === "ai" ? () => navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`) : undefined} onReconnected={() => { queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) }); @@ -593,8 +604,8 @@ export function AppDetail() { setAudienceOpenGrantId(null); setAudienceError(null); }} - onConnectAsMe={() => startPersonalAuth.mutate()} - onConnectOrganization={() => startOAuth.mutate()} + onConnectAsMe={() => onReconnect ? onReconnect(connection) : startPersonalAuth.mutate()} + onConnectOrganization={() => onReconnect ? onReconnect(connection) : startOAuth.mutate()} onConnectAgent={(agentId) => startOAuth.mutate({ asAgentId: agentId })} onRefreshAccess={() => refreshGitHubAccess.mutate()} refreshAccessPending={refreshGitHubAccess.isPending} @@ -602,12 +613,13 @@ export function AppDetail() { replaceAudience.mutate({ grantId: grant.id, memberUserIds })} /> apply({ access: accessIncludingInstalls(next, install) })} + onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" ? next : accessIncludingInstalls(next, install) })} onRefreshActions={() => refreshTools.mutate()} onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))} onReviewQuarantined={reviewQuarantined} @@ -779,7 +791,7 @@ function statusFor(connection: ToolConnection): StatusInfo { if (connection.enabled === false || connection.status === "disabled") { return { label: "Paused", tone: "paused" }; } - if (isAttentionHealthStatus(connection.healthStatus)) { + if (isAttentionHealthStatus(connection.healthStatus) || (connection.connectionPurpose === "ai" && (connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(connection.config)))) { return { label: "Needs attention", tone: "attention" }; } return { label: "Connected", tone: "connected" }; diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index f7b3479b44..d096e55a26 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -438,6 +438,27 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { // credential is entered. // ------------------------------------------------------------------------- + it("keeps the existing Anthropic tool method reachable alongside AI authentication", async () => { + mockParams.appKey = "anthropic"; + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")] }); + await render(); + await passAccessStep(); + expect(container.textContent).toContain("How do you want to connect?"); + expect(radioContaining("Claude subscription")).toBeTruthy(); + expect(radioContaining("Claude API key")).toBeTruthy(); + await act(async () => radioContaining("Use an API key")!.click()); + await flushReact(); + const key = container.querySelector('input[type="password"]'); + expect(key).toBeTruthy(); + await act(async () => setInputValue(key!, "fixture-anthropic-tool-key")); + await act(async () => buttonByText("Connect")!.click()); + await flushReact(); + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ + galleryKey: "anthropic", connectionMethodKey: "api-key", + })); + expect(container.textContent).not.toContain("Connect for tool access instead"); + }); + it("asks for a GitHub identity and defaults to the current user and every agent", async () => { mockParams.appKey = "github"; listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] }); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 4cf843506b..5a956d6bcd 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; +import { ManagedAiConnectionRow } from "@/components/ai-connections/ManagedAiConnectionDetails"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, @@ -19,6 +20,7 @@ import { getAppDefinitionForUrl, getAppStoreDefinition, isToolConnectionAttentionHealth, + aiSubscriptionNeedsIsolatedLogin, } from "@paperclipai/shared"; import { useNavigate } from "@/lib/router"; import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled"; @@ -173,7 +175,7 @@ function connectionState(connection: ToolConnection): ConnectionState { message: "Agents can’t use this account right now.", }; } - if (isToolConnectionAttentionHealth(connection.healthStatus)) { + if ((connection.connectionPurpose === "ai" && (connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(connection.config))) || isToolConnectionAttentionHealth(connection.healthStatus)) { return { kind: "attention", label: "Needs attention", @@ -264,7 +266,7 @@ function accountActionHref( * surface. Connected providers sort first and expand in place to show every * account; unconnected providers retain the same catalog setup flows. */ -export function Browse() { +export function Browse({ renderAccountDetails = (connection) => connection.connectionPurpose === "ai" ? : null }: { renderAccountDetails?: (connection: ToolConnection) => ReactNode } = {}) { const navigate = useNavigate(); const preselectedChatAgentId = typeof window === "undefined" @@ -676,6 +678,7 @@ export function Browse() {

{visibleRows.map((row) => ( ReactNode; row: ConnectorRowModel; allConnections: ToolConnection[]; userProfileById: ReadonlyMap; @@ -805,6 +810,7 @@ export function ConnectorCard({
{row.connections.map((connection) => ( {accountName} + {details} {state.message ? (
(method.purpose ?? "tool") === "tool" && method.transport !== "chat_sdk", + (method) => (method.purpose ?? "tool") !== "channel" && method.transport !== "chat_sdk", ), }), ); @@ -71,7 +71,7 @@ export function canEnterAppsConnect( if ( !chatConnectorsEnabled && entry?.methods.some((method) => method.transport === "chat_sdk") && - !entry.methods.some((method) => (method.purpose ?? "tool") === "tool" && method.transport !== "chat_sdk") + !entry.methods.some((method) => (method.purpose ?? "tool") !== "channel" && method.transport !== "chat_sdk") ) return false; // A retained connection may belong to a provider hidden from fresh catalog // setup. Admit only known providers here; the setup flow then proves the diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx index 6679ba9418..ba952948a0 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx @@ -141,12 +141,14 @@ export function ReconnectCard({ connection, galleryEntry, onReconnected, + onReconnect, canReconnect = true, reconnectUnavailableMessage, }: { connection: ToolConnection; galleryEntry: AppDefinition | null; onReconnected: () => void; + onReconnect?: () => void; canReconnect?: boolean; reconnectUnavailableMessage?: string; }) { @@ -216,6 +218,8 @@ export function ReconnectCard({

{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}

+ ) : onReconnect ? ( + ) : managedByVercel && !oauth ? (
); } diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index 51592afd83..ebabff6000 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -307,6 +307,11 @@ function installStorybookApiFixtures() { ? Response.json({ secretId: "saved-claude-subscription", latestVersion: 1 }) : new Response(null, { status: 404 }); } + if (/^\/api\/companies\/[^/]+\/ai-connections$/.test(url.pathname)) { + return init?.method === "POST" + ? Response.json({ connectionId: "managed-storybook", grantId: "grant-storybook" }) + : Response.json({ currentUserId: "user-storybook", connections: [] }); + } if (/^\/api\/companies\/[^/]+\/me\/user-secrets$/.test(url.pathname)) { return Response.json(onboardingFixtureState.savedApiKeys ? ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].map((key) => ({ definition: { id: key, companyId: "company-storybook", key: `${key}.setup.storybook`, name: key === "ANTHROPIC_API_KEY" ? "My Claude key" : "My OpenAI key", status: "active" }, diff --git a/ui/storybook/fixtures/aiConnections.ts b/ui/storybook/fixtures/aiConnections.ts new file mode 100644 index 0000000000..d04d9ad13f --- /dev/null +++ b/ui/storybook/fixtures/aiConnections.ts @@ -0,0 +1,139 @@ +import type { + AiConnectionBinding, + AiConnectionRequirement, + AiConnectionSummary, +} from "@/components/ai-connections/model"; + +export const AI_REVIEW_REQUIREMENT: AiConnectionRequirement = { + companyId: "ai-review-company", + provider: "anthropic", + method: "subscription", +}; +export const AI_REVIEW_BINDING: AiConnectionBinding = { + provider: "anthropic", + method: "subscription", + mode: "responsible_user", +}; +export const AI_REVIEW_CONNECTIONS: AiConnectionSummary[] = [ + { + id: "claude-dotta", + grantId: "grant-dotta", + companyId: "ai-review-company", + provider: "anthropic", + method: "subscription", + name: "My Claude subscription", + accountLabel: "dotta@example.test", + ownership: "personal", + ownerUserId: "dotta", + ownerName: "Dotta", + isDefault: true, + status: "connected", + }, + { + id: "claude-second", + grantId: "grant-second", + companyId: "ai-review-company", + provider: "anthropic", + method: "subscription", + name: "My research account", + accountLabel: "research@example.test", + ownership: "personal", + ownerUserId: "dotta", + ownerName: "Dotta", + status: "connected", + }, + { + id: "claude-shared", + grantId: "grant-shared", + companyId: "ai-review-company", + provider: "anthropic", + method: "subscription", + name: "Engineering Claude", + accountLabel: "engineering@example.test", + ownership: "shared", + status: "connected", + }, + { + id: "claude-sam", + grantId: "grant-sam", + companyId: "ai-review-company", + provider: "anthropic", + method: "subscription", + name: "Sam’s Claude subscription", + accountLabel: "sam@example.test", + ownership: "personal", + ownerUserId: "sam", + ownerName: "Sam", + status: "connected", + }, + { + id: "openai-personal", + grantId: "grant-openai", + companyId: "ai-review-company", + provider: "openai", + method: "subscription", + name: "My ChatGPT subscription", + accountLabel: "dotta@example.test", + ownership: "personal", + ownerUserId: "dotta", + ownerName: "Dotta", + isDefault: true, + status: "connected", + }, + { + id: "openai-api", + grantId: "grant-openai-api", + companyId: "ai-review-company", + provider: "openai", + method: "api_key", + name: "Engineering OpenAI API", + ownership: "shared", + status: "connected", + }, + { + id: "claude-api", + grantId: "grant-claude-api", + companyId: "ai-review-company", + provider: "anthropic", + method: "api_key", + name: "Claude API — staging", + ownership: "shared", + status: "needs_attention", + }, + { + id: "openrouter-api", + grantId: "grant-openrouter", + companyId: "ai-review-company", + provider: "openrouter", + method: "api_key", + name: "OpenRouter research", + ownership: "personal", + ownerUserId: "dotta", + ownerName: "Dotta", + isDefault: true, + status: "connected", + }, + { + id: "grok-personal", + grantId: "grant-grok", + companyId: "ai-review-company", + provider: "xai", + method: "subscription", + name: "My Grok subscription", + ownership: "personal", + ownerUserId: "dotta", + ownerName: "Dotta", + isDefault: true, + status: "expired", + }, + { + id: "grok-api", + grantId: "grant-grok-api", + companyId: "ai-review-company", + provider: "xai", + method: "api_key", + name: "Grok API", + ownership: "shared", + status: "revoked", + }, +]; diff --git a/ui/storybook/prototypes/AiConnectionsReview.tsx b/ui/storybook/prototypes/AiConnectionsReview.tsx new file mode 100644 index 0000000000..54400cedbc --- /dev/null +++ b/ui/storybook/prototypes/AiConnectionsReview.tsx @@ -0,0 +1,375 @@ +import { AiReviewBoundary } from "./AiReviewFrame"; +import { AiConnectorPages } from "./AiConnectorPages"; +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ModelSourceTiles } from "@/components/onboarding/ModelSourceTiles"; +import { + AiConnectionAuth, + type AiAuthState, +} from "@/components/ai-connections/AiConnectionAuth"; +import { AiConnectionPicker } from "@/components/ai-connections/AiConnectionPicker"; +import { + AiConnectionLegacyNotice, +} from "@/components/ai-connections/AiConnectionManagement"; +import { + AI_PROVIDERS, + aiMethodLabel, + bindingProblem, + matchesAiRequirement, + type AiConnectionBinding, + type AiConnectionRequirement, + type AiConnectionSummary, +} from "@/components/ai-connections/model"; +import { + AI_REVIEW_BINDING, + AI_REVIEW_CONNECTIONS, + AI_REVIEW_REQUIREMENT, +} from "../fixtures/aiConnections"; + +export interface AiConnectionsReviewProps { + host?: "onboarding" | "new_agent" | "settings" | "task" | "connections"; + initialStage?: "picker" | "auth" | "manage" | "legacy" | "providers"; + initialConnections?: AiConnectionSummary[]; + initialBinding?: AiConnectionBinding; + requirement?: AiConnectionRequirement; + currentUserId?: string; + initialAuthState?: AiAuthState; + failFirstAttempt?: boolean; + readOnly?: boolean; + loading?: boolean; + error?: string; +} + +/** Storybook-only orchestration. Explicit simulator controls; no provider/network transport. */ +export function AiConnectionsReview(props: AiConnectionsReviewProps) { + if (props.host === "connections") return ; + return ; +} + +function AgentConnectionReview({ + host = "settings", + initialStage = "picker", + initialConnections = AI_REVIEW_CONNECTIONS, + initialBinding, + requirement: initialRequirement = AI_REVIEW_REQUIREMENT, + currentUserId = "dotta", + initialAuthState = { phase: "idle" }, + failFirstAttempt = false, + readOnly, + loading, + error, +}: AiConnectionsReviewProps) { + const requirement = initialRequirement; + const [connections, setConnections] = useState(initialConnections); + const [binding, setBinding] = useState( + initialBinding ?? { + ...AI_REVIEW_BINDING, + provider: requirement.provider, + method: requirement.method, + }, + ); + const [stage, setStage] = useState( + host === "connections" && initialStage === "picker" ? "list" : initialStage, + ); + const [auth, setAuth] = useState(initialAuthState); + const [name] = useState( + `My ${aiMethodLabel(requirement.provider, requirement.method) === "API key" ? `${AI_PROVIDERS[requirement.provider].name} API` : aiMethodLabel(requirement.provider, requirement.method)}`, + ); + const [tested, setTested] = useState(false); + const [adopting, setAdopting] = useState(false); + const [saved, setSaved] = useState(false); + const [hasFailed, setHasFailed] = useState(false); + const [connectionError, setConnectionError] = useState(error); + const returnFocus = useRef(null); + const returnFocusLabel = useRef(null); + const region = useRef(null); + const problem = bindingProblem( + binding, + requirement, + connections, + currentUserId, + "nova", + ); + const titles = { + onboarding: "Connect your model provider", + new_agent: "Connect Nova", + settings: "Nova · Configuration", + task: "Nova needs an AI connection", + connections: "Connections", + }; + const runtime = + requirement.provider === "openai" + ? ["Codex", "Configured OpenAI model"] + : requirement.provider === "anthropic" + ? ["Claude Code", "Configured Claude model"] + : requirement.provider === "xai" + ? ["Grok Build", "Configured Grok model"] + : ["OpenCode", "Configured OpenRouter model"]; + + function restoreFocus() { + requestAnimationFrame(() => { + const target = returnFocus.current?.isConnected + ? returnFocus.current + : Array.from( + region.current?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent === returnFocusLabel.current); + target?.focus(); + }); + } + function openAuth() { + returnFocus.current = document.activeElement as HTMLElement; + returnFocusLabel.current = returnFocus.current?.textContent ?? null; + setAuth({ phase: "idle" }); + setStage("auth"); + } + function connected() { + if (failFirstAttempt && !hasFailed) { + setHasFailed(true); + setAuth({ + phase: "error", + message: + "The provider could not verify this account. Check your credentials and try again.", + }); + return; + } + const id = `review-account-${connections.length + 1}`; + const connection: AiConnectionSummary = { + ...requirement, id, grantId: `grant-${id}`, name: name.trim(), + ownership: "personal", ownerUserId: currentUserId, + ownerName: currentUserId === "dotta" ? "Dotta" : "Sam", + isDefault: !connections.some((row) => matchesAiRequirement(row, requirement) && row.ownerUserId === currentUserId && row.isDefault), + status: "connected", + }; + setConnections((rows) => [...rows, connection]); + setAuth({ phase: "connected" }); + } + + return ( +
+ +
+

Example page context · Storybook only

+
+

{titles[host]}

+ {host === "onboarding" && ( +

+ Connect → Configure agent → First task +

+ )} + {host === "task" && ( +

+ Connect an account for the responsible user to continue this task. +

+ )} +
+ {host !== "connections" && ( +
+
+
Harness
+
{runtime[0]}
+
+
+
Model
+
{runtime[1]}
+
+
+ )} +
+ {stage === "list" && } + {stage === "legacy" && ( + { + setAdopting(true); + setStage("picker"); + }} + /> + )} + {stage === "picker" && ( + <> + {adopting && ( +

+ Confirm this account’s ownership and use, then test it before + replacing existing authentication. +

+ )} + + setConnectionError(undefined)} + onChange={(next) => { + setBinding(next); + setTested(false); + setSaved(false); + }} + onConnect={() => openAuth()} + /> + + {!readOnly && !loading && !connectionError && ( +
+

Example form actions · Storybook only

+
+ {adopting && ( + + )} + +
+
+ )} + {tested && ( +

+ Connection test passed for{" "} + {currentUserId === "dotta" ? "Dotta" : "Sam"}. Harness and model + are unchanged. +

+ )} + + )} + {stage === "auth" && ( + <> + + ) : null, + }, + ]} + mode={requirement.method === "api_key" ? "api" : "subscription"} + selectedId={requirement.provider} + collapsed + onSelect={() => {}} + /> + + { + if (!name.trim()) return; + setAuth({ + phase: "waiting", + authorizationUrl: "#storybook-provider-simulator", + code: "DEMO-CODE", + }); + }} + onSubmit={() => { + if (name.trim()) connected(); + }} + onCancel={() => { + setAuth({ phase: "cancelled" }); + setStage("picker"); + restoreFocus(); + }} + onDone={() => { + setStage("picker"); + restoreFocus(); + }} + /> + + + )} + {stage === "saved" && ( + <> +

+ {saved + ? adopting + ? "Managed connection adopted." + : "Connection selected for Nova." + : "Connection saved."}{" "} + Harness and model are unchanged. +

+

+ The account remains in Connections even if you leave agent setup. +

+ + + + )} +
+ ); +} diff --git a/ui/storybook/prototypes/AiConnectorPages.tsx b/ui/storybook/prototypes/AiConnectorPages.tsx new file mode 100644 index 0000000000..54b746e9da --- /dev/null +++ b/ui/storybook/prototypes/AiConnectorPages.tsx @@ -0,0 +1,184 @@ +import { BreadcrumbBar } from "@/components/BreadcrumbBar"; +import { AiReviewBoundary } from "./AiReviewFrame"; +import { AiConnectionAccountControls } from "@/components/ai-connections/AiConnectionAccountControls"; +import { useEffect, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Route, Routes, useNavigate, useSearchParams } from "@/lib/router"; +import { APP_DEFINITIONS, getAppStoreDefinition, type AppDefinition, type ToolApplication, type ToolConnection, type ConnectionGrantsResponse } from "@paperclipai/shared"; +import { Browse } from "@/pages/apps/Browse"; +import { AppDetail } from "@/pages/apps/AppDetail"; +import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { AiConnectionAuth, type AiAuthState } from "@/components/ai-connections/AiConnectionAuth"; +import { CredentialModeLink } from "@/components/onboarding/CredentialModeLink"; +import { AI_PROVIDERS, aiMethodLabel, type AiConnectionSummary, type AiProvider, type AiAuthMethod } from "@/components/ai-connections/model"; +import { AI_REVIEW_CONNECTIONS } from "../fixtures/aiConnections"; +import { storybookAgents } from "../fixtures/paperclipData"; + +const companyId = "company-storybook"; +const date = new Date("2026-09-10T12:00:00Z"); +const agents = storybookAgents.slice(0, 2).map((agent, index) => ({ ...agent, id: index ? "atlas" : "nova", name: index ? "Atlas" : "Nova" })); +const capabilities = { canConfigure: true, canCreateOrganizationGrant: true, canSetCompanyInstall: true, canConnectAsCurrentUser: true, canManageAgentInstalls: true, canViewOtherPersonalIdentities: true, editableAgentIds: ["nova", "atlas"] }; +const members = [{ userId: "dotta", name: "Dotta", email: "dotta@example.test" }, { userId: "sam", name: "Sam", email: "sam@example.test" }]; + +/** Use the production provider catalog; only accounts and actions are simulated. */ +const gallery: AppDefinition[] = (Object.keys(AI_PROVIDERS) as AiProvider[]).map((provider) => getAppStoreDefinition(provider)!); +for (const slug of ["github", "gmail"]) { + const app = getAppStoreDefinition(slug); + if (app) gallery.push(app); +} +function asConnection(account: AiConnectionSummary): ToolConnection { + return { + id: account.id, companyId, applicationId: `app-${account.provider}`, name: account.name, uid: account.id, + connectionKind: "managed", ownership: "customer", connectionPurpose: "ai", transport: "runtime_auth", authKind: account.method === "subscription" ? "oauth" : "api_key", + credentialSource: "paperclip_vault", credentialPolicy: account.ownership === "shared" ? "shared" : "per_user", + status: account.status === "revoked" ? "disabled" : "active", enabled: account.status !== "revoked", + transportConfig: {}, config: { sourceTemplateKey: account.provider, ai: { provider: account.provider, method: account.method }, aiIsolatedSubscription: true }, credentialSecretRefs: [], + healthStatus: account.status === "connected" ? "ok" : "error", healthCheckedAt: date, + healthMessage: account.status === "connected" ? null : "Sign in again to restore this account. No other account will be used.", + lastError: account.status === "connected" ? null : "This account needs to be connected again.", + createdByAgentId: null, createdByUserId: account.ownerUserId ?? "dotta", createdAt: date, updatedAt: date, + }; +} +function grantsFor(account: AiConnectionSummary, readOnly: boolean): ConnectionGrantsResponse { + return { connection: { id: account.id, uid: account.id }, currentUserId: "dotta", members, + capabilities: Object.fromEntries(Object.entries(capabilities).map(([key, value]) => [key, typeof value === "boolean" ? !readOnly && value : value])) as typeof capabilities, + grants: [{ id: account.grantId, companyId, connectionId: account.id, + kind: account.ownership === "shared" ? "organization" : "user", subjectUserId: account.ownerUserId ?? null, + providerTenant: { name: account.accountLabel ?? account.name }, credentialSecretRefs: [], + status: account.status === "connected" ? "active" : account.status === "revoked" ? "revoked" : "expired", + isDefault: account.ownership === "shared", createdByAgentId: null, createdByUserId: account.ownerUserId ?? "dotta", + revokedAt: null, revokedByAgentId: null, revokedByUserId: null, lastUsedAt: null, createdAt: date, updatedAt: date, + members: [], capabilities: { canRevoke: !readOnly && (account.ownership === "shared" || account.ownerUserId === "dotta"), canEditAudience: !readOnly && account.ownership === "shared" }, + }], + }; +} + +/** Mount the production route components against an isolated, deterministic in-memory API. */ +export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, detail = false, readOnly = false }: { + initialConnections?: AiConnectionSummary[]; detail?: boolean; readOnly?: boolean; +}) { + const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, refetchOnWindowFocus: false }, mutations: { retry: false } } })); + const [store] = useState(() => ({ profiles: new Map(initialConnections.filter((row) => row.id === "claude-dotta").map((row) => [row.id, { + id: `profile-${row.id}`, companyId, profileKey: `app:${row.id}`, entries: [], bindings: ["nova"].map((targetId) => ({ targetType: "agent", targetId })), + }])), accounts: initialConnections.map((row) => ({ ...row })), removed: new Set(), installs: new Map(), audience: new Map() })); + const [ready, setReady] = useState(false); + const [, render] = useState(0); + const navigate = useNavigate(); + function update(account: AiConnectionSummary) { + store.accounts = store.accounts.some((row) => row.id === account.id) ? store.accounts.map((row) => row.id === account.id ? account : row) : [...store.accounts, account]; + void client.invalidateQueries(); render((n) => n + 1); + } + useEffect(() => { + const previous = window.fetch; + window.fetch = async (input, init) => { + const request = input instanceof Request ? input : null; + const url = new URL(request?.url ?? String(input), window.location.origin); + const method = init?.method ?? request?.method ?? "GET"; + const payload = typeof init?.body === "string" ? JSON.parse(init.body) : {}; + const path = url.pathname; + const rows = store.accounts.filter((row) => !store.removed.has(row.id)); + const applications: ToolApplication[] = gallery.map((app) => ({ id: `app-${app.slug}`, companyId, applicationKey: `app-gallery:${app.slug}:review`, name: app.name, description: app.description, type: "mcp_http", status: "active", pluginId: null, ownerAgentId: null, ownerUserId: "dotta", metadata: { sourceTemplateKey: app.slug }, archivedAt: null, createdAt: date, updatedAt: date })); + const reply = (value: unknown) => Promise.resolve(Response.json(value)); + if (path === `/api/companies/${companyId}/tools/gallery`) return reply({ apps: gallery, capabilities: { canCreateOrganizationGrant: !readOnly, organizationGrantReason: null, canSetCompanyInstall: !readOnly, companyInstallReason: null } }); + if (path === `/api/companies/${companyId}/tools/applications`) return reply({ applications }); + if (path === `/api/companies/${companyId}/tools/connections`) return reply({ connections: rows.map(asConnection) }); + if (path === `/api/companies/${companyId}/tools/profiles`) return reply({ profiles: [...store.profiles.values()] }); + if (path === `/api/companies/${companyId}/tools/policies`) return reply({ policies: [] }); + if (path === `/api/companies/${companyId}/agents`) return reply(agents); + if (path === `/api/companies/${companyId}/user-directory`) return reply({ users: members.map((member) => ({ principalId: member.userId, status: "active", user: { name: member.name, email: member.email } })) }); + const match = path.match(/^\/api\/tool-connections\/([^/]+)(.*)$/); + if (match) { + const row = store.accounts.find((candidate) => candidate.id === match[1]); + if (!row) return Response.json({ error: "Unknown review connection" }, { status: 404 }); + const suffix = match[2]; + if (!suffix) { + if (method === "PATCH") update({ ...row, name: payload.name ?? row.name }); + if (method === "DELETE") store.removed.add(row.id); + return reply(asConnection(store.accounts.find((candidate) => candidate.id === row.id)!)); + } + if (suffix === "/grants") { + const result = grantsFor(row, readOnly); + result.grants[0].members = (store.audience.get(row.id) ?? []).map((userId) => ({ id: `member-${userId}`, companyId, grantId: row.grantId, subjectType: "user" as const, subjectId: userId, createdAt: date })); + return reply(result); + } + if (method === "DELETE" && /^\/grants\/[^/]+$/.test(suffix)) { update({ ...row, status: "revoked" }); return reply(grantsFor({ ...row, status: "revoked" }, readOnly).grants[0]); } + if (suffix.endsWith("/members")) { store.audience.set(row.id, payload.memberUserIds ?? []); return reply(grantsFor(row, readOnly).grants[0]); } + if (suffix === "/installs") { + if (method === "PUT") store.installs.set(row.id, payload.installs ?? []); + return reply({ connectionId: row.id, installs: store.installs.get(row.id) ?? [] }); + } + if (suffix === "/catalog") return reply({ catalog: [] }); + return Response.json({ error: `Unsupported review operation: ${suffix}` }, { status: 400 }); + } + if (path.startsWith(`/api/companies/${companyId}/tools/apps/`) && path.endsWith("/finish")) { + const id = path.split("/").at(-2)!; + const profile = { id: `profile-${id}`, companyId, profileKey: `app:${id}`, entries: [], bindings: payload.access === "all_agents" ? [{ targetType: "company", targetId: companyId }] : (payload.access?.agentIds ?? []).map((targetId: string) => ({ targetType: "agent", targetId })) }; + store.profiles.set(id, profile); + return reply({ connection: asConnection(rows.find((row) => row.id === id)!), profile, policy: null }); + } + // Unhandled fixture mutations must never reach a live API/provider. + if (method !== "GET" && path.startsWith("/api/")) return Response.json({ error: "This review does not perform live operations." }, { status: 400 }); + return previous(input, init); + }; + navigate(detail ? `/PAP/apps/${initialConnections[0]?.id ?? "claude-dotta"}/permissions` : "/PAP/apps", { replace: true }); + setReady(true); + return () => { window.fetch = previous; client.clear(); }; + }, []); + if (!ready) return

Loading Connectors review…

; + return +
+

Existing app page components below · Fixture accounts · Review annotation

+ +
+ + { + const row = store.accounts.find((account) => account.id === connection.id); + return row ?

{aiMethodLabel(row.provider, row.method)} · {row.ownership === "shared" ? "Company shared" : "Personal"}{row.isDefault ? " · Personal default" : ""}{row.accountLabel ? ` · ${row.accountLabel}` : ""}

: null; + }} />} /> + } /> + navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&stage=setup&reconnect=${connection.id}`)} renderActions={(connection) => { + const account = store.accounts.find((row) => row.id === connection.id); + return account ? { + store.accounts = store.accounts.map((row) => row.provider === account.provider && row.method === account.method && row.ownerUserId === "dotta" ? { ...row, isDefault: row.id === account.id } : row); update({ ...account, isDefault: true }); + }} + onReconnect={() => navigate(`/apps/connect?source=${account.provider}&stage=setup&reconnect=${account.id}`)} + onRevoke={() => update({ ...account, status: "revoked" })} + /> : undefined; + }} />} /> +
+
+
+
; +} + +function Setup({ accounts, onSave }: { accounts: AiConnectionSummary[]; onSave: (account: AiConnectionSummary) => void }) { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const provider = (params.get("source") ?? "anthropic") as AiProvider; + const reconnect = accounts.find((row) => row.id === params.get("reconnect")); + const [method, setMethod] = useState(reconnect?.method ?? (provider === "openrouter" ? "api_key" : "subscription")); + const [state, setState] = useState({ phase: "idle" }); + const [name, setName] = useState(reconnect?.name ?? `My ${AI_PROVIDERS[provider]?.subscriptionName ?? "OpenRouter API"}`); + const [savedId, setSavedId] = useState(); + function complete(grantKind: string, agentIds: string[], allAgents: boolean) { + const id = reconnect?.id ?? `review-${provider}-${accounts.length}`; + const personal = reconnect ? reconnect.ownership === "personal" : grantKind === "user"; + onSave(reconnect ? { ...reconnect, status: "connected" } : { id, grantId: `grant-${id}`, companyId, provider, method, name, ownership: personal ? "personal" : "shared", ownerUserId: personal ? "dotta" : undefined, ownerName: personal ? "Dotta" : undefined, status: "connected", isDefault: personal && !accounts.some((row) => row.ownerUserId === "dotta" && row.provider === provider && row.method === method && row.isDefault) }); + if (!reconnect) void fetch(`/api/companies/${companyId}/tools/apps/${id}/finish`, { method: "POST", body: JSON.stringify({ access: allAgents ? "all_agents" : { agentIds } }) }); + setSavedId(id); setState({ phase: "connected" }); + } + if (!(provider in AI_PROVIDERS)) return <>

This review focuses on AI authentication. The existing connector remains in the same list.

; + return navigate("/apps")} renderCredentialStep={({ grantKind, agentIds, allAgents }) =>
+ + {!reconnect && provider !== "openrouter" && { setMethod(method === "subscription" ? "api_key" : "subscription"); setState({ phase: "idle" }); }} />} + setState({ phase: "waiting", authorizationUrl: "https://example.test/review-authorization", code: provider === "openai" ? "REVIEW-CODE" : undefined })} + onSubmit={() => complete(grantKind, agentIds, allAgents)} + onCancel={() => navigate("/apps")} + onDone={() => navigate(`/apps/${savedId}/permissions`)} /> + {state.phase === "waiting" && method === "subscription" && provider !== "anthropic" && } +
} />; +} diff --git a/ui/storybook/prototypes/AiReviewFrame.tsx b/ui/storybook/prototypes/AiReviewFrame.tsx new file mode 100644 index 0000000000..35345d29fe --- /dev/null +++ b/ui/storybook/prototypes/AiReviewFrame.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; + +/** Review annotations live only in Storybook; none of this frame ships in the app. */ +export function AiReviewFrame({ location, existing, proposed, wrapper, children }: { + location: string; + existing: string; + proposed: string; + wrapper: string; + children: ReactNode; +}) { + return
+ +
{children}
+
; +} + +/** Marks a precise component boundary within a simulated page or an existing app page. */ +export function AiReviewBoundary({ label, children }: { label: string; children: ReactNode }) { + return
+

{label} · Review annotation

+ {children} +
; +} + +export function aiReviewContext(id: string, args: { host?: string; initialStage?: string }) { + const story = id.replace("ai-connections-review--", ""); + if (story === "review-index") return { + location: "This is a Storybook review index. It has no app location.", + existing: "The linked stories identify the existing pages they use.", + proposed: "The linked stories identify the new AI-specific components.", + wrapper: "This entire index and its links exist only for review.", + }; + if (story.startsWith("inline-task")) return { + location: "A task → connection request card → Connect / Use existing modal.", + existing: "ConnectionIntentInteractionBody: the task card, modal, reuse chooser and focus handling. ConnectionSetupFlow: access/setup steps.", + proposed: "AI authentication content inside that existing setup flow.", + wrapper: "The example task title, Nova, accounts and successful continuation are simulated. There is no live task or run.", + }; + if (args.host === "connections" || story === "identity-matrix") { + const detail = args.initialStage === "manage"; + return { + location: detail ? "Connectors → choose an account → account permissions/details." : "Connectors (/:company/apps) → provider → Add account or open an account.", + existing: detail ? "AppDetail: header/rename, ownership display and agent-access controls. BreadcrumbBar supplies existing page navigation. The existing revoke dialog and reconnect banner are reused." : "Browse: provider groups, account rows, search and menus. Navigation opens the existing AppDetail and ConnectionSetupFlow components.", + proposed: detail ? "The marked AI account section: personal default and AI credential actions." : "AI provider/account fixture entries and their method/default labels. AI-specific sections are marked when you open an account or sign in.", + wrapper: "This frame, page margins and in-memory API. The page components are real; the AI accounts and all changes are simulated.", + }; + } + const location = args.host === "onboarding" ? "Onboarding → existing provider connection step." + : args.host === "new_agent" ? "Create agent → provider/harness configuration → AI connection field." + : args.host === "task" ? "A task blocked on its responsible user’s AI credentials. This story isolates the picker; see Inline task connection for the real task host." + : "Agent → configuration → AI connection field beside harness/model."; + return { + location, + existing: "ConnectionChoiceList (extracted from connection setup), AppLogo, and the existing onboarding subscription/API-key cards and fields.", + proposed: "The marked AI connection picker and authentication composition. The app uses these controls beside its harness/model settings.", + wrapper: "The Nova heading, harness/model values, save/continue buttons and simulator controls form a mock page. They are not the real agent configuration form.", + }; +} diff --git a/ui/storybook/prototypes/AiTaskConnectionReview.tsx b/ui/storybook/prototypes/AiTaskConnectionReview.tsx new file mode 100644 index 0000000000..4a7797ef13 --- /dev/null +++ b/ui/storybook/prototypes/AiTaskConnectionReview.tsx @@ -0,0 +1,67 @@ +import { AiReviewBoundary } from "./AiReviewFrame"; +import { useEffect, useState } from "react"; +import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { APP_DEFINITIONS, type ConnectionIntentInteraction } from "@paperclipai/shared"; +import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody"; +import { ConnectionSetupFlow, type ConnectionSetupFlowProps } from "@/features/connections/ConnectionSetupFlow"; +import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures"; +import { AiConnectionAuth, type AiAuthState } from "@/components/ai-connections/AiConnectionAuth"; +import { AI_REVIEW_CONNECTIONS } from "../fixtures/aiConnections"; +import { storybookAgents } from "../fixtures/paperclipData"; + +const initial: ConnectionIntentInteraction = { + ...pendingConnectionIntentInteraction, + id: "ai-task-request", companyId: "company-storybook", addresseeUserId: "dotta", + payload: { version: 1, purpose: "ai", serviceSlug: "anthropic", serviceName: "Claude", serviceLogoUrl: "/brands/claude-color.svg", requestingAgentId: "nova", requestingAgentName: "Nova", phase: "requested" }, +}; + +/** The actual task connection request card, modal, reuse flow and focus lifecycle. */ +export function AiTaskConnectionReview({ reuse = false }: { reuse?: boolean }) { + const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } })); + const [ready, setReady] = useState(false); + useEffect(() => { + let interaction = initial; + const previous = window.fetch; + window.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), window.location.origin); + const path = url.pathname; + const app = APP_DEFINITIONS.find((entry) => entry.slug === "anthropic")!; + const payload = typeof init?.body === "string" ? JSON.parse(init.body) : {}; + if (path.startsWith("/api/connection-intents/ai-task-request/")) { + if (path.endsWith("/complete")) interaction = { ...interaction, status: "accepted", result: { version: 1, outcome: "connected", connectionId: payload.connectionId } }; + if (path.endsWith("/decline")) interaction = { ...interaction, status: "rejected", result: { version: 1, outcome: "declined" } }; + if (path.endsWith("/phase")) interaction = { ...interaction, payload: { ...interaction.payload, phase: payload.phase } }; + return Response.json(path.endsWith("setup-options") ? { + version: 1, interaction, service: { service: "anthropic", name: "Claude", methods: [], state: "needs_user_action", connectionId: null }, + aiConnection: { provider: "anthropic", method: "subscription", mode: "responsible_user" }, requestedAgentId: "nova", existingConnections: reuse ? [{ id: "claude-dotta", applicationId: "app-anthropic", name: AI_REVIEW_CONNECTIONS[0].name, status: "active", enabled: true }] : [], + } : interaction); + } + if (path === "/api/companies/company-storybook/tools/gallery") return Response.json({ apps: [{ ...app, name: "Claude" }], capabilities: { canCreateOrganizationGrant: false, canSetCompanyInstall: false } }); + if (path === "/api/companies/company-storybook/agents") return Response.json([{ ...storybookAgents[0], id: "nova", name: "Nova" }]); + if (path === "/api/ai-review-interactions") return Response.json([interaction]); + return previous(input, init); + }; + setReady(true); + return () => { window.fetch = previous; client.clear(); }; + }, [client, reuse]); + return ready ? : null; +} +function TaskCard() { + const query = useQuery({ queryKey: ["issues", "interactions", "ai-review"], queryFn: async (): Promise => (await fetch("/api/ai-review-interactions")).json() }); + const interaction = query.data?.[0]; + return
+

Example task context · Storybook only

+

Nova needs your Claude connection

+

Existing task connection request · Fixture data. Harness: Claude Code. Model: Configured Claude model.

+ {interaction && } />} +
; +} +function TaskSetup(props: ConnectionSetupFlowProps) { + const [state, setState] = useState({ phase: "idle" }); + return setState({ phase: "waiting", authorizationUrl: "https://example.test/review-login" })} + onSubmit={() => setState({ phase: "connected" })} + onCancel={() => props.onCancel?.()} + onDone={() => props.onComplete?.({ connectionId: "review-task-claude" })} + />} />; +} diff --git a/ui/storybook/stories/ai-connections.stories.tsx b/ui/storybook/stories/ai-connections.stories.tsx new file mode 100644 index 0000000000..5f748ea3e8 --- /dev/null +++ b/ui/storybook/stories/ai-connections.stories.tsx @@ -0,0 +1,525 @@ +import { AiReviewFrame, aiReviewContext } from "../prototypes/AiReviewFrame"; +import { AiTaskConnectionReview } from "../prototypes/AiTaskConnectionReview"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { AiConnectionsReview } from "../prototypes/AiConnectionsReview"; +import { AiConnectorPages } from "../prototypes/AiConnectorPages"; +import { + AI_REVIEW_BINDING, + AI_REVIEW_CONNECTIONS, + AI_REVIEW_REQUIREMENT, +} from "../fixtures/aiConnections"; + +const meta = { + title: "AI Connections/Review", + component: AiConnectionsReview, + parameters: { layout: "fullscreen" }, + decorators: [(Story, context) => ( + + )], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const groups = [ + [ + "Connections", + [ + ["Existing Connectors page with AI providers", "provider-catalog"], + ["Connection list", "connections"], + ["Add account through existing Connectors", "connect-from-existing-catalog"], + ["Provider and identity matrix", "identity-matrix"], + ["Manage account", "management"], + ["Change personal default", "change-personal-default"], + ["Unavailable default", "revoked-default"], + ], + ], + [ + "Choose an account", + [ + ["Responsible user", "responsible-user"], + ["Company shared", "shared-selected"], + ["Human access denied", "shared-audience-denied"], + ["Another responsible user", "another-user-missing"], + ["Incompatible selection", "incompatible-selection"], + ], + ], + [ + "Authentication", + [ + ["Claude subscription", "claude-subscription"], + ["ChatGPT subscription", "chat-gpt-subscription"], + ["Grok subscription", "grok-subscription"], + ["API key", "open-router-api-key"], + ["Invalid credentials and retry", "api-key-retry"], + ["Expired sign-in", "expired-attempt"], + ["Cancel and restore focus", "cancel-and-restore-focus"], + ], + ], + [ + "Complete flows", + [ + ["First onboarding", "first-onboarding"], + ["Onboarding reuse", "onboarding-reuse"], + ["New-agent reuse", "new-agent-reuse"], + ["Inline task connection", "inline-task-connection"], + ["Inline task reuse", "inline-task-reuse"], + ["Settings change", "settings-change"], + ["Legacy adoption", "legacy-adoption"], + ], + ], +] as const; + +export const ReviewIndex: Story = { + render: () => ( +
+

AI Connections · Review index

+

+ Milestone 1: shared UI, simulated accounts, no live authentication. + Start with the existing Connectors page, then account details, provider + sign-in and agent connection selection. Use the Storybook toolbar for light/dark themes and narrow + viewports. +

+

+ Personal defaults are per company, provider, and sign-in method. + Connection selection never changes harness or model. Unavailable + accounts block without fallback. +

+ {groups.map(([title, links]) => ( +
+

{title}

+ {links.map(([label, id]) => ( + + {label} + + ))} +
+ ))} +

+ Additional stories cover read-only, loading, denied access, + reauthorization, revocation, mobile, and unsupported environments. + Runtime enforcement and data migration follow UI review. +

+
+ ), +}; +export const ProviderCatalog: Story = { + args: { host: "connections", initialStage: "providers" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("button", { name: "Add account Anthropic" })).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Connect GitHub" })).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Connect Gmail" })).toBeVisible(); + await userEvent.type(canvas.getByPlaceholderText("Search connectors…"), "Claude"); + await expect(canvas.queryByRole("button", { name: "Connect Gmail" })).not.toBeInTheDocument(); + await userEvent.clear(canvas.getByPlaceholderText("Search connectors…")); + }, +}; +export const ConnectFromExistingCatalog: Story = { + args: { host: "connections" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Add account Anthropic" })); + await userEvent.click(await canvas.findByRole("radio", { name: "Any agent" })); + await userEvent.click(canvas.getByRole("button", { name: /^(Save and continue|Continue)$/ })); + const name = await canvas.findByLabelText("Connection name"); + await userEvent.clear(name); await userEvent.type(name, "My additional Claude account"); + await userEvent.click(canvas.getByRole("button", { name: "Sign in" })); + await userEvent.type(await canvas.findByLabelText("Authorization code"), "fixture-code"); + await userEvent.click(canvas.getByRole("button", { name: "Submit code" })); + await userEvent.click(canvas.getByRole("button", { name: "Use connection" })); + await expect(await canvas.findByLabelText("AI account settings")).toBeVisible(); + await expect(canvas.getByRole("heading", { name: "My additional Claude account" })).toBeVisible(); + await userEvent.click(canvas.getByRole("link", { name: "Connectors" })); + await expect(await canvas.findByRole("button", { name: "Open My additional Claude account permissions" })).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Open My Claude subscription permissions" })).toBeVisible(); + }, +}; +export const Connections: Story = { args: { host: "connections" } }; +export const IdentityMatrix: Story = { + render: () => , +}; +export const ResponsibleUser: Story = {}; +export const SharedSelected: Story = { + args: { + initialBinding: { + ...AI_REVIEW_BINDING, + mode: "shared", + connectionId: "claude-shared", + grantId: "grant-shared", + }, + }, +}; +export const LegacyPersonalSelectionBlocked: Story = { + args: { + initialBinding: { + ...AI_REVIEW_BINDING, + mode: "delegated", + connectionId: "claude-sam", + grantId: "grant-sam", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("status")).toHaveTextContent("This credential is not shared with you"); + await expect(canvas.queryByRole("button", { name: /Sam’s Claude/ })).not.toBeInTheDocument(); + }, +}; +export const NoAccounts: Story = { args: { initialConnections: [] } }; +export const AnotherUserMissing: Story = { + args: { currentUserId: "sam", host: "task" }, +}; +export const IncompatibleSelection: Story = { + args: { + initialBinding: { + mode: "shared", + provider: "openai", + method: "api_key", + connectionId: "openai-api", + grantId: "grant-openai-api", + }, + }, +}; +export const Loading: Story = { args: { loading: true } }; +export const LoadFailed: Story = { + args: { error: "Could not load AI connections. Try again." }, +}; +export const ReadOnly: Story = { args: { readOnly: true } }; +export const SharedAudienceDenied: Story = { + args: { + initialConnections: AI_REVIEW_CONNECTIONS.map((row) => + row.id === "claude-shared" + ? { + ...row, + unavailableReason: + "The responsible user is not permitted to use this company account.", + } + : row, + ), + }, +}; +export const RevokedDefault: Story = { + args: { + initialConnections: AI_REVIEW_CONNECTIONS.map((row) => + row.id === "claude-dotta" ? { ...row, status: "revoked" } : row, + ), + }, +}; +export const ChangePersonalDefault: Story = { + args: { + host: "connections", initialStage: "manage", + initialConnections: [AI_REVIEW_CONNECTIONS[1], ...AI_REVIEW_CONNECTIONS.filter((row) => row.id !== AI_REVIEW_CONNECTIONS[1].id)], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Make default" })); + await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Personal default"); + await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Your default"); + await expect(canvas.queryByRole("button", { name: "Make default" })).not.toBeInTheDocument(); + }, +}; + +const authArgs = { initialStage: "auth" as const }; +const waiting = { + phase: "waiting" as const, + authorizationUrl: "#storybook-provider-simulator", + code: "DEMO-CODE", +}; +export const ClaudeSubscription: Story = { + args: { ...authArgs, initialAuthState: waiting }, +}; +export const ChatGptSubscription: Story = { + args: { + ...authArgs, + requirement: { ...AI_REVIEW_REQUIREMENT, provider: "openai" }, + initialAuthState: waiting, + }, +}; +export const GrokSubscription: Story = { + args: { + ...authArgs, + requirement: { ...AI_REVIEW_REQUIREMENT, provider: "xai" }, + initialAuthState: waiting, + }, +}; +export const ClaudeApiKey: Story = { + args: { + ...authArgs, + requirement: { ...AI_REVIEW_REQUIREMENT, method: "api_key" }, + }, +}; +export const OpenAiApiKey: Story = { + args: { + ...authArgs, + requirement: { + ...AI_REVIEW_REQUIREMENT, + provider: "openai", + method: "api_key", + }, + }, +}; +export const OpenRouterApiKey: Story = { + args: { + ...authArgs, + requirement: { + ...AI_REVIEW_REQUIREMENT, + provider: "openrouter", + method: "api_key", + }, + }, +}; +export const GrokApiKey: Story = { + args: { + ...authArgs, + requirement: { + ...AI_REVIEW_REQUIREMENT, + provider: "xai", + method: "api_key", + }, + }, +}; +export const PreparingLogin: Story = { + args: { ...authArgs, initialAuthState: { phase: "starting" } }, +}; +export const Connected: Story = { + args: { ...authArgs, initialAuthState: { phase: "connected" } }, +}; +export const Cancelled: Story = { + args: { ...authArgs, initialAuthState: { phase: "cancelled" } }, +}; +export const ExpiredAttempt: Story = { + args: { + ...authArgs, + initialAuthState: { + phase: "expired", + message: + "This sign-in attempt expired. Start again to receive a new code.", + }, + }, +}; +export const UnsupportedEnvironment: Story = { + args: { + ...authArgs, + initialAuthState: { + phase: "unsupported", + message: + "Subscription sign-in is unavailable in this environment. Choose an environment that supports this provider’s login.", + }, + }, +}; +export const InvalidCredentials: Story = { + args: { + ...authArgs, + requirement: { ...AI_REVIEW_REQUIREMENT, method: "api_key" }, + initialAuthState: { + phase: "error", + message: + "The provider rejected this API key. Check the key and try again.", + }, + }, +}; +export const ApiKeyRetry: Story = { + args: { + ...authArgs, + initialConnections: [], + requirement: { + ...AI_REVIEW_REQUIREMENT, + provider: "openrouter", + method: "api_key", + }, + failFirstAttempt: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type( + canvas.getByLabelText("API key"), + "storybook-not-a-key", + ); + await userEvent.click(canvas.getByRole("button", { name: "Connect" })); + await expect(canvas.getByRole("alert")).toHaveTextContent( + "could not verify", + ); + await expect(canvas.getByLabelText("API key")).toHaveValue(""); + await userEvent.type( + canvas.getByLabelText("API key"), + "storybook-retry-not-a-key", + ); + await userEvent.click(canvas.getByRole("button", { name: "Connect" })); + await expect(canvas.getByRole("status")).toHaveTextContent("Connected."); + await userEvent.click( + canvas.getByRole("button", { name: "Use connection" }), + ); + await expect(canvas.getByText("For you: My OpenRouter API")).toBeVisible(); + }, +}; + +export const FirstOnboarding: Story = { + args: { host: "onboarding", initialConnections: [], initialStage: "auth" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Sign in" })); + await userEvent.type( + canvas.getByLabelText("Authorization code"), + "storybook-code", + ); + await userEvent.click(canvas.getByRole("button", { name: "Submit code" })); + await userEvent.click( + canvas.getByRole("button", { name: "Use connection" }), + ); + await userEvent.click(canvas.getByRole("button", { name: "Continue" })); + await userEvent.click( + canvas.getByRole("button", { + name: "Create another agent using existing connections", + }), + ); + await expect( + canvas.getByText("For you: My Claude subscription"), + ).toBeVisible(); + await expect(canvas.getByTestId("ai-harness")).toHaveTextContent( + "Claude Code", + ); + }, +}; +export const OnboardingReuse: Story = { args: { host: "onboarding" } }; +export const NewAgentReuse: Story = { args: { host: "new_agent" } }; +export const InlineTaskConnection: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await canvas.findByRole("button", { name: "Connect" })); + const dialog = within(await body.findByRole("dialog")); + await userEvent.click(await dialog.findByRole("button", { name: /^(Save and continue|Continue)$/ })); + await userEvent.click(await dialog.findByRole("button", { name: "Sign in" })); + await userEvent.type(dialog.getByLabelText("Authorization code"), "fixture-task-code"); + await userEvent.click(dialog.getByRole("button", { name: "Submit code" })); + await userEvent.click(dialog.getByRole("button", { name: "Use connection" })); + await expect(await canvas.findByText("Claude connected")).toBeVisible(); + await expect(canvas.getByTestId("connection-intent-focus-target")).toHaveFocus(); + }, +}; +export const InlineTaskReuse: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await canvas.findByRole("button", { name: "Connect / Use existing" })); + const dialog = within(await body.findByRole("dialog")); + await userEvent.click(await dialog.findByRole("button", { name: "My Claude subscription" })); + await expect(await canvas.findByText("Claude connected")).toBeVisible(); + }, +}; +export const SettingsChange: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Engineering Claude" }), + ); + await expect( + canvas.getByRole("button", { name: "Engineering Claude" }), + ).toHaveAttribute("aria-pressed", "true"); + await expect(canvas.getByTestId("ai-harness")).toHaveTextContent( + "Claude Code", + ); + await expect(canvas.getByTestId("ai-model")).toHaveTextContent( + "Configured Claude model", + ); + await userEvent.click( + canvas.getByRole("button", { name: "Save connection" }), + ); + await expect(canvas.getByRole("status")).toHaveTextContent( + "Connection selected for Nova", + ); + }, +}; +export const CancelAndRestoreFocus: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Connect another account" }), + ); + await userEvent.click(canvas.getByRole("button", { name: "Sign in" })); + await userEvent.click(canvas.getByRole("button", { name: "Cancel" })); + await expect( + canvas.getByRole("button", { name: "Connect another account" }), + ).toHaveFocus(); + await expect( + canvas.getByText("For you: My Claude subscription"), + ).toBeVisible(); + }, +}; +export const Management: Story = { + args: { host: "connections", initialStage: "manage" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("heading", { name: "Which humans can use this credential?" })).toBeVisible(); + await expect(canvas.getByRole("heading", { name: "Which agents can use this connection?" })).toBeVisible(); + await expect(canvas.queryByText("Authorized use for other users’ tasks")).not.toBeInTheDocument(); + }, +}; +export const ManagementReadOnly: Story = { + args: { host: "connections", initialStage: "manage", readOnly: true }, +}; +export const ReconnectExisting: Story = { + args: { + host: "connections", + initialStage: "manage", + initialConnections: AI_REVIEW_CONNECTIONS.map((connection) => + connection.id === "claude-dotta" + ? { ...connection, status: "expired" } + : connection, + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Reconnect" })); + await userEvent.click(await canvas.findByRole("button", { name: "Sign in" })); + await userEvent.type(await canvas.findByLabelText("Authorization code"), "fixture-reconnect"); + await userEvent.click(canvas.getByRole("button", { name: "Submit code" })); + await userEvent.click(canvas.getByRole("button", { name: "Use connection" })); + await expect(await canvas.findByRole("heading", { name: "My Claude subscription" })).toBeVisible(); + await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Your default"); + }, +}; +export const RevokeConnection: Story = { + args: { host: "connections", initialStage: "manage" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await canvas.findByRole("button", { name: "Revoke identity" })); + const dialog = within(await body.findByRole("alertdialog")); + await expect(dialog.getByText(/Existing runs may retain credentials/)).toBeVisible(); + await userEvent.click(dialog.getByRole("button", { name: "Revoke identity" })); + await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Default unavailable"); + }, +}; +export const LegacyAdoption: Story = { + args: { initialStage: "legacy" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Choose a managed connection" }), + ); + await expect( + canvas.getByRole("button", { name: "Adopt connection" }), + ).toBeDisabled(); + await userEvent.click( + canvas.getByRole("button", { name: "Test selected connection" }), + ); + await userEvent.click( + canvas.getByRole("button", { name: "Adopt connection" }), + ); + await expect(canvas.getByRole("status")).toHaveTextContent( + "Managed connection adopted.", + ); + }, +}; +export const MobilePicker: Story = { + globals: { viewport: { value: "mobile1", isRotated: false } }, +}; +export const MobileAuthentication: Story = { + args: { ...authArgs, initialAuthState: waiting }, + globals: { viewport: { value: "mobile1", isRotated: false } }, +}; diff --git a/ui/storybook/stories/in-feed-connections.stories.tsx b/ui/storybook/stories/in-feed-connections.stories.tsx index 8b084f206b..5cb3aa4977 100644 --- a/ui/storybook/stories/in-feed-connections.stories.tsx +++ b/ui/storybook/stories/in-feed-connections.stories.tsx @@ -31,7 +31,7 @@ const connection = { createdByAgentId: null, createdByUserId: "user-board", createdAt: new Date("2026-09-07"), updatedAt: new Date("2026-09-07"), } satisfies ToolConnection; -type Scenario = { checking?: boolean; count?: number; loading?: boolean; loadError?: boolean; completeError?: boolean; submitting?: boolean; denied?: boolean }; +type Scenario = { ai?: boolean; ownerOnly?: boolean; checking?: boolean; count?: number; loading?: boolean; loadError?: boolean; completeError?: boolean; submitting?: boolean; denied?: boolean }; const meta: Meta = { title: "Connections/In-task connections", parameters: { layout: "padded" }, @@ -45,13 +45,16 @@ const meta: Meta = { channel.on("unhandledErrorsWhilePlaying", reportPlayError); const original = window.fetch; const scenario = (parameters.connectionScenario ?? {}) as Scenario; - let current = structuredClone(pending); + let current = structuredClone(scenario.ai ? aiPending : pending); window.fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); if (url.pathname.endsWith("/tools/gallery")) return Response.json({ apps: CONNECTABLE_APP_DEFINITIONS.filter((app) => ["notion", "github", "posthog", "zapier"].includes(app.slug)), capabilities: { canCreateOrganizationGrant: true, canSetCompanyInstall: true }, }); + if (scenario.ai && url.pathname.endsWith("/ai-connections") && init?.method === "POST") return scenario.completeError + ? Response.json({ error: "This key could not be verified. Check it and try again." }, { status: 422 }) + : Response.json({ connectionId: aiAccount.id, grantId: aiAccount.grantId }); if (url.pathname.endsWith("/agents")) return Response.json([{ id: pending.payload.requestingAgentId, companyId: pending.companyId, name: pending.payload.requestingAgentName, status: "active", adapterType: "paperclip_runner", role: "researcher" }]); if (url.pathname.startsWith("/api/connection-intents/")) { if (url.pathname.endsWith("setup-options")) { @@ -59,13 +62,14 @@ const meta: Meta = { if (scenario.loadError) return Response.json({ error: "Connection options are temporarily unavailable. Try again." }, { status: 503 }); return Response.json({ version: 1, interaction: current, requestedAgentId: pending.payload.requestingAgentId, service: { service: "notion", name: "Notion", state: "available", methods: [] }, + ...(scenario.ai ? { aiConnection: { provider: "openrouter", method: "api_key", mode: "responsible_user" }, aiRepair: { connection: aiAccount, canReconnect: !scenario.ownerOnly } } : {}), existingConnections: Array.from({ length: scenario.count ?? 0 }, (_, i) => ({ ...connection, id: `${connection.id.slice(0, -1)}${i}`, name: i ? "Team Notion workspace" : connection.name })), }); } if (scenario.submitting) return new Promise(() => {}); if (scenario.completeError || scenario.denied) return Response.json({ error: scenario.denied ? "You no longer have permission to share this connection." : "Connection has no permitted tools. Review action permissions and try again." }, { status: scenario.denied ? 403 : 409 }); if (url.pathname.endsWith("decline")) current = { ...declined, id: pending.id }; - else if (url.pathname.endsWith("complete")) current = { ...connected, id: pending.id }; + else if (url.pathname.endsWith("complete")) current = { ...connected, id: pending.id, payload: current.payload }; else if (url.pathname.endsWith("phase")) current = { ...current, payload: { ...current.payload, phase: "needs_retry" } }; return Response.json(current); } @@ -238,3 +242,49 @@ export const SetupFailureRetry: Story = { ...SetupFailure, play: async (context) await userEvent.click(within(document.body).getByRole("button", { name: /Check link/i })); await expect(await within(document.body).findByText(/Fixture connection could not be verified/)).toBeVisible(); }}; + + +// Storybook supplies only deterministic data/actions. The card and credential +// form below are the production components used inside the task. +const openrouter = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "openrouter")!; +const aiPending: ConnectionIntentInteraction = { ...pending, payload: { ...pending.payload, purpose: "ai", serviceName: "OpenRouter", serviceSlug: "openrouter", serviceLogoUrl: openrouter.branding.logoUrl ?? null, serviceDarkLogoUrl: openrouter.branding.darkLogoUrl ?? null } }; +const aiAccount = { id: connection.id, grantId: "storybook-grant", companyId: pending.companyId, provider: "openrouter", method: "api_key", name: "My OpenRouter account", ownership: "personal", ownerName: "Alex", isDefault: true, status: "revoked" }; +const aiRepair: Story = { + parameters: { connectionScenario: { ai: true } }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Fix connection" })); + await expect(await canvas.findByLabelText("Connection name")).toBeDisabled(); + await expect(within(document.body).queryByRole("dialog")).not.toBeInTheDocument(); + }, +}; +export const AiInlineRepair = aiRepair; +export const AiInlineRepairNarrow: Story = { ...aiRepair, globals: { viewport: { value: "mobile1", isRotated: false } } }; +export const AiRepairCancel: Story = { ...aiRepair, play: async context => { + await aiRepair.play!(context); + const canvas = within(context.canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Cancel" })); + await expect(canvas.queryByTestId("ai-connection-inline-repair")).not.toBeInTheDocument(); + await waitFor(() => expect(canvas.getByTestId("connection-intent-focus-target")).toHaveFocus()); +}}; +export const AiRepairComplete: Story = { ...aiRepair, play: async context => { + await aiRepair.play!(context); + const canvas = within(context.canvasElement); + await userEvent.type(canvas.getByPlaceholderText("Enter API key here"), "storybook-placeholder"); + await userEvent.click(canvas.getByRole("button", { name: "Connect" })); + await expect(await canvas.findByText("OpenRouter connected")).toBeVisible(); +}}; +export const AiRepairInvalidKey: Story = { ...aiRepair, parameters: { connectionScenario: { ai: true, completeError: true } }, play: async context => { + await aiRepair.play!(context); + const canvas = within(context.canvasElement); + await userEvent.type(canvas.getByPlaceholderText("Enter API key here"), "storybook-placeholder"); + await userEvent.click(canvas.getByRole("button", { name: "Connect" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("This key could not be verified. Check it and try again."); +}}; +export const AiRepairOwnerRequired: Story = { ...aiRepair, parameters: { connectionScenario: { ai: true, ownerOnly: true } }, play: async ({canvasElement}) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Fix connection" })); + await expect(await canvas.findByText(/Alex must reconnect/)).toBeVisible(); + await expect(canvas.queryByPlaceholderText("Enter API key here")).not.toBeInTheDocument(); +}};