fix(xai): validate x_search handle filters (#103393)

This commit is contained in:
Peter Steinberger
2026-07-10 08:02:18 +01:00
committed by GitHub
parent 19111cf29d
commit d7dc6ee845
4 changed files with 130 additions and 9 deletions

View File

@@ -478,13 +478,15 @@ legacy `tools.web.search.grok.baseUrl`, and finally the public xAI endpoint
| Parameter | Description |
| ---------------------------- | ------------------------------------------------------ |
| `query` | Search query (required) |
| `allowed_x_handles` | Restrict results to specific X handles |
| `excluded_x_handles` | Exclude specific X handles |
| `allowed_x_handles` | Restrict results to at most 20 X handles |
| `excluded_x_handles` | Exclude at most 20 X handles |
| `from_date` | Only include posts on or after this date (YYYY-MM-DD) |
| `to_date` | Only include posts on or before this date (YYYY-MM-DD) |
| `enable_image_understanding` | Let xAI inspect images attached to matching posts |
| `enable_video_understanding` | Let xAI inspect videos attached to matching posts |
`allowed_x_handles` and `excluded_x_handles` are mutually exclusive.
### x_search example
```javascript

View File

@@ -2,6 +2,8 @@
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { Type } from "typebox";
export const X_SEARCH_HANDLE_LIMIT = 20;
export function buildMissingXSearchApiKeyPayload() {
return {
error: "missing_xai_api_key",
@@ -26,12 +28,16 @@ export function createXSearchToolDefinition(
}),
allowed_x_handles: Type.Optional(
Type.Array(Type.String({ minLength: 1 }), {
description: "Only include posts from these X handles.",
description:
"Only include posts from these X handles (max 20). Cannot be combined with excluded_x_handles.",
maxItems: X_SEARCH_HANDLE_LIMIT,
}),
),
excluded_x_handles: Type.Optional(
Type.Array(Type.String({ minLength: 1 }), {
description: "Exclude posts from these X handles.",
description:
"Exclude posts from these X handles (max 20). Cannot be combined with allowed_x_handles.",
maxItems: X_SEARCH_HANDLE_LIMIT,
}),
),
from_date: Type.Optional(

View File

@@ -3,6 +3,8 @@ import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createXSearchTool } from "./x-search.js";
const XAI_DOCUMENTED_HANDLE_LIMIT = 20;
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
@@ -74,6 +76,28 @@ function parseFirstRequestBody(mockFetch: ReturnType<typeof installXSearchFetch>
>;
}
function createConfiguredXSearchTool() {
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
},
},
},
},
},
});
if (!tool) {
throw new Error("expected x_search tool to be configured");
}
return tool;
}
afterEach(() => {
vi.restoreAllMocks();
});
@@ -107,6 +131,21 @@ describe("xai x_search tool", () => {
expect(queryDescription).not.toContain("allowed_x_handles");
});
it("publishes xAI handle-filter constraints in the tool schema", () => {
const tool = createConfiguredXSearchTool();
const parameters = tool.parameters as {
properties?: Record<string, { description?: string; maxItems?: number }>;
};
for (const [key, counterpart] of [
["allowed_x_handles", "excluded_x_handles"],
["excluded_x_handles", "allowed_x_handles"],
] as const) {
expect(parameters.properties?.[key]?.maxItems).toBe(XAI_DOCUMENTED_HANDLE_LIMIT);
expect(parameters.properties?.[key]?.description).toContain(counterpart);
}
});
it("enables x_search when runtime config carries the shared xAI key", () => {
const tool = createXSearchTool({
config: {},
@@ -178,9 +217,7 @@ describe("xai x_search tool", () => {
webSearch: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
xSearch: {
maxTurns: 2,
},
xSearch: { maxTurns: 2 },
},
},
},
@@ -191,7 +228,6 @@ describe("xai x_search tool", () => {
const result = await tool?.execute?.("x-search:1", {
query: "dinner recipes",
allowed_x_handles: ["openclaw"],
excluded_x_handles: ["spam"],
from_date: "2026-03-01",
to_date: "2026-03-20",
enable_image_understanding: true,
@@ -208,7 +244,6 @@ describe("xai x_search tool", () => {
{
type: "x_search",
allowed_x_handles: ["openclaw"],
excluded_x_handles: ["spam"],
from_date: "2026-03-01",
to_date: "2026-03-20",
enable_image_understanding: true,
@@ -219,6 +254,61 @@ describe("xai x_search tool", () => {
]);
});
it("rejects combined allow and exclude handle filters before calling xAI", async () => {
const mockFetch = installXSearchFetch();
const tool = createConfiguredXSearchTool();
await expect(
tool.execute("x-search:combined-handle-filters", {
query: "dinner recipes",
allowed_x_handles: ["openclaw"],
excluded_x_handles: ["spam"],
}),
).rejects.toThrow("allowed_x_handles and excluded_x_handles cannot be used together");
expect(mockFetch).not.toHaveBeenCalled();
});
it.each(["allowed_x_handles", "excluded_x_handles"] as const)(
"accepts the xAI limit for %s",
async (key) => {
const mockFetch = installXSearchFetch();
const tool = createConfiguredXSearchTool();
const handles = Array.from(
{ length: XAI_DOCUMENTED_HANDLE_LIMIT },
(_, index) => `${key}-${index}`,
);
await tool.execute(`x-search:${key}:limit`, {
query: `${key} boundary`,
[key]: handles,
});
expect(parseFirstRequestBody(mockFetch).tools).toEqual([
{ type: "x_search", [key]: handles },
]);
},
);
it.each(["allowed_x_handles", "excluded_x_handles"] as const)(
"rejects %s above the xAI limit before calling xAI",
async (key) => {
const mockFetch = installXSearchFetch();
const tool = createConfiguredXSearchTool();
const handles = Array.from(
{ length: XAI_DOCUMENTED_HANDLE_LIMIT + 1 },
(_, index) => `${key}-${index}`,
);
await expect(
tool.execute(`x-search:${key}:over-limit`, {
query: `${key} over limit`,
[key]: handles,
}),
).rejects.toThrow(`${key} cannot contain more than ${XAI_DOCUMENTED_HANDLE_LIMIT} handles`);
expect(mockFetch).not.toHaveBeenCalled();
},
);
it("routes x_search through plugin-owned xSearch.baseUrl", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({

View File

@@ -27,6 +27,7 @@ import {
import {
buildMissingXSearchApiKeyPayload,
createXSearchToolDefinition,
X_SEARCH_HANDLE_LIMIT,
} from "./x-search-tool-shared.js";
class PluginToolInputError extends Error {
@@ -106,6 +107,27 @@ function normalizeOptionalIsoDate(value: string | undefined, label: string): str
return trimmed;
}
function validateXSearchHandleFilters(params: {
allowedXHandles?: string[];
excludedXHandles?: string[];
}): void {
if (params.allowedXHandles && params.excludedXHandles) {
throw new PluginToolInputError(
"allowed_x_handles and excluded_x_handles cannot be used together",
);
}
for (const [label, handles] of [
["allowed_x_handles", params.allowedXHandles],
["excluded_x_handles", params.excludedXHandles],
] as const) {
if (handles && handles.length > X_SEARCH_HANDLE_LIMIT) {
throw new PluginToolInputError(
`${label} cannot contain more than ${X_SEARCH_HANDLE_LIMIT} handles`,
);
}
}
}
function buildXSearchCacheKey(params: {
query: string;
model: string;
@@ -161,6 +183,7 @@ export function createXSearchTool(options?: {
const query = readStringParam(args, "query", { required: true });
const allowedXHandles = readStringArrayParam(args, "allowed_x_handles");
const excludedXHandles = readStringArrayParam(args, "excluded_x_handles");
validateXSearchHandleFilters({ allowedXHandles, excludedXHandles });
const fromDate = normalizeOptionalIsoDate(readStringParam(args, "from_date"), "from_date");
const toDate = normalizeOptionalIsoDate(readStringParam(args, "to_date"), "to_date");
if (fromDate && toDate && fromDate > toDate) {