feat: reuse provider sign-in across AI connection workflows (#13248)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Users connect provider accounts during onboarding and agent setup.
> - They should reuse and manage those accounts through the existing
Connectors interface.
> - A second login wizard would diverge from the established provider
workflows.
> - This pull request composes the existing sign-in components into
Connections and agent configuration.
> - Users can select accounts without changing their agent's harness or
model.

## Linked Issues or Issue Description

**Problem or motivation**
AI credentials are configured separately from Connections. Agents cannot
consistently reuse a responsible user's account or a permitted shared
account.

**Proposed solution**
Manage AI accounts with the existing Connections grants and permissions.
Keep model and harness selection independent from credential selection.
Preserve legacy authentication until validated adoption.

**Alternatives considered**
A separate credential registry would duplicate ownership and access
policy. Automatic fallback would risk using the wrong account.

**Roadmap alignment**
This extends the shipped Apps, multi-user, secrets, and agent-runtime
capabilities. The maintainer requested the feature and reviewed the UI.
Related groundwork: #11899 (connection permissions), #10910 (connection
wizard), #11692 (Claude subscription profiles), and #11854 (Codex
account rotation).

## What Changed

- Add compact AI-account management to the existing Connectors pages.
- Reuse AgentProviderConnection, AdapterLoginPanel, AdapterLoginChrome,
and authentication controllers.
- Add the shared connection picker to agent setup/settings and task
requests.
- Preserve onboarding's sequence and reuse existing accounts.
- Add local-login recovery, retry, cancellation, and React StrictMode
handling.
- Add interactive Storybook scenarios, design-guide examples, and app
acceptance checks.

This is part 2 of the AI Connections change. The runtime foundation in
#13247 is merged. This PR now targets master.

## Verification

- Updated against master `47ded8bf9`, including the landed runtime
foundation and upstream task-search changes.
- Full workspace typecheck, production build, Storybook build, and token
gates passed on the integrated branch. Final local-login changes passed
59 focused tests; new-agent and inbox regression suites passed 63 tests.
- Browser checks verified automatic local Claude account detection,
resumable Codex login commands, retry, focus restoration, and
desktop/phone layouts. Commands create their isolated directory before
invoking the CLI.
- All CI test, browser, build, packaging, and runner jobs passed on
final head `dd17d3211931dd70aaa6ea619d83a7f9966dd18e`. The fresh
Greptile review is 5/5, the security scan passed, and there are no
unresolved review threads. The final CI aggregate gates passed.
- Local general-server coverage passed 11,804 tests; three
port-collision failures passed in an isolated 25-test rerun. All 6,111
UI tests passed. CLI coverage passed 484 tests; its remaining doctor
test requires port 3199, which is occupied by an unrelated report server
on this Mac. The complete CLI suite passed in CI.
- Live browser testing verified Codex API-key reconnect inside a task
card on desktop and phone. Real provider runs resumed and completed with
unchanged connection/grant identity and agent routing.
- Tested opening, cancelling, reopening, and completing connection
creation. A regression confirms Connect another account cannot submit
the new-agent form or copy provider keys into agent settings.
- Added shared inline repair, automatic local sign-in checks, and
responsive connection dialogs. Standalone Daytona installation ignores
workspace configuration and suppresses dependency scripts. Its
standalone build also passed with CI's exact pnpm 9.15.4.
- Destructive live tests are excluded by default. Explicit opt-in, local
deployment checks, and matching disposable fixture identities are
required before any mutation.

## Risks

- Local Codex/Grok creation requires the connection-specific terminal
login command.
- Browser sign-in uses the existing supported-environment controllers.
- This update verifies live local Claude detection and Codex API-key
task repair. New subscription authorization/refresh and
independent-human/native-runner isolation were not reverified in this
update.
- No agent automatically adopts managed Connections.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, code
execution, and browser testing. The exact runtime model identifier and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 16:51:26 -05:00 committed by GitHub
parent 47ded8bf97
commit 8d6232e7b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 4615 additions and 314 deletions

View File

@ -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 <marker>`
(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.

View File

@ -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.

View File

@ -0,0 +1,6 @@
packages:
- '.'
# The SDK ships generated protobuf code; its optional install script is not needed.
allowBuilds:
protobufjs: false

View File

@ -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(

View File

@ -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(

View File

@ -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 users connection/ }).click();
await expect(page.getByRole("dialog")).toContainText(`Adopt Connections for ${agent.name}`);
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
const after = await (await request.get(`/api/agents/${agent.id}`)).json();
expect(after.adapterType).toBe(agent.adapterType);
expect(after.adapterConfig).toEqual(agent.adapterConfig);
expect(after.runtimeConfig).toEqual(agent.runtimeConfig);
});
test("connection setup waits for provider details before enabling Continue", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/companies/${companyId}/tools/gallery`, async route => {
await held;
await route.continue();
});
await page.goto(`/${prefix}/apps/connect?source=openrouter&method=ai-api_key`);
const next = page.getByRole("button", { name: /^(Save and continue|Continue)$/ });
await expect(next).toBeDisabled();
release();
await next.click();
await expect(page.getByLabel("Connection name")).toBeVisible();
await expect(page).toHaveURL(/stage=setup/);
});
test("new OpenRouter agents use the visible binding and provider model catalog", async ({ page }) => {
let tested: { aiConnection?: unknown; adapterConfig?: { model?: string } } | undefined;
await page.route(`**/api/companies/${companyId}/adapters/opencode_local/models*`, async route => {
expect(new URL(route.request().url()).searchParams.get("provider")).toBe("openrouter");
await route.fulfill({ json: [{ id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet 4.5" }] });
});
await page.route(`**/api/companies/${companyId}/adapters/opencode_local/test-environment`, async route => {
tested = route.request().postDataJSON();
await route.fulfill({ json: { status: "pass", checks: [], testedAt: new Date().toISOString() } });
});
await page.goto(`/${prefix}/agents/new?name=OpenRouter+binding+regression&adapterType=opencode_local`);
await expect(page.getByText("Existing authentication — not managed by Connections", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Responsible users connection/ })).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "Select model (required)", exact: true }).click();
await page.getByRole("button", { name: "anthropic/claude-sonnet-4.5", exact: true }).click();
await page.getByRole("button", { name: "Run test", exact: true }).click();
await expect.poll(() => tested).toBeTruthy();
expect(tested?.aiConnection).toEqual({ provider: "openrouter", method: "api_key", mode: "responsible_user" });
expect(tested?.adapterConfig?.model).toBe("openrouter/anthropic/claude-sonnet-4.5");
});
test("ordinary Anthropic setup keeps the existing tool method available", async ({ page }) => {
await page.goto(`/${prefix}/apps/connect?source=anthropic`);
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
await expect(page.getByText("How do you want to connect?", { exact: true })).toBeVisible();
await page.getByRole("radio", { name: "Use an API key", exact: true }).click();
await expect(page.getByLabel("Your Anthropic key", { exact: true })).toBeVisible();
await expect(page.getByRole("radiogroup", { name: "Connect your model provider" })).toHaveCount(0);
await page.getByRole("button", { name: "Cancel", exact: true }).first().click();
await expect(page).toHaveURL(new RegExp(`/${prefix}/apps$`));
});
test("explicit OpenAI API method survives continuing and reloading", async ({ page }) => {
await page.goto(`/${prefix}/apps/connect?source=openai&method=ai-api_key`);
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
await expect(page).toHaveURL(/method=ai-api_key/);
await page.reload();
await page.getByRole("radio", { name: /OpenAI/ }).click();
await expect(page.getByLabel("API key", { exact: true })).toBeVisible();
await expect(page.getByText(/CODEX_HOME=/)).toHaveCount(0);
await page.getByRole("button", { name: "Cancel", exact: true }).first().click();
});
for (const [provider, label] of [["anthropic", "Claude"], ["openai", "OpenAI"]]) {
test(`Connections reuses the agent provider step for ${label}`, async ({ page }, testInfo) => {
await page.goto(`/${prefix}/apps/connect?source=${provider}&method=ai-subscription`);
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
await expect(page.getByRole("radiogroup", { name: "Connect your model provider" })).toBeVisible();
await expect(page.getByRole("button", { name: "Connect for tool access instead" })).toHaveCount(0);
await page.getByRole("radio", { name: new RegExp(label) }).click();
if (provider === "anthropic") {
await expect(page.getByText(/Connect uses your local/)).toBeVisible();
await expect(page.getByText("claude auth login", { exact: true })).toBeVisible();
} else {
await expect(page.getByText(/Your existing terminal login stays separate/)).toBeVisible();
const command = page.getByText(/^CODEX_HOME=.* codex login$/);
await expect(command).toBeVisible();
const preparedCommand = await command.textContent();
await page.reload();
await page.getByRole("radio", { name: new RegExp(label) }).click();
await expect(command).toHaveText(preparedCommand!);
}
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled();
// Let the shared tile-collapse and card-enter animations settle for visual review.
await page.waitForTimeout(1000);
await page.screenshot({ path: testInfo.outputPath(`${provider}-shared-provider-step.png`), fullPage: true });
await page.getByRole("button", { name: "Back", exact: true }).click();
await page.getByRole("button", { name: "Use API key instead", exact: true }).click();
await page.getByRole("radio", { name: new RegExp(label) }).click();
await expect(page.getByLabel("API key", { exact: true })).toBeVisible();
await expect(page.getByText(/Provide your .* API key to connect/)).toBeVisible();
await page.getByRole("button", { name: "Cancel", exact: true }).first().click();
await expect(page).toHaveURL(new RegExp(`/${prefix}/apps$`));
});
}
// Exercise the real onboarding login controllers; only provider/server replies
// are simulated. No credentials, sandbox leases or accounts are created here.
for (const [provider, label, adapter] of [["anthropic", "Claude", "claude_local"], ["openai", "OpenAI", "codex_local"]]) {
test(`Connections uses onboarding browser sign-in for ${label}`, async ({ page }, testInfo) => {
const environmentId = "11111111-1111-4111-8111-111111111111";
const sessionId = "22222222-2222-4222-8222-222222222222";
const base = `/api/companies/${companyId}`;
const sessions = provider === "anthropic" ? `${base}/setup-token-login-sessions` : `${base}/adapters/${adapter}/login-sessions`;
let starts = 0;
let cancels = 0;
let intent: Record<string, unknown> | undefined;
const session = () => ({ sessionId, environmentId, adapterType: adapter, status: provider === "anthropic" ? "awaiting_code" : "awaiting_user", expiresAt: new Date(Date.now() + 300000).toISOString(), aiConnection: intent, prompt: provider === "anthropic" ? { authorizationUrl: "https://provider.example/authorize" } : { url: "https://provider.example/authorize", code: "ABCD-EFGH" } });
await page.route(`**${base}/environments`, route => route.fulfill({ json: [{ id: environmentId, name: "Browser sign-in test sandbox", driver: "sandbox", status: "active", config: { provider: "browser-test" } }] }));
await page.route(`**${base}/environments/capabilities`, route => route.fulfill({ json: { sandboxProviders: { "browser-test": { supportsLoginPty: true } } } }));
await page.route(`**${sessions}**`, async route => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/active")) return route.fulfill(starts ? { json: session() } : { status: 404, json: { error: "Not found" } });
if (path.endsWith("/cancel")) { cancels++; return route.fulfill({ json: {} }); }
if (path.endsWith("/prompt")) return route.fulfill({ json: { authorizationUrl: "https://provider.example/authorize" } });
if (path === sessions && route.request().method() === "POST") {
starts++;
const payload = route.request().postDataJSON();
expect(payload.environmentId).toBe(environmentId);
expect(payload.aiConnection.provider).toBe(provider);
intent = payload.aiConnection;
}
return route.fulfill({ json: session() });
});
await page.addInitScript(() => { window.open = (url) => { (window as unknown as { loginDestination: string }).loginDestination = String(url); return null; }; });
await page.goto(`/${prefix}/apps/connect?source=${provider}&method=ai-subscription`);
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
await page.getByRole("radio", { name: new RegExp(label) }).click();
const signIn = page.getByRole("button", { name: `Sign in to ${label}`, exact: true });
await expect(signIn).toBeEnabled();
await expect(page.getByText(/login on this machine/)).toHaveCount(0);
if (provider === "anthropic") await expect(page.locator('input[type="password"]')).toBeVisible();
else await expect(page.getByText("ABCD-EFGH", { exact: true })).toBeVisible();
await signIn.click();
expect(await page.evaluate(() => (window as unknown as { loginDestination: string }).loginDestination)).toBe("https://provider.example/authorize");
await expect(page.getByRole("button", { name: "Waiting for code", exact: true })).toBeDisabled();
// The shared footer label animates; capture after it has settled.
await page.waitForTimeout(600);
await page.screenshot({ path: testInfo.outputPath(`${provider}-onboarding-browser-login.png`), fullPage: true });
if (provider === "anthropic") {
const submitted = page.waitForRequest(request => request.url().endsWith(`${sessionId}/code`) && request.method() === "POST");
await page.locator('input[type="password"]').fill("storybook-fixture-code");
await page.locator('input[type="password"]').press("Enter");
await submitted;
await expect(page.getByRole("button", { name: "Connecting", exact: true })).toBeDisabled();
}
await page.getByRole("button", { name: "Back", exact: true }).click();
await page.getByRole("radio", { name: new RegExp(label) }).click();
await expect(page.getByRole("button", { name: `Sign in to ${label}`, exact: true })).toBeEnabled();
expect(starts).toBe(1);
expect(cancels).toBe(0);
});
}

