diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index b5aee21b4f4..76bad4a695d 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -1,4 +1,4 @@ -import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/text-runtime"; +import { normalizeOptionalTrimmedStringList } from "openclaw/plugin-sdk/text-runtime"; import { type BrowserConfig, type BrowserProfileConfig, @@ -113,13 +113,7 @@ function resolveCdpPortRangeStart( return start; } -function normalizeStringList(raw: string[] | undefined): string[] | undefined { - if (!Array.isArray(raw) || raw.length === 0) { - return undefined; - } - const values = normalizeTrimmedStringList(raw); - return values.length > 0 ? values : undefined; -} +const normalizeStringList = normalizeOptionalTrimmedStringList; function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined { const rawPolicy = cfg?.ssrfPolicy as diff --git a/extensions/feishu/src/docx-batch-insert.ts b/extensions/feishu/src/docx-batch-insert.ts index 4686f913be5..83f9aee8c22 100644 --- a/extensions/feishu/src/docx-batch-insert.ts +++ b/extensions/feishu/src/docx-batch-insert.ts @@ -7,6 +7,7 @@ */ import type * as Lark from "@larksuiteoapi/node-sdk"; +import { readStringValue } from "openclaw/plugin-sdk/text-runtime"; import { cleanBlocksForDescendant } from "./docx-table-ops.js"; import type { FeishuDocxBlock, FeishuDocxBlockChild } from "./docx-types.js"; @@ -25,7 +26,8 @@ function normalizeChildIds(children: string[] | string | undefined): string[] | if (Array.isArray(children)) { return children; } - return typeof children === "string" ? [children] : undefined; + const child = readStringValue(children); + return child ? [child] : undefined; } function toDescendantBlock(block: FeishuDocxBlock): DocxDescendantCreateBlock { diff --git a/packages/plugin-package-contract/src/index.ts b/packages/plugin-package-contract/src/index.ts index 10c016742a1..579ba4a5901 100644 --- a/packages/plugin-package-contract/src/index.ts +++ b/packages/plugin-package-contract/src/index.ts @@ -25,10 +25,6 @@ export const EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [ "openclaw.build.openclawVersion", ] as const; -function getTrimmedString(value: unknown): string | undefined { - return normalizeOptionalString(value); -} - function readOpenClawBlock(packageJson: unknown) { const root = isRecord(packageJson) ? packageJson : undefined; const openclaw = isRecord(root?.openclaw) ? root.openclaw : undefined; @@ -42,26 +38,26 @@ export function normalizeExternalPluginCompatibility( packageJson: unknown, ): ExternalPluginCompatibility | undefined { const { root, compat, build, install } = readOpenClawBlock(packageJson); - const version = getTrimmedString(root?.version); - const minHostVersion = getTrimmedString(install?.minHostVersion); + const version = normalizeOptionalString(root?.version); + const minHostVersion = normalizeOptionalString(install?.minHostVersion); const compatibility: ExternalPluginCompatibility = {}; - const pluginApi = getTrimmedString(compat?.pluginApi); + const pluginApi = normalizeOptionalString(compat?.pluginApi); if (pluginApi) { compatibility.pluginApiRange = pluginApi; } - const minGatewayVersion = getTrimmedString(compat?.minGatewayVersion) ?? minHostVersion; + const minGatewayVersion = normalizeOptionalString(compat?.minGatewayVersion) ?? minHostVersion; if (minGatewayVersion) { compatibility.minGatewayVersion = minGatewayVersion; } - const builtWithOpenClawVersion = getTrimmedString(build?.openclawVersion) ?? version; + const builtWithOpenClawVersion = normalizeOptionalString(build?.openclawVersion) ?? version; if (builtWithOpenClawVersion) { compatibility.builtWithOpenClawVersion = builtWithOpenClawVersion; } - const pluginSdkVersion = getTrimmedString(build?.pluginSdkVersion); + const pluginSdkVersion = normalizeOptionalString(build?.pluginSdkVersion); if (pluginSdkVersion) { compatibility.pluginSdkVersion = pluginSdkVersion; } @@ -72,10 +68,10 @@ export function normalizeExternalPluginCompatibility( export function listMissingExternalCodePluginFieldPaths(packageJson: unknown): string[] { const { compat, build } = readOpenClawBlock(packageJson); const missing: string[] = []; - if (!getTrimmedString(compat?.pluginApi)) { + if (!normalizeOptionalString(compat?.pluginApi)) { missing.push("openclaw.compat.pluginApi"); } - if (!getTrimmedString(build?.openclawVersion)) { + if (!normalizeOptionalString(build?.openclawVersion)) { missing.push("openclaw.build.openclawVersion"); } return missing; diff --git a/scripts/e2e/mcp-channels-harness.ts b/scripts/e2e/mcp-channels-harness.ts index 4d3069114e8..b4ea225c094 100644 --- a/scripts/e2e/mcp-channels-harness.ts +++ b/scripts/e2e/mcp-channels-harness.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { PROTOCOL_VERSION } from "../../src/gateway/protocol/index.ts"; import { formatErrorMessage } from "../../src/infra/errors.ts"; import { rawDataToString } from "../../src/infra/ws.ts"; +import { readStringValue } from "../../src/shared/string-coerce.ts"; export const ClaudeChannelNotificationSchema = z.object({ method: z.literal("notifications/claude/channel"), @@ -66,8 +67,7 @@ export function extractTextFromGatewayPayload( if (!first || typeof first !== "object") { return undefined; } - const text = (first as { text?: unknown }).text; - return typeof text === "string" ? text : undefined; + return readStringValue((first as { text?: unknown }).text); } export async function waitFor( diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 6171dd97596..115de5264ec 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -2,6 +2,7 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index. import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js"; import { splitMediaFromOutput } from "../media/parse.js"; import { pluginRegistrationContractRegistry } from "../plugins/contracts/registry.js"; +import { normalizeOptionalString, readStringValue } from "../shared/string-coerce.js"; import { truncateUtf16Safe } from "../utils.js"; import { collectTextContentBlocks } from "./content-blocks.js"; import { type MessagingToolSend } from "./pi-embedded-messaging.js"; @@ -98,12 +99,12 @@ export function sanitizeToolResult(result: unknown): unknown { return item; } const entry = item as Record; - const type = typeof entry.type === "string" ? entry.type : undefined; + const type = readStringValue(entry.type); if (type === "text" && typeof entry.text === "string") { return { ...entry, text: truncateToolText(entry.text) }; } if (type === "image") { - const data = typeof entry.data === "string" ? entry.data : undefined; + const data = readStringValue(entry.data); const bytes = data ? data.length : undefined; const cleaned = { ...entry }; delete cleaned.data; @@ -181,7 +182,7 @@ function readToolResultDetails(result: unknown): Record | undef function readToolResultStatus(result: unknown): string | undefined { const status = readToolResultDetails(result)?.status; - return typeof status === "string" ? status.trim().toLowerCase() : undefined; + return normalizeOptionalString(status)?.toLowerCase(); } function isExternalToolResult(result: unknown): boolean { @@ -372,11 +373,11 @@ export function extractToolErrorMessage(result: unknown): string | undefined { } function resolveMessageToolTarget(args: Record): string | undefined { - const toRaw = typeof args.to === "string" ? args.to : undefined; + const toRaw = readStringValue(args.to); if (toRaw) { return toRaw; } - return typeof args.target === "string" ? args.target : undefined; + return readStringValue(args.target); } export function extractMessagingToolSend( @@ -385,8 +386,7 @@ export function extractMessagingToolSend( ): MessagingToolSend | undefined { // Provider docking: new provider tools must implement plugin.actions.extractToolSend. const action = typeof args.action === "string" ? args.action.trim() : ""; - const accountIdRaw = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - const accountId = accountIdRaw ? accountIdRaw : undefined; + const accountId = normalizeOptionalString(args.accountId); if (toolName === "message") { if (action !== "send" && action !== "thread-reply") { return undefined; diff --git a/src/auto-reply/reply/reply-threading.ts b/src/auto-reply/reply/reply-threading.ts index 3ca9b35aa14..47ad90326d5 100644 --- a/src/auto-reply/reply/reply-threading.ts +++ b/src/auto-reply/reply/reply-threading.ts @@ -2,6 +2,7 @@ import { normalizeChannelId as normalizePluginChannelId } from "../../channels/p import type { ChannelThreadingAdapter } from "../../channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { ReplyToMode } from "../../config/types.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload, ReplyThreadingPolicy } from "../types.js"; import { isSingleUseReplyToMode } from "./reply-reference.js"; @@ -27,7 +28,7 @@ function resolveReplyToModeChannelKey(channel?: OriginatingChannelType): string if (normalized) { return normalized; } - return typeof channel === "string" ? channel.trim().toLowerCase() || undefined : undefined; + return normalizeOptionalString(channel)?.toLowerCase(); } export function resolveConfiguredReplyToMode( @@ -159,7 +160,7 @@ export function createReplyToModeFilterForChannel( mode: ReplyToMode, channel?: OriginatingChannelType, ) { - const normalized = typeof channel === "string" ? channel.trim().toLowerCase() : undefined; + const normalized = normalizeOptionalString(channel)?.toLowerCase(); const isWebchat = normalized === "webchat"; // Default: allow explicit reply tags/directives even when replyToMode is "off". // Unknown channels fail closed; internal webchat stays allowed. diff --git a/src/gateway/cli-session-history.merge.ts b/src/gateway/cli-session-history.merge.ts index f4250305aa3..f45b37df84d 100644 --- a/src/gateway/cli-session-history.merge.ts +++ b/src/gateway/cli-session-history.merge.ts @@ -1,4 +1,5 @@ import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; +import { normalizeOptionalString, readStringValue } from "../shared/string-coerce.js"; const DEDUPE_TIMESTAMP_WINDOW_MS = 5 * 60 * 1000; @@ -7,17 +8,22 @@ function extractComparableText(message: unknown): string | undefined { return undefined; } const record = message as { role?: unknown; text?: unknown; content?: unknown }; - const role = typeof record.role === "string" ? record.role : undefined; + const role = readStringValue(record.role); const parts: string[] = []; - if (typeof record.text === "string") { - parts.push(record.text); + const text = readStringValue(record.text); + if (text !== undefined) { + parts.push(text); } - if (typeof record.content === "string") { - parts.push(record.content); + const content = readStringValue(record.content); + if (content !== undefined) { + parts.push(content); } else if (Array.isArray(record.content)) { for (const block of record.content) { - if (block && typeof block === "object" && "text" in block && typeof block.text === "string") { - parts.push(block.text); + if (block && typeof block === "object" && "text" in block) { + const blockText = readStringValue(block.text); + if (blockText !== undefined) { + parts.push(blockText); + } } } } @@ -48,8 +54,7 @@ function resolveComparableRole(message: unknown): string | undefined { if (!message || typeof message !== "object") { return undefined; } - const role = (message as { role?: unknown }).role; - return typeof role === "string" ? role : undefined; + return readStringValue((message as { role?: unknown }).role); } function resolveImportedExternalId(message: unknown): string | undefined { @@ -62,8 +67,7 @@ function resolveImportedExternalId(message: unknown): string | undefined { typeof (message as { __openclaw?: unknown }).__openclaw === "object" ? ((message as { __openclaw?: Record }).__openclaw ?? {}) : undefined; - const externalId = meta?.externalId; - return typeof externalId === "string" && externalId.trim() ? externalId : undefined; + return normalizeOptionalString(meta?.externalId); } function isEquivalentImportedMessage(existing: unknown, imported: unknown): boolean { diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index e34ddefb179..0a371615178 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { resolveMissingRequestedScope } from "../shared/operator-scope-compat.js"; -import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; +import { normalizeArrayBackedTrimmedStringList } from "../shared/string-normalization.js"; import { type NodeApprovalScope, resolveNodePairApprovalScopes } from "./node-pairing-authz.js"; import { createAsyncLock, @@ -67,14 +67,6 @@ const OPERATOR_ROLE = "operator"; const withLock = createAsyncLock(); -function normalizeStringList(values?: string[]): string[] | undefined { - if (!Array.isArray(values)) { - return undefined; - } - const normalized = normalizeTrimmedStringList(values); - return normalized.length > 0 ? normalized : []; -} - function buildPendingNodePairingRequest(params: { requestId?: string; req: NodePairingRequestInput; @@ -89,8 +81,8 @@ function buildPendingNodePairingRequest(params: { uiVersion: params.req.uiVersion, deviceFamily: params.req.deviceFamily, modelIdentifier: params.req.modelIdentifier, - caps: normalizeStringList(params.req.caps), - commands: normalizeStringList(params.req.commands), + caps: normalizeArrayBackedTrimmedStringList(params.req.caps), + commands: normalizeArrayBackedTrimmedStringList(params.req.commands), permissions: params.req.permissions, remoteIp: params.req.remoteIp, silent: params.req.silent, @@ -111,8 +103,8 @@ function refreshPendingNodePairingRequest( uiVersion: incoming.uiVersion ?? existing.uiVersion, deviceFamily: incoming.deviceFamily ?? existing.deviceFamily, modelIdentifier: incoming.modelIdentifier ?? existing.modelIdentifier, - caps: normalizeStringList(incoming.caps) ?? existing.caps, - commands: normalizeStringList(incoming.commands) ?? existing.commands, + caps: normalizeArrayBackedTrimmedStringList(incoming.caps) ?? existing.caps, + commands: normalizeArrayBackedTrimmedStringList(incoming.commands) ?? existing.commands, permissions: incoming.permissions ?? existing.permissions, remoteIp: incoming.remoteIp ?? existing.remoteIp, // Preserve interactive visibility if either request needs attention. diff --git a/src/shared/chat-message-content.ts b/src/shared/chat-message-content.ts index 36322e7855a..391211be688 100644 --- a/src/shared/chat-message-content.ts +++ b/src/shared/chat-message-content.ts @@ -1,10 +1,13 @@ +import { readStringValue } from "./string-coerce.js"; + export function extractFirstTextBlock(message: unknown): string | undefined { if (!message || typeof message !== "object") { return undefined; } const content = (message as { content?: unknown }).content; - if (typeof content === "string") { - return content; + const inline = readStringValue(content); + if (inline !== undefined) { + return inline; } if (!Array.isArray(content) || content.length === 0) { return undefined; @@ -13,8 +16,7 @@ export function extractFirstTextBlock(message: unknown): string | undefined { if (!first || typeof first !== "object") { return undefined; } - const text = (first as { text?: unknown }).text; - return typeof text === "string" ? text : undefined; + return readStringValue((first as { text?: unknown }).text); } export type AssistantPhase = "commentary" | "final_answer"; diff --git a/src/shared/string-normalization.ts b/src/shared/string-normalization.ts index 65c7576741e..262c32c7b24 100644 --- a/src/shared/string-normalization.ts +++ b/src/shared/string-normalization.ts @@ -15,6 +15,18 @@ export function normalizeTrimmedStringList(value: unknown): string[] { ); } +export function normalizeOptionalTrimmedStringList(value: unknown): string[] | undefined { + const normalized = normalizeTrimmedStringList(value); + return normalized.length > 0 ? normalized : undefined; +} + +export function normalizeArrayBackedTrimmedStringList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return normalizeTrimmedStringList(value); +} + export function normalizeSingleOrTrimmedStringList(value: unknown): string[] { if (Array.isArray(value)) { return normalizeTrimmedStringList(value);