diff --git a/CHANGELOG.md b/CHANGELOG.md index 980ac436422..0ab9e08bbd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index ef591ca8107..6b7d5eb0a4f 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -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 }); + } + }); }); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index ff7bbe25de9..ef1bc6e86be 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -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,