fix(clawhub): sanitize archive temp filenames (openclaw#56779)

Verified:
- pnpm build
- pnpm check
- pnpm test

Co-authored-by: soimy <1550237+soimy@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Shen Yiming 2026-03-29 13:38:03 +08:00 committed by GitHub
parent 7a16a48198
commit eee8e9679e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 42 additions and 2 deletions

View File

@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Control UI/agents: auto-load agent workspace files on initial Files panel open, and populate overview model/workspace/fallbacks from effective runtime agent metadata so defaulted models no longer show as `Not set`. (#56637) Thanks @dxsx84.
- Control UI/slash commands: make `/steer` and `/redirect` work from the chat command palette with visible pending state for active-run `/steer`, correct redirected-run tracking, and a single canonical `/steer` entry in the command menu. (#54625) Thanks @fuller-stack-dev.
- Exec: fail closed when the implicit sandbox host has no sandbox runtime, and stop denied async approval followups from reusing prior command output from the same session. (#56800) Thanks @scoootscooob.
- Plugins/ClawHub: sanitize temporary archive filenames for scoped package names and slash-containing skill slugs so `openclaw plugins install @scope/name` no longer fails with `ENOENT` during archive download. (#56452) Thanks @soimy.
## 2026.3.28

View File

@ -3,6 +3,8 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
downloadClawHubPackageArchive,
downloadClawHubSkillArchive,
parseClawHubPluginSpec,
resolveClawHubAuthToken,
searchClawHubSkills,
@ -164,4 +166,40 @@ describe("clawhub helpers", () => {
await expect(searchClawHubSkills({ query: "calendar", fetchImpl })).resolves.toEqual([]);
});
it("writes scoped package archives to a safe temp file name", async () => {
const archive = await downloadClawHubPackageArchive({
name: "@soimy/dingtalk",
fetchImpl: async () =>
new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "application/zip" },
}),
});
try {
expect(path.basename(archive.archivePath)).toBe("@soimy__dingtalk.zip");
await expect(fs.readFile(archive.archivePath)).resolves.toEqual(Buffer.from([1, 2, 3]));
} finally {
await fs.rm(path.dirname(archive.archivePath), { recursive: true, force: true });
}
});
it("writes skill archives to a safe temp file name when slugs contain separators", async () => {
const archive = await downloadClawHubSkillArchive({
slug: "ops/calendar",
fetchImpl: async () =>
new Response(new Uint8Array([4, 5, 6]), {
status: 200,
headers: { "content-type": "application/zip" },
}),
});
try {
expect(path.basename(archive.archivePath)).toBe("ops__calendar.zip");
await expect(fs.readFile(archive.archivePath)).resolves.toEqual(Buffer.from([4, 5, 6]));
} finally {
await fs.rm(path.dirname(archive.archivePath), { recursive: true, force: true });
}
});
});

View File

@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { safeDirName } from "./install-safe-path.js";
import { isAtLeast, parseSemver } from "./runtime-guard.js";
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
@ -580,7 +581,7 @@ export async function downloadClawHubPackageArchive(params: {
}
const bytes = new Uint8Array(await response.arrayBuffer());
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-package-"));
const archivePath = path.join(tmpDir, `${params.name}.zip`);
const archivePath = path.join(tmpDir, `${safeDirName(params.name)}.zip`);
await fs.writeFile(archivePath, bytes);
return {
archivePath,
@ -618,7 +619,7 @@ export async function downloadClawHubSkillArchive(params: {
}
const bytes = new Uint8Array(await response.arrayBuffer());
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-skill-"));
const archivePath = path.join(tmpDir, `${params.slug}.zip`);
const archivePath = path.join(tmpDir, `${safeDirName(params.slug)}.zip`);
await fs.writeFile(archivePath, bytes);
return {
archivePath,