diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 17bd47183102..1d4ccc8007fc 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -101,7 +101,7 @@ extensions/browser/src/browser/routes/agent.act.shared.ts 3 extensions/browser/src/browser/routes/agent.act.ts 2 extensions/browser/src/browser/routes/agent.shared.ts 2 extensions/browser/src/browser/routes/agent.snapshot.ts 8 -extensions/browser/src/browser/routes/agent.storage.ts 3 +extensions/browser/src/browser/routes/agent.storage.ts 2 extensions/browser/src/browser/routes/basic.ts 6 extensions/browser/src/browser/routes/permissions.ts 1 extensions/browser/src/browser/routes/tabs.ts 8 @@ -250,7 +250,6 @@ extensions/codex/src/app-server/transcript-mirror-attestation.ts 2 extensions/codex/src/app-server/transcript-mirror.ts 4 extensions/codex/src/app-server/transport-stdio.ts 1 extensions/codex/src/app-server/upstream-fork-boundary.ts 2 -extensions/codex/src/app-server/upstream-prompt-provenance.ts 4 extensions/codex/src/app-server/usage-limit-error.ts 2 extensions/codex/src/app-server/user-prompt-message.ts 1 extensions/codex/src/command-handlers.ts 3 @@ -513,7 +512,7 @@ extensions/file-transfer/src/node-host/dir-list.ts 1 extensions/file-transfer/src/node-host/file-fetch.ts 1 extensions/file-transfer/src/node-host/file-write.ts 2 extensions/file-transfer/src/shared/node-invoke-policy.ts 3 -extensions/file-transfer/src/shared/policy.ts 12 +extensions/file-transfer/src/shared/policy.ts 7 extensions/file-transfer/src/tools/dir-fetch-tool.ts 1 extensions/file-transfer/src/tools/dir-list-tool.ts 2 extensions/file-transfer/src/tools/file-fetch-tool.ts 2 @@ -549,13 +548,13 @@ extensions/google/cli-backend-auth.runtime.ts 1 extensions/google/cli-backend-isolated-auth.runtime.ts 5 extensions/google/cli-backend.ts 1 extensions/google/embedding-batch.ts 3 -extensions/google/index.ts 4 +extensions/google/index.ts 1 extensions/google/music-generation-provider.ts 1 extensions/google/oauth-token-shared.ts 1 extensions/google/onboard.ts 3 extensions/google/provider-catalog.ts 2 extensions/google/provider-registration.ts 2 -extensions/google/realtime-voice-provider.ts 9 +extensions/google/realtime-voice-provider.ts 6 extensions/google/src/gemini-web-search-provider.runtime.ts 1 extensions/google/transport-stream.ts 15 extensions/google/vertex-adc.ts 6 @@ -1017,7 +1016,7 @@ extensions/qa-lab/src/crabline-transport.ts 6 extensions/qa-lab/src/cron-run-wait.ts 1 extensions/qa-lab/src/discovery-eval.ts 1 extensions/qa-lab/src/docker-up.runtime.ts 1 -extensions/qa-lab/src/evidence-gallery.ts 3 +extensions/qa-lab/src/evidence-gallery.ts 2 extensions/qa-lab/src/evidence-summary.ts 1 extensions/qa-lab/src/execution-identity-storage-inspection.ts 1 extensions/qa-lab/src/fixture-utils.ts 10 @@ -1086,7 +1085,7 @@ extensions/qa-lab/src/providers/index.ts 1 extensions/qa-lab/src/providers/live-config.ts 1 extensions/qa-lab/src/providers/mock-openai/mock-anthropic-wire.ts 3 extensions/qa-lab/src/providers/mock-openai/mock-openai-assistant-text.ts 3 -extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts 3 +extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts 2 extensions/qa-lab/src/providers/mock-openai/mock-openai-directives.ts 4 extensions/qa-lab/src/providers/mock-openai/mock-openai-events.ts 4 extensions/qa-lab/src/providers/mock-openai/mock-openai-input.ts 11 @@ -1396,7 +1395,6 @@ extensions/voice-call/src/runtime.ts 3 extensions/voice-call/src/tts-provider-voice.ts 1 extensions/voice-call/src/webhook.ts 11 extensions/voice-call/src/webhook/realtime-handler.ts 3 -extensions/voice-call/src/webhook/stream-frame-adapter.ts 2 extensions/volcengine/tts.ts 2 extensions/voyage/embedding-batch.ts 1 extensions/vydra/shared.ts 2 diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4d1b47971c06..c761ba5ef69e 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -154,7 +154,6 @@ extensions/memory-lancedb/index.test.ts extensions/memory-wiki/src/chatgpt-import.ts extensions/memory-wiki/src/cli.ts extensions/memory-wiki/src/compile.ts -extensions/memory-wiki/src/markdown.ts extensions/memory-wiki/src/query.test.ts extensions/memory-wiki/src/query.ts extensions/microsoft-foundry/index.test.ts diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 3fe503cfd0f9..8a691c58f1ae 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -301,7 +301,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | `plugin-sdk/concurrency-runtime` | Private-local after July 2026; Bounded async task concurrency helper | | `plugin-sdk/dedupe-runtime` | In-memory and persistent-backed dedupe cache helpers | | `plugin-sdk/delivery-queue-runtime` | Private-local after July 2026; Outbound pending-delivery drain helper | - | `plugin-sdk/file-access-runtime` | Private-local after July 2026; Safe local-file, temp-root, media-source path, and directory-durability helpers | + | `plugin-sdk/file-access-runtime` | Private-local after July 2026; Safe local-file, path-containment, temp-root, media-source path, and directory-durability helpers | | `plugin-sdk/heartbeat-runtime` | Private-local after July 2026; Heartbeat wake, event, and visibility helpers | | `plugin-sdk/expect-runtime` | Private-local after July 2026; Required-value assertion helper for provable runtime invariants | | `plugin-sdk/number-runtime` | Private-local after July 2026; Numeric coercion helper | @@ -322,7 +322,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | `plugin-sdk/context-visibility-runtime` | Private-local after July 2026; Context visibility resolution and supplemental context filtering without broad config/security imports | | `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports | | `plugin-sdk/html-entity-runtime` | Private-local after July 2026; Single-pass semicolon-terminated HTML5 entity decoding without broad text utilities | - | `plugin-sdk/text-utility-runtime` | Private-local after July 2026; Low-level text and path helpers, including five-entity HTML escaping | + | `plugin-sdk/text-utility-runtime` | Private-local after July 2026; Low-level text and path helpers, including UTF-8 prefix truncation and five-entity HTML escaping | | `plugin-sdk/widget-html` | Complete-document detection, size validation, and tool input errors for self-contained HTML widgets | | `plugin-sdk/host-runtime` | Private-local after July 2026; Hostname and SCP host normalization helpers | | `plugin-sdk/retry-runtime` | Private-local after July 2026; Retry config and retry runner helpers | diff --git a/extensions/acpx/src/pi-session-store.ts b/extensions/acpx/src/pi-session-store.ts index 43390b66c335..e33f96f0194b 100644 --- a/extensions/acpx/src/pi-session-store.ts +++ b/extensions/acpx/src/pi-session-store.ts @@ -1,6 +1,7 @@ import { createReadStream } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { isPathStrictlyInside } from "openclaw/plugin-sdk/file-access-runtime"; import type { SessionCatalogSession } from "openclaw/plugin-sdk/session-catalog"; import { isRecord, @@ -172,7 +173,7 @@ async function scanPiFileCandidates(env: NodeJS.ProcessEnv): Promise[] { return content.split(/\r?\n/u).flatMap((line) => { if (!line.trim()) { diff --git a/extensions/anthropic/session-catalog-scan.ts b/extensions/anthropic/session-catalog-scan.ts index dbb838f375a2..0ca3592ef74b 100644 --- a/extensions/anthropic/session-catalog-scan.ts +++ b/extensions/anthropic/session-catalog-scan.ts @@ -2,6 +2,7 @@ import type { Dirent, Stats } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; const MAX_CATALOG_JSON_CACHE_ENTRIES = 4_000; export const CLAUDE_CATALOG_IO_CONCURRENCY = 32; @@ -72,23 +73,18 @@ export function setBoundedCache( } } -function isWithin(root: string, candidate: string): boolean { - const relative = path.relative(path.resolve(root), path.resolve(candidate)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - async function safeSessionFile( root: string, resolvedRoot: string, candidate: string, sessionId: string, ): Promise { - if (!isWithin(root, candidate) || path.basename(candidate) !== `${sessionId}.jsonl`) { + if (!isPathInside(root, candidate) || path.basename(candidate) !== `${sessionId}.jsonl`) { return undefined; } try { const resolvedCandidate = await fs.realpath(candidate); - if (!isWithin(resolvedRoot, resolvedCandidate)) { + if (!isPathInside(resolvedRoot, resolvedCandidate)) { return undefined; } const stat = await fs.stat(resolvedCandidate); diff --git a/extensions/browser/src/browser-proxy-upload.ts b/extensions/browser/src/browser-proxy-upload.ts index 34ef1324369a..8f0b62f286d8 100644 --- a/extensions/browser/src/browser-proxy-upload.ts +++ b/extensions/browser/src/browser-proxy-upload.ts @@ -8,6 +8,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf8Prefix } from "openclaw/plugin-sdk/text-utility-runtime"; import { assertBrowserProxyFileBytesWithinLimits, assertBrowserProxyFileCountWithinLimit, @@ -150,20 +151,6 @@ export async function prepareBrowserProxyUploadRequest(params: { return { body: bodyWithoutPaths, upload }; } -function truncateUtf8(value: string, maxBytes: number): string { - let result = ""; - let bytes = 0; - for (const character of value) { - const nextBytes = Buffer.byteLength(character, "utf8"); - if (bytes + nextBytes > maxBytes) { - break; - } - result += character; - bytes += nextBytes; - } - return result; -} - function sanitizeUploadName(name: string): string { const basename = path.posix.basename(name.replaceAll("\\", "/")); const cleaned = Array.from(basename, (character) => { @@ -177,7 +164,7 @@ function sanitizeUploadName(name: string): string { .replace(/[. ]+$/u, ""); const portable = WINDOWS_RESERVED_NAME.test(cleaned) ? `_${cleaned}` : cleaned; const safe = portable && portable !== "." && portable !== ".." ? portable : "upload"; - return truncateUtf8(safe, MAX_STAGED_NAME_BYTES) || "upload"; + return truncateUtf8Prefix(safe, MAX_STAGED_NAME_BYTES) || "upload"; } function decodedBase64Size(value: string): number { diff --git a/extensions/browser/src/browser/extension-native-host.ts b/extensions/browser/src/browser/extension-native-host.ts index f5ec05911c49..1dcc34fca353 100644 --- a/extensions/browser/src/browser/extension-native-host.ts +++ b/extensions/browser/src/browser/extension-native-host.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { @@ -99,7 +100,7 @@ async function validateNativeManifest(params: { "browser", "native-messaging", ); - if (launcherPath !== managedRoot && !launcherPath.startsWith(`${managedRoot}${path.sep}`)) { + if (!isPathInside(managedRoot, launcherPath)) { throw new Error("launcher is outside the managed root"); } const parsed: unknown = JSON.parse(await fs.readFile(manifestPath, "utf8")); diff --git a/extensions/browser/src/browser/routes/agent.storage.ts b/extensions/browser/src/browser/routes/agent.storage.ts index dab110c77672..ba23e1979da0 100644 --- a/extensions/browser/src/browser/routes/agent.storage.ts +++ b/extensions/browser/src/browser/routes/agent.storage.ts @@ -5,6 +5,7 @@ * and related browser-context mutations for the selected profile/tab. */ import { + asNullableRecord, normalizeOptionalString, readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -200,10 +201,7 @@ export function registerBrowserAgentStorageRoutes( app.post("/cookies/set", async (req, res) => { const body = readBody(req); const targetId = resolveTargetIdFromBody(body); - const cookie = - body.cookie && typeof body.cookie === "object" && !Array.isArray(body.cookie) - ? (body.cookie as Record) - : null; + const cookie = asNullableRecord(body.cookie); if (!cookie) { return jsonError(res, 400, "cookie is required"); } diff --git a/extensions/browser/src/doctor-browser.ts b/extensions/browser/src/doctor-browser.ts index ef67773dc443..abbada7ad8b0 100644 --- a/extensions/browser/src/doctor-browser.ts +++ b/extensions/browser/src/doctor-browser.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { asNullableRecord, normalizeOptionalString, @@ -126,16 +127,6 @@ function resolveManagedBrowserUserDataDir(configDir: string, profileName: string return path.join(resolveManagedBrowserProfileDir(configDir, profileName), "user-data"); } -function normalizeComparablePath(targetPath: string): string { - return path.resolve(targetPath); -} - -function isSameOrChildPath(candidatePath: string, parentPath: string): boolean { - const candidate = normalizeComparablePath(candidatePath); - const parent = normalizeComparablePath(parentPath); - return candidate === parent || candidate.startsWith(`${parent}${path.sep}`); -} - function isLegacyClawdProfileConfigured(cfg: OpenClawConfig, legacyProfileDir: string): boolean { const browser = asNullableRecord(cfg.browser); if (!browser) { @@ -156,7 +147,7 @@ function isLegacyClawdProfileConfigured(cfg: OpenClawConfig, legacyProfileDir: s for (const rawProfile of Object.values(configuredProfiles)) { const profile = asNullableRecord(rawProfile); const userDataDir = normalizeOptionalString(profile?.userDataDir); - if (userDataDir && isSameOrChildPath(resolveUserPath(userDataDir), legacyProfileDir)) { + if (userDataDir && isPathInside(legacyProfileDir, resolveUserPath(userDataDir))) { return true; } } diff --git a/extensions/buzz/src/directory-relay.ts b/extensions/buzz/src/directory-relay.ts index 9f06d56427d6..8b4688d2ff9a 100644 --- a/extensions/buzz/src/directory-relay.ts +++ b/extensions/buzz/src/directory-relay.ts @@ -1,4 +1,5 @@ import type { Event, Filter, Relay } from "nostr-tools"; +import { chunkItems } from "openclaw/plugin-sdk/text-chunking"; import { BUZZ_PROFILE_KIND, BUZZ_PROFILE_QUERY_CHUNK_SIZE, @@ -23,14 +24,6 @@ type ProfileSubscriptionGeneration = { readyTimeout?: ReturnType; }; -function chunkValues(values: readonly T[], size: number): T[][] { - const chunks: T[][] = []; - for (let index = 0; index < values.length; index += size) { - chunks.push(values.slice(index, index + size)); - } - return chunks; -} - async function queryBuzzDirectoryBatch(params: { relay: Relay; filter: Filter; @@ -112,7 +105,7 @@ export async function queryBuzzDirectoryProfiles(params: { onTimeout?: (error: Error) => void; signal?: AbortSignal; }): Promise { - for (const authors of chunkValues(params.publicKeys, BUZZ_PROFILE_QUERY_CHUNK_SIZE)) { + for (const authors of chunkItems(params.publicKeys, BUZZ_PROFILE_QUERY_CHUNK_SIZE)) { await queryBuzzDirectoryBatch({ relay: params.relay, filter: { @@ -138,7 +131,7 @@ export async function queryBuzzDirectoryRooms(params: { onTimeout?: (error: Error) => void; signal?: AbortSignal; }): Promise { - for (const roomIds of chunkValues(params.channelIds, BUZZ_ROOM_QUERY_CHUNK_SIZE)) { + for (const roomIds of chunkItems(params.channelIds, BUZZ_ROOM_QUERY_CHUNK_SIZE)) { await queryBuzzDirectoryBatch({ relay: params.relay, filter: { @@ -228,7 +221,7 @@ export function startBuzzDirectoryRelay(params: { const publicKeys = queuedProfilePublicKeys; queuedProfilePublicKeys = undefined; closeProfileGeneration(PROFILE_SUBSCRIPTION_REPLACED_REASON); - const authorChunks = chunkValues(publicKeys, BUZZ_PROFILE_QUERY_CHUNK_SIZE); + const authorChunks = chunkItems(publicKeys, BUZZ_PROFILE_QUERY_CHUNK_SIZE); if (authorChunks.length === 0) { return; } diff --git a/extensions/canvas/src/tool.ts b/extensions/canvas/src/tool.ts index c885c2a7b70f..1eb646161986 100644 --- a/extensions/canvas/src/tool.ts +++ b/extensions/canvas/src/tool.ts @@ -15,6 +15,7 @@ import { jsonResult, readStringParam, } from "openclaw/plugin-sdk/channel-actions"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; import { addTimerTimeoutGraceMs, @@ -122,13 +123,6 @@ function wrapCanvasEvalResult(result: unknown): string { return wrappedText; } -function isPathInsideRoot(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return ( - relative === "" || (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - async function readJsonlFromPath(jsonlPath: string, workspaceDir?: string): Promise { const trimmed = jsonlPath.trim(); if (!trimmed) { @@ -140,7 +134,7 @@ async function readJsonlFromPath(jsonlPath: string, workspaceDir?: string): Prom fs.realpath(workspaceRoot), fs.realpath(resolved), ]); - if (!isPathInsideRoot(workspaceReal, resolvedReal)) { + if (!isPathInside(workspaceReal, resolvedReal)) { throw new Error("jsonlPath outside workspace"); } return ( diff --git a/extensions/codex/src/app-server/event-projector-snapshot.ts b/extensions/codex/src/app-server/event-projector-snapshot.ts index cc8f73367cfb..d0e25db6e2a2 100644 --- a/extensions/codex/src/app-server/event-projector-snapshot.ts +++ b/extensions/codex/src/app-server/event-projector-snapshot.ts @@ -5,6 +5,7 @@ import type { import { projectAgentHarnessTranscriptMessageForDisplay } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { attachCodexMirrorIdentity } from "./upstream-prompt-provenance.js"; import { promptSnapshot } from "./user-prompt-message.js"; @@ -13,9 +14,7 @@ const CODEX_META_KEY = "__openclaw"; function readTurnTaintMetadata(message: AgentMessage): TurnTaintMetadata | undefined { const metadata = CODEX_META_KEY in message ? message[CODEX_META_KEY] : undefined; - return metadata && typeof metadata === "object" && !Array.isArray(metadata) - ? (metadata as TurnTaintMetadata) - : undefined; + return asOptionalRecord(metadata) as TurnTaintMetadata | undefined; } function applyStickyTurnTaint(messages: readonly AgentMessage[]): AgentMessage[] { diff --git a/extensions/codex/src/app-server/native-skill-isolation.ts b/extensions/codex/src/app-server/native-skill-isolation.ts index 3130c4ad1992..6cbb34c9b6cb 100644 --- a/extensions/codex/src/app-server/native-skill-isolation.ts +++ b/extensions/codex/src/app-server/native-skill-isolation.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { resolveRequiredHomeDir, resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import type { CodexAppServerClient } from "./client.js"; import type { JsonObject, JsonValue } from "./protocol.js"; @@ -27,14 +28,6 @@ function isMissingPathError(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === "ENOENT"; } -function isPathWithin(root: string, candidate: string): boolean { - const relative = path.relative(path.resolve(root), path.resolve(candidate)); - return ( - relative === "" || - (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..") - ); -} - async function canonicalizeExistingPath(candidate: string): Promise { try { return await fs.realpath(candidate); @@ -72,13 +65,13 @@ async function collectPersonalSkillRealPaths( const realDefaultCodexHome = await canonicalizeExistingPath(defaultCodexHome); roots.push({ dir: path.join(defaultCodexHome, "skills"), - onlyEscapedStateTargets: isPathWithin(realStateDir, realDefaultCodexHome), + onlyEscapedStateTargets: isPathInside(realStateDir, realDefaultCodexHome), }); } const configuredCodexHome = codexHome?.trim() || process.env.CODEX_HOME?.trim(); if (configuredCodexHome) { const realCodexHome = await canonicalizeExistingPath(configuredCodexHome); - const stateOwned = isPathWithin(realStateDir, realCodexHome); + const stateOwned = isPathInside(realStateDir, realCodexHome); roots.push({ dir: path.join(configuredCodexHome, "skills"), // Direct descendants of a state-owned Codex home belong to this isolated instance. @@ -98,7 +91,7 @@ async function collectPersonalSkillRealPaths( const recordSkillFile = async (filePath: string, onlyEscapedStateTargets: boolean) => { try { const skillRealPath = await fs.realpath(filePath); - if (!onlyEscapedStateTargets || !isPathWithin(realStateDir, skillRealPath)) { + if (!onlyEscapedStateTargets || !isPathInside(realStateDir, skillRealPath)) { skillPaths.add(skillRealPath); } } catch (error) { diff --git a/extensions/codex/src/app-server/plan-compaction-state.ts b/extensions/codex/src/app-server/plan-compaction-state.ts index c3d7c7c81364..b4d3c518aa61 100644 --- a/extensions/codex/src/app-server/plan-compaction-state.ts +++ b/extensions/codex/src/app-server/plan-compaction-state.ts @@ -1,6 +1,7 @@ import { Buffer } from "node:buffer"; import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { AgentPlanStep } from "openclaw/plugin-sdk/channel-outbound"; +import { truncateUtf8Prefix } from "openclaw/plugin-sdk/text-utility-runtime"; import { stripInvisibleUnicode } from "openclaw/plugin-sdk/web-content-extractor"; import type { CodexAppServerClient } from "./client.js"; import { isJsonObject } from "./protocol.js"; @@ -131,16 +132,11 @@ function serializePlan(plan: StoredPlan): string { } function truncateUtf8(value: string, maxBytes: number): string { - const bytes = Buffer.from(value); - if (bytes.byteLength <= maxBytes) { + if (Buffer.byteLength(value, "utf8") <= maxBytes) { return value; } const suffixBytes = Buffer.byteLength(RESTORED_PLAN_TRUNCATION_SUFFIX, "utf8"); - let end = Math.max(0, maxBytes - suffixBytes); - while (end > 0 && (bytes[end] ?? 0) >> 6 === 0b10) { - end -= 1; - } - return `${bytes.subarray(0, end).toString("utf8")}${RESTORED_PLAN_TRUNCATION_SUFFIX}`; + return `${truncateUtf8Prefix(value, maxBytes - suffixBytes)}${RESTORED_PLAN_TRUNCATION_SUFFIX}`; } function readPlanSteps(value: unknown): AgentPlanStep[] { diff --git a/extensions/codex/src/app-server/remote-workspace-media.ts b/extensions/codex/src/app-server/remote-workspace-media.ts index d50ebd57a797..62aa0dfdddea 100644 --- a/extensions/codex/src/app-server/remote-workspace-media.ts +++ b/extensions/codex/src/app-server/remote-workspace-media.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { root } from "openclaw/plugin-sdk/file-access-runtime"; +import { isPathStrictlyInside, root } from "openclaw/plugin-sdk/file-access-runtime"; import { getMediaDir } from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; import type { CodexCommandExecParams, CodexCommandExecResponse } from "./command-exec-protocol.js"; @@ -236,7 +236,7 @@ export async function prepareCodexRemoteWorkspaceMessageMedia(params: { if (typeof value !== "string") { return value; } - if (isGatewayManagedMediaPath(value, gatewayMediaRoot)) { + if (path.isAbsolute(value) && isPathStrictlyInside(gatewayMediaRoot, value)) { attachmentEntries += 1; gatewayManagedPaths.add(value); return value; @@ -374,19 +374,6 @@ export async function prepareCodexRemoteWorkspaceMessageMedia(params: { return mapMessageMediaValues(mappedArgs, (value) => stagedPaths.get(value) ?? value); } -function isGatewayManagedMediaPath(value: string, mediaRoot: string): boolean { - if (!path.isAbsolute(value)) { - return false; - } - const relativePath = path.relative(mediaRoot, value); - return Boolean( - relativePath && - relativePath !== ".." && - !relativePath.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativePath), - ); -} - async function assertGatewayManagedMediaPath(value: string, mediaRoot: string): Promise { const media = await root(mediaRoot, { symlinks: "reject" }); const opened = await media.open(path.relative(mediaRoot, value), { symlinks: "reject" }); diff --git a/extensions/codex/src/app-server/runtime-artifact.ts b/extensions/codex/src/app-server/runtime-artifact.ts index 32f1be07a77e..2bf1d7a2be63 100644 --- a/extensions/codex/src/app-server/runtime-artifact.ts +++ b/extensions/codex/src/app-server/runtime-artifact.ts @@ -4,6 +4,7 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { AgentHarnessRuntimeArtifactBinding } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { resolveWindowsExecutablePath, resolveWindowsSpawnProgram, @@ -223,14 +224,6 @@ async function listPackageFiles(params: { return files; } -function pathIsWithin(rootPath: string, candidatePath: string): boolean { - const relative = path.relative(rootPath, candidatePath); - return ( - relative === "" || - (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..") - ); -} - function assertSafeNodeOptions(env: NodeJS.ProcessEnv): void { for (const [rawKey, value] of Object.entries(env)) { const key = rawKey.toUpperCase(); @@ -376,7 +369,7 @@ async function hashSelectedArtifactFiles( } } const externalFiles = [...new Set(allFiles)] - .filter((filePath) => !packageRoot || !pathIsWithin(packageRoot, filePath)) + .filter((filePath) => !packageRoot || !isPathInside(packageRoot, filePath)) .toSorted(compareArtifactNames); const budget: ArtifactHashBudget = { fileCount: 0, totalBytes: 0n }; const hash = createHash("sha256"); @@ -517,7 +510,7 @@ async function resolvePackageRoot(nativePath: string): Promise { - const relativePath = path.relative(root, rolloutPath); - return ( - relativePath !== "" && - relativePath !== ".." && - !relativePath.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativePath) - ); - }) + ? roots.find((root) => isPathStrictlyInside(root, rolloutPath)) : undefined; if ( rolloutPath && diff --git a/extensions/codex/src/app-server/transcript-history-projection.ts b/extensions/codex/src/app-server/transcript-history-projection.ts index 20c231ab2381..1e3a74c61cca 100644 --- a/extensions/codex/src/app-server/transcript-history-projection.ts +++ b/extensions/codex/src/app-server/transcript-history-projection.ts @@ -3,6 +3,7 @@ import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm"; import type { SessionTranscriptMessageEntry } from "openclaw/plugin-sdk/session-transcript-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf8Prefix } from "openclaw/plugin-sdk/text-utility-runtime"; import type { CodexThread, JsonValue } from "./protocol.js"; import { attachCodexMirrorIdentity } from "./upstream-prompt-provenance.js"; @@ -38,22 +39,6 @@ type ProjectedCodexHistoryMessage = { textBytes: number; }; -function isUtf8ContinuationByte(byte: number | undefined): boolean { - return byte !== undefined && (byte & 0xc0) === 0x80; -} - -function truncateUtf8Prefix(value: string, maxBytes: number): string { - const bytes = Buffer.from(value); - if (bytes.byteLength <= maxBytes) { - return value; - } - let end = Math.max(0, maxBytes); - while (end > 0 && isUtf8ContinuationByte(bytes[end])) { - end -= 1; - } - return bytes.subarray(0, end).toString("utf8"); -} - function normalizeImportedHistoryText(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; diff --git a/extensions/codex/src/app-server/upstream-prompt-provenance.ts b/extensions/codex/src/app-server/upstream-prompt-provenance.ts index 246798bf0795..e32b7aab7271 100644 --- a/extensions/codex/src/app-server/upstream-prompt-provenance.ts +++ b/extensions/codex/src/app-server/upstream-prompt-provenance.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const UPSTREAM_USER_TEXT_META_KEY = "upstreamUserText" as const; const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const; @@ -6,10 +7,7 @@ const CODEX_META_KEY = "__openclaw"; export function attachCodexMirrorIdentity(message: T, identity: string): T { const existing = CODEX_META_KEY in message ? message[CODEX_META_KEY] : undefined; - const baseMeta = - existing && typeof existing === "object" && !Array.isArray(existing) - ? (existing as Record) - : {}; + const baseMeta = asOptionalRecord(existing) ?? {}; return { ...message, __openclaw: { ...baseMeta, [MIRROR_IDENTITY_META_KEY]: identity }, @@ -18,19 +16,17 @@ export function attachCodexMirrorIdentity(message: T, id export function readMirrorIdentity(message: AgentMessage): string | undefined { const meta = CODEX_META_KEY in message ? message[CODEX_META_KEY] : undefined; - if (!meta || typeof meta !== "object" || Array.isArray(meta)) { + const record = asOptionalRecord(meta); + if (!record) { return undefined; } - const id = (meta as Record)[MIRROR_IDENTITY_META_KEY]; + const id = record[MIRROR_IDENTITY_META_KEY]; return typeof id === "string" && id ? id : undefined; } export function attachUpstreamUserText(message: T, text: string): T { const existing = CODEX_META_KEY in message ? message[CODEX_META_KEY] : undefined; - const baseMeta = - existing && typeof existing === "object" && !Array.isArray(existing) - ? (existing as Record) - : {}; + const baseMeta = asOptionalRecord(existing) ?? {}; return { ...message, __openclaw: { ...baseMeta, [UPSTREAM_USER_TEXT_META_KEY]: text }, @@ -39,9 +35,10 @@ export function attachUpstreamUserText(message: T, text: export function readUpstreamUserText(message: AgentMessage | undefined): string | undefined { const meta = message && CODEX_META_KEY in message ? message[CODEX_META_KEY] : undefined; - if (!meta || typeof meta !== "object" || Array.isArray(meta)) { + const record = asOptionalRecord(meta); + if (!record) { return undefined; } - const text = (meta as Record)[UPSTREAM_USER_TEXT_META_KEY]; + const text = record[UPSTREAM_USER_TEXT_META_KEY]; return typeof text === "string" && text ? text : undefined; } diff --git a/extensions/feishu/src/doctor.ts b/extensions/feishu/src/doctor.ts index 12dea2661654..fe36ffef0612 100644 --- a/extensions/feishu/src/doctor.ts +++ b/extensions/feishu/src/doctor.ts @@ -7,6 +7,7 @@ import type { ChannelDoctorSequenceResult, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { isPathStrictlyInside } from "openclaw/plugin-sdk/file-access-runtime"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { isValidAgentHarnessSessionStoreEntry, @@ -133,13 +134,6 @@ function safeReadDir(dir: string): fs.Dirent[] { } } -function isPathWithinRoot(targetPath: string, rootPath: string): boolean { - const resolvedTarget = path.resolve(targetPath); - const resolvedRoot = path.resolve(rootPath); - const relative = path.relative(resolvedRoot, resolvedTarget); - return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); -} - function formatDisplayPath(filePath: string): string { const home = os.homedir(); const resolved = path.resolve(filePath); @@ -327,8 +321,8 @@ function resolveSessionTranscriptCandidates(params: { const resolved = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(sessionsDir, candidate); - const isStoreCandidate = isPathWithinRoot(resolved, sessionsDir); - const isAgentSessionCandidate = isPathWithinRoot(resolved, agentSessionsDir); + const isStoreCandidate = isPathStrictlyInside(sessionsDir, resolved); + const isAgentSessionCandidate = isPathStrictlyInside(agentSessionsDir, resolved); if ( resolved === sessionsDir || resolved === agentSessionsDir || diff --git a/extensions/file-transfer/src/shared/policy.ts b/extensions/file-transfer/src/shared/policy.ts index 301b4cb04a2e..e1abd7dff26e 100644 --- a/extensions/file-transfer/src/shared/policy.ts +++ b/extensions/file-transfer/src/shared/policy.ts @@ -50,7 +50,10 @@ import path from "node:path"; import { minimatch } from "minimatch"; import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; -import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asNullableRecord, + asOptionalObjectRecord, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export type FilePolicyKind = "read" | "write"; type FilePolicyAskMode = "off" | "on-miss" | "always"; @@ -90,31 +93,29 @@ function asFilePolicyConfig(value: unknown): FilePolicyConfig | null { } function readFilePolicyConfigFromPluginConfig(pluginConfig: unknown): FilePolicyConfig | null { - if (!pluginConfig || typeof pluginConfig !== "object" || Array.isArray(pluginConfig)) { + const pluginRecord = asNullableRecord(pluginConfig); + if (!pluginRecord) { return null; } - const nodes = (pluginConfig as { nodes?: unknown }).nodes; + const nodes = pluginRecord.nodes; return asFilePolicyConfig(nodes); } function readPluginConfigFromRuntimeConfig(): Record | null { const cfg = getRuntimeConfig(); - const plugins = (cfg as { plugins?: unknown }).plugins; - if (!plugins || typeof plugins !== "object") { + const plugins = asOptionalObjectRecord((cfg as { plugins?: unknown }).plugins); + if (!plugins) { return null; } - const entries = (plugins as { entries?: unknown }).entries; - if (!entries || typeof entries !== "object") { + const entries = asOptionalObjectRecord(plugins.entries); + if (!entries) { return null; } - const entry = (entries as Record)["file-transfer"]; - if (!entry || typeof entry !== "object") { + const entry = asOptionalObjectRecord(entries["file-transfer"]); + if (!entry) { return null; } - const pluginConfig = (entry as { config?: unknown }).config; - return pluginConfig && typeof pluginConfig === "object" && !Array.isArray(pluginConfig) - ? (pluginConfig as Record) - : null; + return asNullableRecord(entry.config); } function readFilePolicyConfig(pluginConfig?: Record): FilePolicyConfig | null { diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 8531afd2cee3..45f7e91eee0a 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -12,7 +12,10 @@ import type { } from "openclaw/plugin-sdk/realtime-voice"; import { createRealtimeVoiceAudioQueue } from "openclaw/plugin-sdk/realtime-voice-audio-queue"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { VideoGenerationProvider } from "openclaw/plugin-sdk/video-generation"; import { buildGoogleGeminiCliBackend } from "./cli-backend.js"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; @@ -161,21 +164,9 @@ function resolveGoogleRealtimeProviderConfig( rawConfig: RealtimeVoiceProviderConfig, cfg?: { models?: { providers?: { google?: { apiKey?: unknown } } } }, ): RealtimeVoiceProviderConfig { - const providers = - typeof rawConfig.providers === "object" && - rawConfig.providers !== null && - !Array.isArray(rawConfig.providers) - ? (rawConfig.providers as Record) - : undefined; - const nested = providers?.google; + const providers = asOptionalRecord(rawConfig.providers); const raw = - typeof nested === "object" && nested !== null && !Array.isArray(nested) - ? (nested as Record) - : typeof rawConfig.google === "object" && - rawConfig.google !== null && - !Array.isArray(rawConfig.google) - ? (rawConfig.google as Record) - : rawConfig; + asOptionalRecord(providers?.google) ?? asOptionalRecord(rawConfig.google) ?? rawConfig; return { ...raw, ...(raw.apiKey === undefined diff --git a/extensions/google/realtime-voice-provider.ts b/extensions/google/realtime-voice-provider.ts index 7523cc75f016..f393d738827d 100644 --- a/extensions/google/realtime-voice-provider.ts +++ b/extensions/google/realtime-voice-provider.ts @@ -50,6 +50,7 @@ import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-i import { asBoolean, asFiniteNumber, + asOptionalRecord, asSafeIntegerInRange, isRecord, normalizeOptionalString, @@ -226,18 +227,8 @@ function asGoogleRealtimeThinkingBudget(value: unknown): number | undefined { function resolveGoogleRealtimeProviderConfigRecord( config: Record, ): Record | undefined { - const providers = - typeof config.providers === "object" && - config.providers !== null && - !Array.isArray(config.providers) - ? (config.providers as Record) - : undefined; - const nested = providers?.google; - return typeof nested === "object" && nested !== null && !Array.isArray(nested) - ? (nested as Record) - : typeof config.google === "object" && config.google !== null && !Array.isArray(config.google) - ? (config.google as Record) - : config; + const providers = asOptionalRecord(config.providers); + return asOptionalRecord(providers?.google) ?? asOptionalRecord(config.google) ?? config; } function normalizeProviderConfig( diff --git a/extensions/lobster/src/lobster-runner.ts b/extensions/lobster/src/lobster-runner.ts index 776c62e82f3d..0d489d49e66a 100644 --- a/extensions/lobster/src/lobster-runner.ts +++ b/extensions/lobster/src/lobster-runner.ts @@ -3,6 +3,7 @@ import { stat } from "node:fs/promises"; import path from "node:path"; import { Readable, Writable } from "node:stream"; import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; export type LobsterEnvelope = | { @@ -80,11 +81,6 @@ type EmbeddedToolRuntime = { const workflowExts = new Set([".lobster", ".yaml", ".yml", ".json"]); -function normalizeForCwdSandbox(p: string): string { - const normalized = path.normalize(p); - return process.platform === "win32" ? normalized.toLowerCase() : normalized; -} - export function resolveLobsterCwd(cwdRaw: unknown): string { if (typeof cwdRaw !== "string" || !cwdRaw.trim()) { return process.cwd(); @@ -96,11 +92,7 @@ export function resolveLobsterCwd(cwdRaw: unknown): string { const base = process.cwd(); const resolved = path.resolve(base, cwd); - const rel = path.relative(normalizeForCwdSandbox(base), normalizeForCwdSandbox(resolved)); - if (rel === "" || rel === ".") { - return resolved; - } - if (rel.startsWith("..") || path.isAbsolute(rel)) { + if (!isPathInside(base, resolved)) { throw new Error("cwd must stay within the gateway working directory"); } return resolved; diff --git a/extensions/mattermost/src/mattermost/slash-commands.ts b/extensions/mattermost/src/mattermost/slash-commands.ts index d6caf101fe3d..ffb538ee7e61 100644 --- a/extensions/mattermost/src/mattermost/slash-commands.ts +++ b/extensions/mattermost/src/mattermost/slash-commands.ts @@ -1,30 +1,14 @@ // Mattermost plugin module implements slash commands behavior. import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf8Prefix } from "openclaw/plugin-sdk/text-utility-runtime"; import type { MattermostClient } from "./client.js"; // ─── Types ─────────────────────────────────────────────────────────────────── -export const MATTERMOST_SLASH_POST_METHOD = "P"; -const MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES = 128; - // Mattermost rejects command descriptions above 128 UTF-8 bytes. Keep portable // descriptions intact until this API boundary so other channels retain their text. -function truncateMattermostCommandDescription(description: string): string { - if (Buffer.byteLength(description, "utf8") <= MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES) { - return description; - } - let bytes = 0; - let end = 0; - for (const char of description) { - const charBytes = Buffer.byteLength(char, "utf8"); - if (bytes + charBytes > MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES) { - break; - } - bytes += charBytes; - end += char.length; - } - return description.slice(0, end); -} +export const MATTERMOST_SLASH_POST_METHOD = "P"; +const MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES = 128; export type MattermostSlashCommandConfig = { /** Enable native slash commands. "auto" resolves to false for now (opt-in). */ @@ -311,7 +295,10 @@ export async function registerSlashCommands(params: { const registered: MattermostRegisteredCommand[] = []; for (const spec of commands) { - const description = truncateMattermostCommandDescription(spec.description); + const description = truncateUtf8Prefix( + spec.description, + MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES, + ); const existingForTrigger = existingByTrigger.get(spec.trigger) ?? []; const ownedCommands = existingForTrigger.filter( (cmd) => cmd.creator_id?.trim() === normalizedCreatorUserId, diff --git a/extensions/memory-core/src/memory/manager-embedding-ops.ts b/extensions/memory-core/src/memory/manager-embedding-ops.ts index e04f6f14db34..88ed39893424 100644 --- a/extensions/memory-core/src/memory/manager-embedding-ops.ts +++ b/extensions/memory-core/src/memory/manager-embedding-ops.ts @@ -32,6 +32,7 @@ import { import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-runtime"; +import { chunkItems } from "openclaw/plugin-sdk/text-chunking"; import { readSessionResetRecallCutoffMetadata } from "../session-reset-recall-metadata.js"; import type { EmbeddingProvider } from "./embeddings.js"; import { @@ -216,12 +217,7 @@ function formatBatchSourceCounts(counts: Record): string { } function splitSourceWideEmbeddingChunks(chunks: T[], maxRequests: number): T[][] { - const limit = Math.max(1, Math.floor(maxRequests)); - const batches: T[][] = []; - for (let start = 0; start < chunks.length; start += limit) { - batches.push(chunks.slice(start, start + limit)); - } - return batches; + return chunkItems(chunks, Math.max(1, Math.floor(maxRequests))); } function resolveEmbeddingTimeoutMs(params: { diff --git a/extensions/memory-core/src/memory/manager-watch-ops.ts b/extensions/memory-core/src/memory/manager-watch-ops.ts index f784a72d1db2..90400c5af6b7 100644 --- a/extensions/memory-core/src/memory/manager-watch-ops.ts +++ b/extensions/memory-core/src/memory/manager-watch-ops.ts @@ -2,6 +2,7 @@ import fsSync from "node:fs"; import path from "node:path"; import chokidar, { type FSWatcher } from "chokidar"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { classifyMemoryMultimodalPath } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { createSubsystemLogger, @@ -102,11 +103,6 @@ function shouldIgnoreMemoryWatchPath( return classifyMemoryMultimodalPath(normalized, multimodalSettings) === null; } -function isWithinMemoryWatchRoot(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function runDetachedMemorySync(sync: () => Promise, reason: "interval" | "watch") { void sync().catch((err: unknown) => { log.warn(`memory sync failed (${reason}): ${String(err)}`); @@ -161,11 +157,9 @@ export abstract class MemoryManagerWatchOps extends MemoryManagerSyncBase { const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => { if (watchPath && stats && !stats.isDirectory?.()) { const normalizedWatchPath = path.resolve(watchPath); - const matchingEntries = isWithinMemoryWatchRoot(memoryDir, normalizedWatchPath) + const matchingEntries = isPathInside(memoryDir, normalizedWatchPath) ? [] - : additionalPaths.filter((entry) => - isWithinMemoryWatchRoot(entry.path, normalizedWatchPath), - ); + : additionalPaths.filter((entry) => isPathInside(entry.path, normalizedWatchPath)); if ( matchingEntries.length > 0 && !matchingEntries.some((entry) => matchesExtraMemoryPathEntry(entry, normalizedWatchPath)) diff --git a/extensions/memory-core/src/memory/memory-path-provenance.ts b/extensions/memory-core/src/memory/memory-path-provenance.ts index 88984fafbfa0..c9d5c88299cd 100644 --- a/extensions/memory-core/src/memory/memory-path-provenance.ts +++ b/extensions/memory-core/src/memory/memory-path-provenance.ts @@ -1,6 +1,7 @@ // Memory Core plugin module classifies indexed workspace paths by provenance owner. import fs from "node:fs/promises"; import path from "node:path"; +import { isPathStrictlyInside } from "openclaw/plugin-sdk/file-access-runtime"; import type { MemoryEntryProvenance, MemorySource, @@ -33,15 +34,10 @@ export async function resolveMemoryPathClassification(params: { } catch { return { curatedRoot: false, originClass: "untrusted" }; } - const relativePath = path.relative(workspacePath, filePath); - if ( - !relativePath || - path.isAbsolute(relativePath) || - relativePath === ".." || - relativePath.startsWith(`..${path.sep}`) - ) { + if (!isPathStrictlyInside(workspacePath, filePath)) { return { curatedRoot: false, originClass: "untrusted" }; } + const relativePath = path.relative(workspacePath, filePath); const segments = relativePath.split(path.sep); const curatedRoot = segments.length === 1 && diff --git a/extensions/memory-wiki/src/bridge.ts b/extensions/memory-wiki/src/bridge.ts index 70dbee07a2af..b5ba4e52a8c6 100644 --- a/extensions/memory-wiki/src/bridge.ts +++ b/extensions/memory-wiki/src/bridge.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { getMemoryCapabilityRegistration, listActiveMemoryPublicArtifacts, @@ -114,7 +115,7 @@ async function collectBridgeArtifacts( continue; } const syncKey = await resolveArtifactKey(artifact.absolutePath); - if (isPathInsideOrEqual(vaultRootKey, syncKey)) { + if (isPathInside(vaultRootKey, syncKey)) { continue; } collected.push({ @@ -132,14 +133,6 @@ async function collectBridgeArtifacts( return [...deduped.values()]; } -function isPathInsideOrEqual(parentPath: string, candidatePath: string): boolean { - const relative = path.relative(parentPath, candidatePath); - return ( - relative === "" || - (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) - ); -} - function resolveBridgeTitle(artifact: BridgeArtifact, agentIds: string[]): string { if (artifact.artifactType === "memory-events") { if (agentIds.length === 0) { diff --git a/extensions/memory-wiki/src/chatgpt-import.ts b/extensions/memory-wiki/src/chatgpt-import.ts index 206ec3856119..b37df903c5a2 100644 --- a/extensions/memory-wiki/src/chatgpt-import.ts +++ b/extensions/memory-wiki/src/chatgpt-import.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { replaceManagedMarkdownBlock, withTrailingNewline, @@ -904,8 +905,7 @@ function resolveContainedImportPath(root: string, relativePath: string, label: s } const resolvedRoot = path.resolve(root); const resolvedPath = path.resolve(resolvedRoot, relativePath); - const relative = path.relative(resolvedRoot, resolvedPath); - if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + if (!isPathInside(resolvedRoot, resolvedPath)) { throw new Error(`${label} must stay inside ${resolvedRoot}: ${relativePath}`); } return resolvedPath; diff --git a/extensions/memory-wiki/src/markdown.ts b/extensions/memory-wiki/src/markdown.ts index 6318a9c77495..60c5209e4957 100644 --- a/extensions/memory-wiki/src/markdown.ts +++ b/extensions/memory-wiki/src/markdown.ts @@ -9,6 +9,7 @@ import { normalizeOptionalString, normalizeSingleOrTrimmedStringList, } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf8Prefix } from "openclaw/plugin-sdk/text-utility-runtime"; import YAML from "yaml"; const WIKI_PAGE_KINDS = ["entity", "concept", "source", "synthesis", "report"] as const; @@ -141,29 +142,15 @@ const WIKI_RESERVED_PAGE_STEMS = new Set(["index"]); const HUMAN_START_MARKER = ""; const HUMAN_END_MARKER = ""; -function truncateUtf8CodePointSafe(value: string, maxBytes: number): string { - let result = ""; - let bytes = 0; - for (const char of value) { - const nextBytes = Buffer.byteLength(char); - if (bytes + nextBytes > maxBytes) { - break; - } - result += char; - bytes += nextBytes; - } - return result; -} - function capWikiValueWithHash(raw: string, maxBytes: number, fallback: string): string { if (Buffer.byteLength(raw) <= maxBytes) { return raw; } const suffix = createHash("sha1").update(raw).digest("hex").slice(0, WIKI_SEGMENT_HASH_BYTES); - const truncated = truncateUtf8CodePointSafe( - raw, - maxBytes - Buffer.byteLength(`-${suffix}`), - ).replace(/-+$/g, ""); + const truncated = truncateUtf8Prefix(raw, maxBytes - Buffer.byteLength(`-${suffix}`)).replace( + /-+$/g, + "", + ); return `${truncated || fallback}-${suffix}`; } @@ -761,4 +748,3 @@ export function toWikiPageSummary(params: { const result = scanWikiPageSummary(params); return result.status === "valid" ? result.page : null; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/memory-wiki/src/unsafe-local.ts b/extensions/memory-wiki/src/unsafe-local.ts index b2d15680c545..6e9e7e3839e1 100644 --- a/extensions/memory-wiki/src/unsafe-local.ts +++ b/extensions/memory-wiki/src/unsafe-local.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { walkMemoryWikiDirectory } from "./bounded-walk.js"; import type { BridgeMemoryWikiResult } from "./bridge.js"; @@ -112,14 +113,6 @@ async function collectUnsafeLocalArtifacts( return { artifacts: [...deduped.values()], unavailableConfiguredPaths }; } -function isSourceWithinConfiguredPath(sourcePath: string, configuredPath: string): boolean { - const relative = path.relative(configuredPath, sourcePath); - return ( - relative === "" || - (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) - ); -} - function resolveUnsafeLocalPagePath(params: { configuredPath: string; absolutePath: string }): { pageId: string; pagePath: string; @@ -240,7 +233,7 @@ export async function syncMemoryWikiUnsafeLocalSources( if ( entry.group === "unsafe-local" && unavailableConfiguredPaths.some((configuredPath) => - isSourceWithinConfiguredPath(entry.sourcePath, configuredPath), + isPathInside(configuredPath, entry.sourcePath), ) ) { // A configured source scope remains authoritative until it is readable again or removed diff --git a/extensions/migrate-hermes/source.ts b/extensions/migrate-hermes/source.ts index 4e1526c7b979..90f74db69d4d 100644 --- a/extensions/migrate-hermes/source.ts +++ b/extensions/migrate-hermes/source.ts @@ -1,5 +1,6 @@ // Migrate Hermes plugin module implements source behavior. import path from "node:path"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { exists, isDirectory, readText, resolveHomePath } from "./helpers.js"; export type HermesSource = { @@ -67,11 +68,6 @@ const HERMES_STATE_MARKERS = [ ...HERMES_ARCHIVE_FILES, ] as const; -function isSameOrInside(parent: string, candidate: string): boolean { - const relative = path.relative(path.resolve(parent), path.resolve(candidate)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function resolveOpenCodeXdgAuthPath(env: NodeJS.ProcessEnv = process.env): string | undefined { const xdgDataHome = env.XDG_DATA_HOME?.trim(); return xdgDataHome ? path.join(resolveHomePath(xdgDataHome), "opencode", "auth.json") : undefined; @@ -88,7 +84,7 @@ async function discoverOpenCodeAuthPath(params: { const candidates = Array.from( new Set( [ - ...(xdgAuthPath && (params.includeGlobalFallback || isSameOrInside(rootParent, xdgAuthPath)) + ...(xdgAuthPath && (params.includeGlobalFallback || isPathInside(rootParent, xdgAuthPath)) ? [xdgAuthPath] : []), path.join(rootParent, OPENCODE_AUTH_RELATIVE_PATH), diff --git a/extensions/mxc/src/mxc-backend.ts b/extensions/mxc/src/mxc-backend.ts index 7faa6eef5460..7f3e6283b532 100644 --- a/extensions/mxc/src/mxc-backend.ts +++ b/extensions/mxc/src/mxc-backend.ts @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import { mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { ContainerConfig } from "@microsoft/mxc-sdk"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { runCommandBuffered } from "openclaw/plugin-sdk/process-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox"; import type { @@ -89,8 +90,7 @@ function createSandboxTempDir(hostEnv: BaselineHostEnv): string { function assertWorkdirInsideWorkspace(workspaceDir: string, workdir: string): string { const workspace = realpathForExistingPath(workspaceDir, "sandbox workspace"); const candidate = realpathForPotentialPath(workdir); - const relative = path.relative(workspace, candidate); - if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) { + if (isPathInside(workspace, candidate)) { return candidate; } throw new Error( diff --git a/extensions/qa-lab/src/cli-paths.ts b/extensions/qa-lab/src/cli-paths.ts index e943dd647780..1e872dbbc634 100644 --- a/extensions/qa-lab/src/cli-paths.ts +++ b/extensions/qa-lab/src/cli-paths.ts @@ -1,5 +1,6 @@ // Qa Lab plugin module implements cli paths behavior. import path from "node:path"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { assertNoSymlinkParents, pathScope } from "openclaw/plugin-sdk/security-runtime"; export function toRepoPath(filePath: string): string { @@ -29,11 +30,10 @@ export function resolveRepoRelativeOutputDir(repoRoot: string, outputDir?: strin } function assertRepoRelativePath(repoRoot: string, targetPath: string, label: string) { - const relative = path.relative(repoRoot, targetPath); - if (relative.startsWith("..") || path.isAbsolute(relative)) { + if (!isPathInside(repoRoot, targetPath)) { throw new Error(`${label} must stay within the repo root.`); } - return relative; + return path.relative(repoRoot, targetPath); } async function assertNoSymlinkSegments(repoRoot: string, targetPath: string, label: string) { diff --git a/extensions/qa-lab/src/evidence-gallery.ts b/extensions/qa-lab/src/evidence-gallery.ts index c8a67b11e47e..40a8a95438a5 100644 --- a/extensions/qa-lab/src/evidence-gallery.ts +++ b/extensions/qa-lab/src/evidence-gallery.ts @@ -6,6 +6,7 @@ import { StringDecoder } from "node:string_decoder"; import { pathToFileURL } from "node:url"; import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { asNullableRecord as readRecord, readStringValue, @@ -57,11 +58,6 @@ function evidenceError(message: string, statusCode: number): QaEvidenceGalleryEr return new QaEvidenceGalleryError(message, statusCode); } -function isInside(root: string, candidate: string) { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function sanitizeGalleryText( value: string, params: { @@ -94,7 +90,7 @@ function displayGalleryPath( const absolute = path.resolve(value); for (const root of [params.repoRoot, ...(params.extraRoots ?? [])]) { const resolvedRoot = path.resolve(root); - if (isInside(resolvedRoot, absolute)) { + if (isPathInside(resolvedRoot, absolute)) { return sanitizeGalleryText(toRepoPath(path.relative(resolvedRoot, absolute)), params); } } @@ -138,7 +134,7 @@ async function resolveContainedFileIfExists( if (!realFile) { return null; } - if (!allowedRoots.some((root) => isInside(root, realFile))) { + if (!allowedRoots.some((root) => isPathInside(root, realFile))) { return null; } const stats = await fs.stat(realFile).catch(() => null); @@ -159,7 +155,7 @@ async function resolveQaEvidenceFile(params: { if (!realCandidate) { throw evidenceError("Evidence path not found.", 404); } - if (!isInside(repoRoot, realCandidate)) { + if (!isPathInside(repoRoot, realCandidate)) { throw evidenceError("Evidence path must stay inside the repo root.", 403); } const stats = await fs.stat(realCandidate); @@ -170,7 +166,7 @@ async function resolveQaEvidenceFile(params: { if (!realEvidencePath) { throw evidenceError("qa-evidence.json not found.", 404); } - if (!isInside(repoRoot, realEvidencePath)) { + if (!isPathInside(repoRoot, realEvidencePath)) { throw evidenceError("qa-evidence.json must stay inside the repo root.", 403); } return realEvidencePath; @@ -313,7 +309,10 @@ async function resolveArtifactFileWithinRoots(params: { if (!realCandidate) { continue; } - if (!isInside(params.repoRoot, realCandidate) && !isInside(params.evidenceDir, realCandidate)) { + if ( + !isPathInside(params.repoRoot, realCandidate) && + !isPathInside(params.evidenceDir, realCandidate) + ) { continue; } const stats = await fs.stat(realCandidate).catch(() => null); @@ -440,9 +439,7 @@ async function readJsonIfExists( } try { const value = JSON.parse(await fs.readFile(realFile, "utf8")) as unknown; - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; + return readRecord(value); } catch { return null; } @@ -518,7 +515,7 @@ async function buildArtifactView(params: { repoRoot: params.repoRoot, }).catch(() => null); const realFileRepoPath = - realFile && isInside(params.repoRoot, realFile) + realFile && isPathInside(params.repoRoot, realFile) ? toRepoRelativePath(params.repoRoot, realFile) : null; const displayPath = @@ -733,7 +730,7 @@ async function candidateProducerRoots(params: { continue; } let current = path.dirname(artifactPath); - while (isInside(repoRoot, current)) { + while (isPathInside(repoRoot, current)) { roots.add(current); const parent = path.dirname(current); if (parent === current) { diff --git a/extensions/qa-lab/src/gateway-process-boundary.ts b/extensions/qa-lab/src/gateway-process-boundary.ts index 8bb7dfc92f12..cacc0b3a80ee 100644 --- a/extensions/qa-lab/src/gateway-process-boundary.ts +++ b/extensions/qa-lab/src/gateway-process-boundary.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; const PROCESS_BOUNDARY_VERSION = 1; @@ -236,8 +237,7 @@ function parseQaGatewayProcessRuntimeProof(value: unknown): QaGatewayProcessRunt async function assertContainedPath(root: string, target: string, label: string) { const rootPath = await fs.realpath(root); const targetPath = await fs.realpath(target); - const relative = path.relative(rootPath, targetPath); - if (relative.startsWith("..") || path.isAbsolute(relative)) { + if (!isPathInside(rootPath, targetPath)) { throw new Error(`${label} escaped its trusted root`); } return targetPath; diff --git a/extensions/qa-lab/src/multipass.runtime.ts b/extensions/qa-lab/src/multipass.runtime.ts index 6b4480056125..074893db9f23 100644 --- a/extensions/qa-lab/src/multipass.runtime.ts +++ b/extensions/qa-lab/src/multipass.runtime.ts @@ -5,6 +5,7 @@ import { access, mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline"; import { coerceErrorMessage, toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; +import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime"; import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime"; @@ -160,11 +161,6 @@ function resolveExistingPath(value: string) { return currentPath; } -function isPathInside(parentPath: string, childPath: string) { - const relativePath = path.relative(parentPath, childPath); - return !relativePath.startsWith("..") && !path.isAbsolute(relativePath); -} - function validatePnpmVersion(version: string) { if (!/^[0-9A-Za-z.+_-]+$/u.test(version)) { throw new Error(`unsupported pnpm version in packageManager: ${version}`); @@ -183,7 +179,7 @@ function resolveMountedOutputPath(repoRoot: string, hostPath: string) { const realRepoRoot = resolveRealPath(repoRoot); const existingHostPath = resolveExistingPath(hostPath); const realExistingHostPath = resolveRealPath(existingHostPath); - if (!isPathInside(realRepoRoot, realExistingHostPath) && realExistingHostPath !== realRepoRoot) { + if (!isPathInside(realRepoRoot, realExistingHostPath)) { throw new Error( `qa suite --runner multipass requires --output-dir to stay under the repo root (${repoRoot}), got ${hostPath}.`, ); diff --git a/extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts b/extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts index 0bacdd88660f..99f6661dbd82 100644 --- a/extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts +++ b/extensions/qa-lab/src/providers/mock-openai/mock-openai-contracts.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { setTimeout as sleep } from "node:timers/promises"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress"; import { writeJson } from "../shared/http-json.js"; @@ -408,9 +409,7 @@ export function readBody(req: IncomingMessage): Promise { export function parseJsonObjectBody(raw: string): Record | null { try { const parsed = raw ? (JSON.parse(raw) as unknown) : {}; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : null; + return asNullableRecord(parsed); } catch { return null; } diff --git a/extensions/slack/src/format.ts b/extensions/slack/src/format.ts index 50a3d7ca2e1f..aec634760847 100644 --- a/extensions/slack/src/format.ts +++ b/extensions/slack/src/format.ts @@ -404,22 +404,6 @@ function protectSlackAssistantTranscriptRoleHeaders(text: string): string { return `${SLACK_ASSISTANT_TRANSCRIPT_PREFIX}${text}`; } -function hardSliceSlackToken(token: string, limit: number): string[] { - const chunks: string[] = []; - let chunk = ""; - for (const character of token) { - if (chunk && chunk.length + character.length > limit) { - chunks.push(chunk); - chunk = ""; - } - chunk += character; - } - if (chunk) { - chunks.push(chunk); - } - return chunks; -} - function buildSlackRenderOptions() { return { annotationMarkers: { @@ -505,12 +489,20 @@ export function chunkSlackMrkdwnText(text: string, limit: number): string[] { if (activeMarker && isAllowedSlackAngleToken(token)) { if (marker) { chunks.push( - ...hardSliceSlackToken(token, contentLimit).map( - (fragment) => `${marker}${fragment}${marker}`, - ), + ...chunkTextForOutbound(token, Math.max(1, Math.floor(contentLimit)), { + preserveWhitespace: true, + }).map((fragment) => `${marker}${fragment}${marker}`), ); } else { - chunks.push(...hardSliceSlackToken(escapeSlackMrkdwnSegment(token), limit)); + chunks.push( + ...chunkTextForOutbound( + escapeSlackMrkdwnSegment(token), + Math.max(1, Math.floor(limit)), + { + preserveWhitespace: true, + }, + ), + ); } continue; } diff --git a/extensions/slack/src/native-data-fallback.ts b/extensions/slack/src/native-data-fallback.ts index f15c559510cb..6222dee53a77 100644 --- a/extensions/slack/src/native-data-fallback.ts +++ b/extensions/slack/src/native-data-fallback.ts @@ -1,5 +1,6 @@ import type { Block, KnownBlock } from "@slack/web-api"; -import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { renderSlackBlockFallbackText } from "./blocks-fallback.js"; import { SLACK_MAX_BLOCKS } from "./blocks-input.js"; import { SLACK_MESSAGE_TEXT_HARD_LIMIT, SLACK_MESSAGE_TEXT_RECOMMENDED_LIMIT } from "./limits.js"; @@ -33,37 +34,22 @@ type OrderedFallbackBlock = { continuesText?: boolean; }; -function sliceSlackTextChunk(text: string, start: number, limit: number): string { - return ( - sliceUtf16Safe(text, start, Math.min(text.length, start + limit)) || - Array.from(text.slice(start))[0] || - "" - ); -} - export function chunkSlackTextAtHardLimit( text: string, limit = SLACK_MESSAGE_TEXT_HARD_LIMIT, ): string[] { - const effectiveLimit = Math.max(1, Math.floor(limit)); - const chunks: string[] = []; - let offset = 0; - while (offset < text.length) { - const chunk = sliceSlackTextChunk(text, offset, effectiveLimit); - if (!chunk) { - throw new Error("Slack plain-text fallback chunking made no progress."); - } - chunks.push(chunk); - offset += chunk.length; + if (!text) { + return []; } - return chunks; + const effectiveLimit = Math.max(1, Math.floor(limit)); + return chunkTextForOutbound(text, effectiveLimit, { preserveWhitespace: true }); } function fitsSlackTextLimit(text: string, limit: number): boolean { if (text.length <= limit) { return true; } - return sliceSlackTextChunk(text, 0, limit).length === text.length; + return truncateUtf16Safe(text, limit).length === text.length; } function buildPlainTextBlocks(text: string, textLimit: number): OrderedFallbackBlock[] { diff --git a/extensions/telegram/src/bot/native-quote.ts b/extensions/telegram/src/bot/native-quote.ts index b3cf4e101f28..7030ed50ab7c 100644 --- a/extensions/telegram/src/bot/native-quote.ts +++ b/extensions/telegram/src/bot/native-quote.ts @@ -1,4 +1,5 @@ // Telegram plugin module implements native quote behavior. +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { TelegramTextEntity } from "./body-helpers.js"; const TELEGRAM_NATIVE_QUOTE_MAX_LENGTH = 1024; @@ -11,18 +12,6 @@ type TelegramNativeQuoteCandidate = { export type TelegramNativeQuoteCandidateByMessageId = Record; -function truncateUtf16Safe(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } - let end = Math.max(0, Math.trunc(maxLength)); - const lastCodeUnit = value.charCodeAt(end - 1); - if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) { - end -= 1; - } - return value.slice(0, end); -} - function sliceTelegramEntitiesForQuote( entities: readonly TelegramTextEntity[] | undefined, quoteLength: number, diff --git a/extensions/telegram/src/rich-plain-fallback.ts b/extensions/telegram/src/rich-plain-fallback.ts index 50c856b40b2a..7f76fc21e5f1 100644 --- a/extensions/telegram/src/rich-plain-fallback.ts +++ b/extensions/telegram/src/rich-plain-fallback.ts @@ -1,6 +1,7 @@ // withTelegramPlainFallback owns formatted-to-plain recovery for durable sends, // final replies, and draft previews. A second orchestrator reintroduces silent drift. import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; import type { TelegramRichBlocksDegradationReason } from "./rich-block-model.js"; // Any RICH_MESSAGE_*_INVALID rejection (entities, media, depth) degrades to @@ -76,14 +77,7 @@ export function splitTelegramPlainTextChunks(text: string, limit: number): strin return []; } const normalizedLimit = Math.max(1, Math.floor(limit)); - const chunks: string[] = []; - let start = 0; - while (start < text.length) { - const end = surrogateSafeChunkEnd(text, start + normalizedLimit, start); - chunks.push(text.slice(start, end)); - start = end; - } - return chunks; + return chunkTextForOutbound(text, normalizedLimit, { preserveWhitespace: true }); } function splitTelegramPlainTextFallback(text: string, chunkCount: number, limit: number): string[] { diff --git a/extensions/voice-call/src/webhook/stream-frame-adapter.ts b/extensions/voice-call/src/webhook/stream-frame-adapter.ts index 1e528c5596b5..572bbb909368 100644 --- a/extensions/voice-call/src/webhook/stream-frame-adapter.ts +++ b/extensions/voice-call/src/webhook/stream-frame-adapter.ts @@ -1,5 +1,9 @@ // Provider-specific media stream frame parsing and serialization. +import { + asNullableRecord, + asOptionalObjectRecord, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { canonicalizeVoiceCallMediaBase64 } from "../media-base64.js"; /** Normalized inbound media stream frame. */ @@ -41,9 +45,7 @@ function parseTimestampMs(value: unknown): number | undefined { function tryParseJson(rawMessage: string): Record | null { try { const parsed = JSON.parse(rawMessage) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } + return asNullableRecord(parsed); } catch { /* fall through */ } @@ -55,10 +57,7 @@ function readRecordField( record: Record, field: string, ): Record | undefined { - const value = record[field]; - return typeof value === "object" && value !== null - ? (value as Record) - : undefined; + return asOptionalObjectRecord(record[field]); } /** Parse a common provider media frame. */ diff --git a/extensions/whatsapp/src/auth-store.ts b/extensions/whatsapp/src/auth-store.ts index fce6fda1389a..0f7d291eb75b 100644 --- a/extensions/whatsapp/src/auth-store.ts +++ b/extensions/whatsapp/src/auth-store.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; +import { isPathStrictlyInside } from "openclaw/plugin-sdk/file-access-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { info, @@ -284,11 +285,6 @@ async function shouldClearOnLogout(authDir: string, isLegacyAuthDir: boolean): P } } -function isPathInsideDirectory(baseDir: string, targetPath: string): boolean { - const relativePath = path.relative(baseDir, targetPath); - return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); -} - async function pathHasSymlinkComponent(baseDir: string, targetPath: string): Promise { const relativePath = path.relative(baseDir, targetPath); let currentPath = baseDir; @@ -320,7 +316,7 @@ async function isLegacyWebAuthDir(authDir: string): Promise { async function classifyWebAuthDirOwnership(authDir: string): Promise { const whatsappAuthBase = path.resolve(resolveOAuthDir(), "whatsapp"); const resolvedAuthDir = path.resolve(authDir); - if (!isPathInsideDirectory(whatsappAuthBase, resolvedAuthDir)) { + if (!isPathStrictlyInside(whatsappAuthBase, resolvedAuthDir)) { return { kind: "external" }; } @@ -331,7 +327,7 @@ async function classifyWebAuthDirOwnership(authDir: string): Promise