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:
Robert Brennan
2026-05-11 17:00:32 -07:00
committed by GitHub
co-authored by openhands
parent 0a52722f41
commit dc9f3c45a3
4 changed files with 96 additions and 3 deletions
@@ -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
+38 -1
View File
@@ -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,
+20
View File
@@ -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,