Fix masked API key echoed back when editing custom providers

The custom provider edit form seeded the API key input with the masked
value (e.g. "abc•••••wxyz") returned by the sanitized GET. Saving or
running "Detect Models" without retyping echoed that mask back — the
probe endpoint rejects it and a persisted mask broke HTTP header
encoding. The field now starts blank with a "leave blank to keep" hint;
an empty field omits apiKey so the stored credential is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-12 01:06:33 -07:00
parent f0d2415c20
commit 0077b10ef7
4 changed files with 55 additions and 2 deletions

View File

@@ -2,4 +2,6 @@
"@runfusion/fusion": patch
---
Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected. Re-enter the real API key once to clear any already-corrupted key.
Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected.
The edit form no longer seeds the API key field with the masked value at all — it starts blank (with a "Leave blank to keep current key" hint) so the mask can never be echoed back to save or "Detect Models". Existing keys are preserved when the field is left empty.

View File

@@ -143,7 +143,11 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
setName(provider.name);
setApiType(provider.apiType);
setBaseUrl(provider.baseUrl);
setApiKey(provider.apiKey ?? "");
// The loaded provider's apiKey is masked (e.g. "abc•••••wxyz") for display.
// Never seed the editable field with the mask — echoing it back would send a
// masked value to save/probe (which the server rejects). Start empty; an
// unchanged blank field leaves the stored key untouched on save.
setApiKey("");
setModels((provider.models ?? []).map((model) => model.id).join(", "));
setFormError(null);
setDetectError(null);
@@ -366,6 +370,9 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
id="custom-provider-api-key"
type="password"
className="input"
placeholder={editingProvider?.apiKey
? t("providers.apiKeyKeepPlaceholder", "Leave blank to keep current key")
: undefined}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
disabled={saving}

View File

@@ -244,6 +244,49 @@ describe("CustomProvidersSection", () => {
});
});
it("does not echo the masked key back when editing without retyping", async () => {
mockFetchCustomProviders
.mockResolvedValueOnce([
{
id: "test-id",
name: "Keyed Provider",
apiType: "openai-compatible",
baseUrl: "https://api.example.com",
// Server returns the key masked for display.
apiKey: "abc•••••wxyz",
},
])
.mockResolvedValueOnce([
{
id: "test-id",
name: "Keyed Provider",
apiType: "openai-compatible",
baseUrl: "https://api.example.com",
},
]);
render(<CustomProvidersSection embedded />);
await waitFor(() => {
expect(screen.getByLabelText("Edit Keyed Provider")).toBeTruthy();
});
fireEvent.click(screen.getByLabelText("Edit Keyed Provider"));
// The API key field must start empty, never seeded with the mask.
const apiKeyInput = screen.getByLabelText("API key") as HTMLInputElement;
expect(apiKeyInput.value).toBe("");
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockUpdateCustomProvider).toHaveBeenCalledTimes(1);
});
// apiKey is omitted entirely so the stored credential is preserved.
const [, payload] = mockUpdateCustomProvider.mock.calls[0];
expect(payload).not.toHaveProperty("apiKey");
});
it("deletes provider after confirmation", async () => {
mockFetchCustomProviders
.mockResolvedValueOnce([

View File

@@ -4299,6 +4299,7 @@
"saving": "Saving..."
},
"addCustom": "Add Custom Provider",
"apiKeyKeepPlaceholder": "Leave blank to keep current key",
"apiKeyLabel": "API key",
"apiTypeAnthropic": "Anthropic-compatible",
"apiTypeInvalid": "API type is invalid.",