From d7dc6ee845133d8665aaf08e83dac77795faefea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 08:02:18 +0100 Subject: [PATCH] fix(xai): validate x_search handle filters (#103393) --- docs/tools/web.md | 6 +- extensions/xai/x-search-tool-shared.ts | 10 ++- extensions/xai/x-search.test.ts | 100 +++++++++++++++++++++++-- extensions/xai/x-search.ts | 23 ++++++ 4 files changed, 130 insertions(+), 9 deletions(-) diff --git a/docs/tools/web.md b/docs/tools/web.md index 03494450d8ef..d72e8e78979f 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -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 diff --git a/extensions/xai/x-search-tool-shared.ts b/extensions/xai/x-search-tool-shared.ts index 28cf96d02a86..0767a669009e 100644 --- a/extensions/xai/x-search-tool-shared.ts +++ b/extensions/xai/x-search-tool-shared.ts @@ -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( diff --git a/extensions/xai/x-search.test.ts b/extensions/xai/x-search.test.ts index 04aec1dca506..89a2eb57b240 100644 --- a/extensions/xai/x-search.test.ts +++ b/extensions/xai/x-search.test.ts @@ -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 >; } +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; + }; + + 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({ diff --git a/extensions/xai/x-search.ts b/extensions/xai/x-search.ts index 50c8110a9269..559b13166fca 100644 --- a/extensions/xai/x-search.ts +++ b/extensions/xai/x-search.ts @@ -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) {