mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
fix: guard SdkSectionPage against schema responses missing sections (#350)
The settings page crashes on production Vercel deployments with `Cannot read properties of undefined (reading 'filter')` because the seeded local backend points at `window.location.origin` when no `VITE_BACKEND_BASE_URL` is configured. The Vercel host does not serve `/api/settings/agent-schema`, but the response sometimes resolves to a truthy non-schema object — and the unchecked `schema.sections.filter` in `SdkSectionPage` throws, which React Router escalates to a full-screen error boundary. Introduce an `isValidSettingsSchema` type guard and apply it everywhere the schema is dereferenced. A malformed schema now falls through to the existing `SETTINGS$SDK_SCHEMA_UNAVAILABLE` message path instead of crashing the route. The companion concern — preventing the frontend from auto-seeding the Vercel preview origin as a default agent-server URL in the first place — is being handled separately by #278. Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
committed by
GitHub
co-authored by
openhands
parent
0a52722f41
commit
dc9f3c45a3
@@ -613,6 +613,35 @@ describe("SdkSectionPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the schema-unavailable fallback instead of crashing when the schema is malformed", async () => {
|
||||
// Simulates the production failure mode we hit on Vercel previews:
|
||||
// the frontend points at a host that does not serve
|
||||
// `/api/settings/agent-schema`, so the schema query resolves with a
|
||||
// truthy object that nevertheless has no `sections` array. The page
|
||||
// must surface this as the existing "schema unavailable" message
|
||||
// instead of throwing
|
||||
// `Cannot read properties of undefined (reading 'filter')` and
|
||||
// letting React Router escalate to a full-screen error.
|
||||
const malformedSchema = {
|
||||
model_name: "AgentSettings",
|
||||
// `sections` deliberately omitted to mimic an SPA shell that
|
||||
// happened to parse into a non-schema object.
|
||||
} as unknown as NonNullable<Settings["agent_settings_schema"]>;
|
||||
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({ agent_settings_schema: malformedSchema }),
|
||||
);
|
||||
|
||||
renderSdkSectionPage({ sectionKeys: ["llm"] });
|
||||
|
||||
expect(
|
||||
await screen.findByText("SETTINGS$SDK_SCHEMA_UNAVAILABLE"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("sdk-section-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows saving custom payloads when only external state is dirty", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());
|
||||
const saveSettingsSpy = vi
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
getVisibleSettingsSections,
|
||||
hasAdvancedSettingsOverrides,
|
||||
inferInitialView,
|
||||
isValidSettingsSchema,
|
||||
SPECIALLY_RENDERED_KEYS,
|
||||
} from "#/utils/sdk-settings-schema";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { Settings } from "#/types/settings";
|
||||
import { Settings, SettingsSchema } from "#/types/settings";
|
||||
|
||||
const BASE_SETTINGS: Settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -338,4 +339,40 @@ describe("sdk settings schema helpers", () => {
|
||||
critic: { enabled: true, mode: "all_actions" },
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidSettingsSchema", () => {
|
||||
it("accepts a schema with an array sections field", () => {
|
||||
expect(
|
||||
isValidSettingsSchema({
|
||||
model_name: "AgentSettings",
|
||||
sections: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null", null],
|
||||
["undefined", undefined],
|
||||
["object without sections", { model_name: "AgentSettings" }],
|
||||
[
|
||||
"object with non-array sections",
|
||||
{ model_name: "AgentSettings", sections: "oops" },
|
||||
],
|
||||
])("rejects %s", (_label, value) => {
|
||||
expect(isValidSettingsSchema(value as unknown as SettingsSchema)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("makes getVisibleSettingsSections tolerate malformed schemas", () => {
|
||||
// Regression test for the Vercel preview crash where the schema
|
||||
// endpoint resolved with a truthy object that had no `sections`
|
||||
// array, causing `.filter` to throw on undefined.
|
||||
const malformed = {
|
||||
model_name: "AgentSettings",
|
||||
} as unknown as SettingsSchema;
|
||||
|
||||
expect(getVisibleSettingsSections(malformed, {}, "basic")).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
hasAdvancedSettings,
|
||||
hasMinorSettings,
|
||||
inferInitialView,
|
||||
isValidSettingsSchema,
|
||||
SettingsDirtyState,
|
||||
SettingsFormValues,
|
||||
type SettingsValueSource,
|
||||
@@ -226,9 +227,15 @@ export function SdkSectionPage({
|
||||
[sectionKeysSignature],
|
||||
);
|
||||
|
||||
// Build a filtered schema containing only the requested sections
|
||||
// Build a filtered schema containing only the requested sections.
|
||||
// `isValidSettingsSchema` guards against truthy-but-malformed schema
|
||||
// responses (e.g. when the deployment is pointed at a host that does
|
||||
// not serve `/api/settings/agent-schema` and returns an SPA shell
|
||||
// that parses into an object without a `sections` array). Without
|
||||
// the guard, `schema.sections.filter(...)` would throw and React
|
||||
// Router would escalate the crash to a full-screen error.
|
||||
const filteredSchema = React.useMemo(() => {
|
||||
if (!schema) return null;
|
||||
if (!isValidSettingsSchema(schema)) return null;
|
||||
const sectionSet = new Set(stableSectionKeys);
|
||||
return {
|
||||
...schema,
|
||||
|
||||
@@ -30,7 +30,26 @@ const VIEW_PROMINENCES: Record<SettingsView, Set<SettingProminence>> = {
|
||||
all: new Set<SettingProminence>(["critical", "major", "minor"]),
|
||||
};
|
||||
|
||||
/**
|
||||
* True when `schema` looks like a usable `SettingsSchema` — i.e. an
|
||||
* object with an array `sections` field. Guards every helper in this
|
||||
* module against malformed/empty schema responses (e.g. when the
|
||||
* frontend ends up pointing at a host that does not actually serve
|
||||
* `/api/settings/agent-schema`, such as an unconfigured Vercel preview
|
||||
* origin that returns the React Router SPA shell for arbitrary
|
||||
* `/api/*` paths). Without this check, `schema.sections.filter(...)`
|
||||
* inside `SdkSectionPage` blows up with
|
||||
* `Cannot read properties of undefined (reading 'filter')` and React
|
||||
* Router escalates to a full-screen error page.
|
||||
*/
|
||||
export function isValidSettingsSchema(
|
||||
schema: SettingsSchema | null | undefined,
|
||||
): schema is SettingsSchema {
|
||||
return !!schema && Array.isArray((schema as SettingsSchema).sections);
|
||||
}
|
||||
|
||||
function getSchemaFields(schema: SettingsSchema): SettingsFieldSchema[] {
|
||||
if (!isValidSettingsSchema(schema)) return [];
|
||||
return schema.sections.flatMap((section) => section.fields);
|
||||
}
|
||||
|
||||
@@ -434,6 +453,7 @@ export function getVisibleSettingsSections(
|
||||
view: SettingsView,
|
||||
excludeKeys: Set<string> = SPECIALLY_RENDERED_KEYS,
|
||||
): SettingsSectionSchema[] {
|
||||
if (!isValidSettingsSchema(schema)) return [];
|
||||
return schema.sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
|
||||
Reference in New Issue
Block a user