diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index c0029a63..22af8b30 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -430,13 +430,16 @@ async function postOnboarding( apiKey: string, baseURL?: string, ): Promise<{ readonly response: Response; readonly body: unknown }> { + // A key copied from a provider console often carries a trailing + // newline; sent verbatim the provider 401s a valid key (CL-6682). + const trimmedKey = apiKey.trim(); const response = await fetch(`/api/onboarding/${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify( baseURL !== undefined - ? { provider, apiKey, baseURL } - : { provider, apiKey }, + ? { provider, apiKey: trimmedKey, baseURL } + : { provider, apiKey: trimmedKey }, ), }); const body: unknown = await response.json().catch(() => null); diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 1749b9c8..41d99783 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -304,6 +304,27 @@ describe("submitCredential", () => { }); }); + // CL-6682: a key copied from a provider console often carries a + // trailing newline; sent verbatim the provider 401s a valid key. + test("strips leading/trailing whitespace from the pasted key", async () => { + let requestBody: unknown = null; + globalThis.fetch = (async (_url: string, init: RequestInit) => { + requestBody = JSON.parse((init as RequestInit).body as string); + return json({ + kind: "ready", + tenantSlug: "ada-user1", + deployed: [], + pending: [], + }); + }) as unknown as typeof fetch; + + await submitCredential("anthropic", " sk-ant-good\n"); + expect(requestBody).toEqual({ + provider: "anthropic", + apiKey: "sk-ant-good", + }); + }); + test("a bench whose agents are all live reports nothing pending", async () => { globalThis.fetch = (async () => json({ diff --git a/packages/settings-ui/src/connections-api.ts b/packages/settings-ui/src/connections-api.ts index 7762024e..b01a97e3 100644 --- a/packages/settings-ui/src/connections-api.ts +++ b/packages/settings-ui/src/connections-api.ts @@ -67,11 +67,13 @@ export function completeConnectorCredential( status: "active"; modelGuidance?: string; }> { + // A key copied from a provider console often carries a trailing + // newline; sent verbatim the provider 401s a valid key (CL-6682). return request( `/api/tenants/${tenantId}/connections/${connectorId}/complete`, CompleteResult, "saving that connection", - { method: "POST", body: JSON.stringify({ apiKey }) }, + { method: "POST", body: JSON.stringify({ apiKey: apiKey.trim() }) }, ); } diff --git a/packages/settings-ui/test/connections-api.test.ts b/packages/settings-ui/test/connections-api.test.ts index 7edfccda..db5772ab 100644 --- a/packages/settings-ui/test/connections-api.test.ts +++ b/packages/settings-ui/test/connections-api.test.ts @@ -94,6 +94,17 @@ describe("completeConnectorCredential", () => { expect(result).toEqual({ credentialId: "cred_1", status: "active" }); }); + // CL-6682: a key copied from a provider console often carries a + // trailing newline; sent verbatim it 401s a perfectly valid key. + test("strips leading/trailing whitespace from the pasted key", async () => { + const calls = stubFetch(() => + json({ credentialId: "cred_1", status: "active" }, 200), + ); + await completeConnectorCredential("tnt_1", "granola", " sk-good\n"); + const body = JSON.parse(String(calls[0]?.init?.body)); + expect(body).toEqual({ apiKey: "sk-good" }); + }); + // CL-6377: connecting is the one round-trip — a rejected key throws // straight from this call, with no separate test step beforehand. test("throws ConnectionsApiError with the probe's own message on a 422", async () => {