mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(tools): reject malformed availability expressions (#92411)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -11,6 +11,16 @@ const baseDescriptor: ToolDescriptor = {
|
||||
executor: { kind: "core", executorId: "example" },
|
||||
};
|
||||
|
||||
function descriptorWithAvailability(availability: unknown): ToolDescriptor {
|
||||
return { ...baseDescriptor, availability } as ToolDescriptor;
|
||||
}
|
||||
|
||||
function sparseArray(): unknown[] {
|
||||
const values: unknown[] = [];
|
||||
values.length = 1;
|
||||
return values;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
@@ -255,4 +265,68 @@ describe("evaluateToolAvailability", () => {
|
||||
}).map((entry) => entry.reason),
|
||||
).toEqual(["unsupported-signal"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
"invalid",
|
||||
[],
|
||||
{ kind: "auth" },
|
||||
{ kind: "config", path: "plugins.demo" },
|
||||
{ kind: "config", path: [1] },
|
||||
{ kind: "config", path: [], check: "invalid" },
|
||||
{ kind: "context", key: "", equals: {} },
|
||||
{ allOf: "invalid" },
|
||||
{ anyOf: [null] },
|
||||
{ allOf: [], anyOf: [] },
|
||||
{ allOf: sparseArray() },
|
||||
{ anyOf: sparseArray() },
|
||||
{ kind: "config", path: sparseArray() },
|
||||
])("rejects malformed availability without throwing: %j", (availability) => {
|
||||
expect(
|
||||
evaluateToolAvailability({ descriptor: descriptorWithAvailability(availability) }),
|
||||
).toStrictEqual([
|
||||
{
|
||||
reason: "unsupported-signal",
|
||||
message: "Unsupported availability expression",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects cyclic availability expressions without overflowing", () => {
|
||||
const availability: { allOf: unknown[] } = { allOf: [] };
|
||||
availability.allOf.push(availability);
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({ descriptor: descriptorWithAvailability(availability) }),
|
||||
).toStrictEqual([
|
||||
{
|
||||
reason: "unsupported-signal",
|
||||
message: "Unsupported availability expression",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows one availability expression to be shared between sibling branches", () => {
|
||||
const signal = { kind: "auth", providerId: "openai" } as const;
|
||||
const descriptor: ToolDescriptor = {
|
||||
...baseDescriptor,
|
||||
availability: { allOf: [signal, signal] },
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({
|
||||
descriptor,
|
||||
context: { authProviderIds: new Set(["openai"]) },
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ allOf: [] }, "Empty availability allOf group"],
|
||||
[{ anyOf: [] }, "Empty availability anyOf group"],
|
||||
] as const)("preserves precise empty-group diagnostics", (availability, message) => {
|
||||
expect(
|
||||
evaluateToolAvailability({ descriptor: descriptorWithAvailability(availability) }),
|
||||
).toStrictEqual([{ reason: "unsupported-signal", message }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,8 +68,71 @@ function hasConfiguredValue(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasAvailabilityExpressionShape(value: ToolAvailabilityExpression): boolean {
|
||||
return "kind" in value || "allOf" in value || "anyOf" in value;
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isJsonPrimitive(value: unknown): value is JsonPrimitive {
|
||||
return value === null || ["string", "number", "boolean"].includes(typeof value);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is readonly string[] {
|
||||
return Array.isArray(value) && Array.from(value).every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function isAvailabilitySignal(
|
||||
value: Record<string, unknown>,
|
||||
): value is Record<string, unknown> & ToolAvailabilitySignal {
|
||||
switch (value.kind) {
|
||||
case "always":
|
||||
return true;
|
||||
case "auth":
|
||||
return isNonEmptyString(value.providerId);
|
||||
case "config":
|
||||
return (
|
||||
isStringArray(value.path) &&
|
||||
(value.check === undefined ||
|
||||
value.check === "exists" ||
|
||||
value.check === "non-empty" ||
|
||||
value.check === "available")
|
||||
);
|
||||
case "env":
|
||||
return isNonEmptyString(value.name);
|
||||
case "plugin-enabled":
|
||||
return isNonEmptyString(value.pluginId);
|
||||
case "context":
|
||||
return isNonEmptyString(value.key) && (!("equals" in value) || isJsonPrimitive(value.equals));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAvailabilityExpression(
|
||||
value: unknown,
|
||||
active: WeakSet<object>,
|
||||
): value is ToolAvailabilityExpression {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || active.has(value)) {
|
||||
return false;
|
||||
}
|
||||
active.add(value);
|
||||
try {
|
||||
const expression = value as Record<string, unknown>;
|
||||
const shapeCount =
|
||||
Number("kind" in expression) + Number("allOf" in expression) + Number("anyOf" in expression);
|
||||
if (shapeCount !== 1) {
|
||||
return false;
|
||||
}
|
||||
if ("kind" in expression) {
|
||||
return isAvailabilitySignal(expression);
|
||||
}
|
||||
const entries = "allOf" in expression ? expression.allOf : expression.anyOf;
|
||||
return (
|
||||
Array.isArray(entries) &&
|
||||
Array.from(entries).every((entry) => isAvailabilityExpression(entry, active))
|
||||
);
|
||||
} finally {
|
||||
active.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function diagnostic(
|
||||
@@ -172,8 +235,11 @@ export function evaluateToolAvailability(params: {
|
||||
context?: ToolAvailabilityContext;
|
||||
}): readonly ToolAvailabilityDiagnostic[] {
|
||||
const context = params.context ?? {};
|
||||
const availability = params.descriptor.availability ?? { kind: "always" };
|
||||
if (!hasAvailabilityExpressionShape(availability)) {
|
||||
const availability = params.descriptor.availability;
|
||||
if (availability === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!isAvailabilityExpression(availability, new WeakSet())) {
|
||||
return [
|
||||
{
|
||||
reason: "unsupported-signal",
|
||||
|
||||
Reference in New Issue
Block a user