fix: keep temporary filename overrides inside their directory (#154828)

* fix: keep temporary filename overrides inside their directory

* chore: sync main after cron and ACPX fixture repairs

* chore: merge main fixes into fs-safe filename cleanup

Integrate the released fs-safe 0.18.0 dependency and the landed approval readiness and request-cleanup fixture repair. Preserve the existing four-file filename patch unchanged.

Validation: all 73 focused SDK, ClawHub, iMessage, signed-runtime approval, and approval-fixture cases passed on the merged tree. Existing managed P2 review and static-check proof apply to the unchanged authored patch.

* chore: merge fixture repairs into fs-safe naming cleanup

Incorporate the landed session-store and survivor fixture repairs while preserving the four-file fs-safe naming change.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Peter Steinberger 2026-09-21 18:08:33 -07:00 • committed by GitHub
parent 16759c1f55
commit 2bf25ef263
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 44 additions and 33 deletions

View file

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

View file

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

View file

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

View file

@ -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", () => {