refactor(gateway): one owner for the submitted-config validation pipeline (#124331)

config.set and config.patch each hand-rolled the same normalize ->
raw-validate -> plugin-validate -> respond-invalid pipeline (including the
duplicated errorShape/details blocks the round-5 error-text fix touched in
both places). validateSubmittedConfigOrRespond now owns it; both handlers
consume { validationCandidate, config }.

Behavior-neutral: same validators in the same order, same error shapes
(pinned by the existing 26 config tests including the round-5 regressions).
Net -11 production LOC and one place to evolve validation error content.
This commit is contained in:
Peter Steinberger
2026-08-15 18:37:23 -07:00
committed by GitHub
parent 1fda883530
commit 9c6539c975
+56 -67
View File
@@ -516,42 +516,18 @@ function parseValidateConfigFromRawOrRespond(
createMergePatch(snapshot.config, restored.result),
)
: restored.result;
const validationCandidate = normalizeSubmittedConfigModelRefs(
stripBundledProviderRuntimeDefaults({
candidate: projectedValidationCandidate,
sourceConfig: snapshot.sourceConfig,
}) as OpenClawConfig,
const validatedSubmission = validateSubmittedConfigOrRespond({
candidate: projectedValidationCandidate,
sourceConfig: snapshot.sourceConfig,
modelIdNormalizationPolicies,
);
const sourceValidated = validateConfigObjectRawWithPlugins(validationCandidate);
if (!sourceValidated.ok) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
summarizeConfigValidationIssues(sourceValidated.issues),
{
details: { issues: sourceValidated.issues },
},
),
);
return null;
}
const validated = validateConfigObjectWithPlugins(validationCandidate);
if (!validated.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, summarizeConfigValidationIssues(validated.issues), {
details: { issues: validated.issues },
}),
);
respond,
});
if (!validatedSubmission) {
return null;
}
return {
config: validated.config,
writeConfig: validationCandidate as OpenClawConfig,
config: validatedSubmission.config,
writeConfig: validatedSubmission.validationCandidate,
schema,
};
}
@@ -595,6 +571,42 @@ function rejectDroppedAgentRosterEntries(params: {
return true;
}
/** Shared normalize -> raw-validate -> plugin-validate pipeline for submitted configs; responds on failure. */
function validateSubmittedConfigOrRespond(params: {
candidate: unknown;
sourceConfig: OpenClawConfig | undefined;
modelIdNormalizationPolicies: Parameters<typeof normalizeSubmittedConfigModelRefs>[1];
respond: RespondFn;
}): { validationCandidate: OpenClawConfig; config: OpenClawConfig } | null {
const validationCandidate = normalizeSubmittedConfigModelRefs(
stripBundledProviderRuntimeDefaults({
candidate: params.candidate,
sourceConfig: params.sourceConfig,
}) as OpenClawConfig,
params.modelIdNormalizationPolicies,
);
const respondInvalid = (issues: ReadonlyArray<ConfigValidationIssue>) => {
params.respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, summarizeConfigValidationIssues(issues), {
details: { issues },
}),
);
};
const sourceValidated = validateConfigObjectRawWithPlugins(validationCandidate);
if (!sourceValidated.ok) {
respondInvalid(sourceValidated.issues);
return null;
}
const validated = validateConfigObjectWithPlugins(validationCandidate);
if (!validated.ok) {
respondInvalid(validated.issues);
return null;
}
return { validationCandidate: validationCandidate as OpenClawConfig, config: validated.config };
}
function summarizeConfigValidationIssues(issues: ReadonlyArray<ConfigValidationIssue>): string {
const trimmed = issues.slice(0, MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE);
const lines = normalizeStringEntries(
@@ -1080,48 +1092,25 @@ export const configHandlers: GatewayRequestHandlers = {
});
return;
}
const validationCandidate = normalizeSubmittedConfigModelRefs(
stripBundledProviderRuntimeDefaults({
candidate: restoredMerge.result,
sourceConfig: snapshot.sourceConfig,
}) as OpenClawConfig,
const validatedSubmission = validateSubmittedConfigOrRespond({
candidate: restoredMerge.result,
sourceConfig: snapshot.sourceConfig,
modelIdNormalizationPolicies,
);
const sourceValidated = validateConfigObjectRawWithPlugins(validationCandidate);
if (!sourceValidated.ok) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
summarizeConfigValidationIssues(sourceValidated.issues),
{
details: { issues: sourceValidated.issues },
},
),
);
return;
}
const writeConfig = validationCandidate as OpenClawConfig;
const validated = validateConfigObjectWithPlugins(validationCandidate);
if (!validated.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, summarizeConfigValidationIssues(validated.issues), {
details: { issues: validated.issues },
}),
);
respond,
});
if (!validatedSubmission) {
return;
}
const writeConfig = validatedSubmission.validationCandidate;
const validatedConfig = validatedSubmission.config;
const preparedSecretsSnapshot = await ensureResolvableSecretRefsOrRespond({
config: validated.config,
config: validatedConfig,
respond,
});
if (!preparedSecretsSnapshot) {
return;
}
const changedPaths = diffConfigPaths(snapshot.config, validated.config);
const changedPaths = diffConfigPaths(snapshot.config, validatedConfig);
// No-op: if the validated config is identical to the current config,
// skip the file write and SIGUSR1 restart entirely. This avoids a full
@@ -1130,7 +1119,7 @@ export const configHandlers: GatewayRequestHandlers = {
if (changedPaths.length === 0) {
respondConfigPatchNoop({
snapshot,
config: validated.config,
config: validatedConfig,
uiHints: schemaPatch.uiHints,
actor,
context,
@@ -1147,7 +1136,7 @@ export const configHandlers: GatewayRequestHandlers = {
const disconnectSharedAuthClients = shouldDisconnectSharedAuthClientsForConfigWrite({
prevConfig: snapshot.config,
prevSourceConfig: snapshot.sourceConfig,
nextConfig: validated.config,
nextConfig: validatedConfig,
preparedSecretsSnapshot,
});
const writeResult = await commitGatewayConfigWriteOrRespond({