refactor: dedupe command config lowercase helpers

This commit is contained in:
Peter Steinberger
2026-04-07 19:54:16 +01:00
parent 493e1c246e
commit 182d41d678
29 changed files with 85 additions and 45 deletions

View File

@@ -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)) &&

View File

@@ -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());
}
}

View File

@@ -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";
}

View File

@@ -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";

View File

@@ -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}`);

View File

@@ -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,
},
}

View File

@@ -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" ||

View File

@@ -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;
}

View File

@@ -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<string[]> {
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;

View File

@@ -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")

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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");
}

View File

@@ -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 {

View File

@@ -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");
}

View File

@@ -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));
}

View File

@@ -142,7 +142,7 @@ function addTags(set: Set<ConfigTag>, tags: ReadonlyArray<ConfigTag>): 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);

View File

@@ -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)) {

View File

@@ -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: {

View File

@@ -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)) {

View File

@@ -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<string>();
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));
}
}
}

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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<ipaddr.IPv4["range"]>;
@@ -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 {

View File

@@ -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 {

View File

@@ -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(/-+$/, "");

View File

@@ -5,7 +5,7 @@ export function normalizeStringEntries(list?: ReadonlyArray<unknown>) {
}
export function normalizeStringEntriesLower(list?: ReadonlyArray<unknown>) {
return normalizeStringEntries(list).map((entry) => entry.toLowerCase());
return normalizeStringEntries(list).map((entry) => normalizeOptionalLowercaseString(entry) ?? "");
}
export function normalizeTrimmedStringList(value: unknown): string[] {

View File

@@ -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;

View File

@@ -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<string>(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;
}