mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
fix(openclaw): preserve custom headers when toggling User-Agent (#7847)
The "Send User-Agent" toggle in the OpenClaw form replaced the whole headers object when turned on and deleted it when turned off, so every other custom header was lost on save. Update only the User-Agent entry: drop existing variants case-insensitively, add a canonical User-Agent when enabled, and remove the headers object only when it ends up empty. Detecting the toggle state is case-insensitive as well. Fixes #7846
This commit is contained in:
3 files changed
+411
-3
No files matched your search
@@ -51,6 +51,12 @@ function parseOpenclawField<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function hasUserAgentHeader(headers?: Record<string, string>): boolean {
|
||||
return Object.keys(headers ?? {}).some(
|
||||
(key) => key.toLowerCase() === "user-agent",
|
||||
);
|
||||
}
|
||||
|
||||
export function useOpenclawFormState({
|
||||
initialData,
|
||||
appId,
|
||||
@@ -99,7 +105,7 @@ export function useOpenclawFormState({
|
||||
"headers",
|
||||
{},
|
||||
);
|
||||
return "User-Agent" in headers;
|
||||
return hasUserAgentHeader(headers);
|
||||
});
|
||||
|
||||
const updateOpenclawConfig = useCallback(
|
||||
@@ -161,8 +167,15 @@ export function useOpenclawFormState({
|
||||
(enabled: boolean) => {
|
||||
setOpenclawUserAgent(enabled);
|
||||
updateOpenclawConfig((config) => {
|
||||
const headers = { ...config.headers };
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === "user-agent") delete headers[key];
|
||||
}
|
||||
if (enabled) {
|
||||
config.headers = { "User-Agent": OPENCLAW_DEFAULT_USER_AGENT };
|
||||
headers["User-Agent"] = OPENCLAW_DEFAULT_USER_AGENT;
|
||||
}
|
||||
if (Object.keys(headers).length > 0) {
|
||||
config.headers = headers;
|
||||
} else {
|
||||
delete config.headers;
|
||||
}
|
||||
@@ -177,7 +190,7 @@ export function useOpenclawFormState({
|
||||
setOpenclawApiKey(config?.apiKey || "");
|
||||
setOpenclawApi(config?.api || "openai-completions");
|
||||
setOpenclawModels(config?.models || []);
|
||||
const ua = config?.headers ? "User-Agent" in config.headers : false;
|
||||
const ua = hasUserAgentHeader(config?.headers);
|
||||
setOpenclawUserAgent(ua);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProviderForm } from "@/components/providers/forms/ProviderForm";
|
||||
import { OPENCLAW_DEFAULT_USER_AGENT } from "@/components/providers/forms/hooks/useOpenclawFormState";
|
||||
import { server } from "../msw/server";
|
||||
import { createTestQueryClient } from "../utils/testQueryClient";
|
||||
|
||||
// Match existing provider-form tests: mock ancillary auth/editor dependencies only.
|
||||
// The actual ProviderForm, switch component, hook and onSubmit path remain real.
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
vi.mock("@/components/JsonEditor", () => ({
|
||||
default: ({ value }: { value: string }) => (
|
||||
<textarea readOnly value={value} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/providers/forms/hooks", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/components/providers/forms/hooks")>();
|
||||
return {
|
||||
...actual,
|
||||
useCopilotAuth: () => ({
|
||||
isAuthenticated: false,
|
||||
isStatusSuccess: true,
|
||||
accounts: [],
|
||||
}),
|
||||
useCodexOauth: () => ({
|
||||
isAuthenticated: false,
|
||||
isStatusSuccess: true,
|
||||
accounts: [],
|
||||
}),
|
||||
useXaiOauth: () => ({ isAuthenticated: false, accounts: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
const base = {
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
apiKey: "synthetic-fixture",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "fixture", name: "Fixture model" }],
|
||||
authHeader: false,
|
||||
fixtureUnknown: { keep: [1, "two", false] },
|
||||
};
|
||||
const customHeaders = { "X-Fixture": "keep", "X-Other": "also-keep" };
|
||||
|
||||
async function mount(headers?: Record<string, string>) {
|
||||
const settingsConfig = { ...base, ...(headers ? { headers } : {}) };
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<ProviderForm
|
||||
appId="openclaw"
|
||||
providerId="fixture-provider"
|
||||
initialData={{ name: "Fixture provider", settingsConfig }}
|
||||
submitLabel="save-provider"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("该供应商已添加到应用配置中,供应商标识不可修改");
|
||||
const section = screen
|
||||
.getByText("发送 User-Agent")
|
||||
.closest("div.border-l") as HTMLElement;
|
||||
const toggle = () => fireEvent.click(within(section).getByRole("switch"));
|
||||
const save = async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
const actual = JSON.parse(onSubmit.mock.calls[0][0].settingsConfig);
|
||||
return actual;
|
||||
};
|
||||
return {
|
||||
settingsConfig,
|
||||
toggle,
|
||||
switchElement: within(section).getByRole("switch"),
|
||||
save,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// All Tauri calls are intercepted fixtures; any unexpected request fails closed.
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
server.use(
|
||||
http.post("http://tauri.local/get_openclaw_live_provider_ids", () =>
|
||||
HttpResponse.json(["fixture-provider"]),
|
||||
),
|
||||
);
|
||||
});
|
||||
describe("ProviderForm OpenClaw User-Agent headers", () => {
|
||||
it("enabling User-Agent preserves headers in final submission", async () => {
|
||||
const test = await mount(customHeaders);
|
||||
test.toggle();
|
||||
expect(await test.save()).toEqual({
|
||||
...base,
|
||||
headers: { ...customHeaders, "User-Agent": OPENCLAW_DEFAULT_USER_AGENT },
|
||||
});
|
||||
});
|
||||
it("disabling User-Agent preserves headers in final submission", async () => {
|
||||
const test = await mount({
|
||||
...customHeaders,
|
||||
"User-Agent": "fixture-agent",
|
||||
});
|
||||
test.toggle();
|
||||
expect(await test.save()).toEqual({ ...base, headers: customHeaders });
|
||||
});
|
||||
it("on-off round trip preserves headers in final submission", async () => {
|
||||
const test = await mount(customHeaders);
|
||||
test.toggle();
|
||||
test.toggle();
|
||||
expect(await test.save()).toEqual(test.settingsConfig);
|
||||
});
|
||||
it("save with no toggle preserves custom headers and User-Agent", async () => {
|
||||
const test = await mount({
|
||||
...customHeaders,
|
||||
"User-Agent": "fixture-agent",
|
||||
});
|
||||
expect(await test.save()).toEqual(test.settingsConfig);
|
||||
});
|
||||
it("missing headers can enable User-Agent in final submission", async () => {
|
||||
const test = await mount();
|
||||
test.toggle();
|
||||
expect(await test.save()).toEqual({
|
||||
...base,
|
||||
headers: { "User-Agent": OPENCLAW_DEFAULT_USER_AGENT },
|
||||
});
|
||||
});
|
||||
it("sole User-Agent can be removed in final submission", async () => {
|
||||
const test = await mount({ "User-Agent": "fixture-agent" });
|
||||
test.toggle();
|
||||
expect(await test.save()).toEqual(base);
|
||||
});
|
||||
});
|
||||
|
||||
const caseFixtures: { label: string; headers: Record<string, string> }[] = [
|
||||
{ label: "lowercase", headers: { "user-agent": "lower-agent" } },
|
||||
{ label: "uppercase", headers: { "USER-AGENT": "upper-agent" } },
|
||||
{ label: "mixed case", headers: { "uSeR-aGeNt": "mixed-agent" } },
|
||||
{
|
||||
label: "coexisting variants",
|
||||
headers: {
|
||||
"User-Agent": "canonical-agent",
|
||||
"user-agent": "lower-agent",
|
||||
"USER-AGENT": "upper-agent",
|
||||
"uSeR-aGeNt": "mixed-agent",
|
||||
},
|
||||
},
|
||||
];
|
||||
// JSON.parse creates own prototype-named properties, just like imported JSON.
|
||||
const preservedHeaders: Record<string, string> = JSON.parse(
|
||||
'{"X-Fixture":"keep","x-Custom":"case-sensitive-value","__proto__":"proto-value","constructor":"ctor-value","toString":"string-value"}',
|
||||
);
|
||||
|
||||
describe.each(caseFixtures)(
|
||||
"ProviderForm imported UA: $label",
|
||||
({ headers }) => {
|
||||
const importedHeaders = { ...preservedHeaders, ...headers };
|
||||
|
||||
it("recognizes an imported User-Agent in the initial switch", async () => {
|
||||
const test = await mount(importedHeaders);
|
||||
expect(test.switchElement).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("keeps untouched imported headers and all settings on save", async () => {
|
||||
const test = await mount(importedHeaders);
|
||||
expect(await test.save()).toEqual(test.settingsConfig);
|
||||
});
|
||||
|
||||
it("turning the switch off removes every UA variant from submission", async () => {
|
||||
const test = await mount(importedHeaders);
|
||||
if (test.switchElement.getAttribute("aria-checked") === "true")
|
||||
test.toggle();
|
||||
expect(await test.save()).toEqual({ ...base, headers: preservedHeaders });
|
||||
});
|
||||
|
||||
it("turning the switch on submits one canonical default UA", async () => {
|
||||
const test = await mount(importedHeaders);
|
||||
if (test.switchElement.getAttribute("aria-checked") === "true")
|
||||
test.toggle();
|
||||
test.toggle();
|
||||
expect(await test.save()).toEqual({
|
||||
...base,
|
||||
headers: {
|
||||
...preservedHeaders,
|
||||
"User-Agent": OPENCLAW_DEFAULT_USER_AGENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("on-off round trip removes imported UA variants from submission", async () => {
|
||||
const test = await mount(importedHeaders);
|
||||
if (test.switchElement.getAttribute("aria-checked") === "false")
|
||||
test.toggle();
|
||||
test.toggle();
|
||||
expect(await test.save()).toEqual({ ...base, headers: preservedHeaders });
|
||||
});
|
||||
|
||||
it("repeated off-on toggles preserve unrelated fields and one UA", async () => {
|
||||
const test = await mount(importedHeaders);
|
||||
if (test.switchElement.getAttribute("aria-checked") === "true")
|
||||
test.toggle();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
test.toggle();
|
||||
test.toggle();
|
||||
}
|
||||
test.toggle();
|
||||
expect(await test.save()).toEqual({
|
||||
...base,
|
||||
headers: {
|
||||
...preservedHeaders,
|
||||
"User-Agent": OPENCLAW_DEFAULT_USER_AGENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,173 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
useOpenclawFormState,
|
||||
OPENCLAW_DEFAULT_USER_AGENT,
|
||||
} from "@/components/providers/forms/hooks/useOpenclawFormState";
|
||||
|
||||
// Only ancillary provider queries are mocked; the hook is unmodified production code.
|
||||
vi.mock("@/lib/query/queries", () => ({
|
||||
useProvidersQuery: () => ({ data: { providers: {} } }),
|
||||
}));
|
||||
|
||||
const base = {
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
apiKey: "synthetic-fixture",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "fixture", name: "Fixture model" }],
|
||||
authHeader: false,
|
||||
fixtureUnknown: { keep: [1, "two", false] },
|
||||
};
|
||||
const customHeaders = { "X-Fixture": "keep", "X-Other": "also-keep" };
|
||||
|
||||
function mount(headers?: Record<string, string>) {
|
||||
const original = { ...base, ...(headers ? { headers } : {}) };
|
||||
let serialized = JSON.stringify(original);
|
||||
const onSettingsConfigChange = vi.fn((next: string) => {
|
||||
serialized = next;
|
||||
});
|
||||
const hook = renderHook(() =>
|
||||
useOpenclawFormState({
|
||||
appId: "openclaw",
|
||||
providerId: "fixture-provider",
|
||||
initialData: { settingsConfig: original },
|
||||
getSettingsConfig: () => serialized,
|
||||
onSettingsConfigChange,
|
||||
}),
|
||||
);
|
||||
const toggle = (enabled: boolean) =>
|
||||
act(() => hook.result.current.handleOpenclawUserAgentChange(enabled));
|
||||
return {
|
||||
...hook,
|
||||
original,
|
||||
toggle,
|
||||
read: () => JSON.parse(serialized),
|
||||
onSettingsConfigChange,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useOpenclawFormState User-Agent headers", () => {
|
||||
it("enabling User-Agent preserves unrelated headers", () => {
|
||||
const test = mount(customHeaders);
|
||||
test.toggle(true);
|
||||
expect(test.read().headers).toEqual({
|
||||
...customHeaders,
|
||||
"User-Agent": OPENCLAW_DEFAULT_USER_AGENT,
|
||||
});
|
||||
});
|
||||
it("disabling User-Agent preserves unrelated headers", () => {
|
||||
const test = mount({ ...customHeaders, "User-Agent": "fixture-agent" });
|
||||
test.toggle(false);
|
||||
expect(test.read().headers).toEqual(customHeaders);
|
||||
});
|
||||
it("enabling then disabling preserves unrelated headers", () => {
|
||||
const test = mount(customHeaders);
|
||||
test.toggle(true);
|
||||
test.toggle(false);
|
||||
expect(test.read()).toEqual(test.original);
|
||||
});
|
||||
it("untouched custom User-Agent and headers survive hydration", () => {
|
||||
const test = mount({ ...customHeaders, "User-Agent": "fixture-agent" });
|
||||
expect(test.result.current.openclawUserAgent).toBe(true);
|
||||
expect(test.read()).toEqual(test.original);
|
||||
expect(test.onSettingsConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
it("missing headers can enable the default User-Agent", () => {
|
||||
const test = mount();
|
||||
expect(test.result.current.openclawUserAgent).toBe(false);
|
||||
test.toggle(true);
|
||||
expect(test.read()).toEqual({
|
||||
...base,
|
||||
headers: { "User-Agent": OPENCLAW_DEFAULT_USER_AGENT },
|
||||
});
|
||||
});
|
||||
it("disabling sole User-Agent leaves no headers", () => {
|
||||
const test = mount({ "User-Agent": "fixture-agent" });
|
||||
test.toggle(false);
|
||||
expect(test.read()).toEqual(base);
|
||||
});
|
||||
it.each([true, false])(
|
||||
"toggle %s preserves all non-header config",
|
||||
(enabled) => {
|
||||
const test = mount({ ...customHeaders, "User-Agent": "fixture-agent" });
|
||||
test.toggle(enabled);
|
||||
const { headers: _headers, ...rest } = test.read();
|
||||
expect(rest).toEqual(base);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const caseFixtures: { label: string; headers: Record<string, string> }[] = [
|
||||
{ label: "lowercase", headers: { "user-agent": "lower-agent" } },
|
||||
{ label: "uppercase", headers: { "USER-AGENT": "upper-agent" } },
|
||||
{ label: "mixed case", headers: { "uSeR-aGeNt": "mixed-agent" } },
|
||||
{
|
||||
label: "coexisting variants",
|
||||
headers: {
|
||||
"User-Agent": "canonical-agent",
|
||||
"user-agent": "lower-agent",
|
||||
"USER-AGENT": "upper-agent",
|
||||
"uSeR-aGeNt": "mixed-agent",
|
||||
},
|
||||
},
|
||||
];
|
||||
const preservedHeaders: Record<string, string> = JSON.parse(
|
||||
'{"X-Fixture":"keep","x-Custom":"case-sensitive-value","__proto__":"proto-value","constructor":"ctor-value","toString":"string-value"}',
|
||||
);
|
||||
|
||||
describe.each(caseFixtures)(
|
||||
"useOpenclawFormState imported UA: $label",
|
||||
({ headers }) => {
|
||||
it("recognizes initial UA without rewriting the imported configuration", () => {
|
||||
const test = mount({ ...preservedHeaders, ...headers });
|
||||
expect(test.result.current.openclawUserAgent).toBe(true);
|
||||
expect(test.read()).toEqual(test.original);
|
||||
expect(test.onSettingsConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reset recognizes UA and then clears it for headers without UA", () => {
|
||||
const test = mount();
|
||||
act(() => test.result.current.resetOpenclawState({ ...base, headers }));
|
||||
expect(test.result.current.openclawUserAgent).toBe(true);
|
||||
act(() =>
|
||||
test.result.current.resetOpenclawState({
|
||||
...base,
|
||||
headers: preservedHeaders,
|
||||
}),
|
||||
);
|
||||
expect(test.result.current.openclawUserAgent).toBe(false);
|
||||
act(() => test.result.current.resetOpenclawState());
|
||||
expect(test.result.current.openclawUserAgent).toBe(false);
|
||||
expect(test.onSettingsConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes all variants on enable and removes all on disable", () => {
|
||||
const test = mount({ ...preservedHeaders, ...headers });
|
||||
test.toggle(true);
|
||||
expect(test.read()).toEqual({
|
||||
...base,
|
||||
headers: {
|
||||
...preservedHeaders,
|
||||
"User-Agent": OPENCLAW_DEFAULT_USER_AGENT,
|
||||
},
|
||||
});
|
||||
test.toggle(false);
|
||||
expect(test.read()).toEqual({ ...base, headers: preservedHeaders });
|
||||
});
|
||||
|
||||
it("disabling sole imported UA variants removes the empty headers object", () => {
|
||||
const test = mount(headers);
|
||||
test.toggle(false);
|
||||
expect(test.read()).toEqual(base);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reset tolerates null headers from JSON without rewriting settings", () => {
|
||||
const test = mount({ "User-Agent": "fixture-agent" });
|
||||
const config = JSON.parse('{"headers":null}');
|
||||
act(() => test.result.current.resetOpenclawState(config));
|
||||
expect(test.result.current.openclawUserAgent).toBe(false);
|
||||
expect(test.read()).toEqual(test.original);
|
||||
expect(test.onSettingsConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
Reference in new issue
Block a user