From e9828f8bf4b9fe24ee2bcac781080aaa98626f6c Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Thu, 10 Sep 2026 12:57:53 -0500
Subject: [PATCH] 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
---
docs/guides/board-operator/managing-agents.md | 20 ++
ui/src/components/OnboardingWizard.test.tsx | 81 +++---
ui/src/components/OnboardingWizard.tsx | 126 +++++----
.../AgentProviderConnection.test.tsx | 244 ++++++++++++++++++
.../new-agent/AgentProviderConnection.tsx | 151 ++++++++---
.../onboarding/SavedProviderKeySelect.tsx | 122 +++++++++
ui/src/lib/saved-provider-credentials.test.ts | 95 +++++++
ui/src/lib/saved-provider-credentials.ts | 79 ++++++
ui/src/pages/DesignGuide.tsx | 7 +
ui/src/pages/NewAgent.test.tsx | 30 ++-
ui/storybook/.storybook/preview.tsx | 24 +-
.../fixtures/onboardingEnvironment.ts | 9 +
.../agent-connection-reuse.stories.tsx | 140 ++++++++++
.../stories/onboarding-agent-arc.stories.tsx | 46 +++-
14 files changed, 1052 insertions(+), 122 deletions(-)
create mode 100644 ui/src/components/new-agent/AgentProviderConnection.test.tsx
create mode 100644 ui/src/components/onboarding/SavedProviderKeySelect.tsx
create mode 100644 ui/src/lib/saved-provider-credentials.test.ts
create mode 100644 ui/src/lib/saved-provider-credentials.ts
create mode 100644 ui/storybook/stories/agent-connection-reuse.stories.tsx
diff --git a/docs/guides/board-operator/managing-agents.md b/docs/guides/board-operator/managing-agents.md
index 1ada4ec87e..663767048a 100644
--- a/docs/guides/board-operator/managing-agents.md
+++ b/docs/guides/board-operator/managing-agents.md
@@ -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.
diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx
index 6b63e02480..15d35e13d5 100644
--- a/ui/src/components/OnboardingWizard.test.tsx
+++ b/ui/src/components/OnboardingWizard.test.tsx
@@ -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 };
@@ -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());
});
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
index 8c1dea01c2..2b7af85e04 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -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(
- (saved?.credentialMode as CredentialMode) ?? "subscription",
+ const [credentialModeChoice, setCredentialMode] = useState(
+ (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(
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>["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 {
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) }
: {};
- 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 && (
+
+ )}
+
{/* 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({
>
- {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.`}