View File

@ -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 });
});

View File

@ -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",
},
});

View File

@ -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,
},
});

View File

@ -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 users 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");
});
}

View File

@ -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.

View File

@ -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 } });
});
}

View File

@ -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<string, unknown> | 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));

View File

@ -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.

View File

@ -223,6 +223,7 @@ export const agentsApi = {
type: string,
data: {
adapterConfig: Record<string, unknown>;
aiConnection?: import("@paperclipai/shared").AiConnectionBinding;
agentId?: string;
testCredentials?: Record<string, string>;
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<AdapterAuthSessionResponse>(
`/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<ClaudeSetupTokenSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`,

View File

@ -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<LocalAiLoginAttempt>(`/companies/${companyId}/ai-connections/local/attempts`, input),
checkLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<LocalAiLoginStatus>(`/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<Array<{ id: string; agentId: string; agentName: string; status: string }>>(`/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)}`),
};

View File

@ -49,6 +49,7 @@ export type AdapterLoginChrome = "panel" | "onboarding";
export const CONNECT_SOURCE_NAMES: Record<string, string> = {
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<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}, []);
useEffect(
() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
},
[],
);
return (
<Button
@ -224,9 +228,12 @@ export function OnboardingLoginCodeRow({
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoCopiedRef = useRef(false);
useEffect(() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}, []);
useEffect(
() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
},
[],
);
useEffect(() => {
// An empty code is not a code. The row renders before the server's one-time
@ -261,7 +268,10 @@ export function OnboardingLoginCodeRow({
// the code is readable, but the claim waits for the rest of the card
// to stop moving — see COPIED_REVEAL_DELAY_MS.
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(true), COPIED_REVEAL_DELAY_MS);
timeoutRef.current = setTimeout(
() => setCopied(true),
COPIED_REVEAL_DELAY_MS,
);
})
.catch(() => {
// Refused. The listener gives it another go when the document comes
@ -282,7 +292,9 @@ export function OnboardingLoginCodeRow({
return (
<div className="flex h-(--sz-44px) items-center gap-2 rounded-lg bg-muted px-4">
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{code}</span>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{code}
</span>
<AnimatePresence initial={false}>
{copied && (
<motion.span
@ -400,3 +412,84 @@ export function OnboardingCardField({
/>
);
}
/** Shared authentication presentation. Hosts retain their existing session lifecycle. */
export function ProviderSubscriptionCard({
providerName,
authorizationUrl,
mode,
loading,
children,
}: {
providerName: string;
authorizationUrl?: string;
mode: "submitted_code" | "displayed_code";
loading?: boolean;
children: ReactNode;
}) {
return (
<OnboardingLoginCard
loading={loading}
instruction={
<>
<a
href={authorizationUrl}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
Sign in to {providerName}
</a>
{mode === "submitted_code"
? " then come back and enter authorization code"
: " by providing the authorization code below"}
</>
}
>
{children}
</OnboardingLoginCard>
);
}
export function ProviderApiKeyCard({
providerName,
...field
}: Omit<Parameters<typeof OnboardingCardField>[0], "masked" | "label"> & {
providerName: string;
}) {
return (
<OnboardingLoginCard
instruction={`Provide your ${providerName} API key to connect`}
>
<OnboardingCardField {...field} label="API key" masked />
</OnboardingLoginCard>
);
}
/** Shared instructions for local subscription setup in every authentication host. */
export function LocalProviderLoginInstructions({ adapterType, login }: {
adapterType: string;
login?: { command?: string; preparing: boolean; status?: "ready" | "sign_in_required" | "expired" | null; error: string | null; retry: () => void };
}) {
const [showCommand, setShowCommand] = useState(false);
const provider = adapterType === "claude_local" ? "Claude Code" : adapterType === "grok_local" ? "Grok CLI" : "Codex CLI";
const isolated = adapterType === "codex_local" || adapterType === "grok_local";
const command = isolated ? login?.command : "claude auth login";
if (login?.preparing) return <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Checking local {provider} sign-in</p>;
const ready = login?.status === "ready";
return <div className="min-w-0 max-w-full space-y-3 text-sm text-muted-foreground">
{ready ? <>
<p role="status" className="flex items-center gap-2 text-foreground"><Check className="size-4 shrink-0 text-(--status-task-icon-done)" />{provider} is signed in. Click Connect to use this account.</p>
{!showCommand && <button type="button" className="underline underline-offset-4" onClick={() => setShowCommand(true)}>Use a different account</button>}
</> : <p>{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.`}</p>}
{(!ready || showCommand) && !login?.error && <>
<p>Run this in a terminal on that machine and finish signing in in your browser. Well check automatically when you return.</p>
{command && <div className="flex min-w-0 max-w-full items-start gap-2 rounded-md border bg-muted p-3 text-foreground">
<pre className="min-w-0 flex-1 whitespace-pre-wrap break-all font-mono text-xs"><code>{command}</code></pre>
<LoginCardCopyButton value={command} label="Copy sign-in command" />
</div>}
</>}
{login?.error && <p role="alert">{login.error}</p>}
{login && !login.preparing && (isolated || login.error) && <button type="button" className="underline underline-offset-4" onClick={login.retry}>{isolated ? "Start sign-in again" : "Check again"}</button>}
</div>;
}

