From d40dc8f025a3f692afbcad97dff95f02768fe66c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 17:15:44 +0100 Subject: [PATCH] refactor: dedupe agent lowercase helpers --- src/agents/anthropic-transport-stream.ts | 2 +- src/agents/auth-profiles/oauth.ts | 3 +- .../bash-tools.exec-approval-request.ts | 3 +- src/agents/bash-tools.exec.ts | 41 +++++++++++-------- src/agents/pi-bundle-mcp-materialize.ts | 3 +- src/agents/pi-bundle-mcp-names.ts | 11 +++-- src/agents/pi-embedded-helpers/errors.ts | 31 +++++++------- .../pi-embedded-helpers/failover-matches.ts | 6 ++- .../pi-embedded-helpers/messaging-dedupe.ts | 6 +-- src/agents/pi-embedded-runner/abort.ts | 6 ++- src/agents/pi-embedded-runner/cache-ttl.ts | 5 ++- .../run.overflow-compaction.harness.ts | 5 ++- src/agents/pi-embedded-runner/run/images.ts | 5 ++- .../pi-embedded-runner/run/incomplete-turn.ts | 5 ++- .../tool-result-truncation.ts | 3 +- src/agents/pi-hooks/context-pruning/tools.ts | 5 +-- src/agents/pty-keys.ts | 6 +-- src/agents/sandbox/shared.ts | 4 +- src/agents/sandbox/ssh-backend.ts | 4 +- src/agents/sandbox/tool-policy.ts | 7 +++- src/agents/skills/command-specs.ts | 37 ++++++++--------- src/agents/tool-display.ts | 3 +- src/agents/tools/web-fetch.test-harness.ts | 3 +- 23 files changed, 116 insertions(+), 88 deletions(-) diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index 5341e8bb956..72e31c75928 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -48,7 +48,7 @@ const CLAUDE_CODE_TOOLS = [ "WebSearch", ] as const; const CLAUDE_CODE_TOOL_LOOKUP = new Map( - CLAUDE_CODE_TOOLS.map((tool) => [tool.toLowerCase(), tool]), + CLAUDE_CODE_TOOLS.map((tool) => [normalizeLowercaseStringOrEmpty(tool), tool]), ); type AnthropicTransportModel = Model<"anthropic-messages"> & { diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 14b3d39ca42..3aa7bf23b12 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -13,6 +13,7 @@ import { refreshProviderOAuthCredentialWithPlugin, } from "../../plugins/provider-runtime.runtime.js"; import { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; import { writeCodexCliCredentials } from "../cli-credentials.js"; import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js"; @@ -124,7 +125,7 @@ function extractErrorMessage(error: unknown): string { } function isRefreshTokenReusedError(error: unknown): boolean { - const message = extractErrorMessage(error).toLowerCase(); + const message = normalizeLowercaseStringOrEmpty(extractErrorMessage(error)); return ( message.includes("refresh_token_reused") || message.includes("refresh token has already been used") || diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 97622bd6a19..0e70e5a80b2 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -1,4 +1,5 @@ import type { ExecAsk, ExecSecurity, SystemRunApprovalPlan } from "../infra/exec-approvals.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, DEFAULT_APPROVAL_TIMEOUT_MS, @@ -120,7 +121,7 @@ export async function waitForExecApprovalDecision(id: string): Promise { - if (token.toLowerCase().endsWith(".py") || token.toLowerCase().endsWith(".js")) { + const normalizedToken = normalizeLowercaseStringOrEmpty(token); + if (normalizedToken.endsWith(".py") || normalizedToken.endsWith(".js")) { return true; } token = ""; @@ -627,7 +636,7 @@ function resolveLeadingShellSegmentExecutable(rawSegment: string): string | unde ) { commandIdx += 1; } - return normalizedArgv[commandIdx]?.toLowerCase(); + return normalizeOptionalLowercaseString(normalizedArgv[commandIdx]); } function analyzeInterpreterHeuristicsFromUnquoted(raw: string): { @@ -666,7 +675,7 @@ function extractShellWrappedCommandPayload( if (!executable) { return null; } - const executableBase = executable.split(/[\\/]/u).at(-1)?.toLowerCase() ?? ""; + const executableBase = normalizeOptionalLowercaseString(executable.split(/[\\/]/u).at(-1)) ?? ""; const normalizedExecutable = executableBase.endsWith(".exe") ? executableBase.slice(0, -4) : executableBase; @@ -724,7 +733,7 @@ function shouldFailClosedInterpreterPreflight(command: string): { ) { commandIdx += 1; } - const directExecutable = argv?.[commandIdx]?.toLowerCase(); + const directExecutable = normalizeOptionalLowercaseString(argv?.[commandIdx]); const args = argv ? argv.slice(commandIdx + 1) : []; const isDirectPythonExecutable = Boolean( @@ -770,7 +779,7 @@ function shouldFailClosedInterpreterPreflight(command: string): { ) { commandIdx += 1; } - const executable = normalizedArgv[commandIdx]?.toLowerCase(); + const executable = normalizeOptionalLowercaseString(normalizedArgv[commandIdx]); if (!executable) { return false; } @@ -988,9 +997,9 @@ function parseExecApprovalShellCommand(raw: string): ParsedExecApprovalCommand | return { approvalId: match[1], decision: - match[2].toLowerCase() === "always" + normalizeLowercaseStringOrEmpty(match[2]) === "always" ? "allow-always" - : (match[2].toLowerCase() as ParsedExecApprovalCommand["decision"]), + : (normalizeLowercaseStringOrEmpty(match[2]) as ParsedExecApprovalCommand["decision"]), }; } diff --git a/src/agents/pi-bundle-mcp-materialize.ts b/src/agents/pi-bundle-mcp-materialize.ts index 262eee041ae..8f085b23fb3 100644 --- a/src/agents/pi-bundle-mcp-materialize.ts +++ b/src/agents/pi-bundle-mcp-materialize.ts @@ -3,6 +3,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { logWarn } from "../logger.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { buildSafeToolName, normalizeReservedToolNames, @@ -95,7 +96,7 @@ export async function materializeBundleMcpToolsForRun(params: { `bundle-mcp: tool "${tool.toolName}" from server "${tool.serverName}" registered as "${safeToolName}" to keep the tool name provider-safe.`, ); } - reservedNames.add(safeToolName.toLowerCase()); + reservedNames.add(normalizeLowercaseStringOrEmpty(safeToolName)); tools.push({ name: safeToolName, label: tool.title ?? tool.toolName, diff --git a/src/agents/pi-bundle-mcp-names.ts b/src/agents/pi-bundle-mcp-names.ts index e08bf452ece..489176eca6a 100644 --- a/src/agents/pi-bundle-mcp-names.ts +++ b/src/agents/pi-bundle-mcp-names.ts @@ -1,4 +1,7 @@ -import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../shared/string-coerce.js"; const TOOL_NAME_SAFE_RE = /[^A-Za-z0-9_-]/g; export const TOOL_NAME_SEPARATOR = "__"; @@ -18,12 +21,12 @@ export function sanitizeServerName(raw: string, usedNames: Set): string const base = sanitizeToolFragment(raw, "mcp", TOOL_NAME_MAX_PREFIX); let candidate = base; let n = 2; - while (usedNames.has(candidate.toLowerCase())) { + while (usedNames.has(normalizeLowercaseStringOrEmpty(candidate))) { const suffix = `-${n}`; candidate = `${base.slice(0, Math.max(1, TOOL_NAME_MAX_PREFIX - suffix.length))}${suffix}`; n += 1; } - usedNames.add(candidate.toLowerCase()); + usedNames.add(normalizeLowercaseStringOrEmpty(candidate)); return candidate; } @@ -53,7 +56,7 @@ export function buildSafeToolName(params: { let candidateToolName = truncatedToolName || "tool"; let candidate = `${params.serverName}${TOOL_NAME_SEPARATOR}${candidateToolName}`; let n = 2; - while (params.reservedNames.has(candidate.toLowerCase())) { + while (params.reservedNames.has(normalizeLowercaseStringOrEmpty(candidate))) { const suffix = `-${n}`; candidateToolName = `${(truncatedToolName || "tool").slice(0, Math.max(1, maxToolChars - suffix.length))}${suffix}`; candidate = `${params.serverName}${TOOL_NAME_SEPARATOR}${candidateToolName}`; diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 89cce12ec38..1174eb747b9 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -8,7 +8,10 @@ import { parseApiErrorInfo, parseApiErrorPayload, } from "../../shared/assistant-error-format.js"; -import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../../shared/string-coerce.js"; export { extractLeadingHttpStatus, formatRawAssistantErrorForUi, @@ -121,7 +124,7 @@ function formatTransportErrorCopy(raw: string): string | undefined { if (!raw) { return undefined; } - const lower = raw.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(raw); if ( /\beconnrefused\b/i.test(raw) || @@ -172,7 +175,7 @@ function formatDiskSpaceErrorCopy(raw: string): string | undefined { if (!raw) { return undefined; } - const lower = raw.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(raw); if ( /\benospc\b/i.test(raw) || lower.includes("no space left on device") || @@ -190,7 +193,7 @@ export function isReasoningConstraintErrorMessage(raw: string): boolean { if (!raw) { return false; } - const lower = raw.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(raw); return ( lower.includes("reasoning is mandatory") || lower.includes("reasoning is required") || @@ -203,7 +206,7 @@ function isInvalidStreamingEventOrderError(raw: string): boolean { if (!raw) { return false; } - const lower = raw.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(raw); return ( lower.includes("unexpected event order") && lower.includes("message_start") && @@ -212,7 +215,7 @@ function isInvalidStreamingEventOrderError(raw: string): boolean { } function hasRateLimitTpmHint(raw: string): boolean { - const lower = raw.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(raw); return /\btpm\b/i.test(lower) || lower.includes("tokens per minute"); } @@ -220,7 +223,7 @@ export function isContextOverflowError(errorMessage?: string): boolean { if (!errorMessage) { return false; } - const lower = errorMessage.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(errorMessage); // Groq uses 413 for TPM (tokens per minute) limits, which is a rate limit, not context overflow. if (hasRateLimitTpmHint(errorMessage)) { @@ -317,7 +320,7 @@ export function isCompactionFailureError(errorMessage?: string): boolean { if (!errorMessage) { return false; } - const lower = errorMessage.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(errorMessage); const hasCompactionTerm = lower.includes("summarization failed") || lower.includes("auto-compaction") || @@ -865,7 +868,7 @@ function isLikelyHttpErrorText(raw: string): boolean { if (status.code < 400) { return false; } - const message = status.rest.toLowerCase(); + const message = normalizeLowercaseStringOrEmpty(status.rest); return HTTP_ERROR_HINTS.some((hint) => message.includes(hint)); } @@ -1155,7 +1158,7 @@ function isJsonApiInternalServerError(raw: string): boolean { if (!raw) { return false; } - const value = raw.toLowerCase(); + const value = normalizeLowercaseStringOrEmpty(raw); // Providers wrap transient 5xx errors in JSON payloads like: // {"type":"error","error":{"type":"api_error","message":"Internal server error"}} // Non-standard providers (e.g. MiniMax) may use different message text: @@ -1183,7 +1186,7 @@ export function parseImageDimensionError(raw: string): { if (!raw) { return null; } - const lower = raw.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(raw); if (!lower.includes("image dimensions exceed max allowed size")) { return null; } @@ -1208,7 +1211,7 @@ export function parseImageSizeError(raw: string): { if (!raw) { return null; } - const lower = raw.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(raw); if (!lower.includes("image exceeds") || !lower.includes("mb")) { return null; } @@ -1241,7 +1244,7 @@ export function isModelNotFoundErrorMessage(raw: string): boolean { if (!raw) { return false; } - const lower = raw.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(raw); // Direct pattern matches from OpenClaw internals and common providers. if ( @@ -1272,7 +1275,7 @@ function isCliSessionExpiredErrorMessage(raw: string): boolean { if (!raw) { return false; } - const lower = raw.toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(raw); return ( lower.includes("session not found") || lower.includes("session does not exist") || diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index 43f980522d4..d2cb54f9898 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; + type ErrorPattern = RegExp | string; const PERIODIC_USAGE_LIMIT_RE = @@ -145,7 +147,7 @@ function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): b if (!raw) { return false; } - const value = raw.toLowerCase(); + const value = normalizeLowercaseStringOrEmpty(raw); return patterns.some((pattern) => pattern instanceof RegExp ? pattern.test(value) : value.includes(pattern), ); @@ -175,7 +177,7 @@ export function isPeriodicUsageLimitErrorMessage(raw: string): boolean { } export function isBillingErrorMessage(raw: string): boolean { - const value = raw.toLowerCase(); + const value = normalizeLowercaseStringOrEmpty(raw); if (!value) { return false; } diff --git a/src/agents/pi-embedded-helpers/messaging-dedupe.ts b/src/agents/pi-embedded-helpers/messaging-dedupe.ts index 71e1c3aa43c..819e8eb24d7 100644 --- a/src/agents/pi-embedded-helpers/messaging-dedupe.ts +++ b/src/agents/pi-embedded-helpers/messaging-dedupe.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; + const MIN_DUPLICATE_TEXT_LENGTH = 10; /** @@ -8,9 +10,7 @@ const MIN_DUPLICATE_TEXT_LENGTH = 10; * - Collapses multiple spaces to single space */ export function normalizeTextForComparison(text: string): string { - return text - .trim() - .toLowerCase() + return normalizeLowercaseStringOrEmpty(text) .replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}/gu, "") .replace(/\s+/g, " ") .trim(); diff --git a/src/agents/pi-embedded-runner/abort.ts b/src/agents/pi-embedded-runner/abort.ts index 8730fa981a6..c81f87bbc93 100644 --- a/src/agents/pi-embedded-runner/abort.ts +++ b/src/agents/pi-embedded-runner/abort.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; + /** * Runner abort check. Catches any abort-related message for embedded runners. * More permissive than the core isAbortError since runners need to catch @@ -12,6 +14,8 @@ export function isRunnerAbortError(err: unknown): boolean { return true; } const message = - "message" in err && typeof err.message === "string" ? err.message.toLowerCase() : ""; + "message" in err && typeof err.message === "string" + ? normalizeLowercaseStringOrEmpty(err.message) + : ""; return message.includes("aborted"); } diff --git a/src/agents/pi-embedded-runner/cache-ttl.ts b/src/agents/pi-embedded-runner/cache-ttl.ts index e026d7a492e..5a216ec573c 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.ts +++ b/src/agents/pi-embedded-runner/cache-ttl.ts @@ -1,4 +1,5 @@ import { resolveProviderCacheTtlEligibility } from "../../plugins/provider-runtime.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { isAnthropicFamilyCacheTtlEligible } from "./anthropic-family-cache-semantics.js"; import { isGooglePromptCacheEligible } from "./prompt-cache-retention.js"; @@ -22,8 +23,8 @@ export function isCacheTtlEligibleProvider( modelId: string, modelApi?: string, ): boolean { - const normalizedProvider = provider.toLowerCase(); - const normalizedModelId = modelId.toLowerCase(); + const normalizedProvider = normalizeLowercaseStringOrEmpty(provider); + const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId); const pluginEligibility = resolveProviderCacheTtlEligibility({ provider: normalizedProvider, context: { diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts index b1d33fed4de..9c39b15eed4 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts @@ -7,6 +7,7 @@ import type { PluginHookBeforeModelResolveResult, PluginHookBeforePromptBuildResult, } from "../../plugins/types.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import type { FailoverReason } from "../pi-embedded-helpers/types.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -144,7 +145,7 @@ export const mockedIsCompactionFailureError = vi.fn(() => false); export const mockedIsFailoverAssistantError = vi.fn(() => false); export const mockedIsFailoverErrorMessage = vi.fn(() => false); export const mockedIsLikelyContextOverflowError = vi.fn((msg?: string) => { - const lower = (msg ?? "").toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(msg ?? ""); return ( lower.includes("request_too_large") || lower.includes("context window exceeded") || @@ -270,7 +271,7 @@ export function resetRunOverflowCompactionHarnessMocks(): void { mockedIsFailoverErrorMessage.mockReturnValue(false); mockedIsLikelyContextOverflowError.mockReset(); mockedIsLikelyContextOverflowError.mockImplementation((msg?: string) => { - const lower = (msg ?? "").toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(msg ?? ""); return ( lower.includes("request_too_large") || lower.includes("context window exceeded") || diff --git a/src/agents/pi-embedded-runner/run/images.ts b/src/agents/pi-embedded-runner/run/images.ts index 9dfc6daf939..498d2fe68c8 100644 --- a/src/agents/pi-embedded-runner/run/images.ts +++ b/src/agents/pi-embedded-runner/run/images.ts @@ -5,6 +5,7 @@ import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../../../infra/lo import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js"; import { resolveMediaBufferPath, getMediaDir } from "../../../media/store.js"; import { loadWebMedia } from "../../../media/web-media.js"; +import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; import { resolveUserPath } from "../../../utils.js"; import type { ImageSanitizationLimits } from "../../image-sanitization.js"; import { @@ -83,12 +84,12 @@ export interface DetectedImageRef { * Checks if a file extension indicates an image file. */ function isImageExtension(filePath: string): boolean { - const ext = path.extname(filePath).toLowerCase(); + const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); return IMAGE_EXTENSIONS.has(ext); } function normalizeRefForDedupe(raw: string): string { - return process.platform === "win32" ? raw.toLowerCase() : raw; + return process.platform === "win32" ? normalizeLowercaseStringOrEmpty(raw) : raw; } export function mergePromptAttachmentImages(params: { diff --git a/src/agents/pi-embedded-runner/run/incomplete-turn.ts b/src/agents/pi-embedded-runner/run/incomplete-turn.ts index a4660d6e57e..6fca4174197 100644 --- a/src/agents/pi-embedded-runner/run/incomplete-turn.ts +++ b/src/agents/pi-embedded-runner/run/incomplete-turn.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; import { isLikelyMutatingToolName } from "../../tool-mutation.js"; import type { EmbeddedRunAttemptResult } from "./types.js"; @@ -137,13 +138,13 @@ function shouldApplyPlanningOnlyRetryGuard(params: { } function normalizeAckPrompt(text: string): string { - return text + const normalized = text .normalize("NFKC") .trim() - .toLowerCase() .replace(/[\p{P}\p{S}]+/gu, " ") .replace(/\s+/g, " ") .trim(); + return normalizeLowercaseStringOrEmpty(normalized); } export function isLikelyExecutionAckPrompt(text: string): boolean { diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.ts b/src/agents/pi-embedded-runner/tool-result-truncation.ts index bd7bcf0b5b3..ac08aa907a0 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.ts @@ -3,6 +3,7 @@ import type { TextContent } from "@mariozechner/pi-ai"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { formatErrorMessage } from "../../infra/errors.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { acquireSessionWriteLock } from "../session-write-lock.js"; import { log } from "./logger.js"; import { formatContextLimitTruncationNotice } from "./tool-result-context-guard.js"; @@ -71,7 +72,7 @@ const MIDDLE_OMISSION_MARKER = */ function hasImportantTail(text: string): boolean { // Check last ~2000 chars for error-like patterns - const tail = text.slice(-2000).toLowerCase(); + const tail = normalizeLowercaseStringOrEmpty(text.slice(-2000)); return ( /\b(error|exception|failed|fatal|traceback|panic|stack trace|errno|exit code)\b/.test(tail) || // JSON closing — if the output is JSON, the tail has closing structure diff --git a/src/agents/pi-hooks/context-pruning/tools.ts b/src/agents/pi-hooks/context-pruning/tools.ts index 054861b63a6..1a04a9dbd9d 100644 --- a/src/agents/pi-hooks/context-pruning/tools.ts +++ b/src/agents/pi-hooks/context-pruning/tools.ts @@ -1,10 +1,9 @@ +import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; import { compileGlobPatterns, matchesAnyGlobPattern } from "../../glob-pattern.js"; import type { ContextPruningToolMatch } from "./settings.js"; function normalizeGlob(value: string) { - return String(value ?? "") - .trim() - .toLowerCase(); + return normalizeLowercaseStringOrEmpty(String(value ?? "")); } export function makeToolPrunablePredicate( diff --git a/src/agents/pty-keys.ts b/src/agents/pty-keys.ts index a3be7f3cca9..aeb101ece95 100644 --- a/src/agents/pty-keys.ts +++ b/src/agents/pty-keys.ts @@ -124,7 +124,7 @@ export function hasCursorModeSensitiveKeys(request: KeyEncodingRequest): boolean if (hasAnyModifier(parsed.mods)) { return false; } - return parsed.base.toLowerCase() in DECCKM_SS3_KEYS; + return normalizeLowercaseStringOrEmpty(parsed.base) in DECCKM_SS3_KEYS; }) ?? false ); } @@ -186,7 +186,7 @@ function encodeKeyToken( const parsed = parseModifiers(token); const base = parsed.base; - const baseLower = base.toLowerCase(); + const baseLower = normalizeLowercaseStringOrEmpty(base); if (baseLower === "tab" && parsed.mods.shift) { return `${ESC}[Z`; @@ -240,7 +240,7 @@ function parseModifiers(token: string) { let sawModifiers = false; while (rest.length > 2 && rest[1] === "-") { - const mod = rest[0].toLowerCase(); + const mod = normalizeLowercaseStringOrEmpty(rest[0]); if (mod === "c") { mods.ctrl = true; } else if (mod === "m") { diff --git a/src/agents/sandbox/shared.ts b/src/agents/sandbox/shared.ts index cb3585aad77..5b68fa63585 100644 --- a/src/agents/sandbox/shared.ts +++ b/src/agents/sandbox/shared.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveUserPath } from "../../utils.js"; import { resolveAgentIdFromSessionKey } from "../agent-scope.js"; import { hashTextSha256 } from "./hash.js"; @@ -7,8 +8,7 @@ import { hashTextSha256 } from "./hash.js"; export function slugifySessionKey(value: string) { const trimmed = value.trim() || "session"; const hash = hashTextSha256(trimmed).slice(0, 8); - const safe = trimmed - .toLowerCase() + const safe = normalizeLowercaseStringOrEmpty(trimmed) .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, ""); const base = safe.slice(0, 32) || "session"; diff --git a/src/agents/sandbox/ssh-backend.ts b/src/agents/sandbox/ssh-backend.ts index a76864d1179..e4a51e46818 100644 --- a/src/agents/sandbox/ssh-backend.ts +++ b/src/agents/sandbox/ssh-backend.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import type { CreateSandboxBackendParams, SandboxBackendCommandParams, @@ -291,8 +292,7 @@ function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): Resolv function buildSshSandboxRuntimeId(scopeKey: string): string { const trimmed = scopeKey.trim() || "session"; - const safe = trimmed - .toLowerCase() + const safe = normalizeLowercaseStringOrEmpty(trimmed) .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 32); diff --git a/src/agents/sandbox/tool-policy.ts b/src/agents/sandbox/tool-policy.ts index 86320eb0bfa..43ed1a77f23 100644 --- a/src/agents/sandbox/tool-policy.ts +++ b/src/agents/sandbox/tool-policy.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { compileGlobPatterns, matchesAnyGlobPattern } from "../glob-pattern.js"; import { expandToolGroups, normalizeToolName } from "../tool-policy.js"; @@ -157,13 +158,15 @@ function filterDefaultDenyForExplicitAllows(params: { function expandResolvedPolicy(policy: SandboxToolPolicy): SandboxToolPolicy { const expandedDeny = expandToolGroups(policy.deny ?? []); let expandedAllow = expandToolGroups(policy.allow ?? []); + const expandedDenyLower = expandedDeny.map(normalizeLowercaseStringOrEmpty); + const expandedAllowLower = expandedAllow.map(normalizeLowercaseStringOrEmpty); // `image` is essential for multimodal workflows; keep the existing sandbox // behavior that auto-includes it for explicit allowlists unless it is denied. if ( expandedAllow.length > 0 && - !expandedDeny.map((value) => value.toLowerCase()).includes("image") && - !expandedAllow.map((value) => value.toLowerCase()).includes("image") + !expandedDenyLower.includes("image") && + !expandedAllowLower.includes("image") ) { expandedAllow = [...expandedAllow, "image"]; } diff --git a/src/agents/skills/command-specs.ts b/src/agents/skills/command-specs.ts index 30bc7d6a3d3..5e994ac1584 100644 --- a/src/agents/skills/command-specs.ts +++ b/src/agents/skills/command-specs.ts @@ -1,6 +1,10 @@ import type { OpenClawConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { loadEnabledClaudeBundleCommands } from "../../plugins/bundle-commands.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../../shared/string-coerce.js"; import { resolveEffectiveAgentSkillFilter } from "./agent-filter.js"; import type { SkillEligibilityContext, SkillCommandSpec, SkillEntry } from "./types.js"; import { @@ -27,8 +31,7 @@ function debugSkillCommandOnce( } function sanitizeSkillCommandName(raw: string): string { - const normalized = raw - .toLowerCase() + const normalized = normalizeLowercaseStringOrEmpty(raw) .replace(/[^a-z0-9_]+/g, "_") .replace(/_+/g, "_") .replace(/^_+|_+$/g, ""); @@ -37,7 +40,7 @@ function sanitizeSkillCommandName(raw: string): string { } function resolveUniqueSkillCommandName(base: string, used: Set): string { - const normalizedBase = base.toLowerCase(); + const normalizedBase = normalizeLowercaseStringOrEmpty(base); if (!used.has(normalizedBase)) { return base; } @@ -46,7 +49,7 @@ function resolveUniqueSkillCommandName(base: string, used: Set): string const maxBaseLength = Math.max(1, SKILL_COMMAND_MAX_LENGTH - suffix.length); const trimmedBase = base.slice(0, maxBaseLength); const candidate = `${trimmedBase}${suffix}`; - const candidateKey = candidate.toLowerCase(); + const candidateKey = normalizeLowercaseStringOrEmpty(candidate); if (!used.has(candidateKey)) { return candidate; } @@ -85,7 +88,7 @@ export function buildWorkspaceSkillCommandSpecs( const userInvocable = eligible.filter((entry) => entry.invocation?.userInvocable !== false); const used = new Set(); for (const reserved of opts?.reservedNames ?? []) { - used.add(reserved.toLowerCase()); + used.add(normalizeLowercaseStringOrEmpty(reserved)); } const specs: SkillCommandSpec[] = []; @@ -107,20 +110,16 @@ export function buildWorkspaceSkillCommandSpecs( { rawName, deduped: `/${unique}` }, ); } - used.add(unique.toLowerCase()); + used.add(normalizeLowercaseStringOrEmpty(unique)); const rawDescription = entry.skill.description?.trim() || rawName; const description = rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH ? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…" : rawDescription; const dispatch = (() => { - const kindRaw = ( - entry.frontmatter?.["command-dispatch"] ?? - entry.frontmatter?.["command_dispatch"] ?? - "" - ) - .trim() - .toLowerCase(); + const kindRaw = normalizeLowercaseStringOrEmpty( + entry.frontmatter?.["command-dispatch"] ?? entry.frontmatter?.["command_dispatch"] ?? "", + ); if (!kindRaw || kindRaw !== "tool") { return undefined; } @@ -139,13 +138,9 @@ export function buildWorkspaceSkillCommandSpecs( return undefined; } - const argModeRaw = ( - entry.frontmatter?.["command-arg-mode"] ?? - entry.frontmatter?.["command_arg_mode"] ?? - "" - ) - .trim() - .toLowerCase(); + const argModeRaw = normalizeOptionalLowercaseString( + entry.frontmatter?.["command-arg-mode"] ?? entry.frontmatter?.["command_arg_mode"] ?? "", + ); const argMode = !argModeRaw || argModeRaw === "raw" ? "raw" : null; if (!argMode) { debugSkillCommandOnce( @@ -187,7 +182,7 @@ export function buildWorkspaceSkillCommandSpecs( { rawName: entry.rawName, deduped: `/${unique}` }, ); } - used.add(unique.toLowerCase()); + used.add(normalizeLowercaseStringOrEmpty(unique)); const description = entry.description.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH ? entry.description.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…" diff --git a/src/agents/tool-display.ts b/src/agents/tool-display.ts index 7ccdeac507b..3d32a8f296b 100644 --- a/src/agents/tool-display.ts +++ b/src/agents/tool-display.ts @@ -1,4 +1,5 @@ import { redactToolDetail } from "../logging/redact.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { shortenHomeInString } from "../utils.js"; import { defaultTitle, @@ -46,7 +47,7 @@ export function resolveToolDisplay(params: { meta?: string; }): ToolDisplay { const name = normalizeToolName(params.name); - const key = name.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(name); const spec = TOOL_MAP[key]; const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩"; const title = spec?.title ?? defaultTitle(name); diff --git a/src/agents/tools/web-fetch.test-harness.ts b/src/agents/tools/web-fetch.test-harness.ts index 3c88582a95f..86ba6301982 100644 --- a/src/agents/tools/web-fetch.test-harness.ts +++ b/src/agents/tools/web-fetch.test-harness.ts @@ -1,10 +1,11 @@ import type { LookupFn } from "../../infra/net/ssrf.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; export function makeFetchHeaders(map: Record): { get: (key: string) => string | null; } { return { - get: (key) => map[key.toLowerCase()] ?? null, + get: (key) => map[normalizeLowercaseStringOrEmpty(key)] ?? null, }; }