diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts index e9ea5b6dd5..75ac213b05 100644 --- a/extensions/bluebubbles/src/media-send.ts +++ b/extensions/bluebubbles/src/media-send.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime"; +import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/text-runtime"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { sendBlueBubblesAttachment } from "./attachments.js"; import { basenameFromMediaSource, safeFileURLToPath } from "./local-file-access.js"; @@ -73,9 +74,9 @@ function isPathInsideRoot(candidate: string, root: string): boolean { ? normalizedRoot : normalizedRoot + path.sep; if (process.platform === "win32") { - const candidateLower = normalizedCandidate.toLowerCase(); - const rootLower = normalizedRoot.toLowerCase(); - const rootWithSepLower = rootWithSep.toLowerCase(); + const candidateLower = lowercasePreservingWhitespace(normalizedCandidate); + const rootLower = lowercasePreservingWhitespace(normalizedRoot); + const rootWithSepLower = lowercasePreservingWhitespace(rootWithSep); return candidateLower === rootLower || candidateLower.startsWith(rootWithSepLower); } return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(rootWithSep); diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts index c9c26266e5..2e0014c6b6 100644 --- a/extensions/google/oauth.credentials.ts +++ b/extensions/google/oauth.credentials.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import type { Dirent } from "node:fs"; import { delimiter, dirname, join } from "node:path"; +import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/text-runtime"; import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js"; type CredentialFs = { @@ -96,7 +97,9 @@ function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[ for (const candidate of candidates) { for (const searchDir of resolveGeminiCliSearchDirs(candidate)) { const key = - process.platform === "win32" ? searchDir.replace(/\\/g, "/").toLowerCase() : searchDir; + process.platform === "win32" + ? lowercasePreservingWhitespace(searchDir.replace(/\\/g, "/")) + : searchDir; if (seen.has(key)) { continue; } diff --git a/extensions/lobster/src/lobster-runner.ts b/extensions/lobster/src/lobster-runner.ts index 8455913bc0..469fbf3779 100644 --- a/extensions/lobster/src/lobster-runner.ts +++ b/extensions/lobster/src/lobster-runner.ts @@ -5,6 +5,10 @@ import path from "node:path"; import { Readable, Writable } from "node:stream"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { + lowercasePreservingWhitespace, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/text-runtime"; export type LobsterEnvelope = | { @@ -168,7 +172,7 @@ type PipelineRuntimeContext = { function normalizeForCwdSandbox(p: string): string { const normalized = path.normalize(p); - return process.platform === "win32" ? normalized.toLowerCase() : normalized; + return process.platform === "win32" ? lowercasePreservingWhitespace(normalized) : normalized; } export function resolveLobsterCwd(cwdRaw: unknown): string { @@ -305,7 +309,7 @@ async function resolveWorkflowFile(candidate: string, cwd: string) { if (!fileStat.isFile()) { throw new Error("Workflow path is not a file"); } - const ext = path.extname(resolved).toLowerCase(); + const ext = normalizeLowercaseStringOrEmpty(path.extname(resolved)); if (![".lobster", ".yaml", ".yml", ".json"].includes(ext)) { throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json"); } diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 64fdc6c5f0..1e56d0efb1 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -18,7 +18,10 @@ import { type MemoryLightDreamingConfig, type MemoryRemDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { + lowercasePreservingWhitespace, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/text-runtime"; import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js"; import { generateAndAppendDreamNarrative, type NarrativePhaseData } from "./dreaming-narrative.js"; import { @@ -425,7 +428,7 @@ type SessionIngestionCollectionResult = { function normalizeWorkspaceKey(workspaceDir: string): string { const resolved = path.resolve(workspaceDir).replace(/\\/g, "/"); - return process.platform === "win32" ? resolved.toLowerCase() : resolved; + return process.platform === "win32" ? lowercasePreservingWhitespace(resolved) : resolved; } function resolveSessionIngestionStatePath(workspaceDir: string): string { diff --git a/extensions/memory-wiki/src/source-path-shared.ts b/extensions/memory-wiki/src/source-path-shared.ts index 070709c966..2473142ce3 100644 --- a/extensions/memory-wiki/src/source-path-shared.ts +++ b/extensions/memory-wiki/src/source-path-shared.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/text-runtime"; export async function pathExists(filePath: string): Promise { try { @@ -12,5 +13,7 @@ export async function pathExists(filePath: string): Promise { export async function resolveArtifactKey(absolutePath: string): Promise { const canonicalPath = await fs.realpath(absolutePath).catch(() => path.resolve(absolutePath)); - return process.platform === "win32" ? canonicalPath.toLowerCase() : canonicalPath; + return process.platform === "win32" + ? lowercasePreservingWhitespace(canonicalPath) + : canonicalPath; } diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 71ef063d24..66b823de49 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -12,6 +12,7 @@ import { resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; import { + lowercasePreservingWhitespace, normalizeLowercaseStringOrEmpty, readStringValue, resolvePrimaryStringValue, @@ -293,7 +294,7 @@ function normalizePathForComparison(input: string): string { // Keep lexical path for non-existent directories. } if (process.platform === "win32") { - return normalizeLowercaseStringOrEmpty(normalized); + return lowercasePreservingWhitespace(normalized); } return normalized; } diff --git a/src/canvas-host/a2ui.ts b/src/canvas-host/a2ui.ts index 8d1de47bb8..f81d20d5bd 100644 --- a/src/canvas-host/a2ui.ts +++ b/src/canvas-host/a2ui.ts @@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { detectMime } from "../media/mime.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { lowercasePreservingWhitespace } from "../shared/string-coerce.js"; import { resolveFileWithinRoot } from "./file-resolver.js"; export const A2UI_PATH = "/__openclaw__/a2ui"; @@ -133,7 +133,7 @@ export function injectCanvasLiveReload(html: string): string { `.trim(); - const idx = normalizeLowercaseStringOrEmpty(html).lastIndexOf(""); + const idx = lowercasePreservingWhitespace(html).lastIndexOf(""); if (idx >= 0) { return `${html.slice(0, idx)}\n${snippet}\n${html.slice(idx)}`; } @@ -181,7 +181,7 @@ export async function handleA2uiHttpRequest( } try { - const lower = normalizeLowercaseStringOrEmpty(result.realPath); + const lower = lowercasePreservingWhitespace(result.realPath); const mime = lower.endsWith(".html") || lower.endsWith(".htm") ? "text/html" diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index 40b33bd185..b0d894f258 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -15,7 +15,7 @@ import { resolveStateDir } from "../config/paths.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { detectMime } from "../media/mime.js"; import type { RuntimeEnv } from "../runtime.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { lowercasePreservingWhitespace } from "../shared/string-coerce.js"; import { ensureDir, resolveUserPath } from "../utils.js"; import { CANVAS_HOST_PATH, @@ -394,7 +394,7 @@ export async function createCanvasHostHandler( await handle.close().catch(() => {}); } - const lower = normalizeLowercaseStringOrEmpty(realPath); + const lower = lowercasePreservingWhitespace(realPath); const mime = lower.endsWith(".html") || lower.endsWith(".htm") ? "text/html" @@ -465,7 +465,7 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise { - if (normalizeLowercaseStringOrEmpty(req.headers.upgrade ?? "") === "websocket") { + if (lowercasePreservingWhitespace(String(req.headers.upgrade ?? "")) === "websocket") { return; } void (async () => { diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts index be7624d340..687b59d56d 100644 --- a/src/memory-host-sdk/dreaming.ts +++ b/src/memory-host-sdk/dreaming.ts @@ -3,6 +3,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import type { OpenClawConfig } from "../config/config.js"; import { asNullableRecord } from "../shared/record-coerce.js"; import { + lowercasePreservingWhitespace, normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; @@ -302,7 +303,7 @@ function resolveExecutionConfig( function normalizePathForComparison(input: string): string { const normalized = path.resolve(input); - return process.platform === "win32" ? normalized.toLowerCase() : normalized; + return process.platform === "win32" ? lowercasePreservingWhitespace(normalized) : normalized; } function formatLocalIsoDay(epochMs: number): string { diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts index a7dbedfc4c..e0fa2f56da 100644 --- a/src/plugin-sdk/text-runtime.ts +++ b/src/plugin-sdk/text-runtime.ts @@ -29,6 +29,7 @@ export * from "../utils/reaction-level.js"; export * from "../utils/with-timeout.js"; export { hasNonEmptyString, + lowercasePreservingWhitespace, normalizeLowercaseStringOrEmpty, normalizeNullableString, normalizeOptionalLowercaseString, diff --git a/src/shared/string-coerce.ts b/src/shared/string-coerce.ts index ab99672da4..0e741f33bc 100644 --- a/src/shared/string-coerce.ts +++ b/src/shared/string-coerce.ts @@ -22,6 +22,10 @@ export function normalizeLowercaseStringOrEmpty(value: unknown): string { return normalizeOptionalLowercaseString(value) ?? ""; } +export function lowercasePreservingWhitespace(value: string): string { + return value.toLowerCase(); +} + export function resolvePrimaryStringValue(value: unknown): string | undefined { if (typeof value === "string") { return normalizeOptionalString(value); diff --git a/src/test-utils/mock-http-response.ts b/src/test-utils/mock-http-response.ts index 7a992695b8..74a6c538cd 100644 --- a/src/test-utils/mock-http-response.ts +++ b/src/test-utils/mock-http-response.ts @@ -1,5 +1,5 @@ import type { ServerResponse } from "node:http"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { lowercasePreservingWhitespace } from "../shared/string-coerce.js"; export function createMockServerResponse(): ServerResponse & { body?: string } { const headers: Record = {}; @@ -14,10 +14,10 @@ export function createMockServerResponse(): ServerResponse & { body?: string } { headersSent: false, statusCode: 200, setHeader: (key: string, value: string) => { - headers[normalizeLowercaseStringOrEmpty(key)] = value; + headers[lowercasePreservingWhitespace(key)] = value; return res; }, - getHeader: (key: string) => headers[normalizeLowercaseStringOrEmpty(key)], + getHeader: (key: string) => headers[lowercasePreservingWhitespace(key)], end: (body?: string) => { res.headersSent = true; res.body = body;