View File

@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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) {
</Field>
)}
{!isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={adapterType === "paperclip_runner" ? eff("adapterConfig", "provider", config.provider) === "codex" ? "codex_local" : eff("adapterConfig", "provider", config.provider) === "opencode" ? "opencode_local" : eff("adapterConfig", "provider", config.provider) === "acpx" && eff("adapterConfig", "acpxAgent", config.acpxAgent) === "claude" ? "claude_local" : adapterType : adapterType}
value={aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | 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) && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{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<string | null>(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 (
<OnboardingLoginCard
<ProviderSubscriptionCard
loading={!prompt && !startError && !failed}
instruction={
<>
{/* 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. */}
<a
href={prompt?.url}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
Sign in to {connectSourceName(adapterType)}
</a>
{" by providing the authorization code below"}
</>
}
providerName={connectSourceName(adapterType)}
authorizationUrl={prompt?.url}
mode="displayed_code"
>
{startError ? (
<p role="alert" className="pl-2 text-xs text-destructive">
@ -2617,7 +2623,7 @@ function DisplayedCodeLoginPanel({
) : (
<OnboardingLoginCodeRow code={prompt?.code ?? ""} autoCopy />
)}
</OnboardingLoginCard>
</ProviderSubscriptionCard>
);
}
@ -2817,6 +2823,7 @@ function SubmittedBrowserCodeLoginPanel({
onCodeSubmitted,
onSubmitFailed,
chrome = "panel",
aiConnection,
onPromptReady,
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(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 (
<OnboardingLoginCard
<ProviderSubscriptionCard
loading={!authorizationUrl && !startError && !failedNow}
instruction={
<>
<a
href={authorizationUrl ?? undefined}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
Sign in to {connectSourceName(adapterType)}
</a>
{" 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}
/>
)}
</OnboardingLoginCard>
</ProviderSubscriptionCard>
);
}

View File

@ -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<typeof import("../api/auth")>();
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<string, unknown> };
};
// 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<string, unknown> } };
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 });

View File

@ -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<ReturnType<typeof storeProviderApiKey>>["binding"] } | null>(null);
const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding?: Awaited<ReturnType<typeof storeProviderApiKey>>["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<string, unknown>) }
@ -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 && (
<div className="mt-5">
<SavedProviderKeySelect
options={savedKeys.subscriptions}
@ -2681,6 +2703,7 @@ function OnboardingWizardInner({
adapterType={adapterType}
environmentId={resolvedLoginEnvironmentId}
chrome="onboarding"
aiConnection={managedProvider ? { provider: managedProvider, method: "subscription", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} subscription`, ownership: "personal", agentIds: [], allAgents: true } : undefined}
autoStart
onPromptReady={(url) => {
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 ? (
<p className="text-sm text-muted-foreground">Use your saved Claude subscription for this agent.</p>
) : 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. */
<p className="text-xs text-muted-foreground">
No managed sandbox is available to sign in against yet.
</p>
resolvedLoginEnvironment?.driver === "local" && managedProvider ? (
<LocalProviderLoginInstructions adapterType={adapterType} login={{ ...localLogin, retry: () => { setError(null); localLogin.retry(); } }} />
) : <p className="text-xs text-muted-foreground">This environment does not support browser sign-in. Choose another sign-in environment or connect with an API key.</p>
) : null}
</motion.div>

View File

@ -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 tasks connection or recovery action below."
: "Waiting for transcript...")
}
/>
</>

View File

@ -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<void>;
revocationDetails?: ReactNode;
}) {
const [revoking, setRevoking] = useState(false);
const [revokePending, setRevokePending] = useState(false);
const [revokeError, setRevokeError] = useState<string>();
const ownPersonal = account.ownership === "personal" && account.ownerUserId === currentUserId;
const available = account.status === "connected";
const activeDefault = account.isDefault && available;
return (
<section className="space-y-4" aria-label="AI account settings">
{ownPersonal && (
<div className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border p-3",
activeDefault && "border-(--status-task-done)/30 bg-(--status-task-done)/5",
)}>
<div className="flex items-center gap-3">
<Star aria-hidden className={cn("size-5 shrink-0", activeDefault ? "fill-current text-(--status-task-icon-done)" : "text-muted-foreground")} />
<div>
<h3 className="text-sm font-semibold">Personal default</h3>
<p className="text-xs text-muted-foreground">{aiMethodLabel(account.provider, account.method)}</p>
</div>
</div>
{account.isDefault ? (
<span role="status" className={cn("inline-flex items-center gap-1.5 text-sm font-medium", available ? "text-(--status-task-icon-done)" : "text-destructive")}>
{available ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />}
{available ? "Your default" : "Default unavailable"}
</span>
) : !readOnly ? (
<Button variant="outline" size="sm" disabled={!available} onClick={onMakeDefault}>Make default</Button>
) : <span className="text-xs text-muted-foreground">Not your default</span>}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 text-sm">
<p className="font-medium">{aiMethodLabel(account.provider, account.method)}</p>
{account.accountLabel && <p className="break-words text-xs text-muted-foreground">{account.accountLabel}</p>}
</div>
{!readOnly && grant.capabilities?.canRevoke && (
<div className="flex flex-wrap items-center gap-2">
{<Button variant="outline" size="sm" onClick={onReconnect}><RefreshCw className="size-4" aria-hidden />Reconnect</Button>}
{account.status !== "revoked" && <Button variant="ghost" size="sm" className="text-destructive" onClick={() => setRevoking(true)}><Unplug className="size-4" aria-hidden />Revoke identity</Button>}
</div>
)}
</div>
{revoking && <RevokeGrantDialog grant={grant} providerName={account.name} pending={revokePending} credentialPolicy={account.ownership === "shared" ? "shared" : "per_user"} isOwnIdentity={account.ownerUserId === currentUserId}
description="New runs using this account will be blocked. Existing runs may retain credentials already issued to them. No other account will be selected automatically."
onCancel={() => 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 && <p role="alert" className="text-sm text-destructive">{revokeError}</p>}{revocationDetails}</RevokeGrantDialog>}
</section>
);
}

View File

@ -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<AiConnectionAuthProps> = {}) {
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<AiConnectionAuthProps>) =>
flushSync(() => root!.render(<AiConnectionAuth {...props} {...next} />));
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();
});
});

View File

@ -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 (
<AuthAttempt
key={`${props.provider}:${props.method}:${props.state.phase}`}
{...props}
/>
);
}
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 (
<section
aria-label={`Connect ${info.name}`}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold">Connect {info.name}</h3>
<p className="text-xs text-muted-foreground">
{aiMethodLabel(provider, method)}
</p>
</div>
{state.phase === "connected" ? (
<>
<p role="status" className="text-sm">
Connected. This account is saved in Connections and can be reused.
</p>
<Button onClick={onDone}>Use connection</Button>
</>
) : (
<>
{unsupported ? (
<p role="status" className="text-sm text-muted-foreground">
{state.phase === "unsupported"
? state.message
: "This provider does not offer a subscription connection."}
</p>
) : (
<>
{(state.phase === "error" || state.phase === "expired") && (
<p role="alert" className="text-sm text-destructive">
{state.message}
</p>
)}
{state.phase === "cancelled" && (
<p role="status" className="text-sm text-muted-foreground">
Sign-in cancelled. No connection was created.
</p>
)}
{method === "api_key" ? (
<ProviderApiKeyCard
providerName={info.name}
value={value}
onChange={setValue}
onSubmit={submit}
placeholder="Enter API key here"
disabled={busy}
autoFocus
/>
) : busy ? (
<ProviderSubscriptionCard
providerName={info.name}
mode={
provider === "anthropic"
? "submitted_code"
: "displayed_code"
}
loading
>
<span />
</ProviderSubscriptionCard>
) : state.phase === "waiting" ? (
<ProviderSubscriptionCard
providerName={info.name}
authorizationUrl={state.authorizationUrl}
mode={
provider === "anthropic"
? "submitted_code"
: "displayed_code"
}
>
{provider === "anthropic" ? (
<OnboardingCardField
value={value}
onChange={setValue}
onSubmit={submit}
/>
) : (
<OnboardingLoginCodeRow code={state.code ?? ""} />
)}
</ProviderSubscriptionCard>
) : (
<p className="text-sm text-muted-foreground">
Sign in with your {info.subscriptionName}.
</p>
)}
</>
)}
<div className="flex flex-wrap justify-between gap-2">
<Button
variant="ghost"
onClick={() => {
setValue("");
onCancel();
}}
>
Cancel
</Button>
{!unsupported &&
(method === "api_key" ? (
<Button disabled={busy || !value.trim()} onClick={submit}>
{busy ? "Connecting…" : "Connect"}
</Button>
) : state.phase === "waiting" ? (
provider === "anthropic" ? (
<Button disabled={!value.trim()} onClick={submit}>
Submit code
</Button>
) : (
<span role="status" className="text-sm text-muted-foreground">
Waiting for sign-in
</span>
)
) : (
<Button disabled={busy} onClick={onStart}>
{busy
? "Preparing sign-in…"
: state.phase === "idle"
? "Sign in"
: "Try again"}
</Button>
))}
</div>
</>
)}
</section>
);
}

View File

@ -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 <ApiKeyConnectionStep {...props} />;
return <SubscriptionConnectionStep {...props} />;
}
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<string>();
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 <div className="mx-auto w-full min-w-0 max-w-xl space-y-6">
<label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>
{!suppliedEnvironmentId && !forced.forced && loginEnvironments.length > 1 && <Select value={environmentId ?? ""} onValueChange={setChosenEnvironment}>
<SelectTrigger aria-label="Sign-in environment"><SelectValue placeholder="Sign-in environment" /></SelectTrigger>
<SelectContent>{loginEnvironments.map((env) => <SelectItem key={env.id} value={env.id}>{env.name}</SelectItem>)}</SelectContent>
</Select>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{loading ? <p role="status" className="text-sm text-muted-foreground">Preparing sign-in</p> : <AgentProviderConnection
key={environmentId ?? "local"}
companyId={companyId}
adapterType={provider === "anthropic" ? "claude_local" : provider === "xai" ? "grok_local" : "codex_local"}
environmentId={environmentId}
canLogin={canLogin}
localEnvironment={environment?.driver === "local"}
onBack={onCancel}
onConnected={() => {}}
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); } }}
/>}
</div>;
}
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 <div className="mx-auto w-full min-w-0 max-w-xl space-y-4">
<label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(connectionId)} /></label>
{save.error && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
<ProviderApiKeyCard providerName="OpenRouter" value={apiKey} onChange={setApiKey} onSubmit={() => save.mutate()} disabled={save.isPending} placeholder="Enter API key here" autoFocus />
<div className="flex justify-between gap-2"><Button variant="ghost" onClick={onCancel}>Cancel</Button><Button disabled={!name.trim() || !apiKey.trim() || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Connecting…" : "Connect"}</Button></div>
</div>;
}

View File

@ -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<AiConnectionBinding>({
provider: "anthropic",
method: "subscription",
mode: "responsible_user",
});
return (
<div className="flex max-w-2xl flex-col gap-5">
<p className="text-sm text-muted-foreground">
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.
</p>
<p className="text-sm text-muted-foreground">Provider lists and account management use Browse and AppDetail from the Connectors interface. The picker below uses ConnectionChoiceList, also used by ConnectionSetupFlow.</p>
<AiConnectionPicker
requirement={requirement}
connections={[account]}
value={binding}
currentUserId="example-user"
agentId="example-agent"
agentName="Nova"
onChange={setBinding}
readOnly
onConnect={() => {}}
/>
<ProviderApiKeyCard
providerName="OpenAI"
value=""
disabled
onChange={() => {}}
onSubmit={() => {}}
placeholder="Enter API key here"
/>
</div>
);
}

View File

@ -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<string, AiProvider>
)[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<HTMLElement | null>(null);
const restoreFocus = (event: Event) => { event.preventDefault(); returnFocus.current?.focus(); };
const [adopting, setAdopting] = useState(false);
const [pendingAdoption, setPendingAdoption] = useState<AiConnectionBinding>();
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 (
<AiConnectionLegacyNotice
readOnly={readOnly}
onAdopt={() => setAdopting(true)}
/>
);
return (
<div className="space-y-4">
{value && (adapterType !== "opencode_local" || Boolean(model)) && !isAiConnectionCompatible(value, adapterType, model) && (
<p role="alert" className="text-sm text-destructive">
This connection does not support the current harness and model. Choose
a compatible connection before saving.
</p>
)}
<AiConnectionPicker
requirement={{ companyId, provider, method }}
connections={accounts.data?.connections ?? []}
value={value}
currentUserId={accounts.data?.currentUserId ?? ""}
agentId={agentId ?? ""}
agentName={agentName}
readOnly={readOnly}
loading={accounts.isPending}
error={accounts.error?.message}
onChange={(binding) =>
changeBinding(aiConnectionBindingSchema.parse(binding))
}
onConnect={() => { returnFocus.current = document.activeElement as HTMLElement; setConnecting(true); }}
onRetry={() => void accounts.refetch()}
/>
<Dialog
open={Boolean(pendingAdoption)}
onOpenChange={(open) => {
if (!open) setPendingAdoption(undefined);
}}
>
<DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-2xl" onCloseAutoFocus={restoreFocus}>
<DialogHeader>
<DialogTitle>Adopt Connections for {agentName}</DialogTitle>
<DialogDescription>
Saving tests this account in {agentName}s environment before
replacing its existing authentication. Other agents keep their
current configuration.
</DialogDescription>
</DialogHeader>
<p className="text-sm">
{pendingAdoption?.mode === "responsible_user"
? `Responsible users 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}
</p>
<p className="text-xs text-muted-foreground">
After adoption, missing credentials block execution. Previous
authentication will not be used as a fallback.
</p>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setPendingAdoption(undefined)}
>
Cancel
</Button>
<Button
onClick={() => {
if (pendingAdoption) onChange(pendingAdoption);
setPendingAdoption(undefined);
}}
>
Use this binding when saved
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={connecting} onOpenChange={setConnecting}>
<DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-2xl" onCloseAutoFocus={restoreFocus}>
<DialogHeader>
<DialogTitle>Connect account</DialogTitle>
</DialogHeader>
<AiConnectionCredentialStep
companyId={companyId}
provider={provider}
initialMethod={method}
name={`My ${provider === "anthropic" ? "Claude" : provider === "openai" ? "OpenAI" : provider === "xai" ? "Grok" : "OpenRouter"} ${method === "subscription" ? "subscription" : "API"}`}
ownership="personal"
agentIds={agentId ? [agentId] : []}
allAgents={false}
environmentId={environmentId}
onCancel={() => setConnecting(false)}
onComplete={({ method: connectedMethod }) => {
void client.invalidateQueries({
queryKey: ["ai-connections", companyId],
});
setConnecting(false);
changeBinding({ provider, method: connectedMethod, mode: "responsible_user" });
}}
/>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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 (
<div className="flex min-w-0 items-start gap-3">
<AppLogo name={provider.name} brandKey={connection.provider} logoUrl={provider.logo} size={24} />
<div className="flex min-w-0 flex-col gap-1">
<span className="break-words text-sm font-medium">
{connection.name}
</span>
<span className="text-xs text-muted-foreground">
{provider.name} ·{" "}
{aiMethodLabel(connection.provider, connection.method)}
{connection.accountLabel ? ` · ${connection.accountLabel}` : ""}
</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Icon aria-hidden className="size-3" />
{connection.ownership === "shared"
? "Company shared"
: `Personal · ${connection.ownerName ?? "Account owner"}`}
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { Button } from "@/components/ui/button";
export function AiConnectionLegacyNotice({
onAdopt,
readOnly = false,
}: {
onAdopt: () => void;
readOnly?: boolean;
}) {
return (
<div className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-semibold">
Existing authentication not managed by Connections
</h3>
<p className="text-sm text-muted-foreground">
This agent keeps its current authentication until you choose and test a
managed connection. Confirm the account and who may use it before
adopting.
</p>
{!readOnly && (
<Button variant="outline" className="self-start" onClick={onAdopt}>
Choose a managed connection
</Button>
)}
</div>
);
}

View File

@ -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 (
<section className="flex flex-col gap-4" aria-label="AI connection">
<div className="flex items-center gap-3">
<AppLogo
name={AI_PROVIDERS[requirement.provider].name}
brandKey={requirement.provider}
logoUrl={AI_PROVIDERS[requirement.provider].logo}
darkLogoUrl={requirement.provider === "xai" ? "/brands/adapters/grok-dark.svg" : undefined}
size={32}
/>
<div className="flex min-w-0 flex-col gap-1">
<h3 className="text-sm font-semibold">AI connection</h3>
<p className="text-xs text-muted-foreground">
{AI_PROVIDERS[requirement.provider].name} ·{" "}
{aiMethodLabel(requirement.provider, requirement.method)}
</p>
</div>
</div>
{loading ? (
<div role="status" aria-label="Loading AI connections">
<Skeleton className="h-24 w-full" />
</div>
) : error ? (
<div className="flex flex-col gap-2">
<p role="alert" className="text-sm text-destructive">
{error}
</p>
{onRetry && (
<Button type="button" variant="outline" onClick={onRetry}>
Retry connections
</Button>
)}
</div>
) : (
<>
<ConnectionChoiceList
disabled={readOnly}
selectedId={value?.mode === "responsible_user" ? "responsible_user" : value?.connectionId}
choices={[
{ id: "responsible_user", name: "Responsible users connection", description: <>
<span className="block">For you: {personalDefault?.name ?? "Not connected"}</span>
<span className="block">Other users tasks use their own {requirement.method === "api_key" ? `${AI_PROVIDERS[requirement.provider].name} API key` : aiMethodLabel(requirement.provider, requirement.method)}.</span>
</> },
...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 && (
<p role="status" className="text-sm text-destructive">
{problem}
</p>
)}
{!readOnly && (
<Button
type="button"
variant="outline"
className="self-end"
onClick={onConnect}
>
Connect another account
</Button>
)}
</>
)}
</section>
);
}

View File

@ -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 (
<p className="text-xs text-muted-foreground">
{aiMethodLabel(metadata.provider, metadata.method)} ·{" "}
{connection.credentialPolicy === "per_user"
? "Personal"
: "Company shared"}
</p>
);
}
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 (
<p role="alert" className="text-sm text-destructive">
{error.message}
</p>
);
if (!account || !grant)
return (
<p role="status" className="text-sm text-muted-foreground">
{accounts.isPending || grants.isPending
? "Loading AI account…"
: "This account is not available to you."}
</p>
);
return (
<div className="space-y-4">
<AiConnectionAccountControls
account={account}
grant={grant}
currentUserId={accounts.data!.currentUserId}
onMakeDefault={() => makeDefault.mutate(grant.id)}
onRevoke={() => revoke.mutateAsync(grant.id).then(() => undefined)}
revocationDetails={
<div className="space-y-2">
{runs.error && (
<p role="alert">
Could not load active runs. Retry before revoking.
</p>
)}
{runs.data?.map((run) => (
<div
key={run.id}
className="flex items-center justify-between gap-3 text-sm"
>
<span>
{run.agentName} · {run.status}
</span>
<Button
variant="outline"
size="sm"
disabled={stop.isPending}
onClick={() => stop.mutate(run.id)}
>
Stop run
</Button>
</div>
))}
</div>
}
onReconnect={() =>
navigate(
`/apps/connect?source=${account.provider}&reconnect=${connection.id}&method=ai-${account.method}`,
)
}
/>
</div>
);
}

View File

@ -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 users 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");
});
});

