fix: reuse saved model connections during agent setup

Default to saved subscriptions or API keys, preserve credential references through onboarding and agent creation, and cover both flows with integration tests and Storybooks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-10 12:35:13 -05:00
1 parent 0d8bbf7cf4
commit 19413ee5f9
14 files changed
+1037 -122

No files matched your search

@@ -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.
+45 -36
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());
});
+79 -47
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
@@ -0,0 +1,231 @@
// @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,
) {
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 } } });
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("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: {} }));
});
});
@@ -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 haven’t 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 haven’t 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()}
@@ -0,0 +1,120 @@
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({
queryKey: ["claude-oauth-token-status", companyId],
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>
);
}
@@ -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" },
},
]);
});
+79
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" },
}));
}
+7
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,
+29 -1
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",
+21 -3
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" }] : [])] : [],
);
}
@@ -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;
}
/**
@@ -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"));
},
};
@@ -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();
},
};