fix(xai): make x_search auth plugin-owned (#59691)

* fix(xai): make x_search auth plugin-owned

* fix(xai): restore x_search runtime migration fallback

* fix(xai): narrow legacy x_search auth migration

* fix(secrets): drop legacy x_search target registry entry

* fix(xai): no-op knob-only x_search migration fallback
This commit is contained in:
Vincent Koc
2026-04-02 23:54:07 +09:00
committed by GitHub
parent b6debb4382
commit 3872a866a1
17 changed files with 239 additions and 279 deletions

View File

@@ -67338,55 +67338,6 @@
"tags": [],
"hasChildren": true
},
{
"path": "tools.web.x_search.apiKey",
"kind": "core",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security",
"tools"
],
"label": "xAI API Key",
"help": "xAI API key for X search (fallback: XAI_API_KEY env var).",
"hasChildren": true
},
{
"path": "tools.web.x_search.apiKey.id",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.x_search.apiKey.provider",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.x_search.apiKey.source",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "tools.web.x_search.cacheTtlMinutes",
"kind": "core",
@@ -67394,13 +67345,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"performance",
"storage",
"tools"
],
"label": "X Search Cache TTL (min)",
"help": "Cache TTL in minutes for x_search results.",
"tags": [],
"hasChildren": false
},
{
@@ -67410,11 +67355,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"tools"
],
"label": "Enable X Search Tool",
"help": "Enable the x_search tool (requires XAI_API_KEY or tools.web.x_search.apiKey).",
"tags": [],
"hasChildren": false
},
{
@@ -67424,11 +67365,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"tools"
],
"label": "X Search Inline Citations",
"help": "Keep inline citations from xAI in x_search responses when available (default: false).",
"tags": [],
"hasChildren": false
},
{
@@ -67438,12 +67375,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"performance",
"tools"
],
"label": "X Search Max Turns",
"help": "Optional max internal search/tool turns xAI may use per x_search request. Omit to let xAI choose.",
"tags": [],
"hasChildren": false
},
{
@@ -67453,12 +67385,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"models",
"tools"
],
"label": "X Search Model",
"help": "Model to use for X search (default: \"grok-4-1-fast-non-reasoning\").",
"tags": [],
"hasChildren": false
},
{
@@ -67468,12 +67395,7 @@
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"performance",
"tools"
],
"label": "X Search Timeout (sec)",
"help": "Timeout in seconds for x_search requests.",
"tags": [],
"hasChildren": false
},
{

View File

@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5781}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5777}
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -5742,16 +5742,12 @@
{"recordType":"path","path":"tools.web.search.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Search Provider","help":"Search provider id. Auto-detected from available API keys if omitted.","hasChildren":false}
{"recordType":"path","path":"tools.web.search.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Search Timeout (sec)","help":"Timeout in seconds for web_search requests.","hasChildren":false}
{"recordType":"path","path":"tools.web.x_search","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"tools.web.x_search.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"xAI API Key","help":"xAI API key for X search (fallback: XAI_API_KEY env var).","hasChildren":true}
{"recordType":"path","path":"tools.web.x_search.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.cacheTtlMinutes","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage","tools"],"label":"X Search Cache TTL (min)","help":"Cache TTL in minutes for x_search results.","hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Enable X Search Tool","help":"Enable the x_search tool (requires XAI_API_KEY or tools.web.x_search.apiKey).","hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.inlineCitations","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"X Search Inline Citations","help":"Keep inline citations from xAI in x_search responses when available (default: false).","hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.maxTurns","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"X Search Max Turns","help":"Optional max internal search/tool turns xAI may use per x_search request. Omit to let xAI choose.","hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"X Search Model","help":"Model to use for X search (default: \"grok-4-1-fast-non-reasoning\").","hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"X Search Timeout (sec)","help":"Timeout in seconds for x_search requests.","hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.cacheTtlMinutes","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.inlineCitations","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.maxTurns","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"tools.web.x_search.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"ui","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"UI","help":"UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.","hasChildren":true}
{"recordType":"path","path":"ui.assistant","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Appearance","help":"Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.","hasChildren":true}
{"recordType":"path","path":"ui.assistant.avatar","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Avatar","help":"Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.","hasChildren":false}

View File

@@ -39,7 +39,6 @@ Scope intent:
- `plugins.entries.firecrawl.config.webSearch.apiKey`
- `plugins.entries.tavily.config.webSearch.apiKey`
- `tools.web.search.apiKey`
- `tools.web.x_search.apiKey`
- `gateway.auth.password`
- `gateway.auth.token`
- `gateway.remote.token`

