mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 20:30:21 +00:00
[codex] harden clawhub plugin publishing and install (#56870)
* fix: harden clawhub plugin publishing and install * fix(process): preserve windows shim exit success
This commit is contained in:
@@ -7,10 +7,10 @@ import {
|
||||
downloadClawHubSkillArchive,
|
||||
parseClawHubPluginSpec,
|
||||
resolveClawHubAuthToken,
|
||||
searchClawHubSkills,
|
||||
resolveLatestVersionFromPackage,
|
||||
satisfiesGatewayMinimum,
|
||||
satisfiesPluginApiRange,
|
||||
searchClawHubSkills,
|
||||
} from "./clawhub.js";
|
||||
|
||||
describe("clawhub helpers", () => {
|
||||
@@ -166,10 +166,10 @@ describe("clawhub helpers", () => {
|
||||
|
||||
await expect(searchClawHubSkills({ query: "calendar", fetchImpl })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("writes scoped package archives to a safe temp file name", async () => {
|
||||
it("downloads package archives to sanitized temp paths and cleans them up", async () => {
|
||||
const archive = await downloadClawHubPackageArchive({
|
||||
name: "@soimy/dingtalk",
|
||||
name: "@hyf/zai-external-alpha",
|
||||
version: "0.0.1",
|
||||
fetchImpl: async () =>
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
@@ -178,16 +178,20 @@ describe("clawhub helpers", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
expect(path.basename(archive.archivePath)).toBe("@soimy__dingtalk.zip");
|
||||
expect(path.basename(archive.archivePath)).toBe("zai-external-alpha.zip");
|
||||
expect(archive.archivePath.includes("@hyf")).toBe(false);
|
||||
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 });
|
||||
const archiveDir = path.dirname(archive.archivePath);
|
||||
await archive.cleanup();
|
||||
await expect(fs.stat(archiveDir)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("writes skill archives to a safe temp file name when slugs contain separators", async () => {
|
||||
it("downloads skill archives to sanitized temp paths and cleans them up", async () => {
|
||||
const archive = await downloadClawHubSkillArchive({
|
||||
slug: "ops/calendar",
|
||||
slug: "agentreceipt",
|
||||
version: "1.0.0",
|
||||
fetchImpl: async () =>
|
||||
new Response(new Uint8Array([4, 5, 6]), {
|
||||
status: 200,
|
||||
@@ -196,10 +200,12 @@ describe("clawhub helpers", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
expect(path.basename(archive.archivePath)).toBe("ops__calendar.zip");
|
||||
expect(path.basename(archive.archivePath)).toBe("agentreceipt.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 });
|
||||
const archiveDir = path.dirname(archive.archivePath);
|
||||
await archive.cleanup();
|
||||
await expect(fs.stat(archiveDir)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,21 +2,17 @@ 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 type { ExternalPluginCompatibility } from "@openclaw/plugin-package-contract";
|
||||
import { isAtLeast, parseSemver } from "./runtime-guard.js";
|
||||
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
|
||||
import { createTempDownloadTarget } from "./temp-download.js";
|
||||
|
||||
const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type ClawHubPackageFamily = "skill" | "code-plugin" | "bundle-plugin";
|
||||
export type ClawHubPackageChannel = "official" | "community" | "private";
|
||||
export type ClawHubPackageCompatibility = {
|
||||
pluginApiRange?: string;
|
||||
builtWithOpenClawVersion?: string;
|
||||
minGatewayVersion?: string;
|
||||
};
|
||||
|
||||
export type ClawHubPackageCompatibility = ExternalPluginCompatibility;
|
||||
export type ClawHubPackageListItem = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -33,7 +29,6 @@ export type ClawHubPackageListItem = {
|
||||
executesCode?: boolean;
|
||||
verificationTier?: string | null;
|
||||
};
|
||||
|
||||
export type ClawHubPackageDetail = {
|
||||
package:
|
||||
| (ClawHubPackageListItem & {
|
||||
@@ -158,6 +153,7 @@ export type ClawHubSkillListResponse = {
|
||||
export type ClawHubDownloadResult = {
|
||||
archivePath: string;
|
||||
integrity: string;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
@@ -580,12 +576,16 @@ 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, `${safeDirName(params.name)}.zip`);
|
||||
await fs.writeFile(archivePath, bytes);
|
||||
const target = await createTempDownloadTarget({
|
||||
prefix: "openclaw-clawhub-package",
|
||||
fileName: `${params.name}.zip`,
|
||||
tmpDir: os.tmpdir(),
|
||||
});
|
||||
await fs.writeFile(target.path, bytes);
|
||||
return {
|
||||
archivePath,
|
||||
archivePath: target.path,
|
||||
integrity: formatSha256Integrity(bytes),
|
||||
cleanup: target.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -618,12 +618,16 @@ 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, `${safeDirName(params.slug)}.zip`);
|
||||
await fs.writeFile(archivePath, bytes);
|
||||
const target = await createTempDownloadTarget({
|
||||
prefix: "openclaw-clawhub-skill",
|
||||
fileName: `${params.slug}.zip`,
|
||||
tmpDir: os.tmpdir(),
|
||||
});
|
||||
await fs.writeFile(target.path, bytes);
|
||||
return {
|
||||
archivePath,
|
||||
archivePath: target.path,
|
||||
integrity: formatSha256Integrity(bytes),
|
||||
cleanup: target.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
107
src/infra/temp-download.ts
Normal file
107
src/infra/temp-download.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import crypto from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
|
||||
|
||||
export { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
|
||||
|
||||
export type TempDownloadTarget = {
|
||||
dir: string;
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
function sanitizePrefix(prefix: string): string {
|
||||
const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return normalized || "tmp";
|
||||
}
|
||||
|
||||
function sanitizeExtension(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, "");
|
||||
return normalized || "download.bin";
|
||||
}
|
||||
|
||||
function resolveTempRoot(tmpDir?: string): string {
|
||||
return tmpDir ?? resolvePreferredOpenClawTmpDir();
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(err: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupTempDir(dir: string) {
|
||||
try {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
if (!isNodeErrorWithCode(err, "ENOENT")) {
|
||||
console.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRandomTempFilePath(params: {
|
||||
prefix: string;
|
||||
extension?: string;
|
||||
tmpDir?: string;
|
||||
now?: number;
|
||||
uuid?: string;
|
||||
}): string {
|
||||
const prefix = sanitizePrefix(params.prefix);
|
||||
const extension = sanitizeExtension(params.extension);
|
||||
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), `${prefix}-${now}-${uuid}${extension}`);
|
||||
}
|
||||
|
||||
export async function createTempDownloadTarget(params: {
|
||||
prefix: string;
|
||||
fileName?: string;
|
||||
tmpDir?: string;
|
||||
}): Promise<TempDownloadTarget> {
|
||||
const tempRoot = resolveTempRoot(params.tmpDir);
|
||||
const prefix = `${sanitizePrefix(params.prefix)}-`;
|
||||
const dir = await mkdtemp(path.join(tempRoot, prefix));
|
||||
return {
|
||||
dir,
|
||||
path: path.join(dir, sanitizeTempFileName(params.fileName ?? "download.bin")),
|
||||
cleanup: async () => {
|
||||
await cleanupTempDir(dir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function withTempDownloadPath<T>(
|
||||
params: {
|
||||
prefix: string;
|
||||
fileName?: string;
|
||||
tmpDir?: string;
|
||||
},
|
||||
fn: (tmpPath: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const target = await createTempDownloadTarget(params);
|
||||
try {
|
||||
return await fn(target.path);
|
||||
} finally {
|
||||
await target.cleanup();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user