fix: reuse saved model connections during agent setup (#13161)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent setup connects each agent to a model provider.
> - The organization can already hold subscription logins and API keys.
> - The simplified setup flow did not consistently offer those saved
credentials.
> - This pull request restores reuse and selects a saved connection by
default.
> - Agents keep secret references, so reuse does not copy or rotate
credentials.

## Linked Issues or Issue Description

Related change: #13011. Searched public issues and PRs; no duplicate fix
found.

**What happened?**

Onboarding and new-agent setup could ask for a new API key or sign-in
despite an existing saved connection. A general environment auth signal
could also be mistaken for the owner's saved Claude subscription.

**Expected behavior**

Offer saved credentials from the selected organization. Default to a
saved subscription when one exists. Otherwise select a saved API key.
Keep the option to enter a new key or sign in to another account.

**Steps to reproduce**

1. Save a Claude or OpenAI API key, or complete a supported subscription
login.
2. Add another agent with the same provider.
3. Open the provider connection step.
4. Check whether the saved credential is available and selected.

**Paperclip version or commit**

Reproduced on 5cb4f061d after #13011. This branch is rebased onto
current master.

**Deployment mode**

Built from source. Tested in an isolated local test drive with embedded
storage and board access.

## What Changed

- Add a shared saved-credential lookup and picker for active personal
and organization keys.
- Reuse saved Claude subscriptions and saved Codex account homes. Select
an existing connection by default.
- Preserve secret references through connection tests and agent
creation, including the native Claude and Codex runner setup paths.
- Store newly entered onboarding keys separately. Do not rotate another
agent's key.
- Keep explicit choices during metadata refresh. Prevent refreshes from
remounting an active login panel.
- Add integration tests and production-component Storybook stories.
Document connection reuse.

## Verification

- All 5,628 UI tests passed before rebase.
- Twenty targeted server credential tests passed.
- UI typecheck, UI build, token gates, and diff whitespace checks
passed.
- Browser walkthroughs covered onboarding and new-agent setup, saved
keys, saved subscription fixtures, and new sign-in screens.
- Live Claude and Codex API-key probes succeeded. Created both agents
and confirmed that each retained its saved-secret reference. Both secret
versions remained unchanged. Codex passed after one retry.
- Live subscription authentication was not repeated. Subscription flows
use fixture browser tests and integration tests.
- After rebase and the cache fix, all 109 focused onboarding and
agent-creation tests passed.
- Full repository `pnpm build` and `pnpm -r typecheck` passed.
- The full local test attempt encountered timeouts and embedded
PostgreSQL startup failures under parallel load. All four affected
suites passed in isolation: 20 tests, with no code changes. The complete
CI matrix passed, including all workspace, general server, serialized
server, browser end-to-end, build, typecheck, and canary dry-run checks.
- Greptile reviewed commit d53ddf6b82c101d35894587afc9b0d135a5abc55:
5/5, successful check, no review threads.

## Risks

- The default connection mode changes when saved credentials exist. A
saved subscription takes priority over saved API keys; personal keys
appear before organization keys.
- A listed credential can be expired or unavailable in the selected
environment. The existing connection test still checks it.
- No database migration or API contract change is required.

## Model Used

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

## 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-10 12:57:53 -05:00 committed by GitHub
parent 86c2e0ac4a
commit e9828f8bf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1052 additions and 122 deletions

View File

@ -40,6 +40,26 @@ should call that server. Both are built-in adapter types from the unified
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
Paperclip validates the selected model against live `opencode models` output.
### Reusing model connections
Both onboarding and the new-agent connection step can reuse saved credentials
in the selected organization. A saved subscription is the default when available;
otherwise a saved API key is selected automatically. Personal keys appear before
organization keys. You can still choose a new key or another account:
- Claude can use your saved subscription login without another sign-in.
- OpenAI lists ChatGPT accounts saved by Paperclip's Codex sign-in flow. Choose
an account or select **Sign in to another account**.
- In API-key mode, choose a saved personal or organization provider key, or
enter a new key. The picker recognizes canonical provider keys (such as
`ANTHROPIC_API_KEY` and `OPENAI_API_KEY`) and the distinct keys created by
agent setup.
Reusing a connection binds its secret reference to the agent. It does not copy
or rotate the saved value. The connection is tested before the agent is created;
being listed does not guarantee that a provider still accepts the credential.
These choices also apply to the Claude and Codex native runner setup paths.
## Agent Hiring via Governance
Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject.

View File

@ -131,6 +131,8 @@ const mockApprovalsApi = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockSecretsApi = vi.hoisted(() => ({
list: vi.fn(),
removeUserSecretDefinition: vi.fn(),
listMyUserSecrets: vi.fn(),
createUserSecretDefinition: vi.fn(),
createMyUserSecret: vi.fn(),
@ -314,6 +316,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
session: { id: "session-b", userId: SESSION_USER_ID },
user: { id: SESSION_USER_ID, name: "B", email: "b@example.com", image: null },
});
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
mockSecretsApi.removeUserSecretDefinition.mockResolvedValue({ ok: true });
window.localStorage.clear();
mockDialog.onboardingOpen = true;
mockDialog.onboardingOptions = {};
@ -855,6 +860,32 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
* what this step did before, and what the Claude token path has always
* avoided by holding a `user_secret_ref` instead.
*/
it.each(["personal", "organization"])("defaults to a saved %s API key and uses the same reference for probe and hire", async (scope) => {
const key = "ANTHROPIC_API_KEY";
const binding = scope === "personal"
? { type: "user_secret_ref", key, version: "latest" }
: { type: "secret_ref", secretId: "saved-org-key", version: "latest" };
if (scope === "personal") {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([{
definition: { id: "saved-key", companyId: "company-new", key, name: "Saved key", status: "active" },
secret: { companyId: "company-new", status: "active" },
}]);
} else {
mockSecretsApi.list.mockResolvedValue([{
id: "saved-org-key", companyId: "company-new", key, name: "Saved key", scope: "company", status: "active",
}]);
}
const { root, clickByText } = await openConnectStep();
const picker = document.body.querySelector('select[aria-label="Saved API key"]') as HTMLSelectElement;
expect(picker.value).toBe(scope === "personal" ? "user:saved-key" : "company:saved-org-key");
await clickByText((t) => isArcPrimary(t));
expect((mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[])[2]).toMatchObject({ adapterConfig: { env: { [key]: binding } } });
expect((mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1]).toMatchObject({ adapterConfig: { env: { [key]: binding } } });
expect(mockSecretsApi.createMyUserSecret).not.toHaveBeenCalled();
expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
describe("an API key typed on the step", () => {
const KEY = "sk-ant-typed-by-the-customer";
@ -892,7 +923,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
string,
{ definitionKey: string; value: string },
];
expect(createBody.definitionKey).toBe("ANTHROPIC_API_KEY");
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 {
@ -901,7 +932,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// The same binding kind the subscription half of this step produces.
expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toEqual({
type: "user_secret_ref",
key: "ANTHROPIC_API_KEY",
key: createBody.definitionKey,
version: "latest",
});
// The whole payload, not just that one field: the point is that the key
@ -911,39 +942,16 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
// Onboarding is the first thing to need this definition, so it creates it.
it("creates the definition once, then reuses it", async () => {
await connectWithApiKey();
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledTimes(1);
it("creates a distinct definition instead of rotating an existing key", async () => {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([
{ definition: { id: "def-1", key: "ANTHROPIC_API_KEY" }, secret: null },
{ definition: { id: "old-def", key: "ANTHROPIC_API_KEY" }, secret: { id: "secret-existing" } },
]);
const { root } = await connectWithApiKey();
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
// A second value against one definition is what the server refuses, so a
// customer who already has a key stored must rotate rather than add.
it("rotates an existing value instead of storing a second one", async () => {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([
{
definition: { id: "def-1", key: "ANTHROPIC_API_KEY" },
secret: { id: "secret-existing" },
},
]);
const { root } = await connectWithApiKey();
expect(mockSecretsApi.rotateMyUserSecret).toHaveBeenCalledWith(
expect.any(String),
"secret-existing",
{ value: KEY },
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledWith(
expect.any(String), expect.objectContaining({ key: expect.stringMatching(/^ANTHROPIC_API_KEY\.setup\./) }),
);
expect(mockSecretsApi.createMyUserSecret).not.toHaveBeenCalled();
expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled();
expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
@ -1099,9 +1107,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
// The status route must not even be asked — the conflict is decided
// from the adapter configuration alone, before any network round trip.
expect(mockAgentsApi.getClaudeOAuthTokenStatus).not.toHaveBeenCalled();
// Discovery reads saved-login metadata once; the hire does not re-read
// or apply it when the configuration already has an API key.
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(1);
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
const hireBody = hireArgs[1] as {
adapterConfig: { env?: Record<string, unknown> };
@ -1263,11 +1271,12 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockAgentsApi.hire.mockRejectedValue(new Error("hire failed"));
const { root, clickByText } = await openConnectStep();
const discoveryReads = mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length;
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(discoveryReads + 1);
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(2);
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(discoveryReads + 2);
await act(async () => root.unmount());
});

View File

@ -1,3 +1,5 @@
import { storeProviderApiKey } from "../lib/provider-credential";
import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect";
import { useEffect, useState, useMemo, useRef } from "react";
import type { ComponentType, CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@ -620,8 +622,10 @@ function OnboardingWizardInner({
* picked keys, left, and came back should not be handed a sign-in panel they
* already said no to.
*/
const [credentialMode, setCredentialMode] = useState<CredentialMode>(
(saved?.credentialMode as CredentialMode) ?? "subscription",
const [credentialModeChoice, setCredentialMode] = useState<CredentialMode | null>(
(saved?.credentialModeChoice !== undefined
? saved.credentialModeChoice as CredentialMode | null
: saved?.credentialMode as CredentialMode | undefined) ?? null,
);
/**
* Where the connect step's sign-in sequence is.
@ -661,6 +665,28 @@ function OnboardingWizardInner({
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
existingCompanyId ?? (saved?.createdCompanyId as string) ?? null
);
const savedKeys = useSavedProviderKeys(
createdCompanyId,
apiKeyEnvKeyFor(adapterType),
effectiveOnboardingOpen && step === 4,
);
const [subscriptionId, setSubscriptionId] = useState<{ companyId: string; id: string } | null>(null);
const savedSubscription = adapterType === "codex_local"
? savedKeys.subscriptions.find((option) => option.id === (
subscriptionId?.companyId === createdCompanyId
? subscriptionId.id
: savedKeys.subscriptions[0]?.id
))
: undefined;
const [selectedSavedKey, setSelectedSavedKey] = useState<{ companyId: string; envKey: string; id: string } | null>(null);
const selectedApiKeyId = selectedSavedKey?.companyId === createdCompanyId && selectedSavedKey?.envKey === apiKeyEnvKeyFor(adapterType)
? selectedSavedKey.id
: 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"
);
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
string | null
>((saved?.createdCompanyPrefix as string) ?? null);
@ -706,7 +732,7 @@ 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 } | null>(null);
const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding: Awaited<ReturnType<typeof storeProviderApiKey>>["binding"] } | null>(null);
createdCompanyIdRef.current = createdCompanyId;
// The step the request wants, mirrored for the same reason. `initialStep` is
@ -843,7 +869,7 @@ function OnboardingWizardInner({
step, companyName,
agentName, agentRole, adapterType, cwd, model, command, args, url,
// The mode, never the key: this blob is localStorage.
credentialMode,
credentialMode, credentialModeChoice,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
};
@ -851,7 +877,7 @@ function OnboardingWizardInner({
}, [
effectiveOnboardingOpen, step, companyName,
agentName, agentRole, adapterType, cwd, model, command, args, url,
credentialMode,
credentialMode, credentialModeChoice,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
]);
@ -1009,7 +1035,7 @@ function OnboardingWizardInner({
Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && canShowAdapterLogin,
});
useEffect(() => {
if (!activeLoginSessionQuery.data) return;
if (!activeLoginSessionQuery.data || credentialMode === "api" || savedSubscription || savedKeys.storedLogin.data) return;
// Re-derive the row's answer along with the sequence: a resumed session
// implies a source was already picked, and the row stays a question
// otherwise (see `sourcePicked` above).
@ -1020,7 +1046,7 @@ function OnboardingWizardInner({
// prompt through `onPromptReady`, below, which is what moves this beat
// from `loading` to `ready`, exactly as a fresh press would.
setConnectPhase((phase) => (phase === "idle" ? "loading" : phase));
}, [activeLoginSessionQuery.data]);
}, [activeLoginSessionQuery.data, credentialMode, savedSubscription, savedKeys.storedLogin.data]);
/**
* The signal is being fetched and has not answered yet.
*
@ -1089,7 +1115,7 @@ function OnboardingWizardInner({
* Anything that gates this step belongs in here, so the next one is added
* once rather than twice.
*/
const connectStepReady = sourceSelected && !adapterEnvLoading;
const connectStepReady = sourceSelected && !adapterEnvLoading && !savedKeys.loading;
/**
* Whether this step has a sign-in to do before it can hire.
@ -1102,7 +1128,10 @@ function OnboardingWizardInner({
*/
const connectStepNeedsLogin = Boolean(
credentialMode !== "api" &&
showAdapterLoginPanel &&
(showAdapterLoginPanel || (canShowAdapterLogin && adapterType === "codex_local" && subscriptionId?.companyId === createdCompanyId && subscriptionId.id === "")) &&
!savedSubscription &&
!(adapterType === "claude_local" && savedKeys.storedLogin.data) &&
!savedKeys.loading &&
createdCompanyId &&
resolvedLoginEnvironmentId,
);
@ -1258,7 +1287,7 @@ function OnboardingWizardInner({
label: "Connect",
icon: "arrow",
disabled:
!connectStepReady || (credentialMode === "api" && !apiKey.trim()),
!connectStepReady || (credentialMode === "api" && !apiKey.trim() && !selectedApiKey),
}
: // Nothing is chosen on arrival, and the row is what chooses. Until
// it has been answered the button has nothing to do.
@ -1404,7 +1433,7 @@ function OnboardingWizardInner({
setAdapterEnvResult(null);
adapterEnvResultAppliedStoredLoginRef.current = false;
setAdapterEnvError(null);
}, [step, adapterType, model, command, args, url, credentialMode, apiKey]);
}, [step, adapterType, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, subscriptionId, savedSubscription?.id]);
/**
* Leaving the step puts the row back to a question.
@ -1646,7 +1675,7 @@ function OnboardingWizardInner({
*
* A user secret needs a definition to hang off. The Claude token's is fixed
* and server-owned; there is no such definition for API keys, so onboarding
* creates one on first use. That needs company owner or admin rights, which
* creates a distinct definition for each new key, preserving existing keys. That needs company owner or admin rights, which
* whoever just created this company in onboarding has.
*
* Returns false on failure, having set the error. Callers must treat false as
@ -1656,31 +1685,10 @@ function OnboardingWizardInner({
async function storeApiKeyUserSecret(companyId: string): Promise<boolean> {
const key = apiKey.trim();
const envKey = apiKeyEnvKeyFor(adapterType);
if (apiKeySecretRef.current?.key === key) return true;
if (apiKeySecretRef.current?.key === key && apiKeySecretRef.current.companyId === companyId && apiKeySecretRef.current.envKey === envKey) return true;
try {
const entries = await secretsApi.listMyUserSecrets(companyId);
const existing = entries.find((entry) => entry.definition.key === envKey);
const definitionId =
existing?.definition.id ??
(
await secretsApi.createUserSecretDefinition(companyId, {
key: envKey,
name: `${envKey} for onboarding`,
description: "Created while connecting a model during onboarding.",
})
).id;
// Rotate rather than create when a value is already stored, because
// creating a second value for one definition is what the server refuses.
if (existing?.secret) {
await secretsApi.rotateMyUserSecret(companyId, existing.secret.id, { value: key });
} else {
await secretsApi.createMyUserSecret(companyId, {
definitionId,
definitionKey: envKey,
value: key,
});
}
apiKeySecretRef.current = { key };
const stored = await storeProviderApiKey(companyId, envKey, key);
apiKeySecretRef.current = { key, companyId, envKey, binding: stored.binding };
return true;
} catch (err) {
setError(
@ -1744,18 +1752,17 @@ 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) {
if (credentialMode === "api" && (bindApiKey || selectedApiKey)) {
const env =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
: {};
env[apiKeyEnvKeyFor(adapterType)] = {
type: "user_secret_ref",
key: apiKeyEnvKeyFor(adapterType),
version: "latest",
};
env[apiKeyEnvKeyFor(adapterType)] = selectedApiKey?.binding ?? apiKeySecretRef.current?.binding;
config.env = env;
}
if (credentialMode === "subscription" && savedSubscription) {
config.env = { ...((config.env as object) ?? {}), CODEX_HOME: savedSubscription.binding };
}
return config;
}
@ -1958,7 +1965,7 @@ function OnboardingWizardInner({
// hire describe it the same way — as a reference. A failure here stops the
// hire rather than falling through to a configuration with no credential.
let apiKeyStored = false;
if (credentialMode === "api" && apiKey.trim()) {
if (credentialMode === "api" && !selectedApiKey && apiKey.trim()) {
apiKeyStored = await storeApiKeyUserSecret(createdCompanyId);
if (!apiKeyStored) return;
}
@ -2514,6 +2521,20 @@ function OnboardingWizardInner({
}}
/>
{credentialMode === "subscription" && adapterType === "codex_local" && savedKeys.subscriptions.length > 0 && (
<div className="mt-5">
<SavedProviderKeySelect
options={savedKeys.subscriptions}
value={savedSubscription?.id ?? ""}
onChange={(id) => setSubscriptionId(createdCompanyId ? { companyId: createdCompanyId, id } : null)}
loading={false}
error={false}
kind="subscription"
disabled={loading || adapterEnvLoading || connectPhase === "connecting"}
/>
</div>
)}
{/* Fades on the first beat but keeps its space until the
second, so pressing a tile moves nothing vertically.
Once a sign-in is running there is no switching to keys
@ -2538,6 +2559,8 @@ function OnboardingWizardInner({
>
<div className="-ml-3 mt-1">
<CredentialModeLink mode={credentialMode} onChange={setCredentialMode} />
{savedKeys.options.length > 0 && <p className="px-3 text-sm text-muted-foreground">{savedKeys.options.length} saved API {savedKeys.options.length === 1 ? "key available" : "keys available"}.</p>}
{credentialMode === "subscription" && authSignalStatus === "present" && <p className="px-3 text-sm text-muted-foreground">An existing provider connection is available.</p>}
</div>
</motion.div>
</div>
@ -2580,11 +2603,15 @@ function OnboardingWizardInner({
*/}
{!connectCardMounted ? null : credentialMode === "api" ? (
<OnboardingLoginCard
instruction={`Provide your ${
instruction={savedKeys.options.length ? "Choose a saved API key or enter a new one" : `Provide your ${
CONNECT_SOURCE_NAMES[adapterType] ?? adapterType
} API key to connect`}
>
<OnboardingCardField
<SavedProviderKeySelect {...savedKeys} disabled={loading || adapterEnvLoading} value={selectedApiKey?.id ?? ""} onChange={(id) => {
setSelectedSavedKey(createdCompanyId ? { companyId: createdCompanyId, envKey: apiKeyEnvKeyFor(adapterType), id } : null);
setApiKey("");
}} />
{!selectedApiKey && <OnboardingCardField
label="API key"
placeholder="Enter API key here"
masked
@ -2593,9 +2620,12 @@ function OnboardingWizardInner({
// over from the key field this card replaced.
autoFocus
value={apiKey}
onChange={setApiKey}
onChange={(value) => {
setSelectedSavedKey(createdCompanyId ? { companyId: createdCompanyId, envKey: apiKeyEnvKeyFor(adapterType), id: "" } : null);
setApiKey(value);
}}
onSubmit={() => handleConnectStepPrimary()}
/>
/>}
</OnboardingLoginCard>
) : connectStepNeedsLogin &&
createdCompanyId &&
@ -2640,6 +2670,8 @@ 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

View File

@ -0,0 +1,244 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentProviderConnection } from "./AgentProviderConnection";
import { ApiError } from "@/api/client";
const mocks = vi.hoisted(() => ({
auth: vi.fn(),
login: vi.fn(),
personal: vi.fn(),
organization: vi.fn(),
}));
vi.mock("@/api/agents", () => ({
agentsApi: {
getAdapterAuthSignal: mocks.auth,
getClaudeOAuthTokenStatus: mocks.login,
},
}));
vi.mock("@/api/secrets", () => ({
secretsApi: { listMyUserSecrets: mocks.personal, list: mocks.organization },
}));
vi.mock("../AgentConfigForm", () => ({
AdapterLoginPanel: () => <div>New subscription login</div>,
}));
let root: Root;
let host: HTMLDivElement;
let client: QueryClient;
afterEach(() => {
flushSync(() => root?.unmount());
host?.remove();
client?.clear();
vi.resetAllMocks();
});
async function mount(
adapterType: "claude_local" | "codex_local" = "claude_local",
savedLogin = false,
canLogin = true,
codexSubscriptions = false,
savedApiKeys = true,
cachedClaudeLogin = false,
) {
const key =
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
mocks.auth.mockResolvedValue({
status: codexSubscriptions ? "unknown" : "present",
});
mocks.login.mockImplementation(async () => {
if (savedLogin) return { secretId: "oauth", latestVersion: 1 };
throw new ApiError("Not found", 404, null);
});
mocks.personal.mockResolvedValue([
{
definition: {
id: "d1",
companyId: "c1",
key: `${key}.setup.1`,
name: "Personal key",
status: "active",
},
secret: { companyId: "c1", status: "active" },
},
]);
mocks.organization.mockResolvedValue([
{
id: "s1",
companyId: "c1",
key,
name: "Company key",
scope: "company",
status: "active",
},
...(codexSubscriptions
? [
{
id: "codex-home",
companyId: "c1",
name: "CODEX_HOME_team",
scope: "company",
status: "active",
},
]
: []),
]);
if (!savedApiKeys) {
mocks.personal.mockResolvedValue([]);
mocks.organization.mockResolvedValue([]);
}
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (cachedClaudeLogin) {
client.setQueryData(["claude-oauth-token-status", "c1"], { secretId: "cached-claude", latestVersion: 1 });
mocks.auth.mockResolvedValue({ status: "absent" });
}
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
const test = vi.fn().mockResolvedValue(true);
const connected = vi.fn();
flushSync(() =>
root.render(
<QueryClientProvider client={client}>
<AgentProviderConnection
companyId="c1"
adapterType={adapterType}
environmentId="e1"
canLogin={canLogin}
onBack={() => {}}
testConnection={test}
onConnected={connected}
/>
</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"));
return { test, connected, key };
}
function click(text: string) {
const button = [...host.querySelectorAll("button")].find((b) =>
b.textContent?.includes(text),
)!;
expect(button).toBeTruthy();
flushSync(() => button.click());
}
function openProvider() {
flushSync(() =>
(host.querySelector('[role="radio"]') as HTMLElement).click(),
);
}
describe("AgentProviderConnection reuse", () => {
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");
click("Use API key instead");
openProvider();
expect(host.querySelector('input[type="password"]')).not.toBeNull();
});
it("does not use a cached Claude login for Codex", async () => {
await mount("codex_local", false, true, false, false, true);
openProvider();
expect(host.textContent).toContain("New subscription login");
expect(host.textContent).not.toContain("saved Claude subscription");
expect(host.textContent).not.toContain("Use saved subscription");
expect(mocks.login).not.toHaveBeenCalled();
});
it("reuses a saved ChatGPT account when the sandbox auth signal is unknown", async () => {
const { test, connected } = await mount("codex_local", false, true, true);
openProvider();
expect(host.textContent).not.toContain("New subscription login");
click("Use saved subscription");
await vi.waitFor(() =>
expect(connected).toHaveBeenCalledWith({
env: {
CODEX_HOME: {
type: "secret_ref",
secretId: "codex-home",
version: "latest",
},
},
}),
);
expect(test).toHaveBeenCalledWith(connected.mock.calls[0][0]);
flushSync(() => {
const select = host.querySelector("select")!;
select.value = "";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(host.textContent).toContain("New subscription login");
});
it.each(["claude_local", "codex_local"] as const)(
"passes a personal reference without credentials for %s",
async (adapter) => {
const { test, connected, key } = await mount(adapter);
openProvider();
const select = host.querySelector("select")!;
expect(select.value).toBe("user:d1");
click("Use saved API key");
await vi.waitFor(() =>
expect(connected).toHaveBeenCalledWith({
env: {
[key]: {
type: "user_secret_ref",
key: `${key}.setup.1`,
version: "latest",
},
},
}),
);
expect(test).toHaveBeenCalledWith(connected.mock.calls[0][0]);
},
);
it("uses an organization reference and requires a new key after switching away", async () => {
const { connected, key } = await mount();
openProvider();
const select = host.querySelector("select")!;
flushSync(() => {
select.value = "company:s1";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
click("Use saved API key");
await vi.waitFor(() =>
expect(connected).toHaveBeenCalledWith({
env: {
[key]: { type: "secret_ref", secretId: "s1", version: "latest" },
},
}),
);
flushSync(() => {
select.value = "";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(host.querySelector('input[type="password"]')).not.toBeNull();
const button = [...host.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Connect"),
)!;
expect(button.disabled).toBe(true);
await client.invalidateQueries();
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
expect(host.querySelector("select")!.value).toBe("");
expect(host.querySelector('input[type="password"]')).not.toBeNull();
});
it("reuses saved Claude login even without a login-capable environment", async () => {
const { test } = await mount("claude_local", true, false);
openProvider();
await vi.waitFor(() =>
expect(host.textContent).toContain("Use saved subscription"),
);
click("Use saved subscription");
await vi.waitFor(() =>
expect(test).toHaveBeenCalledWith(
expect.objectContaining({ applyStoredClaudeLogin: true }),
),
);
});
it("does not treat an environment credential as a stored Claude login", async () => {
const { test } = await mount();
click("Use subscription instead");
openProvider();
click("Connect");
await vi.waitFor(() => expect(test).toHaveBeenCalledWith({ env: {} }));
});
});

View File

@ -1,6 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion } from "motion/react";
import {
SavedProviderKeySelect,
useSavedProviderKeys,
} from "../onboarding/SavedProviderKeySelect";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { AdapterLoginPanel } from "../AgentConfigForm";
@ -53,7 +57,7 @@ export function AgentProviderConnection({
setBusy(false);
setOpened(false);
};
const [method, setMethod] = useState<"subscription" | "api">("subscription");
const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(null);
const [opened, setOpened] = useState(false);
const [apiKey, setApiKey] = useState("");
const [busy, setBusy] = useState(false);
@ -63,6 +67,24 @@ export function AgentProviderConnection({
const provider = adapterType === "claude_local" ? "Claude" : "OpenAI";
const envKey =
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
const savedKeys = useSavedProviderKeys(companyId, envKey);
const [subscriptionId, setSubscriptionId] = useState<string | null>(null);
const savedSubscription =
adapterType === "codex_local"
? savedKeys.subscriptions.find(
(option) =>
option.id === (subscriptionId ?? savedKeys.subscriptions[0]?.id),
)
: undefined;
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const selectedKey = savedKeys.options.find(
(option) => option.id === (selectedKeyId ?? savedKeys.options[0]?.id),
);
const storedLogin = savedKeys.storedLogin;
const method = methodChoice ?? (
(adapterType === "claude_local" ? storedLogin.data : savedKeys.subscriptions.length)
? "subscription" : savedKeys.options.length ? "api" : "subscription"
);
const auth = useQuery({
queryKey: queryKeys.agents.authSignal(
companyId,
@ -85,15 +107,17 @@ export function AgentProviderConnection({
try {
const connection =
method === "api"
? (storedConnection ?? {
env: {},
credentials: { [envKey]: apiKey.trim() },
})
? selectedKey
? { env: { [envKey]: selectedKey.binding } }
: (storedConnection ?? {
env: {},
credentials: { [envKey]: apiKey.trim() },
})
: {
env: {},
...(adapterType === "claude_local" &&
canLogin &&
auth.data?.status === "present"
env: savedSubscription
? { CODEX_HOME: savedSubscription.binding }
: {},
...(adapterType === "claude_local" && storedLogin.data
? {
env: buildFixedClaudeOAuthBinding(),
applyStoredClaudeLogin: true,
@ -103,7 +127,7 @@ export function AgentProviderConnection({
if (run !== epoch.current) return;
if (method === "api") {
setApiKey("");
setStoredConnection(connection);
if (!selectedKey) setStoredConnection(connection);
}
const connected = await testConnection(connection);
if (run !== epoch.current) return;
@ -127,7 +151,10 @@ export function AgentProviderConnection({
method === "subscription" &&
canLogin &&
environmentId &&
auth.data?.status !== "present";
!savedSubscription &&
!savedKeys.loading &&
!storedLogin.data &&
(auth.data?.status !== "present" || subscriptionId === "");
return (
<div>
<ModelSourceTiles
@ -161,6 +188,25 @@ export function AgentProviderConnection({
/>
</div>
)}
{!opened && savedKeys.options.length > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{savedKeys.options.length} saved API{" "}
{savedKeys.options.length === 1 ? "key available" : "keys available"}.
</p>
)}
{method === "subscription" &&
adapterType === "codex_local" &&
savedKeys.subscriptions.length > 0 && (
<SavedProviderKeySelect
options={savedKeys.subscriptions}
value={savedSubscription?.id ?? ""}
onChange={setSubscriptionId}
loading={false}
error={false}
kind="subscription"
disabled={busy}
/>
)}
<motion.div
initial={false}
animate={{ height: opened ? "auto" : 0, opacity: opened ? 1 : 0 }}
@ -171,25 +217,43 @@ export function AgentProviderConnection({
<div className="pt-5">
{method === "api" ? (
<OnboardingLoginCard
instruction={`Provide your ${provider} API key to connect`}
instruction={
savedKeys.options.length
? "Choose a saved API key or enter a new one"
: `Provide your ${provider} API key to connect`
}
>
<OnboardingCardField
label="API key"
masked
autoFocus
value={apiKey}
placeholder={
storedConnection
? "Key entered. Retry the connection."
: "Enter API key here"
}
onChange={(value) => {
setApiKey(value);
setStoredConnection(null);
}}
onSubmit={() => void connect()}
<SavedProviderKeySelect
{...savedKeys}
value={selectedKey?.id ?? ""}
disabled={busy}
onChange={(id) => {
setSelectedKeyId(id);
setApiKey("");
setStoredConnection(null);
setError(null);
}}
/>
{!selectedKey && (
<OnboardingCardField
label="API key"
masked
autoFocus
value={apiKey}
placeholder={
storedConnection
? "Key entered. Retry the connection."
: "Enter API key here"
}
onChange={(value) => {
setSelectedKeyId("");
setApiKey(value);
setStoredConnection(null);
}}
onSubmit={() => void connect()}
disabled={busy}
/>
)}
</OnboardingLoginCard>
) : needsLogin ? (
<AdapterLoginPanel
@ -210,16 +274,23 @@ export function AgentProviderConnection({
if (adapterType === "codex_local") onConnected({ env: {} });
}}
/>
) : (
) : savedSubscription ? null : (
<p className="text-sm text-muted-foreground">
{canLogin
? "Use the subscription already connected to 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.`}
{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.`}
</p>
)}
</div>
)}
</motion.div>
{method === "subscription" && storedLogin.isError && (
<p role="alert" className="mt-4 text-sm text-destructive">
Could not check your saved Claude subscription. Try again.
</p>
)}
{error && (
<p role="alert" className="mt-4 text-sm text-destructive">
{testError ?? error}
@ -230,12 +301,26 @@ export function AgentProviderConnection({
if (opened) cancel();
else onBack();
}}
primaryLabel={busy ? "Connecting" : "Connect"}
primaryLabel={
busy
? "Connecting"
: method === "subscription" &&
(storedLogin.data || savedSubscription)
? "Use saved subscription"
: method === "api" && selectedKey
? "Use saved API key"
: "Connect"
}
primaryDisabled={
auth.isPending ||
savedKeys.loading ||
(adapterType === "claude_local" && storedLogin.isPending) ||
!opened ||
Boolean(needsLogin) ||
(method === "api" && !apiKey.trim() && !storedConnection)
(method === "api" &&
!apiKey.trim() &&
!storedConnection &&
!selectedKey)
}
loading={busy}
onPrimary={() => void connect()}

View File

@ -0,0 +1,122 @@
import { useQuery } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
import { ApiError } from "@/api/client";
import { secretsApi } from "@/api/secrets";
import { queryKeys } from "@/lib/queryKeys";
import {
savedProviderKeys,
savedCodexSubscriptions,
type SavedProviderKey,
} from "@/lib/saved-provider-credentials";
export function useSavedProviderKeys(
companyId: string | null,
envKey: string,
enabled = true,
) {
const personal = useQuery({
queryKey: queryKeys.secrets.myUserSecrets(companyId ?? ""),
queryFn: () => secretsApi.listMyUserSecrets(companyId!),
enabled: Boolean(companyId) && enabled,
retry: false,
});
const organization = useQuery({
queryKey: queryKeys.secrets.list(companyId ?? ""),
queryFn: () => secretsApi.list(companyId!),
enabled: Boolean(companyId) && enabled,
retry: false,
});
const storedLogin = useQuery({
// Disabled queries still return cached data. Keep other providers away
// from the shared Claude login cache.
queryKey: ["claude-oauth-token-status", envKey === "ANTHROPIC_API_KEY" ? companyId : null],
queryFn: async () => {
try {
return await agentsApi.getClaudeOAuthTokenStatus(companyId!);
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
},
enabled: Boolean(companyId) && enabled && envKey === "ANTHROPIC_API_KEY",
retry: false,
});
return {
storedLogin,
options: savedProviderKeys(
companyId ?? "",
envKey,
personal.data ?? [],
organization.data ?? [],
),
subscriptions: 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,
};
}
export function SavedProviderKeySelect({
options,
value,
onChange,
loading,
error,
disabled,
kind = "api",
}: {
options: SavedProviderKey[];
value: string;
onChange: (id: string) => void;
loading: boolean;
error: boolean;
disabled?: boolean;
kind?: "api" | "subscription";
}) {
return (
<div className="space-y-2">
{options.length > 0 && (
<label className="block space-y-2 text-sm">
<span>{kind === "api" ? "API key" : "Subscription"}</span>
<select
aria-label={kind === "api" ? "Saved API key" : "Saved subscription"}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
<option value="">
{kind === "api"
? "Enter a new API key"
: "Sign in to another account"}
</option>
</select>
</label>
)}
{loading && (
<p role="status" className="text-sm text-muted-foreground">
Checking saved API keys
</p>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
Some saved keys could not be loaded. You can still enter a new key.
</p>
)}
{value && (
<p className="text-sm text-muted-foreground">
Reuse this saved {kind === "api" ? "key" : "subscription"} for this
agent.
</p>
)}
</div>
);
}

View File

@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import type { CompanySecret } from "@paperclipai/shared";
import type { MyUserSecretEntry } from "../api/secrets";
import {
savedProviderKeys,
savedCodexSubscriptions,
} from "./saved-provider-credentials";
const secret = (overrides = {}) =>
({
id: "s1",
companyId: "c1",
key: "ANTHROPIC_API_KEY",
name: "Claude",
scope: "company",
status: "active",
...overrides,
}) as CompanySecret;
const personal = (key = "ANTHROPIC_API_KEY.setup.abc", overrides = {}) =>
({
definition: {
id: "d1",
companyId: "c1",
key,
name: "My Claude",
status: "active",
...overrides,
},
secret: secret({ scope: "user" }),
}) as MyUserSecretEntry;
describe("saved provider keys", () => {
it("reuses canonical and setup keys with references, including normalized organization keys", () => {
expect(
savedProviderKeys(
"c1",
"ANTHROPIC_API_KEY",
[personal()],
[secret({ key: "anthropic_api_key" })],
),
).toEqual([
{
id: "user:d1",
label: "My Claude (Your key)",
binding: {
type: "user_secret_ref",
key: "ANTHROPIC_API_KEY.setup.abc",
version: "latest",
},
},
{
id: "company:s1",
label: "Claude (Organization key)",
binding: { type: "secret_ref", secretId: "s1", version: "latest" },
},
]);
});
it("excludes unavailable, wrong-provider, and foreign-company credentials", () => {
expect(
savedProviderKeys(
"c1",
"ANTHROPIC_API_KEY",
[
personal("OPENAI_API_KEY"),
personal(undefined, { status: "disabled" }),
{ ...personal(), secret: null },
{ ...personal(), secret: secret({ status: "archived" }) },
personal(undefined, { companyId: "c2" }),
],
[
secret({ companyId: "c2" }),
secret({ status: "disabled" }),
secret({ scope: "user" }),
secret({ key: "ANTHROPIC_API_KEY_OTHER" }),
],
),
).toEqual([]);
});
});
it("lists only active company Codex account connections", () => {
const account = secret({ name: "CODEX_HOME_team" });
expect(
savedCodexSubscriptions("c1", [
account,
{ ...account, status: "disabled" },
{ ...account, companyId: "c2" },
secret(),
]),
).toEqual([
{
id: "company:s1",
label: "ChatGPT account · team",
binding: { type: "secret_ref", secretId: "s1", version: "latest" },
},
]);
});

View File

@ -0,0 +1,79 @@
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
import type { MyUserSecretEntry } from "../api/secrets";
export interface SavedProviderKey {
id: string;
label: string;
binding: EnvBinding;
}
/** Match the canonical onboarding key and distinct keys created by agent setup. */
export function savedProviderKeys(
companyId: string,
envKey: string,
personal: MyUserSecretEntry[],
organization: CompanySecret[],
): SavedProviderKey[] {
const matches = (key: string | null) =>
key?.toUpperCase() === envKey ||
key?.toUpperCase().startsWith(`${envKey}.SETUP.`);
return [
...personal.flatMap(({ definition, secret }) =>
definition.companyId === companyId &&
definition.status === "active" &&
secret?.companyId === companyId &&
secret.status === "active" &&
matches(definition.key)
? [
{
id: `user:${definition.id}`,
label: `${definition.name} (Your key)`,
binding: {
type: "user_secret_ref" as const,
key: definition.key,
version: "latest" as const,
},
},
]
: [],
),
...organization.flatMap((secret) =>
secret.companyId === companyId &&
secret.scope === "company" &&
secret.status === "active" &&
matches(secret.key)
? [
{
id: `company:${secret.id}`,
label: `${secret.name} (Organization key)`,
binding: {
type: "secret_ref" as const,
secretId: secret.id,
version: "latest" as const,
},
},
]
: [],
),
];
}
/** Codex device login creates one reusable company secret per account home. */
export function savedCodexSubscriptions(
companyId: string,
organization: CompanySecret[],
): SavedProviderKey[] {
return organization
.filter(
(secret) =>
secret.companyId === companyId &&
secret.scope === "company" &&
secret.status === "active" &&
secret.name.startsWith("CODEX_HOME_"),
)
.map((secret) => ({
id: `company:${secret.id}`,
label: secret.name.replace("CODEX_HOME_", "ChatGPT account · "),
binding: { type: "secret_ref", secretId: secret.id, version: "latest" },
}));
}

View File

@ -1,3 +1,4 @@
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
import { RepositoryEditor } from "@/components/RepositoryEditor";
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
@ -2179,6 +2180,12 @@ export function DesignGuide() {
</p>
</Section>
<Section title="Saved provider API keys">
<SavedProviderKeySelect options={[{ id: "example", label: "Claude API key (Your key)", binding: { type: "user_secret_ref", key: "ANTHROPIC_API_KEY", version: "latest" } }]} value="example" onChange={() => {}} loading={false} error={false} />
<SavedProviderKeySelect options={[]} value="" onChange={() => {}} loading error={false} />
<SavedProviderKeySelect options={[]} value="" onChange={() => {}} loading={false} error />
</Section>
<Section title="Connection Intent">
<p className="text-sm text-muted-foreground">
The task card is the dialog host for the shared connection setup flow. Provider forms,

View File

@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { queryKeys } from "@/lib/queryKeys";
import { NewAgent } from "./NewAgent";
import { ApiError } from "@/api/client";
const api = vi.hoisted(() => ({
get: vi.fn(),
@ -14,6 +15,7 @@ const api = vi.hoisted(() => ({
hire: vi.fn(),
testEnvironment: vi.fn(),
getAdapterAuthSignal: vi.fn(),
getClaudeOAuthTokenStatus: vi.fn(),
}));
const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() }));
const settings = vi.hoisted(() => ({
@ -165,6 +167,7 @@ beforeEach(() => {
api.adapterModels.mockResolvedValue([]);
api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]);
api.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null));
api.testEnvironment.mockResolvedValue(pass);
api.hire.mockImplementation(async (_company, input) => ({
agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" },
@ -383,6 +386,29 @@ describe("New agent setup", () => {
expect(api.hire.mock.calls[0][1].adapterConfig.env[key].type).toBe("user_secret_ref");
expect(JSON.stringify(api.hire.mock.calls)).not.toContain("connection-key");
});
it.each([
["claude_local", "claude", "Claude", "ANTHROPIC_API_KEY"],
["codex_local", "codex", "OpenAI", "OPENAI_API_KEY"],
["paperclip_runner", "claude", "Claude", "ANTHROPIC_API_KEY"],
["paperclip_runner", "codex", "OpenAI", "OPENAI_API_KEY"],
])("defaults %s %s to a saved key and preserves its reference through hire", async (adapter, runner, provider, key) => {
secrets.listMyUserSecrets.mockResolvedValue([{
definition: { id: "existing-key", companyId: "company-1", key, name: "Existing key", status: "active" },
secret: { companyId: "company-1", status: "active" },
}]);
await render(adapter, runner);
await click(provider + "API");
expect((container.querySelector("select[aria-label='Saved API key']") as HTMLSelectElement).value).toBe("user:existing-key");
await click("Use saved API key");
const binding = { type: "user_secret_ref", key, version: "latest" };
expect(api.testEnvironment.mock.calls[0][2].adapterConfig.env[key]).toEqual(binding);
expect(api.testEnvironment.mock.calls[0][2].testCredentials).toEqual({});
await click("Finish setup");
expect(api.hire.mock.calls[0][1].adapterConfig.env[key]).toEqual(binding);
expect(secrets.createUserSecretDefinition).not.toHaveBeenCalled();
expect(secrets.createMyUserSecret).not.toHaveBeenCalled();
expect(secrets.rotateMyUserSecret).not.toHaveBeenCalled();
});
it.each(["opencode_local", "pi_local"])(
"persists %s OpenRouter credentials only as a secret reference",
async (adapter) => {
@ -514,8 +540,10 @@ describe("New agent setup", () => {
settings.getExperimental.mockResolvedValue({
enableManagedSandboxOnly: true,
});
api.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-oauth", latestVersion: 1 });
await render("claude_local");
await connect("Claude");
await click("ClaudeSubscription");
await click("Use saved subscription");
await click("Finish setup");
expect(api.testEnvironment.mock.calls[0][2].environmentId).toBe(
"sandbox-1",

View File

@ -11,6 +11,7 @@ import { ONBOARDING_STORAGE_KEY } from "@/components/OnboardingWizard";
import { STORYBOOK_COMPANY_ID } from "../fixtures/onboardingDraft";
import {
STORYBOOK_SANDBOX_ENVIRONMENT_ID,
onboardingFixtureState,
storybookAuthSignal,
storybookEnvironmentCapabilities,
storybookEnvironmentTest,
@ -204,6 +205,7 @@ function installStorybookApiFixtures() {
/^\/api\/companies\/[^/]+\/adapters\/([^/]+)\/login-sessions(?:\/([^/]+))?(\/cancel)?$/,
);
if (adapterLoginMatch) {
if (adapterLoginMatch[2] === "active") return new Response(null, { status: 404 });
const session = {
sessionId: "adapter-login-storybook",
environmentId: STORYBOOK_SANDBOX_ENVIRONMENT_ID,
@ -249,6 +251,9 @@ function installStorybookApiFixtures() {
failure: null,
});
}
if (/^\/api\/companies\/[^/]+\/setup-token-login-sessions\/active$/.test(url.pathname)) {
return new Response(null, { status: 404 });
}
if (
/^\/api\/companies\/[^/]+\/setup-token-login-sessions\/[^/]+$/.test(
url.pathname,
@ -298,7 +303,15 @@ function installStorybookApiFixtures() {
if (
/^\/api\/companies\/[^/]+\/claude-oauth-token-status$/.test(url.pathname)
) {
return new Response(null, { status: 404 });
return onboardingFixtureState.savedClaudeLogin
? Response.json({ secretId: "saved-claude-subscription", latestVersion: 1 })
: new Response(null, { status: 404 });
}
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" },
secret: { id: `secret-${key}`, companyId: "company-storybook", scope: "user", status: "active" },
})) : []);
}
// The hire, and the three calls either side of it.
@ -315,7 +328,12 @@ function installStorybookApiFixtures() {
/^\/api\/companies\/[^/]+\/adapters\/([^/]+)\/test-environment$/,
);
if (testEnvMatch) {
return Response.json(storybookEnvironmentTest(testEnvMatch[1]));
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
const env = body.adapterConfig?.env ?? {};
const usesSavedCodex = onboardingFixtureState.savedCodexLogin && env.CODEX_HOME?.secretId === "saved-codex-home";
const usesSavedKey = onboardingFixtureState.savedApiKeys && ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].some((key) => env[key]?.type === "user_secret_ref" && env[key]?.key === `${key}.setup.storybook`);
const usesSavedClaude = onboardingFixtureState.savedClaudeLogin && env.CLAUDE_CODE_OAUTH_TOKEN?.type === "user_secret_ref" && env.CLAUDE_CODE_OAUTH_TOKEN?.key === "CLAUDE_CODE_OAUTH_TOKEN";
return Response.json(usesSavedCodex || usesSavedKey || usesSavedClaude ? { adapterType: testEnvMatch[1], status: "pass", checks: [], testedAt: new Date(0).toISOString() } : storybookEnvironmentTest(testEnvMatch[1]));
}
if (/^\/api\/companies\/[^/]+\/agent-hires$/.test(url.pathname)) {
// `approval: null` on purpose. A hire that returns one sends the wizard
@ -501,7 +519,7 @@ function installStorybookApiFixtures() {
if (secretsListMatch) {
const [, companyId] = secretsListMatch;
return Response.json(
companyId === "company-storybook" ? storybookSecrets : [],
companyId === "company-storybook" ? [...storybookSecrets, ...(onboardingFixtureState.savedCodexLogin ? [{ ...storybookSecrets[0], id: "saved-codex-home", key: "codex_home_saved", name: "CODEX_HOME_team-account" }] : [])] : [],
);
}

View File

@ -29,6 +29,9 @@ export const STORYBOOK_SANDBOX_ENVIRONMENT_ID = "environment-storybook-sandbox";
interface FixtureState {
environments: OnboardingEnvironmentState;
authSignal: AdapterAuthSignal;
savedApiKeys: boolean;
savedClaudeLogin: boolean;
savedCodexLogin: boolean;
}
/**
@ -38,6 +41,9 @@ interface FixtureState {
export const onboardingFixtureState: FixtureState = {
environments: "managed-sandbox",
authSignal: "absent",
savedApiKeys: false,
savedClaudeLogin: false,
savedCodexLogin: false,
};
export function setOnboardingFixtureState(next: Partial<FixtureState>): void {
@ -47,6 +53,9 @@ export function setOnboardingFixtureState(next: Partial<FixtureState>): void {
export function resetOnboardingFixtureState(): void {
onboardingFixtureState.environments = "managed-sandbox";
onboardingFixtureState.authSignal = "absent";
onboardingFixtureState.savedApiKeys = false;
onboardingFixtureState.savedClaudeLogin = false;
onboardingFixtureState.savedCodexLogin = false;
}
/**

View File

@ -0,0 +1,140 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import {
AgentProviderConnection,
type ProviderConnection,
} from "@/components/new-agent/AgentProviderConnection";
import {
resetOnboardingFixtureState,
setOnboardingFixtureState,
STORYBOOK_SANDBOX_ENVIRONMENT_ID,
} from "../fixtures/onboardingEnvironment";
function ConnectionStory({
adapterType = "claude_local",
}: {
adapterType?: "claude_local" | "codex_local";
}) {
const [connection, setConnection] = useState<ProviderConnection | null>(null);
return (
<div className="w-full max-w-lg p-6">
<h2 className="mb-6 text-xl font-semibold">Connect a model</h2>
{connection ? (
<div role="status">
Connection selected. Continue to agent configuration.
</div>
) : (
<AgentProviderConnection
companyId="company-storybook"
adapterType={adapterType}
environmentId={STORYBOOK_SANDBOX_ENVIRONMENT_ID}
canLogin
onBack={() => {}}
onConnected={setConnection}
testConnection={async (value) => {
if (
Object.values(value.env).some(
(binding) =>
typeof binding === "string" || binding.type === "plain",
)
)
throw new Error("Expected a stored reference");
return true;
}}
/>
)}
</div>
);
}
const meta = {
title: "Onboarding/Saved connections",
component: ConnectionStory,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Production agent connection component with fixture API responses. Provider tests and subsequent hiring are simulated. Choose subscription or API key, reuse a saved connection, or enter a new key.",
},
},
},
beforeEach: () => {
resetOnboardingFixtureState();
return resetOnboardingFixtureState;
},
} satisfies Meta<typeof ConnectionStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ClaudeSubscription: Story = {
beforeEach: () => {
setOnboardingFixtureState({
savedClaudeLogin: true,
authSignal: "present",
});
return resetOnboardingFixtureState;
},
play: async ({ canvasElement }) => {
await userEvent.click(await within(canvasElement).findByRole("radio"));
},
};
export const NewClaudeSubscription: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(await within(canvasElement).findByRole("radio"));
},
};
export const NewCodexSubscription: Story = {
args: { adapterType: "codex_local" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "Use subscription instead" }));
await userEvent.click(canvas.getByRole("radio"));
},
};
export const ClaudeApiKeys: Story = {
beforeEach: () => {
setOnboardingFixtureState({ savedApiKeys: true });
return resetOnboardingFixtureState;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("radio"));
await expect(
await canvas.findByRole("combobox", { name: "Saved API key" }),
).not.toHaveValue("");
},
};
export const CodexApiKeys: Story = {
...ClaudeApiKeys,
args: { adapterType: "codex_local" },
};
export const ReuseClaudeApiKey: Story = {
...ClaudeApiKeys,
play: async (context) => {
await ClaudeApiKeys.play!(context);
const canvas = within(context.canvasElement);
await userEvent.selectOptions(
canvas.getByRole("combobox"),
"user:ANTHROPIC_API_KEY",
);
await userEvent.click(
canvas.getByRole("button", { name: "Use saved API key" }),
);
await expect(await canvas.findByRole("status")).toHaveTextContent(
"Connection selected",
);
},
};
export const CodexSubscription: Story = {
args: { adapterType: "codex_local" },
beforeEach: () => {
setOnboardingFixtureState({ savedCodexLogin: true, authSignal: "unknown" });
return resetOnboardingFixtureState;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole("combobox", { name: "Saved subscription" });
await userEvent.click(canvas.getByRole("radio"));
},
};

View File

@ -348,8 +348,9 @@ function signedOutConnectionFixture() {
async function openProviderConnection(provider: "Claude" | "OpenAI", mode: "subscription" | "api") {
await advance("Connect a model");
if (mode === "api") {
await userEvent.click(await screen.findByRole("button", { name: "Use API key instead" }, { timeout: STEP_TIMEOUT_MS }));
await userEvent.click(await screen.findByRole("button", { name: "Use API key instead" }, { timeout: STEP_TIMEOUT_MS }));
if (mode === "subscription") {
await userEvent.click(await screen.findByRole("button", { name: "Use subscription instead" }, { timeout: STEP_TIMEOUT_MS }));
}
await userEvent.click(await screen.findByRole("radio", { name: new RegExp(`^${provider} `) }, { timeout: STEP_TIMEOUT_MS }));
if (mode === "api") {
@ -385,3 +386,44 @@ export const CodexApiKey: StoryObj = {
render: () => <WizardArc />,
play: () => openProviderConnection("OpenAI", "api"),
};
/** Production onboarding, with the current user's previously saved provider key. */
export const ConnectWithSavedApiKey: StoryObj = {
beforeEach: () => {
setOnboardingFixtureState({ savedApiKeys: true, authSignal: "absent" });
return resetOnboardingFixtureState;
},
render: () => <WizardArc />,
play: async () => {
await advance("Connect a model");
await pickFirstSource();
await screen.findByRole("combobox", { name: "Saved API key" }, { timeout: STEP_TIMEOUT_MS });
await expect(screen.getByRole("combobox", { name: "Saved API key" })).toHaveValue("user:ANTHROPIC_API_KEY");
},
};
export const ConnectWithSavedChatGptSubscription: StoryObj = {
beforeEach: () => {
setOnboardingFixtureState({ savedCodexLogin: true, authSignal: "unknown" });
return resetOnboardingFixtureState;
},
render: () => <WizardArc />,
play: async () => {
await advance("Connect a model");
await userEvent.click(screen.getByRole("radio", {name: /OpenAI/}));
await screen.findByRole("combobox", {name: "Saved subscription"}, {timeout: STEP_TIMEOUT_MS});
},
};
export const ConnectWithSavedClaudeSubscription: StoryObj = {
beforeEach: () => {
setOnboardingFixtureState({ savedClaudeLogin: true, savedApiKeys: true, authSignal: "absent" });
return resetOnboardingFixtureState;
},
render: () => <WizardArc />,
play: async () => {
await advance("Connect a model");
await pickFirstSource();
await expect(screen.queryByRole("combobox", { name: "Saved API key" })).not.toBeInTheDocument();
},
};