View File

@ -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<AiManagedConnectionSummary, "isDefault"> & { isDefault?: boolean };
export interface AiConnectionRequirement {
companyId: string;
provider: AiProvider;
method: AiAuthMethod;
}
export const AI_CONNECTION_STATUS: Record<AiConnectionStatus, string> = {
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);
}

View File

@ -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<typeof createRoot>;
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 <><LocalProviderLoginInstructions adapterType={provider === "anthropic" ? "claude_local" : "codex_local"} login={login} /><button onClick={() => void login.connect()}>Connect</button></>;
}
it("checks once under StrictMode, preserves renaming and navigation, and cancels only on explicit retry", async () => {
flushSync(() => root.render(<StrictMode><Harness name="First name" /></StrictMode>));
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(<StrictMode><Harness name="Renamed" /></StrictMode>));
expect(api.startLocalLogin).toHaveBeenCalledTimes(1);
flushSync(() => root.render(<StrictMode><Harness name="Renamed" enabled={false} /></StrictMode>));
flushSync(() => root.render(<StrictMode><Harness name="Renamed" /></StrictMode>));
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(<Harness provider={provider} />));
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(<Harness />));
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(<Harness />));
await vi.waitFor(() => expect(host.textContent).toContain("isolated codex login"));
flushSync(() => root.render(<div>Another page</div>));
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(<Harness />));
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 }));
});