View File

@@ -523,13 +523,6 @@
"path": "tools.web.search.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.x_search.apiKey",
"configFile": "openclaw.json",
"path": "tools.web.x_search.apiKey",
"secretShape": "secret_input",
"optIn": true
}
]
}

View File

@@ -231,6 +231,44 @@ describe("xai x_search tool", () => {
);
});
it("uses migrated runtime auth when the source config still carries legacy x_search apiKey", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
x_search: {
apiKey: "legacy-x-search-key", // pragma: allowlist secret
enabled: true,
} as Record<string, unknown>,
},
},
},
runtimeConfig: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "migrated-runtime-key", // pragma: allowlist secret
},
},
},
},
},
},
});
await tool?.execute?.("x-search:migrated-runtime-key", {
query: "migrated runtime auth",
});
const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((request?.headers as Record<string, string> | undefined)?.Authorization).toBe(
"Bearer migrated-runtime-key",
);
});
it("rejects invalid date ordering before calling xAI", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({

View File

@@ -77,11 +77,6 @@ function resolveFallbackXaiApiKey(cfg?: OpenClawConfig): string | undefined {
return readPluginXaiWebSearchApiKey(cfg) ?? readLegacyGrokApiKey(cfg);
}
function readLegacyXSearchApiKey(cfg?: OpenClawConfig): string | undefined {
const legacyConfig = resolveLegacyXSearchConfig(cfg);
return readConfiguredSecretString(legacyConfig?.apiKey, "tools.web.x_search.apiKey");
}
function resolveXSearchConfig(cfg?: OpenClawConfig): Record<string, unknown> | undefined {
return resolveEffectiveXSearchConfig(cfg);
}
@@ -94,33 +89,19 @@ function resolveXSearchEnabled(params: {
if (params.config?.enabled === false) {
return false;
}
if (
resolveFallbackXaiApiKey(params.runtimeConfig) ||
readLegacyXSearchApiKey(params.runtimeConfig)
) {
if (resolveFallbackXaiApiKey(params.runtimeConfig)) {
return true;
}
return Boolean(
resolveFallbackXaiApiKey(params.cfg) ||
readLegacyXSearchApiKey(params.cfg) ||
readProviderEnvValue(["XAI_API_KEY"]),
);
return Boolean(resolveFallbackXaiApiKey(params.cfg) || readProviderEnvValue(["XAI_API_KEY"]));
}
function resolveXSearchApiKey(params: {
sourceConfig?: OpenClawConfig;
runtimeConfig?: OpenClawConfig;
}): string | undefined {
const sourceXSearchConfig = resolveXSearchConfig(params.sourceConfig);
const runtimeXSearchConfig =
params.runtimeConfig && params.runtimeConfig !== params.sourceConfig
? resolveXSearchConfig(params.runtimeConfig)
: undefined;
return (
resolveFallbackXaiApiKey(params.runtimeConfig) ??
resolveFallbackXaiApiKey(params.sourceConfig) ??
readLegacyXSearchApiKey(params.runtimeConfig) ??
readLegacyXSearchApiKey(params.sourceConfig) ??
readProviderEnvValue(["XAI_API_KEY"])
);
}

View File

@@ -125,7 +125,7 @@ describe("normalizeCompatibilityConfigValues", () => {
]);
});
it("migrates legacy x_search config into xai plugin-owned config", () => {
it("migrates legacy x_search auth into xai plugin-owned config", () => {
const res = normalizeCompatibilityConfigValues({
tools: {
web: {
@@ -138,25 +138,21 @@ describe("normalizeCompatibilityConfigValues", () => {
},
});
expect(
(res.config.tools?.web as Record<string, unknown> | undefined)?.x_search,
).toBeUndefined();
expect((res.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
model: "grok-4-1-fast",
});
expect(res.config.plugins?.entries?.xai).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "xai-legacy-key",
},
xSearch: {
enabled: true,
model: "grok-4-1-fast",
},
},
});
expect(res.changes).toEqual(
expect.arrayContaining([
"Moved tools.web.x_search.apiKey → plugins.entries.xai.config.webSearch.apiKey.",
"Moved tools.web.x_search → plugins.entries.xai.config.xSearch.",
]),
);
});

View File

@@ -286,6 +286,38 @@ describe("legacy migrate tts provider shape", () => {
});
});
describe("legacy migrate x_search auth", () => {
it("moves only legacy x_search auth into plugin-owned xai config", () => {
const res = migrateLegacyConfig({
tools: {
web: {
x_search: {
apiKey: "xai-legacy-key",
enabled: true,
model: "grok-4-1-fast",
},
},
},
});
expect((res.config?.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
model: "grok-4-1-fast",
});
expect(res.config?.plugins?.entries?.xai).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "xai-legacy-key",
},
},
});
expect(res.changes).toEqual([
"Moved tools.web.x_search.apiKey → plugins.entries.xai.config.webSearch.apiKey.",
]);
});
});
describe("legacy migrate heartbeat config", () => {
it("moves top-level heartbeat into agents.defaults.heartbeat", () => {
const res = migrateLegacyConfig({

View File

@@ -3,7 +3,7 @@ import type { OpenClawConfig } from "./config.js";
import { listLegacyXSearchConfigPaths, migrateLegacyXSearchConfig } from "./legacy-x-search.js";
describe("legacy x_search config migration", () => {
it("moves legacy x_search auth and settings into the xai plugin config", () => {
it("moves only legacy x_search auth into the xai plugin config", () => {
const res = migrateLegacyXSearchConfig({
tools: {
web: {
@@ -16,28 +16,24 @@ describe("legacy x_search config migration", () => {
},
} as OpenClawConfig);
expect(
(res.config.tools?.web as Record<string, unknown> | undefined)?.x_search,
).toBeUndefined();
expect((res.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
model: "grok-4-1-fast",
});
expect(res.config.plugins?.entries?.xai).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "xai-legacy-key",
},
xSearch: {
enabled: true,
model: "grok-4-1-fast",
},
},
});
expect(res.changes).toEqual([
"Moved tools.web.x_search.apiKey → plugins.entries.xai.config.webSearch.apiKey.",
"Moved tools.web.x_search → plugins.entries.xai.config.xSearch.",
]);
});
it("keeps explicit plugin-owned values when migrating legacy x_search config", () => {
it("keeps explicit plugin-owned auth when migrating legacy x_search config", () => {
const res = migrateLegacyXSearchConfig({
tools: {
web: {
@@ -66,18 +62,40 @@ describe("legacy x_search config migration", () => {
},
} as OpenClawConfig);
expect((res.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
model: "legacy-model",
cacheTtlMinutes: 5,
});
expect(res.config.plugins?.entries?.xai?.config).toEqual({
webSearch: {
apiKey: "plugin-key",
},
xSearch: {
enabled: true,
model: "plugin-model",
cacheTtlMinutes: 5,
},
});
});
it("does nothing for knob-only x_search config without a legacy apiKey", () => {
const config = {
tools: {
web: {
x_search: {
enabled: true,
model: "grok-4-1-fast",
},
} as Record<string, unknown>,
},
} as OpenClawConfig;
const res = migrateLegacyXSearchConfig(config);
expect(res.config).toEqual(config);
expect(res.changes).toEqual([]);
expect(res.config.plugins?.entries?.xai).toBeUndefined();
});
it("lists legacy x_search paths", () => {
expect(
listLegacyXSearchConfigPaths({
@@ -90,6 +108,6 @@ describe("legacy x_search config migration", () => {
} as Record<string, unknown>,
},
} as OpenClawConfig),
).toEqual(["tools.web.x_search.apiKey", "tools.web.x_search.enabled"]);
).toEqual(["tools.web.x_search.apiKey"]);
});
});

View File

@@ -1,10 +1,7 @@
import { mergeMissing } from "./legacy.shared.js";
type JsonRecord = Record<string, unknown>;
const XAI_PLUGIN_ID = "xai";
const X_SEARCH_LEGACY_PATH = "tools.web.x_search";
const X_SEARCH_PLUGIN_PATH = `plugins.entries.${XAI_PLUGIN_ID}.config.xSearch`;
const XAI_WEB_SEARCH_PLUGIN_KEY_PATH = `plugins.entries.${XAI_PLUGIN_ID}.config.webSearch.apiKey`;
function isRecord(value: unknown): value is JsonRecord {
@@ -37,29 +34,16 @@ function resolveLegacyXSearchConfig(raw: unknown): JsonRecord | undefined {
return isRecord(web?.x_search) ? web.x_search : undefined;
}
function splitLegacyXSearchPayload(legacy: JsonRecord): {
auth: unknown;
xSearch: JsonRecord | undefined;
} {
const next: JsonRecord = {};
for (const [key, value] of Object.entries(legacy)) {
if (key === "apiKey") {
continue;
}
next[key] = value;
}
return {
auth: legacy.apiKey,
xSearch: Object.keys(next).length > 0 ? next : undefined,
};
function resolveLegacyXSearchAuth(legacy: JsonRecord): unknown {
return legacy.apiKey;
}
export function listLegacyXSearchConfigPaths(raw: unknown): string[] {
const legacy = resolveLegacyXSearchConfig(raw);
if (!legacy) {
if (!legacy || !Object.prototype.hasOwnProperty.call(legacy, "apiKey")) {
return [];
}
return Object.keys(legacy).map((key) => `${X_SEARCH_LEGACY_PATH}.${key}`);
return [`${X_SEARCH_LEGACY_PATH}.apiKey`];
}
export function migrateLegacyXSearchConfig<T>(raw: T): { config: T; changes: string[] } {
@@ -67,14 +51,20 @@ export function migrateLegacyXSearchConfig<T>(raw: T): { config: T; changes: str
return { config: raw, changes: [] };
}
const legacy = resolveLegacyXSearchConfig(raw);
if (!legacy) {
if (!legacy || !Object.prototype.hasOwnProperty.call(legacy, "apiKey")) {
return { config: raw, changes: [] };
}
const nextRoot = structuredClone(raw);
const tools = ensureRecord(nextRoot, "tools");
const web = ensureRecord(tools, "web");
delete web.x_search;
const nextLegacy = cloneRecord(legacy) ?? {};
delete nextLegacy.apiKey;
if (Object.keys(nextLegacy).length === 0) {
delete web.x_search;
} else {
web.x_search = nextLegacy;
}
const plugins = ensureRecord(nextRoot, "plugins");
const entries = ensureRecord(plugins, "entries");
@@ -84,7 +74,7 @@ export function migrateLegacyXSearchConfig<T>(raw: T): { config: T; changes: str
entry.enabled = true;
}
const config = ensureRecord(entry, "config");
const { auth, xSearch } = splitLegacyXSearchPayload(legacy);
const auth = resolveLegacyXSearchAuth(legacy);
const changes: string[] = [];
if (auth !== undefined) {
@@ -107,24 +97,7 @@ export function migrateLegacyXSearchConfig<T>(raw: T): { config: T; changes: str
}
}
if (xSearch) {
const existingXSearch = isRecord(config.xSearch) ? cloneRecord(config.xSearch) : undefined;
if (!existingXSearch) {
config.xSearch = cloneRecord(xSearch);
changes.push(`Moved ${X_SEARCH_LEGACY_PATH}${X_SEARCH_PLUGIN_PATH}.`);
} else {
const merged = cloneRecord(existingXSearch);
mergeMissing(merged, xSearch);
config.xSearch = merged;
if (JSON.stringify(existingXSearch) !== JSON.stringify(merged) || !hadEnabled) {
changes.push(
`Merged ${X_SEARCH_LEGACY_PATH}${X_SEARCH_PLUGIN_PATH} (filled missing fields from legacy; kept explicit plugin config values).`,
);
} else {
changes.push(`Removed ${X_SEARCH_LEGACY_PATH} (${X_SEARCH_PLUGIN_PATH} already set).`);
}
}
} else if (!hadEnabled) {
if (auth !== undefined && Object.keys(nextLegacy).length === 0 && !hadEnabled) {
changes.push(`Removed empty ${X_SEARCH_LEGACY_PATH}.`);
}

View File

@@ -4,6 +4,7 @@ import {
isGatewayNonLoopbackBindMode,
resolveGatewayPortWithDefault,
} from "./gateway-control-ui-origins.js";
import { migrateLegacyXSearchConfig } from "./legacy-x-search.js";
import {
defineLegacyConfigMigration,
ensureRecord,
@@ -259,6 +260,12 @@ const HEARTBEAT_RULE: LegacyConfigRule = {
"top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
};
const X_SEARCH_RULE: LegacyConfigRule = {
path: ["tools", "web", "x_search", "apiKey"],
message:
"tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead (auto-migrated on load).",
};
const LEGACY_TTS_RULES: LegacyConfigRule[] = [
{
path: ["messages", "tts"],
@@ -287,6 +294,22 @@ const LEGACY_TTS_RULES: LegacyConfigRule[] = [
];
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
id: "tools.web.x_search.apiKey->plugins.entries.xai.config.webSearch.apiKey",
describe: "Move legacy x_search auth into the xAI plugin webSearch config",
legacyRules: [X_SEARCH_RULE],
apply: (raw, changes) => {
const migrated = migrateLegacyXSearchConfig(raw);
if (!migrated.changes.length) {
return;
}
for (const key of Object.keys(raw)) {
delete raw[key];
}
Object.assign(raw, migrated.config);
changes.push(...migrated.changes);
},
}),
defineLegacyConfigMigration({
// v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the
// host-header fallback flag) for any non-loopback bind. The setup wizard was updated

View File

@@ -5342,72 +5342,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
enabled: {
type: "boolean",
},
apiKey: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
model: {
type: "string",
},
@@ -15221,10 +15155,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
sensitive: true,
tags: ["security", "auth", "tools"],
},
"tools.web.x_search.apiKey": {
sensitive: true,
tags: ["security", "auth", "tools"],
},
"mcp.servers.*.headers.*": {
sensitive: true,
tags: ["security"],

View File

@@ -503,6 +503,21 @@ export type ToolsConfig = {
};
};
} & Record<string, unknown>;
/** X (formerly Twitter) search tool configuration using xAI Grok. */
x_search?: {
/** Enable X search tool (default: true when xAI auth is available via plugin config or XAI_API_KEY). */
enabled?: boolean;
/** Model id to use for X search. */
model?: string;
/** Keep inline citations in the xAI response payload when available. */
inlineCitations?: boolean;
/** Optional max search/tool turns for xAI to use internally. */
maxTurns?: number;
/** Timeout in seconds for X search requests. */
timeoutSeconds?: number;
/** Cache TTL in minutes for X search results. */
cacheTtlMinutes?: number;
};
fetch?: {
/** Enable web fetch tool (default: true). */
enabled?: boolean;

View File

@@ -345,7 +345,6 @@ export const ToolsWebFetchSchema = z
export const ToolsWebXSearchSchema = z
.object({
enabled: z.boolean().optional(),
apiKey: SecretInputSchema.optional().register(sensitive),
model: z.string().optional(),
inlineCitations: z.boolean().optional(),
maxTurns: z.number().int().optional(),

View File

@@ -2739,17 +2739,73 @@ describe("secrets runtime snapshot", () => {
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(
(snapshot.config.tools?.web as Record<string, unknown> | undefined)?.x_search,
).toBeUndefined();
expect((snapshot.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
model: "grok-4-1-fast",
});
expect(snapshot.config.plugins?.entries?.xai?.config).toEqual({
webSearch: {
apiKey: "xai-runtime-key",
},
xSearch: {
enabled: true,
model: "grok-4-1-fast",
});
});
it("still migrates legacy x_search auth when general legacy migration returns an invalid config", async () => {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
web: {
x_search: {
apiKey: { source: "env", provider: "default", id: "X_SEARCH_KEY_REF" },
enabled: true,
},
},
},
channels: {
telegram: {
groupMentionsOnly: true,
groups: [],
},
},
}),
env: {
X_SEARCH_KEY_REF: "xai-runtime-key-invalid-config",
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect((snapshot.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
});
expect(snapshot.config.plugins?.entries?.xai?.config).toEqual({
webSearch: {
apiKey: "xai-runtime-key-invalid-config",
},
});
});
it("does not force-enable xai at runtime for knob-only x_search config", async () => {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
web: {
x_search: {
enabled: true,
model: "grok-4-1-fast",
},
},
},
}),
env: {},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect((snapshot.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
model: "grok-4-1-fast",
});
expect(snapshot.config.plugins?.entries?.xai).toBeUndefined();
});
});

View File

@@ -169,10 +169,10 @@ export async function prepareSecretsRuntimeSnapshot(params: {
loadablePluginOrigins?: ReadonlyMap<string, PluginOrigin>;
}): Promise<PreparedSecretsRuntimeSnapshot> {
const runtimeEnv = mergeSecretsRuntimeEnv(params.env);
const sourceConfig = structuredClone(migrateLegacyXSearchConfig(params.config).config);
const resolvedConfig = structuredClone(
migrateLegacyXSearchConfig(migrateLegacyConfig(params.config).config ?? params.config).config,
);
const migrated = migrateLegacyConfig(params.config);
const migratedConfig = migrated.config ?? migrateLegacyXSearchConfig(params.config).config;
const sourceConfig = structuredClone(migratedConfig);
const resolvedConfig = structuredClone(migratedConfig);
const loadablePluginOrigins =
params.loadablePluginOrigins ??
resolveLoadablePluginOrigins({ config: sourceConfig, env: runtimeEnv });

View File

@@ -714,17 +714,6 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.x_search.apiKey",
targetType: "tools.web.x_search.apiKey",
configFile: "openclaw.json",
pathPattern: "tools.web.x_search.apiKey",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "plugins.entries.brave.config.webSearch.apiKey",
targetType: "plugins.entries.brave.config.webSearch.apiKey",