diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 1e838d43ceb2..35997fb1502d 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -390,7 +390,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | `plugin-sdk/tool-results` | Typed text and JSON agent tool result builders | | `plugin-sdk/tool-send` | Extract canonical send target fields from tool args | | `plugin-sdk/sandbox` | Private-local after July 2026; Sandbox backend types and SSH/OpenShell command helpers, including fail-fast exec command preflight and `resolveReadOnlyWorkspaceSkillMounts` for canonical read-only skill overlays in writable workspaces | - | `plugin-sdk/temp-path` | Shared temp-download path helpers and private secure temp workspaces | + | `plugin-sdk/temp-path` | Shared temp-download path helpers and private secure temp workspaces. `buildRandomTempFilePath` retains relative `tmpDir` paths and trims an optional `uuid`; blank values generate a UUID, while nonblank values must be safe filename components. `sanitizeTempFileName` adds a suffix to reserved Windows device names, preserving their extension. | | `plugin-sdk/logging-core` | Subsystem logger and redaction helpers | | `plugin-sdk/markdown-table-runtime` | Private-local after July 2026; Markdown table mode and conversion helpers | | `plugin-sdk/model-session-runtime` | Model/session override helpers such as `applyModelOverrideToSessionEntry` and `resolveAgentMaxConcurrent` | diff --git a/src/infra/clawhub-artifacts.test.ts b/src/infra/clawhub-artifacts.test.ts index e418c673447c..b2782f7e38d0 100644 --- a/src/infra/clawhub-artifacts.test.ts +++ b/src/infra/clawhub-artifacts.test.ts @@ -63,7 +63,7 @@ function createArchiveResponse(bytes: Uint8Array, headers?: HeadersInit): Respon `sha512-${createHash("sha512").update(bytes).digest("base64")}`, ); responseHeaders.set("X-ClawHub-Npm-Shasum", createHash("sha1").update(bytes).digest("hex")); - responseHeaders.set("X-ClawHub-Npm-Tarball-Name", "registry-selected.tgz"); + responseHeaders.set("X-ClawHub-Npm-Tarball-Name", "NUL.tgz"); responseHeaders.set("X-ClawHub-ClawPack-Spec-Version", "3"); return new Response(new Uint8Array(bytes), { status: 200, headers: responseHeaders }); } @@ -167,7 +167,7 @@ const archiveDownloadCases: Array<{ fetchImpl: async () => response, }), expectedResource: "ClawPack download for demo@1.2.3", - expectedFileName: "registry-selected.tgz", + expectedFileName: "NUL_.tgz", expectedArtifact: "clawpack", }, { @@ -374,7 +374,7 @@ describe("clawhub artifacts", () => { `sha512-${createHash("sha512").update(bytes).digest("base64")}`, ); expect(archive.npmShasum).toBe(createHash("sha1").update(bytes).digest("hex")); - expect(archive.npmTarballName).toBe("registry-selected.tgz"); + expect(archive.npmTarballName).toBe("NUL.tgz"); } await archive.cleanup(); diff --git a/src/infra/temp-download.ts b/src/infra/temp-download.ts index 9046a8f39590..f8d82c504a11 100644 --- a/src/infra/temp-download.ts +++ b/src/infra/temp-download.ts @@ -1,7 +1,10 @@ // Creates private temporary workspaces for downloads. import "./fs-safe-defaults.js"; -import crypto from "node:crypto"; import path from "node:path"; +import { + buildRandomTempFilePath as buildRandomTempFilePathBase, + sanitizeTempFileName, +} from "@openclaw/fs-safe/advanced"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { tempWorkspace } from "./private-temp-workspace.js"; import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; @@ -9,6 +12,7 @@ import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; const logger = createSubsystemLogger("infra:temp-download"); export { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; +export { sanitizeTempFileName }; // Download targets expose both a default path and a name-safe file builder so // callers can keep all transient files inside the same workspace. @@ -29,24 +33,6 @@ function sanitizeTempPrefix(prefix: string): string { return normalized || "tmp"; } -function sanitizeTempExtension(extension?: string): string { - if (!extension) { - return ""; - } - const normalized = extension.startsWith(".") ? extension : `.${extension}`; - const suffix = normalized.match(/[a-zA-Z0-9._-]+$/)?.[0] ?? ""; - const token = suffix.replace(/^[._-]+/, ""); - return token ? `.${token}` : ""; -} - -export function sanitizeTempFileName(fileName: string): string { - const base = path.basename(fileName).replace(/[^a-zA-Z0-9._-]+/g, "-"); - const normalized = base.replace(/^-+|-+$/g, ""); - // "." and ".." pass the character class above but are rejected as workspace - // leaf names, so returning them hands callers a throw instead of a safe name. - return !normalized || normalized === "." || normalized === ".." ? "download.bin" : normalized; -} - /** Build a stable temp path shape while keeping caller-controlled text filename-safe. */ export function buildRandomTempFilePath(params: { prefix: string; @@ -55,16 +41,13 @@ export function buildRandomTempFilePath(params: { now?: number; uuid?: string; }): string { - const nowCandidate = params.now; - const now = - typeof nowCandidate === "number" && Number.isFinite(nowCandidate) - ? Math.trunc(nowCandidate) - : Date.now(); - const uuid = params.uuid?.trim() || crypto.randomUUID(); - return path.join( - resolveTempRoot(params.tmpDir), - `${sanitizeTempPrefix(params.prefix)}-${now}-${uuid}${sanitizeTempExtension(params.extension)}`, - ); + const rootDir = resolveTempRoot(params.tmpDir); + const filePath = buildRandomTempFilePathBase({ + ...params, + rootDir, + uuid: params.uuid?.trim() || undefined, + }); + return path.join(rootDir, path.basename(filePath)); } export async function createTempDownloadTarget(params: { diff --git a/src/plugin-sdk/temp-path.test.ts b/src/plugin-sdk/temp-path.test.ts index ccac9eb3d0fe..5b377cb45867 100644 --- a/src/plugin-sdk/temp-path.test.ts +++ b/src/plugin-sdk/temp-path.test.ts @@ -34,6 +34,19 @@ describe("buildRandomTempFilePath", () => { expectedBasename: "line-media-123-abc.jpg", verifyInsideTmpRoot: false, }, + { + name: "preserves relative roots, trimmed UUIDs, and compound extensions", + input: { + prefix: "archive", + extension: "tar.gz", + tmpDir: "relative/tmp", + now: 123.9, + uuid: " abc ", + }, + expectedPath: path.join("relative/tmp", "archive-123-abc.tar.gz"), + expectedBasename: "archive-123-abc.tar.gz", + verifyInsideTmpRoot: false, + }, { name: "sanitizes prefix and extension to avoid path traversal segments", input: { @@ -55,6 +68,21 @@ describe("buildRandomTempFilePath", () => { expectPathInsideTmpRoot(result); } }); + + it.each(["../../../escaped", "..\\..\\escaped", "id/name", "id\0name"])( + "rejects path-control bytes in the UUID override %j", + (uuid) => { + expect(() => + buildRandomTempFilePath({ prefix: "download", tmpDir: "/tmp/owned", now: 1, uuid }), + ).toThrow(/safe path segment/); + }, + ); + + it.each(["", " "])("generates a UUID for a blank override %j", (uuid) => { + const result = buildRandomTempFilePath({ prefix: "media", now: 123, extension: ".jpg", uuid }); + expect(path.basename(result)).toMatch(/^media-123-[\da-f-]{36}\.jpg$/u); + expectPathInsideTmpRoot(result); + }); }); describe("withTempDownloadPath", () => {