Files
openclaw/src/plugins/marketplace.test.ts

1036 lines
32 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { withTempDir } from "../test-utils/temp-dir.js";
import {
cleanupTrackedTempDirsAsync,
makeTrackedTempDirAsync,
} from "./test-helpers/fs-fixtures.js";
const installPluginFromPathMock = vi.fn();
const fetchWithSsrFGuardMock = vi.hoisted(() =>
vi.fn(async (params: { url: string; init?: RequestInit }) => {
// Keep unit tests focused on guarded call sites, not AbortSignal timer behavior.
const { signal: _signal, ...init } = params.init ?? {};
const response = await fetch(params.url, init);
return {
response,
finalUrl: params.url,
release: async () => {
await response.body?.cancel().catch(() => undefined);
},
};
}),
);
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
let installPluginFromMarketplace: typeof import("./marketplace.js").installPluginFromMarketplace;
let listMarketplacePlugins: typeof import("./marketplace.js").listMarketplacePlugins;
let resolveMarketplaceInstallShortcut: typeof import("./marketplace.js").resolveMarketplaceInstallShortcut;
const tempOutsideDirs: string[] = [];
vi.mock("./install.js", () => ({
installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args),
}));
vi.mock("../infra/net/fetch-guard.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/net/fetch-guard.js")>();
return {
...actual,
fetchWithSsrFGuard: (params: { url: string; init?: RequestInit }) =>
fetchWithSsrFGuardMock(params),
};
});
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));
beforeAll(async () => {
({ installPluginFromMarketplace, listMarketplacePlugins, resolveMarketplaceInstallShortcut } =
await import("./marketplace.js"));
});
async function listMarketplaceDownloadTempDirs(): Promise<string[]> {
const entries = await fs.readdir(os.tmpdir(), { withFileTypes: true });
return entries
.filter(
(entry) => entry.isDirectory() && entry.name.startsWith("openclaw-marketplace-download-"),
)
.map((entry) => entry.name)
.toSorted();
}
async function writeMarketplaceManifest(rootDir: string, manifest: unknown): Promise<string> {
const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json");
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.writeFile(manifestPath, JSON.stringify(manifest));
return manifestPath;
}
async function writeRemoteMarketplaceFixture(params: {
repoDir: string;
manifest: unknown;
pluginDir?: string;
pluginFile?: string;
}) {
await fs.mkdir(path.join(params.repoDir, ".claude-plugin"), { recursive: true });
if (params.pluginDir) {
await fs.mkdir(path.join(params.repoDir, params.pluginDir), { recursive: true });
}
if (params.pluginFile) {
const pluginFilePath = path.join(params.repoDir, params.pluginFile);
await fs.mkdir(path.dirname(pluginFilePath), { recursive: true });
await fs.writeFile(pluginFilePath, "plugin fixture");
}
await fs.writeFile(
path.join(params.repoDir, ".claude-plugin", "marketplace.json"),
JSON.stringify(params.manifest),
);
}
async function writeLocalMarketplaceFixture(params: {
rootDir: string;
manifest: unknown;
pluginDir?: string;
}) {
if (params.pluginDir) {
await fs.mkdir(params.pluginDir, { recursive: true });
}
return writeMarketplaceManifest(params.rootDir, params.manifest);
}
function mockRemoteMarketplaceClone(params: {
manifest: unknown;
pluginDir?: string;
pluginFile?: string;
}) {
runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => {
const repoDir = argv.at(-1);
expect(typeof repoDir).toBe("string");
await writeRemoteMarketplaceFixture({
repoDir: repoDir as string,
manifest: params.manifest,
...(params.pluginDir ? { pluginDir: params.pluginDir } : {}),
...(params.pluginFile ? { pluginFile: params.pluginFile } : {}),
});
return { code: 0, stdout: "", stderr: "", killed: false };
});
}
function mockRemoteMarketplaceCloneWithOutsideSymlink(params: {
manifest: unknown;
symlinkPath: string;
}) {
runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => {
const repoDir = argv.at(-1);
expect(typeof repoDir).toBe("string");
await writeRemoteMarketplaceFixture({
repoDir: repoDir as string,
manifest: params.manifest,
});
const outsideDir = await makeTrackedTempDirAsync(
"openclaw-marketplace-outside",
tempOutsideDirs,
);
await fs.mkdir(path.dirname(path.join(repoDir as string, params.symlinkPath)), {
recursive: true,
});
await fs.symlink(outsideDir, path.join(repoDir as string, params.symlinkPath));
return { code: 0, stdout: "", stderr: "", killed: false };
});
}
async function expectRemoteMarketplaceError(params: { manifest: unknown; expectedError: string }) {
mockRemoteMarketplaceClone({ manifest: params.manifest });
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: false,
error: params.expectedError,
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
}
function expectRemoteMarketplaceInstallResult(result: unknown) {
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
["git", "clone", "--depth", "1", "https://github.com/owner/repo.git", expect.any(String)],
{ timeoutMs: 120_000 },
);
expect(installPluginFromPathMock).toHaveBeenCalledWith(
expect.objectContaining({
path: expect.stringMatching(/[\\/]repo[\\/]plugins[\\/]frontend-design$/),
}),
);
expect(result).toMatchObject({
ok: true,
pluginId: "frontend-design",
marketplacePlugin: "frontend-design",
marketplaceSource: "owner/repo",
});
}
function expectMarketplaceManifestListing(
result: Awaited<ReturnType<typeof import("./marketplace.js").listMarketplacePlugins>>,
) {
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("expected marketplace listing to succeed");
}
expect(result.sourceLabel.replaceAll("\\", "/")).toContain(".claude-plugin/marketplace.json");
expect(result.manifest).toEqual({
name: "Example Marketplace",
version: "1.0.0",
plugins: [
{
name: "frontend-design",
version: "0.1.0",
description: "Design system bundle",
source: { kind: "path", path: "./plugins/frontend-design" },
},
],
});
}
function expectLocalMarketplaceInstallResult(params: {
result: unknown;
pluginDir: string;
marketplaceSource: string;
}) {
expect(installPluginFromPathMock).toHaveBeenCalledWith(
expect.objectContaining({
path: params.pluginDir,
}),
);
expect(params.result).toMatchObject({
ok: true,
pluginId: "frontend-design",
marketplacePlugin: "frontend-design",
marketplaceSource: params.marketplaceSource,
});
}
describe("marketplace plugins", () => {
afterEach(async () => {
fetchWithSsrFGuardMock.mockClear();
installPluginFromPathMock.mockReset();
runCommandWithTimeoutMock.mockReset();
vi.unstubAllGlobals();
await cleanupTrackedTempDirsAsync(tempOutsideDirs);
});
it("lists plugins from a local marketplace root", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
await writeMarketplaceManifest(rootDir, {
name: "Example Marketplace",
version: "1.0.0",
plugins: [
{
name: "frontend-design",
version: "0.1.0",
description: "Design system bundle",
source: "./plugins/frontend-design",
},
],
});
expectMarketplaceManifestListing(await listMarketplacePlugins({ marketplace: rootDir }));
});
});
it("resolves relative plugin paths against the marketplace root", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
const manifestPath = await writeLocalMarketplaceFixture({
rootDir,
pluginDir,
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expectLocalMarketplaceInstallResult({
result,
pluginDir,
marketplaceSource: path.join(rootDir, ".claude-plugin", "marketplace.json"),
});
});
});
it("preserves the logical local install path instead of canonicalizing it", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const canonicalRootDir = await fs.realpath(rootDir);
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
const canonicalPluginDir = path.join(canonicalRootDir, "plugins", "frontend-design");
const manifestPath = await writeLocalMarketplaceFixture({
rootDir,
pluginDir,
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expectLocalMarketplaceInstallResult({
result,
pluginDir,
marketplaceSource: manifestPath,
});
if (canonicalPluginDir !== pluginDir) {
expect(installPluginFromPathMock).not.toHaveBeenCalledWith(
expect.objectContaining({
path: canonicalPluginDir,
}),
);
}
});
});
it("passes dangerous force unsafe install through to marketplace path installs", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
const manifestPath = await writeLocalMarketplaceFixture({
rootDir,
pluginDir,
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
dangerouslyForceUnsafeInstall: true,
});
expect(installPluginFromPathMock).toHaveBeenCalledWith(
expect.objectContaining({
path: pluginDir,
dangerouslyForceUnsafeInstall: true,
}),
);
});
});
it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => {
await withTempDir("openclaw-marketplace-test-", async (homeDir) => {
const openClawHome = path.join(homeDir, "openclaw-home");
await fs.mkdir(path.join(homeDir, ".claude", "plugins"), { recursive: true });
await fs.mkdir(openClawHome, { recursive: true });
await fs.writeFile(
path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"),
JSON.stringify({
"claude-plugins-official": {
source: {
source: "github",
repo: "anthropics/claude-plugins-official",
},
installLocation: path.join(homeDir, ".claude", "plugins", "marketplaces", "official"),
},
}),
);
const shortcut = await withEnvAsync(
{ HOME: homeDir, OPENCLAW_HOME: openClawHome },
async () => await resolveMarketplaceInstallShortcut("superpowers@claude-plugins-official"),
);
expect(shortcut).toEqual({
ok: true,
plugin: "superpowers",
marketplaceName: "claude-plugins-official",
marketplaceSource: "claude-plugins-official",
});
});
});
it("installs remote marketplace plugins from relative paths inside the cloned repo", async () => {
mockRemoteMarketplaceClone({
pluginDir: path.join("plugins", "frontend-design"),
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const result = await installPluginFromMarketplace({
marketplace: "owner/repo",
plugin: "frontend-design",
});
expectRemoteMarketplaceInstallResult(result);
});
it("preserves remote marketplace file path sources inside the cloned repo", async () => {
mockRemoteMarketplaceClone({
pluginFile: path.join("plugins", "frontend-design.tgz"),
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design.tgz",
},
],
},
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const result = await installPluginFromMarketplace({
marketplace: "owner/repo",
plugin: "frontend-design",
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
expect(installPluginFromPathMock).toHaveBeenCalledWith(
expect.objectContaining({
path: expect.stringMatching(/[\\/]repo[\\/]plugins[\\/]frontend-design\.tgz$/),
}),
);
expect(result).toMatchObject({
ok: true,
pluginId: "frontend-design",
marketplacePlugin: "frontend-design",
marketplaceSource: "owner/repo",
});
});
it("lists remote marketplace file path sources inside the cloned repo", async () => {
mockRemoteMarketplaceClone({
pluginFile: path.join("plugins", "frontend-design.tgz"),
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design.tgz",
},
],
},
});
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: true,
manifest: {
name: undefined,
version: undefined,
plugins: [
{
name: "frontend-design",
description: undefined,
version: undefined,
source: {
kind: "path",
path: "./plugins/frontend-design.tgz",
},
},
],
},
sourceLabel: "owner/repo",
});
});
it.runIf(process.platform !== "win32")(
"rejects remote marketplace plugin paths that resolve through symlinks outside the cloned repo",
async () => {
mockRemoteMarketplaceCloneWithOutsideSymlink({
symlinkPath: "plugins/evil-link",
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/evil-link",
},
],
},
});
const result = await installPluginFromMarketplace({
marketplace: "owner/repo",
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"plugin source escapes marketplace root: ./plugins/evil-link",
});
expect(installPluginFromPathMock).not.toHaveBeenCalled();
},
);
it("returns a structured error for archive downloads with an empty response body", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const release = vi.fn(async () => undefined);
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(null, { status: 200 }),
finalUrl: "https://example.com/frontend-design.tgz",
release,
});
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error: "failed to download https://example.com/frontend-design.tgz: empty response body",
});
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/frontend-design.tgz",
timeoutMs: 120_000,
auditContext: "marketplace-plugin-download",
}),
);
expect(installPluginFromPathMock).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
});
it("returns a structured error for invalid archive URLs", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://%/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error: "failed to download https://%/frontend-design.tgz: Invalid URL",
});
expect(installPluginFromPathMock).not.toHaveBeenCalled();
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
});
});
it("rejects Windows drive-relative archive filenames from redirects", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(new Blob([Buffer.from("tgz-bytes")]), {
status: 200,
}),
finalUrl: "https://cdn.example.com/C:plugin.tgz",
release: vi.fn(async () => undefined),
});
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error:
"failed to download https://example.com/frontend-design.tgz: invalid download filename",
});
expect(installPluginFromPathMock).not.toHaveBeenCalled();
});
});
it("falls back to the default archive timeout when the caller passes NaN", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(new Blob([Buffer.from("tgz-bytes")]), {
status: 200,
}),
finalUrl: "https://cdn.example.com/releases/12345",
release: vi.fn(async () => undefined),
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
timeoutMs: Number.NaN,
});
expect(result).toMatchObject({
ok: true,
pluginId: "frontend-design",
});
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/frontend-design.tgz",
timeoutMs: 120_000,
auditContext: "marketplace-plugin-download",
}),
);
});
});
it("downloads archive plugin sources through the SSRF guard", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const release = vi.fn(async () => {
throw new Error("dispatcher close failed");
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(new Blob([Buffer.from("tgz-bytes")]), {
status: 200,
}),
finalUrl: "https://cdn.example.com/releases/12345",
release,
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toMatchObject({
ok: true,
pluginId: "frontend-design",
marketplacePlugin: "frontend-design",
marketplaceSource: manifestPath,
});
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/frontend-design.tgz",
timeoutMs: 120_000,
auditContext: "marketplace-plugin-download",
}),
);
expect(installPluginFromPathMock).toHaveBeenCalledWith(
expect.objectContaining({
path: expect.stringMatching(/[\\/]frontend-design\.tgz$/),
}),
);
expect(release).toHaveBeenCalledTimes(1);
});
});
it("rejects non-streaming archive responses before buffering them", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const arrayBuffer = vi.fn(async () => new Uint8Array([1, 2, 3]).buffer);
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
body: {} as Response["body"],
headers: new Headers(),
arrayBuffer,
} as unknown as Response,
finalUrl: "https://cdn.example.com/releases/frontend-design.tgz",
release: vi.fn(async () => undefined),
});
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error:
"failed to download https://example.com/frontend-design.tgz: " +
"streaming response body unavailable",
});
expect(arrayBuffer).not.toHaveBeenCalled();
expect(installPluginFromPathMock).not.toHaveBeenCalled();
});
});
it("rejects oversized streamed archive responses without falling back to arrayBuffer", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const arrayBuffer = vi.fn(async () => new Uint8Array([1, 2, 3]).buffer);
const reader = {
read: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
length: 256 * 1024 * 1024 + 1,
} as Uint8Array,
})
.mockResolvedValueOnce({ done: true, value: undefined }),
cancel: vi.fn(async () => undefined),
releaseLock: vi.fn(),
};
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
body: {
getReader: () => reader,
} as unknown as Response["body"],
headers: new Headers(),
arrayBuffer,
} as unknown as Response,
finalUrl: "https://cdn.example.com/releases/frontend-design.tgz",
release: vi.fn(async () => undefined),
});
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error:
"failed to download https://example.com/frontend-design.tgz: " +
"download too large: 268435457 bytes (limit: 268435456 bytes)",
});
expect(arrayBuffer).not.toHaveBeenCalled();
expect(installPluginFromPathMock).not.toHaveBeenCalled();
});
});
it("cleans up a partial download temp dir when streaming the archive fails", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const beforeTempDirs = await listMarketplaceDownloadTempDirs();
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response("x".repeat(1024), {
status: 200,
headers: {
"content-length": String(300 * 1024 * 1024),
},
}),
finalUrl: "https://cdn.example.com/releases/frontend-design.tgz",
release: vi.fn(async () => undefined),
});
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error:
"failed to download https://example.com/frontend-design.tgz: " +
"download too large: 314572800 bytes (limit: 268435456 bytes)",
});
expect(await listMarketplaceDownloadTempDirs()).toEqual(beforeTempDirs);
expect(installPluginFromPathMock).not.toHaveBeenCalled();
});
});
it("sanitizes archive download errors before returning them", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
fetchWithSsrFGuardMock.mockRejectedValueOnce(
new Error(
"blocked\n\u001b[31mAuthorization: Bearer sk-1234567890abcdefghijklmnop\u001b[0m",
),
);
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://user:pass@example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error).toContain(
"failed to download https://***:***@example.com/frontend-design.tgz:",
);
expect(result.error).toContain("Authorization: Bearer sk-123…mnop");
expect(result.error).not.toContain("user:pass@");
let hasControlChars = false;
for (const char of result.error) {
const codePoint = char.codePointAt(0);
if (codePoint != null && (codePoint < 0x20 || codePoint === 0x7f)) {
hasControlChars = true;
break;
}
}
expect(hasControlChars).toBe(false);
expect(installPluginFromPathMock).not.toHaveBeenCalled();
});
});
it("returns a structured error when the SSRF guard rejects an archive URL", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
fetchWithSsrFGuardMock.mockRejectedValueOnce(
new Error("Blocked hostname (not in allowlist): 169.254.169.254"),
);
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error:
"failed to download https://example.com/frontend-design.tgz: " +
"Blocked hostname (not in allowlist): 169.254.169.254",
});
expect(installPluginFromPathMock).not.toHaveBeenCalled();
});
});
it.each([
{
name: "rejects remote marketplace git plugin sources before cloning nested remotes",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "git",
url: "https://evil.example/repo.git",
},
},
],
},
expectedError:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may not use git plugin sources",
},
{
name: "rejects remote marketplace absolute plugin paths",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "/tmp/frontend-design",
},
},
],
},
expectedError:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may only use relative plugin paths",
},
{
name: "rejects remote marketplace HTTP plugin paths",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "https://evil.example/plugin.tgz",
},
},
],
},
expectedError:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may not use HTTP(S) plugin paths",
},
] as const)("$name", async ({ manifest, expectedError }) => {
await expectRemoteMarketplaceError({ manifest, expectedError });
});
it.runIf(process.platform !== "win32")(
"rejects remote marketplace symlink plugin paths during manifest validation",
async () => {
mockRemoteMarketplaceCloneWithOutsideSymlink({
symlinkPath: "evil-link",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "evil-link",
},
},
],
},
});
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: false,
error:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"plugin source escapes marketplace root: evil-link",
});
},
);
it("reports missing remote marketplace paths as not found instead of escapes", async () => {
mockRemoteMarketplaceClone({
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "plugins/missing-plugin",
},
},
],
},
});
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: false,
error:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"plugin source not found in marketplace root: plugins/missing-plugin",
});
});
});