mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
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:
1 parent
79c724b292
commit
17901d74ba
4 files changed
+359
-32
No files matched your search
@@ -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 } });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user