From d64cb7be52b6bb05214694dd38f77a608e5014da Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:28:59 -0500 Subject: [PATCH] fix(ui): identify accounts in AI connection choices (#17738) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent settings select an authorized AI account independently of the harness and model. > - Multiple accounts can have the same provider name. > - The account picker and legacy adoption dialog showed only that name. > - Operators could not distinguish a personal API key from a shared subscription during credential repair. > - This pull request shows the existing sign-in method, sharing scope, and available account identity before selection and adoption. > - Operators can select the intended account while the existing access and validation checks remain effective. ## Linked Issues or Issue Description Refs #14970 for the shared account selection control. No duplicate account-identity repair was found. **What happened?** Two authorized OpenAI accounts appeared as identical `OpenAI` choices. The adoption dialog repeated that ambiguous name. The list response already contained the account method, scope, and identity, but the UI omitted them. **Expected behavior** An operator can distinguish a personal API key from a company-shared subscription and confirm the selected account before saving. **Steps to reproduce** 1. Make a personal OpenAI API-key account and a shared OpenAI subscription available to an agent. Give both the name `OpenAI`. 2. Open Harness / Runtime and choose a managed connection for a legacy agent. 3. Inspect the two choices and the adoption confirmation. **Paperclip version or commit** Reproduced on master `a1f77821b9cd5c9c846fdf7a0fa5cc620ad2b6bb`. **Deployment mode** Hosted staging and the source UI regression. ## What Changed - Show the sign-in method, personal/shared scope, and available account or owner identity in connection labels. - Use the same label in the selected value and legacy adoption confirmation. Match the confirmation to both the connection and grant. Wrap dropdown choices so narrow screens retain the account identity, and expose the full selected label in the trigger title. - Add a behavioral regression with duplicate provider names, exact grant selection, and disabled unavailable accounts. - Update affected Storybook interaction selectors, the account guide, and the agent verification recipe. ## Verification - Before the fix, the new regression fails because duplicate accounts have no distinguishing metadata. The other 13 field tests pass. - After the fix, all 67 tests in `ui/src/components/ai-connections` pass. The regression selects the exact personal grant and keeps the unavailable option disabled. - `pnpm -r typecheck` and `pnpm build` pass locally. The full `pnpm test:run` command was stopped after embedded PostgreSQL failed to initialize after five attempts in an unrelated native-runtime suite. Its log is retained. Current-head CI partitions must establish full test coverage. - Token gates, map checks, Storybook catalog checks, and all 30 map/catalog contract tests pass. - In local Chrome, the existing production-component Storybook dropdown shows method and scope. Its nine interaction steps pass, as do all 19 steps in the new-agent connection-modes story. At a 320-pixel preview width, two shared OpenAI subscriptions show their distinct account identities in full, including during keyboard selection. These fixtures do not establish provider authentication. - Separately, the staging credential-repair workflow validated an existing authorized shared OpenAI connection, persisted the agent binding across reload, and resumed the original task into real provider/tool execution. This configuration recovery does not deploy this UI patch or establish task completion. - All CI completes on final head `a283bda2ad2f026f4f3c0fee03bf2962b0ad6e02`: 55 successful checks, the neutral Paperclip review, and two intentionally skipped Storybook checks; no pending or failed checks. This includes the partitioned tests, typecheck, build, runner suites, canary, and eight browser shards. Greptile and Paperclip review both rate this exact head 5/5 with no unresolved findings. [CI run](https://github.com/paperclipai/paperclip/actions/runs/40338613167). - The review's selected-trigger truncation finding is fixed in the final head. Its title assertion failed before the fix, and all 67 account-selection tests, UI typecheck, and token gates pass afterward. ## Risks - Labels are longer and can truncate in narrow selected-value controls; the trigger title exposes the complete label. Dropdown choices wrap, and the adoption dialog uses the complete label. - The UI uses existing authorized account metadata. It adds no credential reads, access grants, provider fallback, or account selection policy. ## Model Used OpenAI Codex (GPT-6). The exact serving model identifier and context window are not exposed in this session. The agent used code editing, terminal tests, and browser tools. ## 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 --- doc/connections/AI-CONNECTIONS.md | 6 ++++ maps/feature-map/agents.md | 8 +++++ .../ai-connections/AiConnectionField.test.tsx | 31 +++++++++++++++++-- .../ai-connections/AiConnectionField.tsx | 12 ++++--- .../ai-connections/AiConnectionSelect.tsx | 12 +++---- ui/src/components/ai-connections/model.ts | 10 ++++++ .../stories/agents/bulk-settings.stories.tsx | 4 +-- .../bulk-agent-settings.stories.tsx | 4 +-- ...provider-routing-06-production.stories.tsx | 17 +++++++--- 9 files changed, 84 insertions(+), 20 deletions(-) diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 1bd9d7a81da..6a12c8930e3 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -59,6 +59,12 @@ the selected connection owns its provider routing. Changing those separately may make a binding incompatible; saving then requires a compatible choice. Agent configuration cannot grant access to another account. +Agent account choices and legacy-adoption confirmations show the sign-in method, +personal or company-shared scope, and available account or owner identity beside +the connection name. Accounts with the same provider name can be distinguished +before selection. These labels use existing authorized account metadata, never +credentials or authentication paths. + Personal defaults are unique per company, user, and provider. A Claude bot can use one user’s subscription and another user’s API key without changing its harness or model. Explicit shared selections remain pinned to the selected account and method. The first successful personal connection sets a default only when none exists. The additive `ai_provider_defaults` table preserves the legacy per-method preferences. Migration selects each user’s most recently updated provider preference (including unavailable accounts), and rerunning it never overwrites a provider default. New writes maintain the legacy table for older servers. A database trigger propagates older servers’ explicit default updates to the provider default. Inserting an additional method default does not replace an existing provider default. diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index bb3b705cd1d..e19eb2205b6 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -128,6 +128,14 @@ from the hiring picker; API fixture creation does not prove this UI journey. Automated: [instruction service](../../server/src/__tests__/agent-instructions-service.test.ts) and [instruction revisions](../../server/src/__tests__/agent-instruction-revisions.test.ts) cover file/revision contracts. +The [AI account field regression](../../ui/src/components/ai-connections/AiConnectionField.test.tsx) +checks duplicate provider names, account identity in choices and adoption confirmation, +the exact selected grant, and disabled unavailable accounts with authorized metadata fixtures. +Manually compare a personal API key and shared subscription with the same name in +Harness / Runtime. Confirm their method, scope and available identity before saving. +Reload the saved choice and verify the next eligible task uses it; labels alone +do not establish credential validity or change account access. + Manual: Save a harmless instruction edit, reload, and verify its revision and effect in the next eligible run. Pause and resume the disposable agent; confirm pause prevents new work and does not erase previous runs. For failed setup, use an unavailable model or invalid command on a disposable agent. diff --git a/ui/src/components/ai-connections/AiConnectionField.test.tsx b/ui/src/components/ai-connections/AiConnectionField.test.tsx index cc9e6395173..ecf976d41cb 100644 --- a/ui/src/components/ai-connections/AiConnectionField.test.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.test.tsx @@ -17,7 +17,6 @@ vi.mock("./AiConnectionCredentialStep", () => ({ return
Provider sign-in
; }, })); -vi.mock("./AiConnectionManagement", () => ({ AiConnectionLegacyNotice: () => null })); vi.mock("./AiProviderSetup", () => ({ AiProviderSetup: () =>
Advanced provider setup
})); vi.mock("@/pages/apps/AppLogo", () => ({ AppLogo: () => null })); @@ -82,6 +81,33 @@ it("reconnects the unavailable personal default in place", async () => { expect(onChange).toHaveBeenCalledWith({ provider: "anthropic", method: "subscription", mode: "responsible_user" }); }); +it("identifies duplicate-named accounts before adopting the exact personal grant", async () => { + const personal = account({ id: "00000000-0000-4000-8000-000000000001", grantId: "00000000-0000-4000-8000-000000000002", provider: "openai", method: "api_key", name: "OpenAI", ownerName: "Avery", status: "connected", isDefault: false }); + const shared = account({ id: "00000000-0000-4000-8000-000000000003", grantId: "00000000-0000-4000-8000-000000000004", provider: "openai", method: "subscription", name: "OpenAI", accountLabel: "engineering@example.test", ownership: "shared", ownerUserId: undefined, status: "connected", isDefault: false }); + const sharedKey = account({ id: "00000000-0000-4000-8000-000000000005", grantId: "00000000-0000-4000-8000-000000000006", provider: "openai", method: "api_key", name: "OpenAI", ownerName: "Engineering", ownership: "shared", ownerUserId: undefined, status: "connected", isDefault: false }); + const unavailable = account({ id: "expired-account", grantId: "expired-grant", provider: "openai", method: "api_key", name: "Expired account", ownerName: "Avery" }); + mocks.list.mockResolvedValue({ currentUserId: "owner", connections: [shared, personal, sharedKey, unavailable], canManageConnections: true }); + flushSync(() => root.render( + + )); + await settle(); + await click("Choose a managed connection"); + const trigger = container.querySelector('[role="combobox"][aria-label="Connection"]')!; + flushSync(() => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + await settle(); + const choices = [...document.querySelectorAll('[role="option"]')]; + expect(choices.map(choice => choice.textContent)).toContain("OpenAI · API key · Personal · Avery"); + expect(choices.map(choice => choice.textContent)).toContain("OpenAI · ChatGPT subscription · Company shared · engineering@example.test"); + expect(choices.map(choice => choice.textContent)).toContain("OpenAI · API key · Company shared · Engineering"); + expect(choices.find(choice => choice.textContent?.includes("Expired account"))?.getAttribute("aria-disabled")).toBe("true"); + flushSync(() => choices.find(choice => choice.textContent === "OpenAI · API key · Personal · Avery")!.click()); + await settle(); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("OpenAI · API key · Personal · Avery"); + expect(onChange).not.toHaveBeenCalled(); + await click("Use this binding when saved"); + expect(onChange).toHaveBeenCalledExactlyOnceWith({ provider: "openai", method: "api_key", mode: "delegated", connectionId: personal.id, grantId: personal.grantId }); +}); + it("opens provider setup directly when connecting from the advanced mode", async () => { await mount([], true, true); await click("Connect another account"); @@ -163,7 +189,7 @@ it("offers Pi account selection before choosing its required model", async () => }); it.each(["openai", "anthropic", "google", "openrouter"] as const)("keeps a matching saved Pi %s API-key account selectable", async provider => { - const saved = account({ provider, method: "api_key", name: "Pi provider account", status: "connected" }); + const saved = account({ provider, method: "api_key", name: "Pi provider account", accountLabel: "avery@example.test", status: "connected" }); mocks.list.mockResolvedValue({ currentUserId: "owner", connections: [saved], canManageConnections: true }); flushSync(() => root.render( trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); await settle(); const choice = [...document.querySelectorAll('[role="option"]')].find(node => node.textContent?.includes("Pi provider account")); diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx index 5fcbe5e2757..d7d40336afd 100644 --- a/ui/src/components/ai-connections/AiConnectionField.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.tsx @@ -14,6 +14,7 @@ import { AiConnectionSelect } from "./AiConnectionSelect"; import { AiProviderSetup } from "./AiProviderSetup"; import { AiConnectionCredentialStep } from "./AiConnectionCredentialStep"; import { AiConnectionLegacyNotice } from "./AiConnectionManagement"; +import { aiConnectionDisplayLabel } from "./model"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -89,6 +90,11 @@ export function AiConnectionField({ enabled: Boolean(provider), }); const personalDefault = accounts.data?.connections.find((account) => account.provider === provider && account.isDefault && account.ownership === "personal" && account.ownerUserId === accounts.data.currentUserId); + const pendingAccount = pendingAdoption?.mode === "responsible_user" + ? personalDefault + : pendingAdoption && pendingAdoption.mode !== "router" + ? accounts.data?.connections.find(account => account.id === pendingAdoption.connectionId && account.grantId === pendingAdoption.grantId) + : undefined; const selectDefault = useMutation({ mutationFn: async (result: NonNullable) => { // Reconnect retains the existing default and its access. A new account @@ -177,10 +183,8 @@ export function AiConnectionField({ {pendingAdoption?.mode !== "router" &&

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

} {pendingAdoption?.mode !== "router" &&

After adoption, missing credentials block execution. Previous diff --git a/ui/src/components/ai-connections/AiConnectionSelect.tsx b/ui/src/components/ai-connections/AiConnectionSelect.tsx index 4eb82c01f1a..1eb36785f1e 100644 --- a/ui/src/components/ai-connections/AiConnectionSelect.tsx +++ b/ui/src/components/ai-connections/AiConnectionSelect.tsx @@ -14,15 +14,15 @@ import { } from "@/components/ui/select"; import { AppLogo } from "@/pages/apps/AppLogo"; import { Button } from "@/components/ui/button"; -import { aiConnectionProblem, personalAiDefault } from "./model"; +import { aiConnectionDisplayLabel, aiConnectionProblem, personalAiDefault } from "./model"; import type { AiConnectionPickerProps } from "./AiConnectionPicker"; import type { AiConnectionSummary } from "./model"; -function ConnectionLabel({ connection, label }: { connection: AiConnectionSummary; label?: string }) { +function ConnectionLabel({ connection, label, wrap = false }: { connection: AiConnectionSummary; label?: string; wrap?: boolean }) { return ( - {label ?? connection.name} + {label ?? aiConnectionDisplayLabel(connection)} ); } @@ -119,7 +119,7 @@ export function AiConnectionSelect({ onValueChange={change} disabled={readOnly || loading || Boolean(error)} > - + {incompatible && selected && ( - + )} {compatible.map((c) => ( @@ -144,7 +144,7 @@ export function AiConnectionSelect({ value={c.id} disabled={Boolean(aiConnectionProblem(c))} > - + ))} {(canUseDefault || value?.mode === "responsible_user") && ( diff --git a/ui/src/components/ai-connections/model.ts b/ui/src/components/ai-connections/model.ts index e6e645be500..3ce4ad2e080 100644 --- a/ui/src/components/ai-connections/model.ts +++ b/ui/src/components/ai-connections/model.ts @@ -57,6 +57,16 @@ export function aiMethodLabel(provider: AiProvider, method: AiAuthMethod) { : provider === "github" ? "Personal access token" : "API key"; } +export function aiConnectionDisplayLabel(connection: AiConnectionSummary) { + const personal = connection.ownership === "personal"; + return [ + connection.name, + aiMethodLabel(connection.provider, connection.method), + personal ? "Personal" : "Company shared", + connection.accountLabel || connection.ownerName, + ].filter(Boolean).join(" · "); +} + export function matchesAiRequirement( connection: AiConnectionSummary, requirement: AiConnectionRequirement, diff --git a/ui/storybook/stories/agents/bulk-settings.stories.tsx b/ui/storybook/stories/agents/bulk-settings.stories.tsx index b1ae65d3042..690ab6ae5e0 100644 --- a/ui/storybook/stories/agents/bulk-settings.stories.tsx +++ b/ui/storybook/stories/agents/bulk-settings.stories.tsx @@ -120,7 +120,7 @@ export const ConnectionJourney: Story = { play: async ({ canvasElement }) => { await expect(await screen.findByText("1 other selected agent won’t change.")).toBeVisible(); await userEvent.click(screen.getByRole("checkbox", { name: "Change connection" })); await userEvent.click(await screen.findByRole("combobox", { name: "Connection" })); - await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" })); + await userEvent.click(screen.getByRole("option", { name: /^OpenAI · Production ·/ })); await expect(screen.getByRole("button", { name: "Update 4 agents" })).toBeEnabled(); await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" })); await expect(await within(canvasElement).findByText("Updated 4 Codex agents.")).toBeVisible(); @@ -131,7 +131,7 @@ export const RetryJourney: Story = { args: { failOne: true }, play: async ({ can const screen = await selectCodex(canvasElement); await userEvent.click(await screen.findByRole("checkbox", { name: "Change connection" })); await userEvent.click(await screen.findByRole("combobox", { name: "Connection" })); - await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" })); + await userEvent.click(screen.getByRole("option", { name: /^OpenAI · Production ·/ })); await expect(screen.getByRole("button", { name: "Update 4 agents" })).toBeEnabled(); await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" })); await expect(await screen.findByText("3 updated · 1 couldn’t be saved")).toBeVisible(); diff --git a/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx b/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx index f92685f6e65..54222b132fe 100644 --- a/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx +++ b/ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx @@ -46,7 +46,7 @@ export const ConnectionJourney: Story = { await step("Changing only the connection keeps every model", async () => { await userEvent.click(screen.getByRole("checkbox", { name: "Change connection" })); await userEvent.click(screen.getByRole("combobox", { name: "Connection" })); - await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" })); + await userEvent.click(screen.getByRole("option", { name: /^OpenAI · Production ·/ })); await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" })); await expect(await canvas.findByText("Updated 4 Codex agents.")).toBeVisible(); await expect(canvas.getByTestId("agent-row-scout")).toHaveTextContent("gpt-5.3-codex"); @@ -100,7 +100,7 @@ export const RetryJourney: Story = { play: async ({ canvasElement }) => { const screen = within(canvasElement.ownerDocument.body); await userEvent.click(await screen.findByRole("combobox", { name: "Connection" })); - await userEvent.click(screen.getByRole("option", { name: "OpenAI · Production" })); + await userEvent.click(screen.getByRole("option", { name: /^OpenAI · Production ·/ })); await userEvent.click(screen.getByRole("button", { name: "Update 4 agents" })); await expect(await screen.findByText("3 updated · 1 couldn’t be saved")).toBeVisible(); await expect(within(canvasElement).getByTestId("agent-row-atlas")).toHaveTextContent("OpenAI · Production"); diff --git a/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx index 540420d7ec1..35235a5ba95 100644 --- a/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx @@ -61,6 +61,13 @@ const accounts: AiManagedConnectionSummary[] = [ isDefault: true, status: "connected", }, + ...["engineering@example.test", "design@example.test"].map((accountLabel, index) => ({ + id: `00000000-0000-4000-8000-00000000000${index + 8}`, + grantId: `00000000-0000-4000-8000-00000000001${index}`, + companyId, provider: "openai" as const, method: "subscription" as const, + name: "OpenAI", ownership: "shared" as const, accountLabel, + isDefault: false, status: "connected" as const, + })), ]; function Providers({ canManageConnections = true }: { canManageConnections?: boolean }) { const [client] = useState(() => { @@ -200,7 +207,7 @@ export const NewAgentConnectionModes: Story = { await expect(canvas.getByRole("radio", { name: "Advanced Custom Gateway" })).toBeChecked(); await expect(canvas.getByRole("button", { name: "Use connection" })).toBeDisabled(); await userEvent.click(canvas.getByRole("combobox", { name: "Connection" })); - await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("option", { name: "Company OpenRouter" })); + await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("option", { name: "Company OpenRouter · API key · Company shared" })); await userEvent.click(canvas.getByRole("button", { name: "Use connection" })); await expect(canvas.getByRole("status")).toHaveTextContent("Continue to Configure with openrouter."); await userEvent.click(canvas.getByRole("radio", { name: "OpenAI Subscription" })); @@ -259,13 +266,15 @@ export const CompatibleConnectionDropdown: Story = { body.getByRole("option", { name: "Responsible user’s default" }), ).toHaveAttribute("data-state", "checked"); await expect( - body.getByRole("option", { name: "Company OpenRouter" }), + body.getByRole("option", { name: "Company OpenRouter · API key · Company shared" }), ).toBeVisible(); + await expect(body.getByRole("option", { name: "OpenAI · ChatGPT subscription · Company shared · engineering@example.test" })).toBeVisible(); + await expect(body.getByRole("option", { name: "OpenAI · ChatGPT subscription · Company shared · design@example.test" })).toBeVisible(); await expect( - body.queryByRole("option", { name: "My Claude subscription" }), + body.queryByRole("option", { name: /My Claude subscription/ }), ).not.toBeInTheDocument(); await userEvent.click( - body.getByRole("option", { name: "Company OpenRouter" }), + body.getByRole("option", { name: "Company OpenRouter · API key · Company shared" }), ); await expect( within(canvasElement).getByRole("combobox", { name: "Connection" }),