From 182d41d67835122b11252c0b171a579596f8f7ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 19:54:16 +0100 Subject: [PATCH] refactor: dedupe command config lowercase helpers --- src/commands/agents.commands.add.ts | 4 +++- src/commands/channels/logs.ts | 2 +- src/commands/doctor-gateway-services.ts | 7 +++++-- src/commands/doctor-update.ts | 3 ++- src/commands/message.ts | 4 +++- src/commands/models/list.auth-overview.ts | 12 ++++++++---- src/commands/models/shared.ts | 3 ++- src/commands/sandbox-explain.ts | 15 ++++++++++----- src/commands/status-all/gateway.ts | 3 ++- src/commands/status.command-sections.ts | 3 ++- src/commands/status.format.ts | 4 +++- src/config/agent-dirs.ts | 3 ++- src/config/allowed-values.ts | 4 +++- src/config/group-policy.ts | 2 +- src/config/redact-snapshot.ts | 3 ++- src/config/schema.hints.ts | 5 +++-- src/config/schema.tags.ts | 2 +- src/config/sessions/paths.ts | 3 ++- src/config/sessions/reset.ts | 2 +- src/config/types.tools.ts | 3 ++- src/config/validation.ts | 7 ++++--- src/shared/avatar-policy.ts | 5 +++-- src/shared/model-param-b.ts | 4 +++- src/shared/net/ip.ts | 4 ++-- src/shared/net/redact-sensitive-url.ts | 3 ++- src/shared/node-match.ts | 9 ++++++--- src/shared/string-normalization.ts | 2 +- src/shared/text/assistant-visible-text.ts | 5 +++-- src/shared/text/auto-linked-file-ref.ts | 4 +++- 29 files changed, 85 insertions(+), 45 deletions(-) diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index 1fb71793eb8..26689925ce5 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -12,6 +12,7 @@ import { logConfigUpdated } from "../config/logging.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { WizardCancelledError } from "../wizard/prompts.js"; @@ -241,7 +242,8 @@ export async function agentsAddCommand( const sourceAuthPath = resolveAuthStorePath(resolveAgentDir(cfg, defaultAgentId)); const destAuthPath = resolveAuthStorePath(agentDir); const sameAuthPath = - path.resolve(sourceAuthPath).toLowerCase() === path.resolve(destAuthPath).toLowerCase(); + normalizeLowercaseStringOrEmpty(path.resolve(sourceAuthPath)) === + normalizeLowercaseStringOrEmpty(path.resolve(destAuthPath)); if ( !sameAuthPath && (await fileExists(sourceAuthPath)) && diff --git a/src/commands/channels/logs.ts b/src/commands/channels/logs.ts index d23af5cd5ad..3d37eec16b8 100644 --- a/src/commands/channels/logs.ts +++ b/src/commands/channels/logs.ts @@ -108,7 +108,7 @@ export async function channelsLogsCommand( } for (const line of lines) { const ts = line.time ? `${line.time} ` : ""; - const level = line.level ? `${line.level.toLowerCase()} ` : ""; + const level = line.level ? `${normalizeLowercaseStringOrEmpty(line.level)} ` : ""; runtime.log(`${ts}${level}${line.message}`.trim()); } } diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index bbdff0ccf0f..308eb0ce8f7 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -21,7 +21,10 @@ import { import { resolveGatewayService } from "../daemon/service.js"; import { uninstallLegacySystemdUnits } from "../daemon/systemd.js"; import type { RuntimeEnv } from "../runtime.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; import { note } from "../terminal/note.js"; import { buildGatewayInstallPlan } from "./daemon-install-helpers.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js"; @@ -34,7 +37,7 @@ const execFileAsync = promisify(execFile); function detectGatewayRuntime(programArguments: string[] | undefined): GatewayDaemonRuntime { const first = programArguments?.[0]; if (first) { - const base = path.basename(first).toLowerCase(); + const base = normalizeLowercaseStringOrEmpty(path.basename(first)); if (base === "bun" || base === "bun.exe") { return "bun"; } diff --git a/src/commands/doctor-update.ts b/src/commands/doctor-update.ts index ee08e5d7267..a4bcf32648d 100644 --- a/src/commands/doctor-update.ts +++ b/src/commands/doctor-update.ts @@ -3,6 +3,7 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { runGatewayUpdate } from "../infra/update-runner.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { note } from "../terminal/note.js"; import type { DoctorOptions } from "./doctor-prompter.js"; @@ -16,7 +17,7 @@ async function detectOpenClawGitCheckout(root: string): Promise<"git" | "not-git if (res.code !== 0) { // Avoid noisy "Update via package manager" notes when git is missing/broken, // but do show it when this is clearly not a git checkout. - if (res.stderr.toLowerCase().includes("not a git repository")) { + if (normalizeLowercaseStringOrEmpty(res.stderr).includes("not a git repository")) { return "not-git"; } return "unknown"; diff --git a/src/commands/message.ts b/src/commands/message.ts index a316e27b150..f5c2f93079b 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -12,6 +12,7 @@ import { loadConfig } from "../config/config.js"; import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; import { runMessageAction } from "../infra/outbound/message-action-runner.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { buildMessageCliJson, formatMessageCliText } from "./message-format.js"; @@ -42,8 +43,9 @@ export async function messageCommand( }); const rawAction = typeof opts.action === "string" ? opts.action.trim() : ""; const actionInput = rawAction || "send"; + const normalizedActionInput = normalizeLowercaseStringOrEmpty(actionInput); const actionMatch = (CHANNEL_MESSAGE_ACTION_NAMES as readonly string[]).find( - (name) => name.toLowerCase() === actionInput.toLowerCase(), + (name) => normalizeLowercaseStringOrEmpty(name) === normalizedActionInput, ); if (!actionMatch) { throw new Error(`Unknown message action: ${actionInput}`); diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index 17803153c42..122a46bf3da 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -13,6 +13,7 @@ import { resolveUsableCustomProviderApiKey, } from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { shortenHomePath } from "../../utils.js"; import { maskApiKey } from "./list.format.js"; import type { ProviderAuthOverview } from "./list.types.js"; @@ -113,8 +114,9 @@ export function resolveProviderAuthOverview(params: { }; } if (envKey) { + const normalizedSource = normalizeLowercaseStringOrEmpty(envKey.source); const isOAuthEnv = - envKey.source.includes("OAUTH_TOKEN") || envKey.source.toLowerCase().includes("oauth"); + envKey.source.includes("OAUTH_TOKEN") || normalizedSource.includes("oauth"); return { kind: "env", detail: isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey), @@ -139,10 +141,12 @@ export function resolveProviderAuthOverview(params: { ...(envKey ? { env: { - value: - envKey.source.includes("OAUTH_TOKEN") || envKey.source.toLowerCase().includes("oauth") + value: (() => { + const normalizedSource = normalizeLowercaseStringOrEmpty(envKey.source); + return envKey.source.includes("OAUTH_TOKEN") || normalizedSource.includes("oauth") ? "OAuth (env)" - : maskApiKey(envKey.apiKey), + : maskApiKey(envKey.apiKey); + })(), source: envKey.source, }, } diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 123197bcb20..5e1e42f5eae 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -18,6 +18,7 @@ import { toAgentModelListLike } from "../../config/model-input.js"; import type { AgentModelEntryConfig } from "../../config/types.agent-defaults.js"; import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; export const ensureFlagCompatibility = (opts: { json?: boolean; plain?: boolean }) => { if (opts.json && opts.plain) { @@ -51,7 +52,7 @@ export const formatMs = (value?: number | null) => { export const isLocalBaseUrl = (baseUrl: string) => { try { const url = new URL(baseUrl); - const host = url.hostname.toLowerCase(); + const host = normalizeLowercaseStringOrEmpty(url.hostname); return ( host === "localhost" || host === "127.0.0.1" || diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index c3f1755a552..70d3b99bf1d 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -109,13 +109,18 @@ function resolveActiveChannel(params: { entry?.lastProvider ?? entry?.provider ?? "" - ) - .trim() - .toLowerCase(); - if (candidate === INTERNAL_MESSAGE_CHANNEL) { + ).trim(); + const normalizedCandidate = normalizeOptionalLowercaseString(candidate); + if (!normalizedCandidate) { + return inferProviderFromSessionKey({ + cfg: params.cfg, + sessionKey: params.sessionKey, + }); + } + if (normalizedCandidate === INTERNAL_MESSAGE_CHANNEL) { return INTERNAL_MESSAGE_CHANNEL; } - const normalized = normalizeAnyChannelId(candidate); + const normalized = normalizeAnyChannelId(normalizedCandidate); if (normalized) { return normalized; } diff --git a/src/commands/status-all/gateway.ts b/src/commands/status-all/gateway.ts index de0fcd0ea66..5baa3e51977 100644 --- a/src/commands/status-all/gateway.ts +++ b/src/commands/status-all/gateway.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; export async function readFileTailLines(filePath: string, maxLines: number): Promise { const raw = await fs.readFile(filePath, "utf8").catch(() => ""); @@ -117,7 +118,7 @@ export function summarizeLogTail(rawLines: string[], opts?: { maxLines?: number const code = parsed?.error?.code?.trim() || null; const msg = parsed?.error?.message?.trim() || null; const msgShort = msg - ? msg.toLowerCase().includes("signing in again") + ? normalizeLowercaseStringOrEmpty(msg).includes("signing in again") ? "re-auth required" : shorten(msg, 52) : null; diff --git a/src/commands/status.command-sections.ts b/src/commands/status.command-sections.ts index daffcfa3cd6..7028e5d3380 100644 --- a/src/commands/status.command-sections.ts +++ b/src/commands/status.command-sections.ts @@ -1,5 +1,6 @@ import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; import type { Tone } from "../memory-host-sdk/status.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { TableColumn } from "../terminal/table.js"; import type { HealthSummary } from "./health.js"; import type { AgentLocalStatus } from "./status.agent-local.js"; @@ -253,7 +254,7 @@ export function buildStatusHealthRows(params: { } const item = line.slice(0, colon).trim(); const detail = line.slice(colon + 1).trim(); - const normalized = detail.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(detail); const status = normalized.startsWith("ok") ? params.ok("OK") : normalized.startsWith("failed") diff --git a/src/commands/status.format.ts b/src/commands/status.format.ts index 5b44f5275db..fc73bf2b32f 100644 --- a/src/commands/status.format.ts +++ b/src/commands/status.format.ts @@ -1,5 +1,6 @@ import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; import { formatRuntimeStatusWithDetails } from "../infra/runtime-status.ts"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { SessionStatus } from "./status.types.js"; export { shortenText } from "./text-format.js"; @@ -109,7 +110,8 @@ export const formatDaemonRuntimeShort = (runtime?: { const details: string[] = []; const detail = runtime.detail?.replace(/\s+/g, " ").trim() || ""; const noisyLaunchctlDetail = - runtime.missingUnit === true && detail.toLowerCase().includes("could not find service"); + runtime.missingUnit === true && + normalizeLowercaseStringOrEmpty(detail).includes("could not find service"); if (detail && !noisyLaunchctlDetail) { details.push(detail); } diff --git a/src/config/agent-dirs.ts b/src/config/agent-dirs.ts index acbdbdeb194..e37e0dd7a8b 100644 --- a/src/config/agent-dirs.ts +++ b/src/config/agent-dirs.ts @@ -2,6 +2,7 @@ import os from "node:os"; import path from "node:path"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveUserPath } from "../utils.js"; import { resolveStateDir } from "./paths.js"; import type { OpenClawConfig } from "./types.js"; @@ -24,7 +25,7 @@ export class DuplicateAgentDirError extends Error { function canonicalizeAgentDir(agentDir: string): string { const resolved = path.resolve(agentDir); if (process.platform === "darwin" || process.platform === "win32") { - return resolved.toLowerCase(); + return normalizeLowercaseStringOrEmpty(resolved); } return resolved; } diff --git a/src/config/allowed-values.ts b/src/config/allowed-values.ts index f85b04df9a0..e9e5f982c9e 100644 --- a/src/config/allowed-values.ts +++ b/src/config/allowed-values.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; + const MAX_ALLOWED_VALUES_HINT = 12; const MAX_ALLOWED_VALUE_CHARS = 160; @@ -86,7 +88,7 @@ export function summarizeAllowedValues( } function messageAlreadyIncludesAllowedValues(message: string): boolean { - const lower = message.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(message); return lower.includes("(allowed:") || lower.includes("expected one of"); } diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index 9110f7e2b2e..5da82587048 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -92,7 +92,7 @@ function normalizeSenderKey( return ""; } const withoutAt = options.stripLeadingAt && trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; - return withoutAt.toLowerCase(); + return normalizeLowercaseStringOrEmpty(withoutAt); } function normalizeTypedSenderKey(value: string, type: SenderKeyType): string { diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index 09f16869f28..118394cc376 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -4,6 +4,7 @@ import { isSensitiveUrlConfigPath, redactSensitiveUrlLikeString, } from "../shared/net/redact-sensitive-url.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { replaceSensitiveValuesInRaw, shouldFallbackToStructuredRawRedaction, @@ -28,7 +29,7 @@ function isEnvVarPlaceholder(value: string): boolean { } function isWholeObjectSensitivePath(path: string): boolean { - const lowered = path.toLowerCase(); + const lowered = normalizeLowercaseStringOrEmpty(path); return lowered.endsWith("serviceaccount") || lowered.endsWith("serviceaccountref"); } diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 49339509d37..1bd8a154af3 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -5,6 +5,7 @@ import { isSensitiveUrlConfigPath, SENSITIVE_URL_HINT_TAG, } from "../shared/net/redact-sensitive-url.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { FIELD_HELP } from "./schema.help.js"; import { FIELD_LABELS } from "./schema.labels.js"; import { applyDerivedTags } from "./schema.tags.js"; @@ -128,7 +129,7 @@ const SENSITIVE_KEY_WHITELIST_SUFFIXES = [ "passwordFile", ] as const; const NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES = SENSITIVE_KEY_WHITELIST_SUFFIXES.map((suffix) => - suffix.toLowerCase(), + normalizeLowercaseStringOrEmpty(suffix), ); const SENSITIVE_PATTERNS = [ @@ -142,7 +143,7 @@ const SENSITIVE_PATTERNS = [ ]; function isWhitelistedSensitivePath(path: string): boolean { - const lowerPath = path.toLowerCase(); + const lowerPath = normalizeLowercaseStringOrEmpty(path); return NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix)); } diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index e66bad6c3f6..ccd2ec1b69d 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -142,7 +142,7 @@ function addTags(set: Set, tags: ReadonlyArray): void { } export function deriveTagsForPath(path: string, hint?: ConfigUiHint): ConfigTag[] { - const lowerPath = path.toLowerCase(); + const lowerPath = normalizeLowercaseStringOrEmpty(path); const override = resolveOverride(path); if (override) { return normalizeTags(override); diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 1be7aec6299..e8e2a222634 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveStateDir } from "../paths.js"; function resolveAgentSessionsDir( @@ -142,7 +143,7 @@ function resolveStructuralSessionFallbackPath( return undefined; } const normalizedAgentId = normalizeAgentId(agentIdPart); - if (normalizedAgentId !== agentIdPart.toLowerCase()) { + if (normalizedAgentId !== normalizeLowercaseStringOrEmpty(agentIdPart)) { return undefined; } if (normalizedAgentId !== normalizeAgentId(expectedAgentId)) { diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index 093b1f7d25d..7e53e0158a8 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -133,7 +133,7 @@ export function resolveChannelResetConfig(params: { if (!key) { return undefined; } - return resetByChannel[key] ?? resetByChannel[key.toLowerCase()]; + return resetByChannel[key]; } export function evaluateSessionFreshness(params: { diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 3587d54c0c0..bf4d051e8f0 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -1,5 +1,6 @@ import type { ChatType } from "../channels/chat-type.js"; import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js"; import type { MemoryQmdIndexPath } from "./types.memory.js"; import type { ConfiguredProviderRequest } from "./types.provider-request.js"; @@ -208,7 +209,7 @@ export function parseToolsBySenderTypedKey( if (!trimmed) { return undefined; } - const lowered = trimmed.toLowerCase(); + const lowered = normalizeLowercaseStringOrEmpty(trimmed); for (const type of TOOLS_BY_SENDER_KEY_TYPES) { const prefix = `${type}:`; if (!lowered.startsWith(prefix)) { diff --git a/src/config/validation.ts b/src/config/validation.ts index 00f6ec4607a..55353804028 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -26,6 +26,7 @@ import { isWindowsAbsolutePath, } from "../shared/avatar-policy.js"; import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js"; @@ -830,7 +831,7 @@ function validateConfigObjectWithPluginsBase( const heartbeatChannelIds = new Set(); for (const channelId of CHANNEL_IDS) { - heartbeatChannelIds.add(channelId.toLowerCase()); + heartbeatChannelIds.add(normalizeLowercaseStringOrEmpty(channelId)); } const validateHeartbeatTarget = (target: string | undefined, path: string) => { @@ -842,7 +843,7 @@ function validateConfigObjectWithPluginsBase( issues.push({ path, message: "heartbeat target must not be empty" }); return; } - const normalized = trimmed.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(trimmed); if (normalized === "last" || normalized === "none") { return; } @@ -855,7 +856,7 @@ function validateConfigObjectWithPluginsBase( for (const channelId of record.channels) { const pluginChannel = channelId.trim(); if (pluginChannel) { - heartbeatChannelIds.add(pluginChannel.toLowerCase()); + heartbeatChannelIds.add(normalizeLowercaseStringOrEmpty(pluginChannel)); } } } diff --git a/src/shared/avatar-policy.ts b/src/shared/avatar-policy.ts index 7913ccc85d1..eff674c13ac 100644 --- a/src/shared/avatar-policy.ts +++ b/src/shared/avatar-policy.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js"; export const AVATAR_MAX_BYTES = 2 * 1024 * 1024; @@ -25,7 +26,7 @@ export const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/; const AVATAR_PATH_EXT_RE = /\.(png|jpe?g|gif|webp|svg|ico)$/i; export function resolveAvatarMime(filePath: string): string { - const ext = path.extname(filePath).toLowerCase(); + const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); return AVATAR_MIME_BY_EXT[ext] ?? "application/octet-stream"; } @@ -78,6 +79,6 @@ export function looksLikeAvatarPath(value: string): boolean { } export function isSupportedLocalAvatarExtension(filePath: string): boolean { - const ext = path.extname(filePath).toLowerCase(); + const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); return LOCAL_AVATAR_EXTENSIONS.has(ext); } diff --git a/src/shared/model-param-b.ts b/src/shared/model-param-b.ts index e6fc3bda5cd..ab52783a1c0 100644 --- a/src/shared/model-param-b.ts +++ b/src/shared/model-param-b.ts @@ -1,5 +1,7 @@ +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js"; + export function inferParamBFromIdOrName(text: string): number | null { - const raw = text.toLowerCase(); + const raw = normalizeLowercaseStringOrEmpty(text); const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g); let best: number | null = null; for (const match of matches) { diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index 52a07f9f470..14872109ee8 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -1,5 +1,5 @@ import ipaddr from "ipaddr.js"; -import { normalizeOptionalString } from "../string-coerce.js"; +import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.js"; export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6; type Ipv4Range = ReturnType; @@ -176,7 +176,7 @@ export function normalizeIpAddress(raw: string | undefined): string | undefined return undefined; } const normalized = normalizeIpv4MappedAddress(parsed); - return normalized.toString().toLowerCase(); + return normalizeLowercaseStringOrEmpty(normalized.toString()); } export function isCanonicalDottedDecimalIPv4(raw: string | undefined): boolean { diff --git a/src/shared/net/redact-sensitive-url.ts b/src/shared/net/redact-sensitive-url.ts index dd22c18c42e..144d0ead19b 100644 --- a/src/shared/net/redact-sensitive-url.ts +++ b/src/shared/net/redact-sensitive-url.ts @@ -1,4 +1,5 @@ import type { ConfigUiHint } from "../config-ui-hints-types.js"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.js"; export const SENSITIVE_URL_HINT_TAG = "url-secret"; @@ -17,7 +18,7 @@ const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([ ]); export function isSensitiveUrlQueryParamName(name: string): boolean { - return SENSITIVE_URL_QUERY_PARAM_NAMES.has(name.toLowerCase()); + return SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalizeLowercaseStringOrEmpty(name)); } export function isSensitiveUrlConfigPath(path: string): boolean { diff --git a/src/shared/node-match.ts b/src/shared/node-match.ts index 2ce3ae30cde..9cdc894266e 100644 --- a/src/shared/node-match.ts +++ b/src/shared/node-match.ts @@ -1,4 +1,8 @@ -import { normalizeOptionalLowercaseString, normalizeOptionalString } from "./string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "./string-coerce.js"; export type NodeMatchCandidate = { nodeId: string; @@ -15,8 +19,7 @@ type ScoredNodeMatch = { }; export function normalizeNodeKey(value: string) { - return value - .toLowerCase() + return normalizeLowercaseStringOrEmpty(value) .replace(/[^a-z0-9]+/g, "-") .replace(/^-+/, "") .replace(/-+$/, ""); diff --git a/src/shared/string-normalization.ts b/src/shared/string-normalization.ts index 2e0985daa9b..2bc97276c72 100644 --- a/src/shared/string-normalization.ts +++ b/src/shared/string-normalization.ts @@ -5,7 +5,7 @@ export function normalizeStringEntries(list?: ReadonlyArray) { } export function normalizeStringEntriesLower(list?: ReadonlyArray) { - return normalizeStringEntries(list).map((entry) => entry.toLowerCase()); + return normalizeStringEntries(list).map((entry) => normalizeOptionalLowercaseString(entry) ?? ""); } export function normalizeTrimmedStringList(value: unknown): string[] { diff --git a/src/shared/text/assistant-visible-text.ts b/src/shared/text/assistant-visible-text.ts index 884b39a548d..19f3d2f2a98 100644 --- a/src/shared/text/assistant-visible-text.ts +++ b/src/shared/text/assistant-visible-text.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.js"; import { findCodeRegions, isInsideCode } from "./code-regions.js"; import { stripModelSpecialTokens } from "./model-special-tokens.js"; import { @@ -133,7 +134,7 @@ function parseToolCallTagAt(text: string, start: number): ParsedToolCallTag | nu cursor += 1; } - const tagName = text.slice(nameStart, cursor).toLowerCase(); + const tagName = normalizeLowercaseStringOrEmpty(text.slice(nameStart, cursor)); if (!TOOL_CALL_TAG_NAMES.has(tagName) || !isToolCallBoundary(text[cursor])) { return null; } @@ -391,7 +392,7 @@ export function stripDowngradedToolCallText(text: string): string { while (index < input.length && (input[index] === " " || input[index] === "\t")) { index += 1; } - if (input.slice(index, index + 9).toLowerCase() === "arguments") { + if (normalizeLowercaseStringOrEmpty(input.slice(index, index + 9)) === "arguments") { index += 9; if (input[index] === ":") { index += 1; diff --git a/src/shared/text/auto-linked-file-ref.ts b/src/shared/text/auto-linked-file-ref.ts index 6fd5693202b..8efd1ccaa06 100644 --- a/src/shared/text/auto-linked-file-ref.ts +++ b/src/shared/text/auto-linked-file-ref.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.js"; + const FILE_REF_EXTENSIONS = ["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"] as const; export const FILE_REF_EXTENSIONS_WITH_TLD = new Set(FILE_REF_EXTENSIONS); @@ -11,7 +13,7 @@ export function isAutoLinkedFileRef(href: string, label: string): boolean { if (dotIndex < 1) { return false; } - const ext = label.slice(dotIndex + 1).toLowerCase(); + const ext = normalizeLowercaseStringOrEmpty(label.slice(dotIndex + 1)); if (!FILE_REF_EXTENSIONS_WITH_TLD.has(ext)) { return false; }