From 9716f970a320b2f70dfa119130e7971618886cea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 12:45:04 +0100 Subject: [PATCH] refactor: dedupe infra lowercase helpers --- src/infra/bonjour-discovery.ts | 7 ++++--- src/infra/exec-command-resolution.ts | 5 ++++- src/infra/heartbeat-events-filter.ts | 3 ++- src/infra/net/hostname.ts | 4 +++- src/infra/net/ssrf.ts | 5 +---- src/infra/ports-format.ts | 7 +++++-- src/infra/push-apns.relay.ts | 3 ++- src/infra/unhandled-rejections.ts | 5 +++-- src/pairing/setup-code.ts | 3 ++- src/plugin-sdk/boolean-param.ts | 16 ++++++++-------- src/plugin-sdk/webhook-request-guards.ts | 3 ++- src/plugins/conversation-binding.ts | 7 +++++-- src/plugins/interactive-registry.ts | 3 ++- src/plugins/interactive-shared.ts | 4 +++- src/shared/frontmatter.ts | 4 ++-- 15 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index 32600e5ad3..e53ca42d42 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -1,4 +1,5 @@ import { runCommandWithTimeout } from "../process/exec.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isTailnetIPv4 } from "./tailnet.js"; import { resolveWideAreaDiscoveryDomain } from "./widearea-dns.js"; @@ -279,7 +280,7 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour beacon.gatewayPort = parseIntOrNull(txt.gatewayPort); beacon.sshPort = parseIntOrNull(txt.sshPort); if (txt.gatewayTls) { - const raw = txt.gatewayTls.trim().toLowerCase(); + const raw = normalizeOptionalLowercaseString(txt.gatewayTls); beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes"; } if (txt.gatewayTlsSha256) { @@ -457,7 +458,7 @@ async function discoverWideAreaViaTailnetDns( cliPath: txtMap.cliPath || undefined, }; if (txtMap.gatewayTls) { - const raw = txtMap.gatewayTls.trim().toLowerCase(); + const raw = normalizeOptionalLowercaseString(txtMap.gatewayTls); beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes"; } if (txtMap.gatewayTlsSha256) { @@ -541,7 +542,7 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] { current.gatewayPort = parseIntOrNull(txt.gatewayPort); current.sshPort = parseIntOrNull(txt.sshPort); if (txt.gatewayTls) { - const raw = txt.gatewayTls.trim().toLowerCase(); + const raw = normalizeOptionalLowercaseString(txt.gatewayTls); current.gatewayTls = raw === "1" || raw === "true" || raw === "yes"; } if (txt.gatewayTlsSha256) { diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index 6e32902386..fba5e1ea84 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { resolveExecWrapperTrustPlan } from "./exec-wrapper-trust-plan.js"; @@ -342,7 +343,9 @@ export function matchAllowlist( // Use the caller-supplied target platform rather than process.platform so that // a Linux gateway evaluating a Windows node command applies argPattern correctly. const effectivePlatform = platform ?? process.platform; - const useArgPattern = String(effectivePlatform).trim().toLowerCase().startsWith("win"); + const useArgPattern = normalizeLowercaseStringOrEmpty(String(effectivePlatform)).startsWith( + "win", + ); let pathOnlyMatch: ExecAllowlistEntry | null = null; for (const entry of entries) { const pattern = entry.pattern?.trim(); diff --git a/src/infra/heartbeat-events-filter.ts b/src/infra/heartbeat-events-filter.ts index 1682c3b308..15475e79bb 100644 --- a/src/infra/heartbeat-events-filter.ts +++ b/src/infra/heartbeat-events-filter.ts @@ -1,4 +1,5 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; // Build a dynamic prompt for cron events by embedding the actual event content. // This ensures the model sees the reminder text directly instead of relying on @@ -72,7 +73,7 @@ function isHeartbeatAckEvent(evt: string): boolean { } function isHeartbeatNoiseEvent(evt: string): boolean { - const lower = evt.trim().toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(evt); if (!lower) { return false; } diff --git a/src/infra/net/hostname.ts b/src/infra/net/hostname.ts index dd048575a0..b28b41f1a0 100644 --- a/src/infra/net/hostname.ts +++ b/src/infra/net/hostname.ts @@ -1,5 +1,7 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; + export function normalizeHostname(hostname: string): string { - const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const normalized = normalizeLowercaseStringOrEmpty(hostname).replace(/\.$/, ""); if (normalized.startsWith("[") && normalized.endsWith("]")) { return normalized.slice(1, -1); } diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 10412cc1ea..2cff500b03 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -120,10 +120,7 @@ function looksLikeUnsupportedIpv4Literal(address: string): boolean { // Returns true for private/internal and special-use non-global addresses. export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolean { - let normalized = address.trim().toLowerCase(); - if (normalized.startsWith("[") && normalized.endsWith("]")) { - normalized = normalized.slice(1, -1); - } + const normalized = normalizeHostname(address); if (!normalized) { return false; } diff --git a/src/infra/ports-format.ts b/src/infra/ports-format.ts index 9cde73eec3..df34031476 100644 --- a/src/infra/ports-format.ts +++ b/src/infra/ports-format.ts @@ -1,8 +1,11 @@ import { formatCliCommand } from "../cli/command-format.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { PortListener, PortListenerKind, PortUsage } from "./ports-types.js"; export function classifyPortListener(listener: PortListener, port: number): PortListenerKind { - const raw = `${listener.commandLine ?? ""} ${listener.command ?? ""}`.trim().toLowerCase(); + const raw = normalizeLowercaseStringOrEmpty( + `${listener.commandLine ?? ""} ${listener.command ?? ""}`, + ); if (raw.includes("openclaw")) { return "gateway"; } @@ -34,7 +37,7 @@ function parseListenerAddress(address: string): { host: string; port: number } | if (lastColon <= 0 || lastColon >= normalized.length - 1) { return null; } - const host = normalized.slice(0, lastColon).trim().toLowerCase(); + const host = normalizeLowercaseStringOrEmpty(normalized.slice(0, lastColon)); const portToken = normalized.slice(lastColon + 1).trim(); if (!/^\d+$/.test(portToken)) { return null; diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts index b25e8558f7..8c093467e0 100644 --- a/src/infra/push-apns.relay.ts +++ b/src/infra/push-apns.relay.ts @@ -7,6 +7,7 @@ import { type DeviceIdentity, } from "./device-identity.js"; import { formatErrorMessage } from "./errors.js"; +import { normalizeHostname } from "./net/hostname.js"; export type ApnsRelayPushType = "alert" | "background"; @@ -74,7 +75,7 @@ function readAllowHttp(value: string | undefined): boolean { } function isLoopbackRelayHostname(hostname: string): boolean { - const normalized = hostname.trim().toLowerCase(); + const normalized = normalizeHostname(hostname); return ( normalized === "localhost" || normalized === "::1" || diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 79926d965e..ba59cb3d9f 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -1,4 +1,5 @@ import process from "node:process"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { restoreTerminalState } from "../terminal/restore.js"; import { collectErrorGraphCandidates, @@ -238,7 +239,7 @@ export function isTransientNetworkError(err: unknown): boolean { continue; } const rawMessage = (candidate as { message?: unknown }).message; - const message = typeof rawMessage === "string" ? rawMessage.toLowerCase().trim() : ""; + const message = normalizeLowercaseStringOrEmpty(rawMessage); if (!message) { continue; } @@ -297,7 +298,7 @@ export function isTransientSqliteError(err: unknown): boolean { (candidate as { errstr?: unknown }).errstr, ]; for (const rawMessage of messageParts) { - const message = typeof rawMessage === "string" ? rawMessage.toLowerCase().trim() : ""; + const message = normalizeLowercaseStringOrEmpty(rawMessage); if (!message) { continue; } diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 6773785e32..5560cabed7 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -6,6 +6,7 @@ import { materializeGatewayAuthSecretRefs } from "../gateway/auth-config-utils.j import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; import { isLoopbackHost, isSecureWebSocketUrl } from "../gateway/net.js"; import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; +import { normalizeHostname } from "../infra/net/hostname.js"; import { pickMatchingExternalInterfaceAddress, safeNetworkInterfaces, @@ -78,7 +79,7 @@ function describeSecureMobilePairingFix(source?: string): string { } function isPrivateLanHostname(host: string): boolean { - const normalized = host.trim().toLowerCase().replace(/\.+$/, ""); + const normalized = normalizeHostname(host); if (!normalized) { return false; } diff --git a/src/plugin-sdk/boolean-param.ts b/src/plugin-sdk/boolean-param.ts index 9e58302705..40752b8f80 100644 --- a/src/plugin-sdk/boolean-param.ts +++ b/src/plugin-sdk/boolean-param.ts @@ -1,3 +1,5 @@ +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; + /** Read loose boolean params from tool input that may arrive as booleans or "true"/"false" strings. */ export function readBooleanParam( params: Record, @@ -7,14 +9,12 @@ export function readBooleanParam( if (typeof raw === "boolean") { return raw; } - if (typeof raw === "string") { - const trimmed = raw.trim().toLowerCase(); - if (trimmed === "true") { - return true; - } - if (trimmed === "false") { - return false; - } + const normalized = normalizeOptionalLowercaseString(raw); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; } return undefined; } diff --git a/src/plugin-sdk/webhook-request-guards.ts b/src/plugin-sdk/webhook-request-guards.ts index 1b18124744..a32168d011 100644 --- a/src/plugin-sdk/webhook-request-guards.ts +++ b/src/plugin-sdk/webhook-request-guards.ts @@ -8,6 +8,7 @@ import { requestBodyErrorToText, } from "../infra/http-body.js"; import { pruneMapToMaxSize } from "../infra/map-size.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; export type WebhookBodyReadProfile = "pre-auth" | "post-auth"; @@ -144,7 +145,7 @@ export function isJsonContentType(value: string | string[] | undefined): boolean if (!first) { return false; } - const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); + const mediaType = normalizeOptionalLowercaseString(first.split(";", 1)[0]); return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); } diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index b8b2a079f1..808a3d4a4e 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -14,7 +14,10 @@ import { writeJsonAtomic } from "../infra/json-files.js"; import { type ConversationRef } from "../infra/outbound/session-binding-service.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveGlobalMap, resolveGlobalSingleton } from "../shared/global-singleton.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "../shared/string-coerce.js"; import { getActivePluginRegistry } from "./runtime.js"; import type { PluginConversationBinding, @@ -155,7 +158,7 @@ function resolveApprovalsPath(): string { } function normalizeChannel(value: string): string { - return value.trim().toLowerCase(); + return normalizeOptionalLowercaseString(value) ?? ""; } function normalizeConversation(params: PluginBindingConversation): PluginBindingConversation { diff --git a/src/plugins/interactive-registry.ts b/src/plugins/interactive-registry.ts index 327cb5dee5..55227ef181 100644 --- a/src/plugins/interactive-registry.ts +++ b/src/plugins/interactive-registry.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { normalizePluginInteractiveNamespace, resolvePluginInteractiveMatch, @@ -49,7 +50,7 @@ export function registerPluginInteractiveHandler( interactiveHandlers.set(key, { ...registration, namespace, - channel: registration.channel.trim().toLowerCase(), + channel: normalizeOptionalLowercaseString(registration.channel) ?? "", pluginId, pluginName: opts?.pluginName, pluginRoot: opts?.pluginRoot, diff --git a/src/plugins/interactive-shared.ts b/src/plugins/interactive-shared.ts index 21b8885f31..26a9bf39aa 100644 --- a/src/plugins/interactive-shared.ts +++ b/src/plugins/interactive-shared.ts @@ -1,5 +1,7 @@ +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; + export function toPluginInteractiveRegistryKey(channel: string, namespace: string): string { - return `${channel.trim().toLowerCase()}:${namespace.trim()}`; + return `${normalizeOptionalLowercaseString(channel) ?? ""}:${namespace.trim()}`; } export function normalizePluginInteractiveNamespace(namespace: string): string { diff --git a/src/shared/frontmatter.ts b/src/shared/frontmatter.ts index 30aaa97bec..8337a20662 100644 --- a/src/shared/frontmatter.ts +++ b/src/shared/frontmatter.ts @@ -1,7 +1,7 @@ import JSON5 from "json5"; import { LEGACY_MANIFEST_KEYS, MANIFEST_KEY } from "../compat/legacy-names.js"; import { parseBooleanValue } from "../utils/boolean.js"; -import { readStringValue } from "./string-coerce.js"; +import { normalizeOptionalLowercaseString, readStringValue } from "./string-coerce.js"; import { normalizeCsvOrLooseStringList } from "./string-normalization.js"; export function normalizeStringList(input: unknown): string[] { @@ -105,7 +105,7 @@ export function parseOpenClawManifestInstallBase( const raw = input as Record; const kindRaw = typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : ""; - const kind = kindRaw.trim().toLowerCase(); + const kind = normalizeOptionalLowercaseString(kindRaw) ?? ""; if (!allowedKinds.includes(kind)) { return undefined; }