View File

@ -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<LocalAiLoginAttempt | null>(null);
const [status, setStatus] = useState<LocalAiLoginStatus["status"] | null>(null);
const [error, setError] = useState<string | null>(null);
const [generation, setGeneration] = useState(0);
const latestIntent = useRef(intent);
const restartRequested = useRef(false);
const pending = useRef<Promise<unknown>>(Promise.resolve());
const current = useRef<{ key: string; companyId: string; request: Promise<LocalAiLoginAttempt> } | 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<typeof setTimeout> | 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 } : {}) });
},
};
}

View File

@ -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: () => <div>New subscription login</div>,
AdapterLoginPanel: (props: unknown) => { mocks.loginPanel(props); return <div>New subscription login</div>; },
}));
let root: Root;
let host: HTMLDivElement;
@ -39,6 +50,8 @@ async function mount(
codexSubscriptions = false,
savedApiKeys = true,
cachedClaudeLogin = false,
managedAccount?: Parameters<typeof AgentProviderConnection>[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}
/>
</QueryClientProvider>,
),
);
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");
});
});

View File

@ -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<string, EnvBinding>;
aiConnection?: AiConnectionBinding;
/** Kept in memory until the user finishes setup. */
credentials?: Record<string, string>;
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<boolean>;
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<string | null>(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<string | null>(null);
const [storedConnection, setStoredConnection] =
useState<ProviderConnection | null>(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<string | null>(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 (
<div>
<div className="min-w-0 max-w-full">
<ModelSourceTiles
label="Connect your model provider"
sources={[
@ -165,7 +223,7 @@ export function AgentProviderConnection({
label: provider,
icon: (
<img
src={`/brands/${adapterType === "claude_local" ? "claude" : "codex"}-color.svg`}
src={adapterType === "grok_local" ? "/brands/adapters/grok.svg" : `/brands/${adapterType === "claude_local" ? "claude" : "codex"}-color.svg`}
className="size-6"
alt=""
/>
@ -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 && (
<div className="-ml-3 mt-1">
<CredentialModeLink
mode={method}
onChange={(next) => {
savedManagedAccount.current = null;
setMethod(next);
setError(null);
}}
@ -195,7 +254,6 @@ export function AgentProviderConnection({
</p>
)}
{method === "subscription" &&
adapterType === "codex_local" &&
savedKeys.subscriptions.length > 0 && (
<SavedProviderKeySelect
options={savedKeys.subscriptions}
@ -261,26 +319,48 @@ export function AgentProviderConnection({
adapterType={adapterType}
environmentId={environmentId}
chrome="onboarding"
aiConnection={managedAccount?.intent ?? { provider: aiProvider, method: "subscription", name: `My ${provider} subscription`, ownership: "personal", agentIds: [], allAgents: true }}
autoStart
onStored={(storedSessionId) => {
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 ? (
<LocalProviderLoginInstructions adapterType={adapterType} login={{ ...localLogin, retry: () => { setError(null); localLogin.retry(); } }} />
) : (
<p className="text-sm text-muted-foreground">
{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 havent 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."}
</p>
)}
</div>
@ -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();
}}
/>
</div>
);

View File

@ -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<EnvBinding | null>(
null,
);
const [runtimeAiBinding, setRuntimeAiBinding] = useState<AiConnectionBinding | undefined>(() =>
brandType === "opencode_local"
? { provider: "openrouter", method: "api_key", mode: "responsible_user" }
: undefined,
);
const [connection, setConnection] = useState<ProviderConnection | null>(null);
const aiBinding = runtimeAiBinding ?? connection?.aiConnection;
const [repository, setRepository] = useState("");
const [branch, setBranch] = useState("");
const [createdInSession, setCreated] = useState<Agent | null>(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({
<div className="mb-8">
<OnboardingHeading
title="Connect a model"
lede={`Connect ${name} to ${connectionAdapter === "claude_local" ? "Claude" : "OpenAI"}.`}
lede={`Connect ${name} to ${connectionAdapter === "claude_local" ? "Claude" : connectionAdapter === "grok_local" ? "Grok" : "OpenAI"}.`}
center
/>
</div>
@ -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({
<p className="text-sm text-muted-foreground">
{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 youre ready for this agent to work."}
</p>
</div>
<div className="flex flex-wrap justify-between gap-3">
@ -797,6 +808,9 @@ function Setup({
<fieldset disabled={busy} className="space-y-8">
<section className="space-y-5">
<h3 className="text-sm font-semibold">Runtime</h3>
{aiProviderForAdapter(brandType) && <AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding}
onChange={binding => { setRuntimeAiBinding(binding); resetTest(); }} />}
{models.error && <p role="alert" className="text-sm text-destructive">Could not load models. Retry or enter a model ID manually.</p>}
{((showModel && !usingKimiApi) ||
efforts.length > 0) && (
<div className="grid items-start gap-5 sm:grid-cols-2">
@ -866,7 +880,7 @@ function Setup({
manually.
</p>
)}
{hasCredentialField && (
{hasCredentialField && !aiBinding && (
<div className="grid gap-5 sm:grid-cols-2">
{chooseProvider && (
<Field label="API key provider">

View File

@ -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<string, AiProvider>)[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,
};
}

View File

@ -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[] = [];

View File

@ -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) {

View File

@ -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 <div className="space-y-2">
{choices.map((choice) => <button
key={choice.id}
type="button"
className="flex w-full items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
aria-label={choice.name}
aria-pressed={selectedId === undefined ? undefined : selectedId === choice.id}
disabled={disabled || Boolean(pendingId) || choice.disabled}
onClick={() => onSelect(choice.id)}
>
<span className="min-w-0">
<span className="block font-medium text-foreground">{choice.name}</span>
<span className="mt-1 block text-xs text-muted-foreground">{choice.description}</span>
</span>
{pendingId === choice.id ? <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
: selectedId === choice.id ? <Check className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
</button>)}
</div>;
}

View File

@ -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 <div data-testid="shared-ai-credentials">
<span>{props.name}</span><span>{String(props.fixedMethod)}</span>
<button onClick={() => props.onComplete({ connectionId: props.connectionId!, grantId: "grant", method: "api_key" })}>Reconnect selected account</button>
<button onClick={props.onCancel}>Cancel repair</button>
</div>; },
}));
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");
});
});

View File

@ -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<HTMLDivElement>(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 ? (
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
connection options
</div>
) : setupQuery.isError ? (
<div className="py-8 text-center">
<p className="font-medium text-foreground">
Couldnt load connection setup
</p>
<p className="mt-1 text-sm text-muted-foreground">
{setupQuery.error instanceof Error
? setupQuery.error.message
: "Try again."}
</p>
<Button
className="mt-4"
variant="outline"
onClick={() => setupQuery.refetch()}
>
Try again
</Button>
</div>
) : setupProps ? (
renderSetup ? renderSetup(setupProps) : <ConnectionSetupFlow {...setupProps} />
) : null;
const inlineContent = setupQuery.isLoading || setupQuery.isError ? setupContent
: selectedReady ? <div className="space-y-3">
<p className="text-sm">{repair.connection.name} is ready.</p>
<Button disabled={completeMutation.isPending} onClick={() => completeMutation.mutate(repair.connection.id)}>
{completeMutation.isPending ? "Continuing…" : "Continue task"}
</Button>
</div>
: repair ? repair.canReconnect ? <AiConnectionCredentialStep
companyId={interaction.companyId}
provider={repair.connection.provider}
initialMethod={repair.connection.method}
fixedMethod
connectionId={repair.connection.id}
name={repair.connection.name}
ownership={repair.connection.ownership}
agentIds={[interaction.payload.requestingAgentId]}
allAgents={false}
onComplete={(result) => { void finishNewConnection(result); }}
onCancel={() => { closeSetup(); returnFocusToCard(); }}
/> : <p role="status" className="text-sm text-muted-foreground">
{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.
</p>
: setupQuery.data?.aiConnection && setupQuery.data.aiConnection.mode !== "responsible_user"
? <p role="status" className="text-sm text-muted-foreground">The selected account is no longer available to you. Ask its owner to restore access, or choose an available AI connection in the agents settings.</p>
: setupContent;
return (
<div
id={focusTargetId}
@ -241,12 +328,12 @@ export function ConnectionIntentInteractionBody({
/>
<div>
<p className="font-medium text-foreground">
{interaction.payload.requestingAgentName} needs{" "}
{interaction.payload.serviceName}
{isAi ? "AI connection needs attention" : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Connect your identity or reuse an eligible connection. Access is
added only for this agent.
{interaction.payload.purpose === "ai"
? "Restore the agents selected AI account, then continue this task."
: "Connect your identity or reuse an eligible connection. Access is added only for this agent."}
</p>
</div>
</div>
@ -260,15 +347,17 @@ export function ConnectionIntentInteractionBody({
) : null}
<div className="mt-4 flex flex-wrap justify-end gap-2">
<Button
{!isAi && <Button
type="button"
variant="ghost"
disabled={declineMutation.isPending || authorizing}
disabled={declineMutation.isPending || completeMutation.isPending || authorizing}
onClick={() => declineMutation.mutate()}
>
Not now
</Button>
<Dialog open={open} onOpenChange={setOpen}>
</Button>}
{isAi ? <Button type="button" disabled={completeMutation.isPending} onClick={() => open ? closeSetup() : setOpen(true)}>
<Plug className="h-4 w-4" />{open ? "Close setup" : "Fix connection"}
</Button> : <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button">
{authorizing ? (
@ -298,51 +387,11 @@ export function ConnectionIntentInteractionBody({
Complete connection setup without leaving this task.
</DialogDescription>
</DialogHeader>
{setupQuery.isLoading ? (
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
connection options
</div>
) : setupQuery.isError ? (
<div className="py-8 text-center">
<p className="font-medium text-foreground">
Couldnt load connection setup
</p>
<p className="mt-1 text-sm text-muted-foreground">
{setupQuery.error instanceof Error
? setupQuery.error.message
: "Try again."}
</p>
<Button
className="mt-4"
variant="outline"
onClick={() => setupQuery.refetch()}
>
Try again
</Button>
</div>
) : setupQuery.data ? (
<ConnectionSetupFlow
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}
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={() => setOpen(false)}
/>
) : null}
{setupContent}
</DialogContent>
</Dialog>
</Dialog>}
</div>
{isAi && open ? <div className="mt-4 border-t border-border pt-4" data-testid="ai-connection-inline-repair">{inlineContent}</div> : null}
{completeMutation.isError ||
declineMutation.isError ||

View File

@ -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<Record<string, string | boolean>>({});
const [googleSheetsLinks, setGoogleSheetsLinks] = useState("");
const [googleSheetsError, setGoogleSheetsError] = useState<string | null>(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<ReturnType<typeof toolsApi.listGallery>>) => 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.
</p>
</div>
<div className="space-y-2">
{existingConnections.map((connection) => (
<button
key={connection.id}
type="button"
className="flex w-full items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
disabled={existingConnectionPendingId !== null}
onClick={async () => {
setExistingConnectionPendingId(connection.id);
setExistingConnectionError(null);
try {
await onUseExisting(connection.id);
} catch (error) {
setExistingConnectionError(error instanceof Error ? error.message : "Couldnt use this connection.");
setExistingConnectionPendingId(null);
}
}}
>
<span>
<span className="block font-medium text-foreground">{connection.name}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{connection.status === "active" && connection.enabled ? "Ready to use" : "Setup needs attention"}
</span>
</span>
{existingConnectionPendingId === connection.id ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
))}
</div>
<ConnectionChoiceList
choices={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 : "Couldnt use this connection.");
setExistingConnectionPendingId(null);
}
}}
/>
{existingConnectionError ? (
<InlineBanner tone="danger" className="mt-4">{existingConnectionError}</InlineBanner>
) : 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 ? <><AiConnectionCredentialStep
companyId={selectedCompanyId} provider={aiMethod.provider} fixedMethod={Boolean(aiConnection)} initialMethod={reconnectConnection?.connectionPurpose === "ai" ? (reconnectConnection.config?.ai as { method: "subscription" | "api_key" }).method : aiMethod.method}
connectionId={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.id : undefined}
name={reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.name : galleryName || `My ${entry.name} ${aiMethod.method === "subscription" ? "subscription" : "API"}`}
ownership={(reconnectConnection?.connectionPurpose === "ai" ? reconnectConnection.credentialPolicy === "shared" : effectiveGrantKind === "organization") ? "shared" : "personal"}
agentIds={[...installAgentIds]} allAgents={installChoice === "all"}
onCancel={() => 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({
</div>
</div>
</div>
) : step === "key" && entry ? (
) : step === "key" && entry && credentialStep !== undefined ? credentialStep : step === "key" && entry ? (
<KeyStep
entry={entry}
error={connectMutation.isError ? (connectMutation.error instanceof Error ? connectMutation.error.message : "Please check your key and try again.") : null}
@ -2347,7 +2375,7 @@ export function ConnectionSetupFlow({
continuesToProvider={accessContinuesToProvider}
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
pending={connectMutation.isPending || oauthStartMutation.isPending}
pending={connectMutation.isPending || oauthStartMutation.isPending || Boolean(requestedAppKey && !entry)}
onBack={backToGallery}
onContinue={() => {
if (directOAuthEntry) {

View File

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

View File

@ -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,

View File

@ -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<SavedProviderKey>((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. */

View File

@ -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<string, unknown>;
agentId?: string;
aiConnection?: import("@paperclipai/shared").AiConnectionBinding;
testCredentials?: Record<string, string>;
environmentId: string | null;
}): Promise<AdapterEnvironmentTestResult> {
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,
};

View File

@ -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() {
</div>
</Section>
<Section title="AI Connections">
<AiConnectionDesignExamples />
</Section>
<Section title="Built-in Agent Lifecycle Chips">
<p className="text-sm text-muted-foreground">
A derived lifecycle chip (amber) for attention states. The lifecycle chip is separate from

View File

@ -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) => {

View File

@ -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());

View File

@ -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<string>;
askFirst: Set<string>;
access: AccessDraft;
reviewed?: Set<string>;
}) =>
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" ? <ManagedAiConnectionDetails connection={connection} /> : 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 })}
/>
<PermissionsPanel
actions={actionsContent}
connectionId={connectionId}
capabilities={grantsQuery.data?.capabilities}
appName={appName}
agents={agents}
access={access}
install={install}
install={connection.connectionPurpose === "ai" ? installStateFrom([]) : install}
readOnly={readOnly}
canChange={canChange}
quarantined={quarantined}
@ -620,7 +632,7 @@ export function AppDetail() {
? "Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls."
: undefined
}
onSaveAccess={(next) => 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" };

View File

@ -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<HTMLInputElement>('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] });

View File

@ -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 cant 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" ? <ManagedAiConnectionRow connection={connection} /> : null }: { renderAccountDetails?: (connection: ToolConnection) => ReactNode } = {}) {
const navigate = useNavigate();
const preselectedChatAgentId =
typeof window === "undefined"
@ -676,6 +678,7 @@ export function Browse() {
<div className="space-y-3" role="list" aria-label="Connector list">
{visibleRows.map((row) => (
<ConnectorCard
renderAccountDetails={renderAccountDetails}
key={row.key}
row={row}
allConnections={connectionsQuery.data?.connections ?? []}
@ -740,6 +743,7 @@ export function Browse() {
}
export function ConnectorCard({
renderAccountDetails,
row,
allConnections,
userProfileById,
@ -748,6 +752,7 @@ export function ConnectorCard({
preselectedAgentId,
chatConnectorsEnabled,
}: {
renderAccountDetails?: (connection: ToolConnection) => ReactNode;
row: ConnectorRowModel;
allConnections: ToolConnection[];
userProfileById: ReadonlyMap<string, ConnectionOwnerProfile>;
@ -805,6 +810,7 @@ export function ConnectorCard({
<div className="divide-y divide-border border-t border-border">
{row.connections.map((connection) => (
<ConnectionAccountRow
details={renderAccountDetails?.(connection)}
key={connection.id}
row={row}
connection={connection}
@ -885,12 +891,14 @@ export function ConnectorCard({
}
function ConnectionAccountRow({
details,
row,
connection,
owner,
onNavigate,
onRemove,
}: {
details?: ReactNode;
row: ConnectorRowModel;
connection: ToolConnection;
owner: ConnectionOwnerProfile | null;
@ -918,6 +926,7 @@ function ConnectionAccountRow({
>
{accountName}
</button>
{details}
{state.message ? (
<div
className={

View File

@ -13,7 +13,7 @@ export function appSupportsToolCatalogSetup(entry: AppDefinition | null | undefi
entry && appSupportsCatalogSetup({
...entry,
methods: entry.methods.filter(
(method) => (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

View File

@ -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({
<p className="text-sm text-amber-800 dark:text-amber-200">
{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}
</p>
) : onReconnect ? (
<Button size="sm" variant="outline" onClick={onReconnect}>Reconnect</Button>
) : managedByVercel && !oauth ? (
<div className="flex items-center gap-2">
<Button type="button" size="sm" variant="outline" asChild>

View File

@ -591,6 +591,8 @@ export function RevokeGrantDialog({
pending,
isOwnIdentity,
credentialPolicy,
description,
children,
onCancel,
onConfirm,
}: {
@ -599,6 +601,8 @@ export function RevokeGrantDialog({
pending: boolean;
isOwnIdentity: boolean;
credentialPolicy: ToolConnectionCredentialPolicy;
description?: string;
children?: ReactNode;
onCancel: () => void;
onConfirm: () => void;
}) {
@ -621,8 +625,9 @@ export function RevokeGrantDialog({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{body}</AlertDialogDescription>
<AlertDialogDescription>{description ?? body}</AlertDialogDescription>
</AlertDialogHeader>
{children}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending} autoFocus>
Cancel

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Ban, Check, FlaskConical, Loader2, RefreshCw, Search, ShieldQuestion } from "lucide-react";
import type { Agent, ToolCatalogEntry, ToolConnectionCapabilities } from "@paperclipai/shared";
import { useSearchParams } from "@/lib/router";
@ -37,6 +37,7 @@ export function PermissionsPanel({
refreshPending,
capabilities,
permissionChangeWarning,
actions,
}: Pick<
AppDetailSectionProps,
| "appName"
@ -58,6 +59,8 @@ export function PermissionsPanel({
refreshPending: boolean;
capabilities: ToolConnectionCapabilities | undefined;
permissionChangeWarning?: string;
/** A credential-only connection can supply its account controls instead of tool actions. */
actions?: ReactNode;
}) {
const [searchParams] = useSearchParams();
return (
@ -70,7 +73,7 @@ export function PermissionsPanel({
disabled={pending}
onSave={onSaveAccess}
/>
<ActionsSection
{actions !== undefined ? actions : <ActionsSection
key={connectionId}
connectionId={connectionId}
appName={appName}
@ -87,7 +90,7 @@ export function PermissionsPanel({
onSetPermission={onSetActionPermission}
onReviewQuarantined={onReviewQuarantined}
onRefreshActions={onRefreshActions}
/>
/>}
</div>
);
}

View File

@ -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" },

View File

@ -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: "Sams 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",
},
];

View File

@ -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 <AiConnectorPages initialConnections={props.initialConnections} detail={props.initialStage === "manage"} readOnly={props.readOnly} />;
return <AgentConnectionReview {...props} />;
}
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<string>(
host === "connections" && initialStage === "picker" ? "list" : initialStage,
);
const [auth, setAuth] = useState<AiAuthState>(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<HTMLElement | null>(null);
const returnFocusLabel = useRef<string | null>(null);
const region = useRef<HTMLDivElement>(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<HTMLButtonElement>("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 (
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6" ref={region}>
<aside
className="flex flex-col gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground"
aria-label="Storybook simulator"
>
<span>
Storybook-only controls · These simulate provider responses and do not appear in the app.
</span>
{stage === "auth" &&
auth.phase === "waiting" &&
requirement.provider !== "anthropic" && (
<Button
size="sm"
variant="outline"
className="self-start"
onClick={connected}
>
Simulate provider completion
</Button>
)}
{stage === "auth" &&
(auth.phase === "waiting" || auth.phase === "starting") && (
<Button
size="sm"
variant="ghost"
className="self-start"
onClick={() =>
setAuth({
phase: "expired",
message:
"This sign-in attempt expired. Start again to receive a new code.",
})
}
>
Simulate expired attempt
</Button>
)}
</aside>
<div className="space-y-3 rounded-lg border border-dashed border-border p-3">
<p className="text-xs text-muted-foreground">Example page context · Storybook only</p>
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{titles[host]}</h2>
{host === "onboarding" && (
<p className="text-sm text-muted-foreground">
Connect Configure agent First task
</p>
)}
{host === "task" && (
<p className="text-sm text-muted-foreground">
Connect an account for the responsible user to continue this task.
</p>
)}
</div>
{host !== "connections" && (
<dl className="flex flex-wrap gap-6 text-sm" aria-label="Agent runtime">
<div>
<dt className="text-xs text-muted-foreground">Harness</dt>
<dd data-testid="ai-harness">{runtime[0]}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Model</dt>
<dd data-testid="ai-model">{runtime[1]}</dd>
</div>
</dl>
)}
</div>
{stage === "list" && <AiConnectorPages initialConnections={connections} />}
{stage === "legacy" && (
<AiConnectionLegacyNotice
readOnly={readOnly}
onAdopt={() => {
setAdopting(true);
setStage("picker");
}}
/>
)}
{stage === "picker" && (
<>
{adopting && (
<p className="text-sm text-muted-foreground">
Confirm this accounts ownership and use, then test it before
replacing existing authentication.
</p>
)}
<AiReviewBoundary label="App component: AiConnectionPicker">
<AiConnectionPicker
requirement={requirement}
connections={connections}
value={binding}
currentUserId={currentUserId}
agentId="nova"
agentName="Nova"
loading={loading}
error={connectionError}
readOnly={readOnly}
onRetry={() => setConnectionError(undefined)}
onChange={(next) => {
setBinding(next);
setTested(false);
setSaved(false);
}}
onConnect={() => openAuth()}
/>
</AiReviewBoundary>
{!readOnly && !loading && !connectionError && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Example form actions · Storybook only</p>
<div className="flex flex-wrap gap-2">
{adopting && (
<Button
variant="outline"
disabled={Boolean(problem)}
onClick={() => setTested(true)}
>
Test selected connection
</Button>
)}
<Button
disabled={Boolean(problem) || (adopting && !tested)}
onClick={() => {
setSaved(true);
setStage("saved");
}}
>
{adopting
? "Adopt connection"
: host === "settings"
? "Save connection"
: "Continue"}
</Button>
</div>
</div>
)}
{tested && (
<p role="status" className="text-sm">
Connection test passed for{" "}
{currentUserId === "dotta" ? "Dotta" : "Sam"}. Harness and model
are unchanged.
</p>
)}
</>
)}
{stage === "auth" && (
<>
<ModelSourceTiles
label="Model provider"
sources={[
{
id: requirement.provider,
label: AI_PROVIDERS[requirement.provider].name,
icon: AI_PROVIDERS[requirement.provider].logo ? (
<img
src={AI_PROVIDERS[requirement.provider].logo}
className={
requirement.provider === "xai"
? "size-6 dark:invert"
: "size-6"
}
alt=""
/>
) : null,
},
]}
mode={requirement.method === "api_key" ? "api" : "subscription"}
selectedId={requirement.provider}
collapsed
onSelect={() => {}}
/>
<AiReviewBoundary label="Simulated authentication controller: AiConnectionAuth · Reuses existing login cards">
<AiConnectionAuth
provider={requirement.provider}
method={requirement.method}
state={auth}
onStart={() => {
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();
}}
/>
</AiReviewBoundary>
</>
)}
{stage === "saved" && (
<>
<p role="status" className="text-sm">
{saved
? adopting
? "Managed connection adopted."
: "Connection selected for Nova."
: "Connection saved."}{" "}
Harness and model are unchanged.
</p>
<p className="text-sm text-muted-foreground">
The account remains in Connections even if you leave agent setup.
</p>
<Button
variant="outline"
onClick={() => {
setSaved(false);
setAdopting(false);
setStage("picker");
}}
>
Create another agent using existing connections
</Button>
<Button variant="ghost" onClick={() => setStage("list")}>
View Connections
</Button>
</>
)}
</div>
);
}

View File

@ -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<string, unknown>(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<string>(), installs: new Map<string, unknown[]>(), audience: new Map<string, string[]>() }));
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 <p className="p-6">Loading Connectors review</p>;
return <QueryClientProvider client={client}>
<main className="mx-auto max-w-5xl p-6">
<p className="mb-6 text-xs text-muted-foreground">Existing app page components below · Fixture accounts · Review annotation</p>
<BreadcrumbBar />
<div className="pt-6">
<Routes>
<Route path="/:companyPrefix/apps" element={<Browse renderAccountDetails={(connection) => {
const row = store.accounts.find((account) => account.id === connection.id);
return row ? <p className="text-xs text-muted-foreground">{aiMethodLabel(row.provider, row.method)} · {row.ownership === "shared" ? "Company shared" : "Personal"}{row.isDefault ? " · Personal default" : ""}{row.accountLabel ? ` · ${row.accountLabel}` : ""}</p> : null;
}} />} />
<Route path="/:companyPrefix/apps/connect" element={<Setup accounts={store.accounts} onSave={update} />} />
<Route path="/:companyPrefix/apps/:connectionId/:tab" element={<AppDetail onReconnect={(connection) => 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 ? <AiReviewBoundary label="App component: AI account controls inside existing AppDetail"><AiConnectionAccountControls account={account} currentUserId="dotta" grant={grantsFor(account, readOnly).grants[0]} readOnly={readOnly}
onMakeDefault={() => {
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" })}
/></AiReviewBoundary> : undefined;
}} />} />
</Routes>
</div>
</main>
</QueryClientProvider>;
}
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<AiAuthMethod>(reconnect?.method ?? (provider === "openrouter" ? "api_key" : "subscription"));
const [state, setState] = useState<AiAuthState>({ phase: "idle" });
const [name, setName] = useState(reconnect?.name ?? `My ${AI_PROVIDERS[provider]?.subscriptionName ?? "OpenRouter API"}`);
const [savedId, setSavedId] = useState<string>();
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 <><p className="text-sm">This review focuses on AI authentication. The existing connector remains in the same list.</p><Button onClick={() => navigate("/apps")}>Back to Connectors</Button></>;
return <ConnectionSetupFlow serviceSlug={provider} onCancel={() => navigate("/apps")} renderCredentialStep={({ grantKind, agentIds, allAgents }) => <AiReviewBoundary label="Shared AI credential presentation · Existing setup shell and login cards"><div className="mx-auto max-w-xl space-y-4">
<label className="block space-y-2 text-sm">Connection name<Input value={name} onChange={(event) => setName(event.target.value)} disabled={Boolean(reconnect)} /></label>
{!reconnect && provider !== "openrouter" && <CredentialModeLink mode={method === "subscription" ? "subscription" : "api"} onChange={() => { setMethod(method === "subscription" ? "api_key" : "subscription"); setState({ phase: "idle" }); }} />}
<AiConnectionAuth provider={provider} method={method} state={state}
onStart={() => 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" && <Button variant="outline" onClick={() => complete(grantKind, agentIds, allAgents)}>Storybook only: simulate browser completion</Button>}
</div></AiReviewBoundary>} />;
}

View File

@ -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 <div className="mx-auto max-w-6xl p-4 sm:p-6" data-testid="ai-review-frame">
<aside aria-label="Storybook context" className="mb-4 space-y-3 rounded-lg border border-dashed border-border bg-muted p-4">
<p className="text-xs font-semibold uppercase tracking-wide">Storybook only · Review guide</p>
<p className="text-sm"><strong>Intended app location:</strong> {location}</p>
<dl className="grid gap-3 text-sm sm:grid-cols-3">
<div><dt className="font-semibold">Already in the app</dt><dd className="mt-1 text-muted-foreground">{existing}</dd></div>
<div><dt className="font-semibold">Integrated AI component</dt><dd className="mt-1 text-muted-foreground">{proposed}</dd></div>
<div><dt className="font-semibold">Storybook simulation</dt><dd className="mt-1 text-muted-foreground">{wrapper}</dd></div>
</dl>
<p className="text-xs text-muted-foreground">Dashed frames and labels are review annotations. Accounts and provider responses are fixtures. The components are integrated in the app; these stories use simulated accounts and authentication.</p>
</aside>
<div className="overflow-hidden rounded-lg border border-border bg-background" data-testid="ai-review-preview">{children}</div>
</div>;
}
/** Marks a precise component boundary within a simulated page or an existing app page. */
export function AiReviewBoundary({ label, children }: { label: string; children: ReactNode }) {
return <div className="min-w-0 space-y-3 rounded-lg border border-dashed border-border p-3" data-testid="ai-component-boundary">
<p className="text-xs font-medium text-muted-foreground">{label} · Review annotation</p>
{children}
</div>;
}
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 users 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.",
};
}

View File

@ -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 ? <QueryClientProvider client={client}><TaskCard /></QueryClientProvider> : null;
}
function TaskCard() {
const query = useQuery({ queryKey: ["issues", "interactions", "ai-review"], queryFn: async (): Promise<ConnectionIntentInteraction[]> => (await fetch("/api/ai-review-interactions")).json() });
const interaction = query.data?.[0];
return <main className="mx-auto max-w-3xl space-y-6 p-6">
<p className="text-xs text-muted-foreground">Example task context · Storybook only</p>
<h1 className="text-lg font-semibold">Nova needs your Claude connection</h1>
<p className="text-sm text-muted-foreground">Existing task connection request · Fixture data. Harness: Claude Code. Model: Configured Claude model.</p>
{interaction && <AiReviewBoundary label="Existing app component: ConnectionIntentInteractionBody"><ConnectionIntentInteractionBody interaction={interaction} currentUserId="dotta" addresseeLabel="Dotta" renderSetup={(props) => <TaskSetup {...props} />} /></AiReviewBoundary>}
</main>;
}
function TaskSetup(props: ConnectionSetupFlowProps) {
const [state, setState] = useState<AiAuthState>({ phase: "idle" });
return <ConnectionSetupFlow {...props} renderCredentialStep={() => <AiReviewBoundary label="Simulated authentication controller · Existing login cards"><AiConnectionAuth provider="anthropic" method="subscription" state={state}
onStart={() => setState({ phase: "waiting", authorizationUrl: "https://example.test/review-login" })}
onSubmit={() => setState({ phase: "connected" })}
onCancel={() => props.onCancel?.()}
onDone={() => props.onComplete?.({ connectionId: "review-task-claude" })}
/></AiReviewBoundary>} />;
}

View File

@ -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) => (
<AiReviewFrame {...aiReviewContext(context.id, context.args)}><Story /></AiReviewFrame>
)],
} satisfies Meta<typeof AiConnectionsReview>;
export default meta;
type Story = StoryObj<typeof meta>;
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: () => (
<main className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<h1 className="text-xl font-semibold">AI Connections · Review index</h1>
<p className="text-sm text-muted-foreground">
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.
</p>
<p className="text-sm">
Personal defaults are per company, provider, and sign-in method.
Connection selection never changes harness or model. Unavailable
accounts block without fallback.
</p>
{groups.map(([title, links]) => (
<section key={title} className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">{title}</h2>
{links.map(([label, id]) => (
<a
className="text-sm underline underline-offset-2"
key={id}
href={`/?path=/story/ai-connections-review--${id}`}
target="_top"
>
{label}
</a>
))}
</section>
))}
<p className="text-xs text-muted-foreground">
Additional stories cover read-only, loading, denied access,
reauthorization, revocation, mobile, and unsupported environments.
Runtime enforcement and data migration follow UI review.
</p>
</main>
),
};
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: () => <AiConnectorPages />,
};
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: /Sams 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 providers 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: () => <AiTaskConnectionReview />,
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: () => <AiTaskConnectionReview reuse />,
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 } },
};

View File

@ -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<Response>(() => {});
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: () => <Host><Card interaction={aiPending} /></Host>,
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();
}};