Fix onboarding account changes after returning from Review (#19557)

Save changed onboarding connections on the existing agent after validation.
Select the chosen personal provider account and serialize default changes so
an abandoned save cannot override a later choice after Back. Preserve runner,
environment, and unrelated agent settings. Reject unsupported managed-account
to legacy-secret switches with an actionable Connect error.

Verified with 327 component tests, three backend credential-selection tests,
nine affected browser tests, typecheck, build, token gates, all CI jobs, and
Greptile 5/5 with no unresolved comments.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-06 11:55:29 -08:00
1 parent 79c724b292
commit 17901d74ba
4 files changed
+359 -32

No files matched your search

+20 -2
View File
@@ -10,14 +10,32 @@ homes. Managed default/shared accounts are additional choices in that same
selector. Selecting “Sign in to another account” survives background refreshes;
Claude authorization paste keeps upstream's immediate Connecting feedback.
The onboarding sign-in link and footer use the same action. Either opens the
provider in a new tab and disables the footer with Waiting for code…. The code
field stays available. A Claude code entered without a paste event can be sent
provider in a new tab and disables the footer with Waiting for auth… for OpenAI
or Waiting for code… for Claude. The Claude code field stays available. A Claude code entered without a paste event can be sent
with Submit code in the footer or Enter in the field. These actions submit to
the current login session and do not open another provider window.
Failed or expired sign-ins show a short message and a Retry sign-in footer
button. Retry releases the old session before starting a fresh sign-in. It keeps
the onboarding setup and clears the old code.
Returning from Review to Connect can change the existing agent's connection.
Connect verifies and saves the current selection before returning to Review;
it does not create another agent. A new API account selected in onboarding becomes
the user's provider default, so a previous subscription does not override that
choice. A failed default selection can retry the saved account without creating
another connection. Failed verification or saving keeps the user on Connect.
Switching back to subscription explicitly selects the completed subscription
account again. Browser sign-in uses its saved session result; local sign-in uses
its saved account result. Reusing the entered API key selects that account again.
Default changes for the same company and provider run in order. Connect waits
for earlier changes before deciding whether the selected account is already the
default. A failed or lost response cannot count as a confirmed default selection.
The unchanged-setup shortcut expires when another default selection is attempted,
including one that finishes after Back.
An agent that already uses a connected account cannot switch to a legacy secret
binding. Selecting one stays on Connect and directs the user to choose a connected
API account or connect a new key. Unmanaged setup can still reuse saved secrets.
New-agent Connect offers three persistent tiles: the provider's subscription,
the provider's API key, and Advanced. All three stay visible while the selected
mode's form is shown below. Advanced opens the existing compatible-connection
@@ -11,4 +11,9 @@ export async function mockOnboardingLocalAiConnection(page: Page) {
expect(route.request().postDataJSON()).toMatchObject({ method: "subscription", ownership: "personal" });
await route.fulfill({ json: { connectionId, grantId } });
});
await page.route("**/ai-connections/default", async (route) => {
expect(route.request().method()).toBe("PUT");
expect(route.request().postDataJSON()).toEqual({ grantId });
await route.fulfill({ json: { ok: true } });
});
}
+195
View File
@@ -36,6 +36,8 @@ const managedApi = vi.hoisted(() => ({
cancelLocalLogin: vi.fn(async () => ({})),
connectLocal: vi.fn(async () => ({ connectionId: "local-connection", grantId: "local-grant" })),
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
setDefault: vi.fn(async (_companyId: string, _grantId: string) => ({})),
loginResult: vi.fn(async () => ({ connectionId: "subscription-account", grantId: "subscription-grant" })),
}));
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi }));
vi.mock("../api/auth", async (importOriginal) => {
@@ -72,6 +74,7 @@ const mockGoalsApi = vi.hoisted(() => ({
list: vi.fn(async () => []),
}));
const mockAgentsApi = vi.hoisted(() => ({
get: vi.fn(),
create: vi.fn(async (_company: string, input: Record<string, unknown>) => ({ ...input, id: input.setupId, pauseReason: "setup" })),
update: vi.fn(async (_id: string, input: Record<string, unknown>) => input),
adapterModels: vi.fn(async () => [] as Array<{ id: string; label: string }>),
@@ -354,6 +357,8 @@ function isArcPrimary(text: string): boolean {
describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => {
beforeEach(() => {
managedApi.list.mockReset().mockResolvedValue({ currentUserId: "user-1", connections: [] });
managedApi.create.mockReset().mockResolvedValue({ connectionId: "managed-connection", grantId: "managed-grant" });
mockAdaptersApi.list.mockReset().mockResolvedValue([]);
mockEnvironmentsApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
@@ -422,6 +427,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
testedAt: new Date().toISOString(),
});
mockAgentsApi.hire.mockReset();
mockAgentsApi.get.mockReset();
managedApi.setDefault.mockReset().mockResolvedValue({});
managedApi.loginResult.mockReset().mockResolvedValue({ connectionId: "subscription-account", grantId: "subscription-grant" });
mockAgentsApi.list.mockReset().mockResolvedValue([]);
mockAgentsApi.create.mockReset().mockImplementation(async (_company, input) => ({ ...input, id: input.setupId, pauseReason: "setup" }));
mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
@@ -1102,6 +1110,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
expect(managedApi.create).toHaveBeenCalledTimes(1);
expect(managedApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", ownership: "personal", apiKey: KEY }));
expect(managedApi.setDefault).toHaveBeenLastCalledWith("company-new", "managed-grant");
const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { runtimeConfig: { aiConnection: unknown }; adapterConfig: { env?: Record<string, unknown> } };
expect(hireBody.runtimeConfig.aiConnection).toEqual({ provider: "anthropic", method: "api_key", mode: "responsible_user" });
expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toBeUndefined();
@@ -1112,6 +1121,19 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
it("retries selecting the saved API account without creating another connection", async () => {
managedApi.setDefault.mockRejectedValueOnce(new Error("default selection failed"));
const { root, clickByText } = await connectWithApiKey();
try {
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("default selection failed");
await clickByText(t => isArcPrimary(t));
expect(managedApi.create).toHaveBeenCalledTimes(1);
expect(managedApi.setDefault).toHaveBeenCalledTimes(2);
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
} finally { await act(async () => root.unmount()); }
});
it("creates a managed connection without rotating an existing saved key", async () => {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([
{ definition: { id: "old-def", key: "ANTHROPIC_API_KEY" }, secret: { id: "secret-existing" } },
@@ -2139,6 +2161,178 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
return { root, queryClient };
}
it.each((["success", "failed probe", "failed save"] as const).flatMap(outcome => [false, true].map(native => ({ outcome, native }))))("saves the API connection after returning from a completed subscription setup: $outcome, native $native", async ({ outcome, native }) => {
managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{
id: "subscription-account", grantId: "subscription-grant", companyId: "company-new",
provider: "openai", method: "subscription", ownership: "personal", ownerUserId: "user-1",
isDefault: true, status: "connected", name: "OpenAI subscription",
}] } as never);
mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT, { ...SANDBOX_ENVIRONMENT, id: "saved-environment" }]);
mockAgentsApi.get.mockResolvedValue({ id: "agent-1", companyId: "company-new", adapterType: native ? "paperclip_runner" : "codex_local", defaultEnvironmentId: "saved-environment",
adapterConfig: { ...(native ? { provider: "codex" } : {}), model: "gpt-5.4", customSetting: "preserve", env: { CODEX_HOME: { type: "secret_ref", secretId: "old-home" }, KEEP: { type: "plain", value: "keep" } } },
runtimeConfig: { aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" }, customRuntime: "preserve", heartbeat: { intervalSec: 300, enabled: false } },
});
const { root } = await openStep4({ adapterType: "codex_local", model: "gpt-5.4" });
try {
await pickSource(/OpenAI/);
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("Let's get started...");
if (outcome === "failed probe") mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "codex_local", status: "fail", checks: [], testedAt: new Date().toISOString() });
if (outcome === "failed save") mockAgentsApi.update.mockRejectedValueOnce(new Error("account save failed"));
const back = () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!;
await act(async () => back().click());
for (let i = 0; i < 10; i++) await flushReact();
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
await pickSource(/OpenAI.*API/);
await act(async () => setControlledValue(document.querySelector('input[aria-label="API key"]')!, "example-api-key"));
await pressArcPrimary();
expect(managedApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "openai", method: "api_key", apiKey: "example-api-key" }));
expect(managedApi.setDefault).toHaveBeenLastCalledWith("company-new", "managed-grant");
expect(mockAgentsApi.testEnvironment).toHaveBeenLastCalledWith("company-new", "codex_local", expect.objectContaining({ agentId: "agent-1", environmentId: "saved-environment", runner: native ? "paperclip" : "legacy" }), expect.anything());
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
const update = mockAgentsApi.update.mock.calls.find(([id, body]) => id === "agent-1" && (body.runtimeConfig as Record<string, unknown>)?.aiConnection);
if (outcome === "failed probe") {
expect(update).toBeUndefined();
expect(document.body.textContent).toContain("Connect a model");
return;
}
expect(update).toBeTruthy();
expect(update![1]).toMatchObject({ runner: native ? "paperclip" : "legacy", runtimeConfig: { aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" }, customRuntime: "preserve", heartbeat: { intervalSec: 300, enabled: false } }, adapterConfig: { customSetting: "preserve", env: { KEEP: { type: "plain", value: "keep" } } } });
expect((update![1].adapterConfig as { env?: Record<string, unknown> }).env).not.toHaveProperty("CODEX_HOME");
expect(JSON.stringify(update![1])).not.toContain("example-api-key");
expect(document.body.textContent).toContain(outcome === "failed save" ? "Connect a model" : "Let's get started...");
} finally { await act(async () => root.unmount()); }
});
it.each(["completed", "before selection", "after selection", "failed response after selection"] as const)("selects the subscription again after a subscription to API to subscription round trip (API save: %s)", async completion => {
const interrupted = completion !== "completed";
const accounts: import("@paperclipai/shared").AiConnectionList = { currentUserId: "user-1", canManageConnections: true, connections: [{
id: "subscription-account", grantId: "subscription-grant", companyId: "company-new", provider: "openai", method: "subscription",
ownership: "personal", ownerUserId: "user-1", isDefault: true, status: "connected", name: "My subscription",
}] };
managedApi.list.mockImplementation(async () => structuredClone(accounts) as never);
managedApi.create.mockImplementationOnce(async () => {
accounts.connections.push({ ...accounts.connections[0]!, id: "managed-connection", grantId: "managed-grant", method: "api_key", isDefault: false, name: "My API key" });
return { connectionId: "managed-connection", grantId: "managed-grant" };
});
let finishApiDefault!: () => void;
let held = false;
managedApi.setDefault.mockImplementation(async (_companyId, grantId) => {
const apply = () => { for (const account of accounts.connections) account.isDefault = account.grantId === grantId; };
if (interrupted && grantId === "managed-grant" && !held) {
held = true;
return new Promise<Record<string, never>>((resolve, reject) => { finishApiDefault = () => {
apply();
if (completion === "failed response after selection") reject(new Error("API default response was lost"));
else resolve({});
}; });
}
apply();
return {};
});
mockAgentsApi.get.mockResolvedValue({ id: "agent-1", companyId: "company-new", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {} });
const { root } = await openStep4({ adapterType: "codex_local" });
const goBack = async () => {
await act(async () => [...document.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
for (let i = 0; i < 10; i++) await flushReact();
};
try {
await pickSource(/OpenAI/);
await goBack();
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
await pickSource(/OpenAI.*API/);
await act(async () => setControlledValue(document.querySelector('input[aria-label="API key"]')!, "example-api-key"));
await pressArcPrimary();
if (!interrupted) expect(accounts.connections.find(account => account.isDefault)?.method).toBe("api_key");
await goBack();
if (completion === "before selection") await act(async () => finishApiDefault());
await act(async () => (document.querySelector('[aria-label="Use subscription instead"]') as HTMLButtonElement).click());
await pickSource(/OpenAI/);
if (completion === "after selection" || completion === "failed response after selection") {
expect(document.body.textContent).toContain("Connect a model");
expect(document.body.textContent).not.toContain("Let's get started...");
await act(async () => finishApiDefault());
await vi.waitFor(() => expect(document.body.textContent).toContain("Let's get started..."));
}
expect(accounts.connections.find(account => account.isDefault)?.grantId).toBe("subscription-grant");
expect(document.body.textContent).toContain("Let's get started...");
await goBack();
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
await pickSource(/OpenAI.*API/);
await pressArcPrimary();
expect(accounts.connections.find(account => account.isDefault)?.grantId).toBe("managed-grant");
if (completion === "completed") for (const completeBeforeReselect of [true, false]) {
let finishSubscriptionDefault!: () => void;
managedApi.setDefault.mockImplementationOnce(async (_companyId, grantId) => new Promise<Record<string, never>>(resolve => {
finishSubscriptionDefault = () => { for (const account of accounts.connections) account.isDefault = account.grantId === grantId; resolve({}); };
}));
await goBack();
await act(async () => (document.querySelector('[aria-label="Use subscription instead"]') as HTMLButtonElement).click());
await pickSource(/OpenAI/);
await goBack();
if (completeBeforeReselect) await act(async () => finishSubscriptionDefault());
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
await pickSource(/OpenAI.*API/);
await pressArcPrimary();
if (!completeBeforeReselect) {
expect(document.body.textContent).toContain("Connect a model");
expect(document.body.textContent).not.toContain("Let's get started...");
await act(async () => finishSubscriptionDefault());
}
await vi.waitFor(() => expect(document.body.textContent).toContain("Let's get started..."));
expect(accounts.connections.find(account => account.isDefault)?.grantId).toBe("managed-grant");
}
expect(managedApi.create).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
} finally { await act(async () => root.unmount()); }
});
it("selects the exact newly signed-in subscription when an API account was the default", async () => {
managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{
id: "api-account", grantId: "api-grant", companyId: "company-new", provider: "openai", method: "api_key",
ownership: "personal", ownerUserId: "user-1", isDefault: true, status: "connected", name: "My API key",
}] } as never);
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({ sessionId: "codex-session-1", status: "authenticated", prompt: { url: "https://auth.openai.com/codex/device", code: "TEST-CODE" } });
managedApi.loginResult.mockResolvedValue({ connectionId: "new-subscription", grantId: "new-subscription-grant" });
const { root } = await openStep4({ adapterType: "codex_local", credentialModeChoice: "subscription" });
try {
await pickSource(/OpenAI/);
await vi.waitFor(() => expect(managedApi.loginResult).toHaveBeenCalledWith("company-new", "codex-session-1"), { timeout: CONNECTED_HOLD_MS + 1500 });
expect(managedApi.setDefault).toHaveBeenLastCalledWith("company-new", "new-subscription-grant");
await vi.waitFor(() => expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1));
} finally { await act(async () => root.unmount()); }
});
it.each(["company", "user"])("keeps a managed agent on Connect when a saved %s key needs a connected API account", async scope => {
managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{
id: "subscription-account", grantId: "subscription-grant", companyId: "company-new", provider: "openai", method: "subscription",
ownership: "personal", ownerUserId: "user-1", isDefault: true, status: "connected", name: "My subscription",
}] } as never);
if (scope === "company") mockSecretsApi.list.mockResolvedValue([{ id: "saved-key", companyId: "company-new", name: "Saved API key", key: "OPENAI_API_KEY", scope: "company", status: "active" }] as never);
else mockSecretsApi.listMyUserSecrets.mockResolvedValue([{ definition: { id: "saved-key", companyId: "company-new", name: "Saved API key", key: "OPENAI_API_KEY", status: "active" }, secret: { companyId: "company-new", status: "active" } }] as never);
mockAgentsApi.get.mockResolvedValue({ id: "agent-1", companyId: "company-new", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: { aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" } } });
const { root } = await openStep4({ adapterType: "codex_local" });
try {
await pickSource(/OpenAI/);
await act(async () => [...document.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
for (let i = 0; i < 10; i++) await flushReact();
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
await pickSource(/OpenAI.*API/);
await pressArcPrimary();
expect(document.body.textContent).toContain("Connect a model");
expect(document.body.textContent).toContain("Enter a new API key");
expect(mockAgentsApi.update.mock.calls.filter(([id]) => id === "agent-1")).toHaveLength(0);
const select = document.querySelector('select[aria-label="Saved API key"]') as HTMLSelectElement;
await act(async () => { select.value = ""; select.dispatchEvent(new Event("change", { bubbles: true })); });
await act(async () => setControlledValue(document.querySelector('input[aria-label="API key"]')!, "example-api-key"));
await pressArcPrimary();
expect(document.body.textContent).toContain("Let's get started...");
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
expect(managedApi.create).toHaveBeenCalledTimes(1);
} finally { await act(async () => root.unmount()); }
});
// N-16: the gateway is an advanced route, not the next thing after the key.
// It sat directly under the API key field, inside the key card; it belongs
// in the step's Advanced disclosure, hidden until that is opened.
@@ -2384,6 +2578,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
["claude_local", "anthropic", /Claude/, "claude-session-1", "claude-setup-token-status"],
["codex_local", "openai", /OpenAI/, "codex-session-1", "adapter-login-status"],
] as const)("finishes %s sign-in when its connection becomes visible before the completion poll", async (adapterType, provider, label, sessionId, statusKey) => {
managedApi.loginResult.mockResolvedValue({ connectionId: "managed-connection", grantId: "managed-grant" });
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.hire.mockRejectedValueOnce(new Error("Temporary hire failure"));
const { root, queryClient } = await openStep4({ adapterType });
+139 -30
View File
@@ -2,7 +2,7 @@ import { useQueryView } from "@/components/QueryView";
import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared";
import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup";
import { runOnboardingConnectionTest } from "../lib/onboarding-connection-test";
import { agentHarnessType } from "@paperclipai/shared";
import { agentHarnessType, agentRunner } from "@paperclipai/shared";
import { adaptersApi, type AdapterInfo } from "../api/adapters";
import { healthApi } from "@/api/health";
import { describeError } from "@/api/errors";
@@ -736,8 +736,9 @@ function OnboardingWizardInner({
* customer on the step to try again — and without this each press would store
* another copy of the same credential.
*/
const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; binding?: Awaited<ReturnType<typeof storeProviderApiKey>>["binding"]; aiConnection?: AiConnectionBinding } | null>(null);
const managedSubscriptionRef = useRef<{ companyId: string; binding: AiConnectionBinding } | null>(null);
const apiKeySecretRef = useRef<{ key: string; companyId: string; envKey: string; grantId?: string; binding?: Awaited<ReturnType<typeof storeProviderApiKey>>["binding"]; aiConnection?: AiConnectionBinding } | null>(null);
const managedSubscriptionRef = useRef<{ companyId: string; binding: AiConnectionBinding; grantId?: string; sessionId?: string } | null>(null);
const defaultSelectionsRef = useRef(new Map<string, { pending: Promise<unknown>; queued: number; revision: number; grantId?: string | null }>());
const managedProvider = aiProviderForAdapter(adapterType);
const managedSubscriptionProvider = managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? managedProvider : undefined;
function managedBindingForStep(): AiConnectionBinding | undefined {
@@ -763,6 +764,15 @@ function OnboardingWizardInner({
);
const connectionModels = connectionModelState.connectionModels;
const hasGatewaySelection = credentialMode === "api" && gatewayConnection?.companyId === createdCompanyId && gatewayConnection.adapterType === adapterType;
const connectionSelection = JSON.stringify([createdCompanyId, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, gatewayConnection]);
const connectedSelectionRef = useRef<{ agentId: string; selection: string; defaultRevision: number } | null>(null);
function rememberConnectedSelection(agentId: string) {
connectedSelectionRef.current = { agentId, selection: connectionSelection,
defaultRevision: defaultSelectionsRef.current.get(JSON.stringify([createdCompanyId, managedProvider]))?.revision ?? 0 };
}
if (createdAgentId && connectedSelectionRef.current?.agentId !== createdAgentId) {
rememberConnectedSelection(createdAgentId);
}
const gatewayLookupFailed = hasGatewaySelection && !connectionModelState.refreshing
&& Boolean(connectionModelState.error || (!connectionModels && !connectionModelState.loading));
const connectionModelReady = (!hasGatewaySelection && !connectionModels) || Boolean(connectionModels
@@ -1974,21 +1984,26 @@ function OnboardingWizardInner({
* a stop: there is deliberately no path that hands the raw key back, because
* the only thing left to do with it would be to embed it.
*/
async function storeApiKeyUserSecret(companyId: string): Promise<boolean> {
async function storeApiKeyUserSecret(companyId: string, isCurrent = () => true): Promise<boolean> {
const key = apiKey.trim();
const envKey = apiKeyEnvKeyFor(adapterType);
if (apiKeySecretRef.current?.key === key && apiKeySecretRef.current.companyId === companyId && apiKeySecretRef.current.envKey === envKey) return true;
const stored = apiKeySecretRef.current?.key === key && apiKeySecretRef.current.companyId === companyId && apiKeySecretRef.current.envKey === envKey
? apiKeySecretRef.current : null;
if (stored?.binding) return true;
try {
if (managedProvider) {
await aiConnectionsApi.create(companyId, { provider: managedProvider, method: "api_key", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} API`, ownership: "personal", apiKey: key, agentIds: [], allAgents: true });
apiKeySecretRef.current = { key, companyId, envKey, aiConnection: { provider: managedProvider, method: "api_key", mode: "responsible_user" } };
const grantId = stored?.grantId ?? (await aiConnectionsApi.create(companyId, { provider: managedProvider, method: "api_key", name: `My ${CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} API`, ownership: "personal", apiKey: key, agentIds: [], allAgents: true })).grantId;
apiKeySecretRef.current = { key, companyId, envKey, grantId };
if (!isCurrent()) return false;
if (!await selectManagedDefault(companyId, managedProvider, grantId, isCurrent)) return false;
apiKeySecretRef.current = { key, companyId, envKey, grantId, aiConnection: { provider: managedProvider, method: "api_key", mode: "responsible_user" } };
return true;
}
const stored = await storeProviderApiKey(companyId, envKey, key);
apiKeySecretRef.current = { key, companyId, envKey, binding: stored.binding };
const legacyStored = await storeProviderApiKey(companyId, envKey, key);
apiKeySecretRef.current = { key, companyId, envKey, binding: legacyStored.binding };
return true;
} catch (err) {
setError(
if (isCurrent()) setError(
err instanceof Error
? `Could not store the API key: ${describeError(err).body}`
: "Could not store the API key.",
@@ -1997,11 +2012,62 @@ function OnboardingWizardInner({
}
}
function buildAdapterConfig(bindApiKey = false, setupRunner?: AgentRunner): Record<string, unknown> {
function selectManagedDefault(companyId: string, provider: string, grantId: string, isCurrent: () => boolean, visibleDefault?: string) {
const key = JSON.stringify([companyId, provider]);
let selection = defaultSelectionsRef.current.get(key);
if (!selection) {
selection = { pending: Promise.resolve(), queued: 0, revision: 0 };
defaultSelectionsRef.current.set(key, selection);
}
const state = selection;
state.revision++;
state.queued++;
const request = state.pending.then(async () => {
if (!isCurrent()) return false;
if (visibleDefault === grantId && (state.grantId === undefined || state.grantId === grantId)) return true;
state.grantId = null;
try {
await aiConnectionsApi.setDefault(companyId, grantId);
state.grantId = grantId;
} finally {
void queryClient.invalidateQueries({ queryKey: ["ai-connections", companyId] });
}
return isCurrent();
}).finally(() => { state.queued--; });
state.pending = request.catch(() => {});
return request;
}
async function selectManagedSubscription(companyId: string, isCurrent: () => boolean) {
const binding = managedBindingForStep();
if (binding?.mode !== "responsible_user") return;
const cached = managedSubscriptionRef.current?.companyId === companyId
&& managedSubscriptionRef.current.binding.provider === binding.provider ? managedSubscriptionRef.current : null;
let grantId = cached?.grantId;
if (!grantId && cached?.sessionId) grantId = (await aiConnectionsApi.loginResult(companyId, cached.sessionId)).grantId;
const visibleDefault = savedSubscription?.aiConnection?.mode === "responsible_user" && savedSubscription.id.startsWith("ai:")
? savedSubscription.id.slice(3) : undefined;
grantId ??= visibleDefault;
if (!grantId) {
const accounts = await aiConnectionsApi.list(companyId);
if (!isCurrent()) return;
const subscriptions = accounts.connections.filter(account => account.provider === binding.provider && account.method === "subscription"
&& account.ownership === "personal" && account.ownerUserId === accounts.currentUserId && account.status === "connected");
const selected = subscriptions.find(account => account.isDefault) ?? (subscriptions.length === 1 ? subscriptions[0] : undefined);
grantId = selected?.grantId;
}
if (!isCurrent()) return;
if (!grantId) throw new Error("Choose a subscription account in Connections, then retry setup.");
managedSubscriptionRef.current = { companyId, binding, grantId };
await selectManagedDefault(companyId, binding.provider, grantId, isCurrent, visibleDefault);
}
function buildAdapterConfig(bindApiKey = false, setupRunner?: AgentRunner, existingAgent?: Agent): Record<string, unknown> {
const adapter = getUIAdapter(adapterType);
const savedConfig = restoredSetup?.companyId === createdCompanyId
&& agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType)
? restoredSetup.adapterConfig : {};
const savedAgent = existingAgent ?? restoredSetup;
const savedConfig = savedAgent?.companyId === createdCompanyId
&& agentHarnessType(savedAgent.adapterType, savedAgent.adapterConfig) === agentHarnessType(adapterType)
? savedAgent.adapterConfig : {};
const builtConfig = adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
@@ -2029,6 +2095,10 @@ function OnboardingWizardInner({
: defaultCreateValues.dangerouslyBypassSandbox
});
const savedEnv = { ...((savedConfig.env as Record<string, unknown>) ?? {}) };
if (adapterType === "codex_local" && (existingAgent || managedBindingForStep())) {
if (credentialMode === "api" || managedBindingForStep()) delete savedEnv.CODEX_HOME;
if (credentialMode === "subscription" || managedBindingForStep()) delete savedEnv.OPENAI_API_KEY;
}
if (adapterType === "claude_local") {
if (credentialMode === "api" || managedBindingForStep()) delete savedEnv[CLAUDE_OAUTH_TOKEN_ENV_KEY];
if (credentialMode === "subscription" || managedBindingForStep()) {
@@ -2081,10 +2151,11 @@ function OnboardingWizardInner({
return config;
}
function buildSetupRuntimeConfig(heartbeatEnabled: boolean) {
const { aiConnection: _savedBinding, ...runtimeConfig } = restoredSetup?.companyId === createdCompanyId ? restoredSetup.runtimeConfig : {};
function buildSetupRuntimeConfig(heartbeatEnabled: boolean, existingAgent?: Agent) {
const savedAgent = existingAgent ?? restoredSetup;
const { aiConnection: _savedBinding, ...runtimeConfig } = savedAgent?.companyId === createdCompanyId ? savedAgent.runtimeConfig : {};
const binding = managedBindingForStep();
return { ...runtimeConfig, ...buildNewAgentRuntimeConfig({ heartbeatEnabled }), ...(binding ? { aiConnection: binding } : {}) };
return { ...runtimeConfig, ...(!existingAgent ? buildNewAgentRuntimeConfig({ heartbeatEnabled }) : {}), ...(binding ? { aiConnection: binding } : {}) };
}
const setupSnapshot = step === 4 && setupPrepared && setupAgentId && !createdAgentId && !loading
@@ -2115,6 +2186,7 @@ function OnboardingWizardInner({
appliedStoredClaudeLoginBinding = false,
isCurrent = () => true,
onQualifiedConfig?: (config: Record<string, unknown>, adapters: AdapterInfo[], environmentId: string | null) => void,
existingAgent?: Agent,
): Promise<AdapterEnvironmentTestResult | null> {
if (!runnerDiscoveryReady) return null;
if (!createdCompanyId) {
@@ -2124,6 +2196,9 @@ function OnboardingWizardInner({
return null;
}
const selection = adapterSetupSelectionRef.current;
const savedAgent = existingAgent ?? restoredSetup;
const testRunner = existingAgent && runner === "auto" && agentHarnessType(existingAgent.adapterType, existingAgent.adapterConfig) === adapterType
? agentRunner(existingAgent.adapterType) : runner;
const selectionIsCurrent = () => isCurrent() && adapterSetupSelectionRef.current === selection;
adapterEnvErrorSelectionRef.current = null;
setAdapterEnvLoading(true);
@@ -2165,7 +2240,7 @@ function OnboardingWizardInner({
// resolver throws when no managed sandbox is available, which the outer
// catch surfaces as a fail-closed error rather than a local host probe.
const environmentId = resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: restoredSetup?.companyId === createdCompanyId ? restoredSetup.defaultEnvironmentId : null,
agentDefaultEnvironmentId: savedAgent?.companyId === createdCompanyId ? savedAgent.defaultEnvironmentId : null,
instanceDefaultEnvironmentId: settings?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environmentList),
managedSandboxOnly,
@@ -2236,8 +2311,8 @@ function OnboardingWizardInner({
adapterType,
{
adapterConfig: testedConfig,
...(restoredSetup?.companyId === createdCompanyId ? { agentId: restoredSetup.id } : {}),
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
...(savedAgent?.companyId === createdCompanyId ? { agentId: savedAgent.id } : {}),
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && testRunner !== "auto" ? { runner: testRunner } : {}),
...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}),
environmentId,
},
@@ -2312,7 +2387,9 @@ function OnboardingWizardInner({
setError("Choose a model for this gateway connection before connecting your agent.");
return;
}
if (createdAgentId) {
const defaultSelection = defaultSelectionsRef.current.get(JSON.stringify([createdCompanyId, managedProvider]));
if (createdAgentId && connectedSelectionRef.current?.selection === connectionSelection
&& !defaultSelection?.queued && connectedSelectionRef.current.defaultRevision === (defaultSelection?.revision ?? 0)) {
setStep(5);
return;
}
@@ -2382,16 +2459,30 @@ function OnboardingWizardInner({
// hire rather than falling through to a configuration with no credential.
let apiKeyStored = false;
if (credentialMode === "api" && !selectedApiKey && apiKey.trim()) {
apiKeyStored = await storeApiKeyUserSecret(createdCompanyId);
apiKeyStored = await storeApiKeyUserSecret(createdCompanyId, isCurrent);
if (!apiKeyStored || !isCurrent()) return;
}
if (credentialMode !== "api" && canUseLocalLogin && managedProvider && !managedBindingForStep() && !savedSubscription && !savedKeys.storedLogin.data) {
await localLogin.connect();
if (!isCurrent()) return;
managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } };
if (credentialMode === "api" && selectedApiKey?.aiConnection?.mode === "responsible_user") {
const grantId = selectedApiKey.id.startsWith("ai:") ? selectedApiKey.id.slice(3) : undefined;
if (!grantId) throw new Error("Choose a connected API account before continuing.");
if (!await selectManagedDefault(createdCompanyId, selectedApiKey.aiConnection.provider, grantId, isCurrent, grantId)) return;
}
if (credentialMode !== "api" && canUseLocalLogin && managedProvider && !managedBindingForStep() && !savedSubscription && !savedKeys.storedLogin.data) {
const account = await localLogin.connect();
if (!isCurrent()) return;
managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" }, grantId: account.grantId };
}
if (credentialMode === "subscription") await selectManagedSubscription(createdCompanyId, isCurrent);
if (!isCurrent()) return;
const managedBinding = managedBindingForStep();
const baseAdapterConfig = buildAdapterConfig(apiKeyStored);
const existingAgent = createdAgentId ? await agentsApi.get(createdAgentId, createdCompanyId) : undefined;
if (!isCurrent()) return;
if (existingAgent?.runtimeConfig.aiConnection && credentialMode === "api" && selectedApiKey?.binding && !managedBinding) {
throw new Error("Choose a connected API account or Enter a new API key to replace this agent’s connected account.");
}
const existingRunner = existingAgent && runner === "auto" && agentHarnessType(existingAgent.adapterType, existingAgent.adapterConfig) === adapterType
? agentRunner(existingAgent.adapterType) : undefined;
const baseAdapterConfig = buildAdapterConfig(apiKeyStored, existingRunner, existingAgent);
let storedClaudeLogin: ClaudeOAuthTokenStatusResponse | null = null;
if (
!managedBinding && adapterType === "claude_local" &&
@@ -2436,7 +2527,7 @@ function OnboardingWizardInner({
const result =
cachedUsable ??
(await runAdapterEnvironmentTest(hireAdapterConfig, shouldApplyStoredClaudeLogin, isCurrent,
(config, adapters, environmentId) => { hireAdapterConfig = config; qualifiedDiscovery = { adapters, environmentId }; }));
(config, adapters, environmentId) => { hireAdapterConfig = config; qualifiedDiscovery = { adapters, environmentId }; }, existingAgent));
if (!result || !isCurrent()) return;
// Block the hire on a failed environment test. Also block it on a
// pass or a warn result that reports missing authentication — the
@@ -2461,6 +2552,22 @@ function OnboardingWizardInner({
if (!agentRole || !isCurrent()) return;
const hireName = agentName.trim() || AGENT_ROLE_LABELS[agentRole];
if (createdAgentId && existingAgent) {
await setupSave.current.catch(() => {});
if (!isCurrent()) return;
await agentsApi.update(createdAgentId, {
adapterType,
adapterConfig: hireAdapterConfig,
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && (runner !== "auto" || existingRunner) ? { runner: existingRunner ?? runner } : {}),
runtimeConfig: buildSetupRuntimeConfig(true, existingAgent),
}, createdCompanyId);
if (!isCurrent()) return;
rememberConnectedSelection(createdAgentId);
void queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(createdAgentId) });
setStep(5);
return;
}
// The company may already hold this agent. A wizard that reopens on the
// agent step after the hire — the dashboard's agentless offer on a stale
@@ -2532,6 +2639,7 @@ function OnboardingWizardInner({
if (!isCurrent()) return;
setCreatedAgentId(agent.id);
rememberConnectedSelection(agent.id);
// Advance to the Review step — the lead is now online. The user drives
// strategy + hiring from the planning chat after "Get started".
setStep(5);
@@ -3107,6 +3215,7 @@ function OnboardingWizardInner({
}}
onSubmit={() => handleConnectStepPrimary()}
/>}
{managedProvider && !selectedApiKey && !hasGatewaySelection && !connectionModels && <p className="text-xs text-muted-foreground">This will be your default {CONNECT_SOURCE_NAMES[adapterType] ?? managedProvider} account.</p>}
{hasGatewaySelection && (gatewayLookupFailed ? <>
<p role="alert" className="text-sm text-destructive">Your saved gateway connection could not be loaded. Retry to choose a model and connect your agent.</p>
<Button type="button" variant="outline" disabled={loading || adapterEnvLoading} onClick={() => { void connectionModelState.refresh(); }}>
@@ -3185,8 +3294,8 @@ function OnboardingWizardInner({
phaseBeforeSubmitRef.current === "ready" ? "ready" : "waiting",
);
}}
onConnected={() => {
if (managedSubscriptionProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedSubscriptionProvider, method: "subscription", mode: "responsible_user" } };
onConnected={(sessionId) => {
if (managedSubscriptionProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedSubscriptionProvider, method: "subscription", mode: "responsible_user" }, sessionId };
setConnectAuthUrl(null);
// Not into a card the customer has left. The panel is
// still mounted through Back's exit, and a login that