fix: harden composite input authoring
This commit is contained in:
@@ -145,6 +145,11 @@ export const hasBoundedInputExpressionNodeBudget = (
|
||||
|
||||
type JsonSchemaRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
const EXPRESSION_BINDING_COMPONENTS = new Set([
|
||||
"StepInputBinding",
|
||||
"InputExpressionBinding",
|
||||
]);
|
||||
|
||||
const schemaRecord = (value: unknown): JsonSchemaRecord | null =>
|
||||
isRecord(value) ? value : null;
|
||||
|
||||
@@ -167,6 +172,13 @@ export const hasBoundedInputExpressionsAtSchema = (
|
||||
components: Readonly<Record<string, unknown>>,
|
||||
maxNodes: number = MAX_INPUT_EXPRESSION_NODES,
|
||||
): boolean => {
|
||||
for (const componentName of EXPRESSION_BINDING_COMPONENTS) {
|
||||
if (!Object.hasOwn(components, componentName)) {
|
||||
throw new Error(
|
||||
`input expression component ${componentName} is missing from the contract`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const activeValues = new WeakSet<object>();
|
||||
const activeComponents = new Set<string>();
|
||||
|
||||
@@ -176,7 +188,7 @@ export const hasBoundedInputExpressionsAtSchema = (
|
||||
|
||||
const componentName = localComponentName(schemaValue.$ref);
|
||||
if (componentName !== null) {
|
||||
if (componentName === "StepInputBinding" || componentName === "InputExpressionBinding") {
|
||||
if (EXPRESSION_BINDING_COMPONENTS.has(componentName)) {
|
||||
return isRecord(value) && "expression" in value
|
||||
? hasBoundedInputExpressionNodeBudget(value.expression, maxNodes)
|
||||
: true;
|
||||
@@ -191,6 +203,8 @@ export const hasBoundedInputExpressionsAtSchema = (
|
||||
|
||||
for (const key of ["allOf", "anyOf", "oneOf"] as const) {
|
||||
const branches = schemaValue[key];
|
||||
// Non-expression positions return true, so checking every branch remains
|
||||
// safe while ensuring no generated union branch can bypass the budget.
|
||||
if (Array.isArray(branches) && !branches.every((branch) => visit(value, branch))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1165,6 +1165,18 @@ describe("authored RPC and manifest schema parity", () => {
|
||||
bindings: [{ target: "request", expression }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
accepts(authoredRpcSchemas["workflow.draft_workspaces.set_step_input_bindings"].payload, {
|
||||
...basePayload,
|
||||
bindings: [{
|
||||
target: "request",
|
||||
expression: {
|
||||
kind: "literal",
|
||||
value: Array.from({ length: 1022 }, () => ({})),
|
||||
},
|
||||
}],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("catalogs every authored RPC exactly once", () => {
|
||||
|
||||
@@ -110,6 +110,25 @@ describe("runtimeSchemasFor", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an input expression at the canonical 1024-node boundary", () => {
|
||||
const schemas = runtimeSchemasFor(
|
||||
"workflow.draft_workspaces.set_step_input_bindings",
|
||||
);
|
||||
const expression = {
|
||||
kind: "literal",
|
||||
value: Array.from({ length: 1022 }, () => ({})),
|
||||
};
|
||||
|
||||
expect(
|
||||
accepts(schemas.payload, {
|
||||
workspace_id: "console.demo",
|
||||
revision: 3,
|
||||
step_id: "render",
|
||||
bindings: [{ target: "request", expression }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a large ordinary JSON value shaped like a literal in a simple binding", () => {
|
||||
const schemas = runtimeSchemasFor(
|
||||
"workflow.draft_workspaces.set_step_input_bindings",
|
||||
|
||||
@@ -63,19 +63,13 @@ const translatedAst = (schema: unknown): AST.AST => {
|
||||
return translated.right.ast;
|
||||
};
|
||||
|
||||
const payloadSchemaFor = <Name extends RuntimeOperationName>(
|
||||
name: Name,
|
||||
): Schema.Schema<WorkflowOperationParams<Name>, unknown, never> => {
|
||||
// The AST and payload type are generated from the same checked operation.
|
||||
const schema = Schema.make<WorkflowOperationParams<Name>, unknown, never>(
|
||||
translatedAst(workflowRuntimeContract.operations[name].payload),
|
||||
);
|
||||
return Schema.compose(BoundedRuntimeValueSchema, schema).pipe(
|
||||
const boundedRuntimeInputFor = (operationSchema: unknown) =>
|
||||
BoundedRuntimeValueSchema.pipe(
|
||||
Schema.filter(
|
||||
(value) =>
|
||||
hasBoundedInputExpressionsAtSchema(
|
||||
value,
|
||||
workflowRuntimeContract.operations[name].payload,
|
||||
operationSchema,
|
||||
workflowRuntimeContract.components,
|
||||
),
|
||||
{
|
||||
@@ -84,6 +78,18 @@ const payloadSchemaFor = <Name extends RuntimeOperationName>(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const payloadSchemaFor = <Name extends RuntimeOperationName>(
|
||||
name: Name,
|
||||
): Schema.Schema<WorkflowOperationParams<Name>, unknown, never> => {
|
||||
// The AST and payload type are generated from the same checked operation.
|
||||
const schema = Schema.make<WorkflowOperationParams<Name>, unknown, never>(
|
||||
translatedAst(workflowRuntimeContract.operations[name].payload),
|
||||
);
|
||||
return Schema.compose(
|
||||
boundedRuntimeInputFor(workflowRuntimeContract.operations[name].payload),
|
||||
schema,
|
||||
);
|
||||
};
|
||||
|
||||
const successSchemaFor = <Name extends RuntimeOperationName>(
|
||||
@@ -93,19 +99,9 @@ const successSchemaFor = <Name extends RuntimeOperationName>(
|
||||
const schema = Schema.make<WorkflowOperationResult<Name>, unknown, never>(
|
||||
translatedAst(workflowRuntimeContract.operations[name].success),
|
||||
);
|
||||
return Schema.compose(BoundedRuntimeValueSchema, schema).pipe(
|
||||
Schema.filter(
|
||||
(value) =>
|
||||
hasBoundedInputExpressionsAtSchema(
|
||||
value,
|
||||
workflowRuntimeContract.operations[name].success,
|
||||
workflowRuntimeContract.components,
|
||||
),
|
||||
{
|
||||
message: () =>
|
||||
"runtime value contains an input expression over the 1024-node budget",
|
||||
},
|
||||
),
|
||||
return Schema.compose(
|
||||
boundedRuntimeInputFor(workflowRuntimeContract.operations[name].success),
|
||||
schema,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user