[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:
George Zhang
2026-03-29 11:59:19 -07:00
committed by GitHub
parent 58dde4b016
commit e133924047
23 changed files with 638 additions and 148 deletions

View File

@@ -52,7 +52,15 @@ and provider plugins have dedicated guides linked above.
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./index.ts"]
"extensions": ["./index.ts"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
}
}
```
@@ -71,7 +79,8 @@ and provider plugins have dedicated guides linked above.
</CodeGroup>
Every plugin needs a manifest, even with no config. See
[Manifest](/plugins/manifest) for the full schema.
[Manifest](/plugins/manifest) for the full schema. The canonical ClawHub
publish snippets live in `docs/snippets/plugin-publish/`.
</Step>
@@ -107,13 +116,16 @@ and provider plugins have dedicated guides linked above.
<Step title="Test and publish">
**External plugins:** publish to [ClawHub](/tools/clawhub) or npm, then install:
**External plugins:** validate and publish with ClawHub, then install:
```bash
openclaw plugins install @myorg/openclaw-my-plugin
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
openclaw plugins install clawhub:@myorg/openclaw-my-plugin
```
OpenClaw checks ClawHub first, then falls back to npm.
OpenClaw also checks ClawHub before npm for bare package specs like
`@myorg/openclaw-my-plugin`.
**In-repo plugins:** place under the bundled plugin workspace tree — automatically discovered.

View File

@@ -32,7 +32,15 @@ API key auth, and dynamic model resolution.
"type": "module",
"openclaw": {
"extensions": ["./index.ts"],
"providers": ["acme-ai"]
"providers": ["acme-ai"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
}
}
```
@@ -68,7 +76,9 @@ API key auth, and dynamic model resolution.
</CodeGroup>
The manifest declares `providerAuthEnvVars` so OpenClaw can detect
credentials without loading your plugin runtime.
credentials without loading your plugin runtime. If you publish the
provider on ClawHub, those `openclaw.compat` and `openclaw.build` fields
are required in `package.json`.
</Step>
@@ -383,6 +393,18 @@ API key auth, and dynamic model resolution.
</Step>
</Steps>
## Publish to ClawHub
Provider plugins publish the same way as any other external code plugin:
```bash
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
```
Do not use the legacy skill-only publish alias here; plugin packages should use
`clawhub package publish`.
## File structure
```

View File

@@ -43,20 +43,31 @@ your plugin provides:
}
```
**Provider plugin:**
**Provider plugin / ClawHub publish baseline:**
```json
```json openclaw-clawhub-package.json
{
"name": "@myorg/openclaw-my-provider",
"name": "@myorg/openclaw-my-plugin",
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./index.ts"],
"providers": ["my-provider"]
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
}
}
```
If you publish the plugin externally on ClawHub, those `compat` and `build`
fields are required. The canonical publish snippets live in
`docs/snippets/plugin-publish/`.
### `openclaw` fields
| Field | Type | Description |
@@ -147,6 +158,18 @@ Even plugins with no config must ship a schema. An empty schema is valid:
See [Plugin Manifest](/plugins/manifest) for the full schema reference.
## ClawHub publishing
For plugin packages, use the package-specific ClawHub command:
```bash
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
```
The legacy skill-only publish alias is for skills. Plugin packages should
always use `clawhub package publish`.
## Setup entry
The `setup-entry.ts` file is a lightweight alternative to `index.ts` that

View File

@@ -0,0 +1,9 @@
{
"id": "my-plugin",
"name": "My Plugin",
"description": "Adds a custom tool to OpenClaw",
"configSchema": {
"type": "object",
"additionalProperties": false
}
}

View File

@@ -0,0 +1,16 @@
{
"name": "@myorg/openclaw-my-plugin",
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./index.ts"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
}
}

View File

@@ -2,7 +2,7 @@
summary: "ClawHub guide: public registry, native OpenClaw install flows, and ClawHub CLI workflows"
read_when:
- Introducing ClawHub to new users
- Installing, searching, or publishing skills
- Installing, searching, or publishing skills or plugins
- Explaining ClawHub CLI flags and sync behavior
title: "ClawHub"
---
@@ -46,7 +46,7 @@ metadata so later `update` calls can stay on ClawHub.
## What ClawHub is
- A public registry for OpenClaw skills.
- A public registry for OpenClaw skills and plugins.
- A versioned store of skill bundles and metadata.
- A discovery surface for search, tags, and usage signals.
@@ -201,15 +201,23 @@ List:
- `clawhub list` (reads `.clawhub/lock.json`)
Publish:
Publish skills:
- `clawhub publish <path>`
- `clawhub skill publish <path>`
- `--slug <slug>`: Skill slug.
- `--name <name>`: Display name.
- `--version <version>`: Semver version.
- `--changelog <text>`: Changelog text (can be empty).
- `--tags <tags>`: Comma-separated tags (default: `latest`).
Publish plugins:
- `clawhub package publish <source>`
- `<source>` can be a local folder, `owner/repo`, `owner/repo@ref`, or a GitHub URL.
- `--dry-run`: Build the exact publish plan without uploading anything.
- `--json`: Emit machine-readable output for CI.
- `--source-repo`, `--source-commit`, `--source-ref`: Optional overrides when auto-detection is not enough.
Delete/undelete (owner/admin only):
- `clawhub delete <slug> --yes`
@@ -251,7 +259,7 @@ clawhub update --all
For a single skill folder:
```bash
clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --tags latest
clawhub skill publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --tags latest
```
To scan and back up many skills at once:
@@ -260,6 +268,36 @@ To scan and back up many skills at once:
clawhub sync --all
```
### Publish a plugin from GitHub
```bash
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
clawhub package publish your-org/your-plugin@v1.0.0
clawhub package publish https://github.com/your-org/your-plugin
```
Code plugins must include the required OpenClaw metadata in `package.json`:
```json
{
"name": "@myorg/openclaw-my-plugin",
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./index.ts"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
}
}
```
## Advanced details (technical)
### Versioning and tags

View File

@@ -1,9 +1,9 @@
---
read_when:
- 向新用户介绍 ClawHub
- 安装、搜索或发布 Skills
- 安装、搜索或发布 Skills 或插件
- 说明 ClawHub CLI 标志和同步行为
summary: ClawHub 指南:公共 Skills 注册中心 + CLI 工作流
summary: ClawHub 指南:公共 Skills / 插件注册中心 CLI 工作流
title: ClawHub
x-i18n:
generated_at: "2026-02-01T21:42:32Z"
@@ -16,9 +16,9 @@ x-i18n:
# ClawHub
ClawHub 是 **OpenClaw 的公共 Skills 注册中心**它是一项免费服务:所有 Skills 都是公开的、开放的所有人都可以查看、共享和复用。Skills 就是一个包含 `SKILL.md` 文件(以及辅助文本文件)的文件夹。你可以在网页应用中浏览 Skills,也可以使用 CLI 来搜索、安装、更新和发布 Skills。
ClawHub 是 **OpenClaw 的公共 Skills 与插件注册中心**。你可以在网页应用中浏览资源,也可以使用 CLI 来搜索、安装、更新和发布 Skills / 插件
网站:[clawhub.com](https://clawhub.com)
网站:[clawhub.ai](https://clawhub.ai)
## 适用人群(新手友好)
@@ -112,15 +112,22 @@ pnpm add -g clawhub
- `clawhub list`(读取 `.clawhub/lock.json`
发布:
发布 Skills
- `clawhub publish <path>`
- `clawhub skill publish <path>`
- `--slug <slug>`Skills 标识符。
- `--name <name>`:显示名称。
- `--version <version>`:语义化版本号。
- `--changelog <text>`:变更日志文本(可以为空)。
- `--tags <tags>`:逗号分隔的标签(默认:`latest`)。
发布插件:
- `clawhub package publish <source>`
- `<source>` 可以是本地文件夹、`owner/repo``owner/repo@ref` 或 GitHub URL。
- `--dry-run`:只生成发布计划,不实际上传。
- `--json`:为 CI 输出结构化 JSON。
删除/恢复(仅所有者/管理员):
- `clawhub delete <slug> --yes`
@@ -162,7 +169,7 @@ clawhub update --all
对于单个 Skills 文件夹:
```bash
clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --tags latest
clawhub skill publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --tags latest
```
一次扫描并备份多个 Skills
@@ -171,6 +178,15 @@ clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --t
clawhub sync --all
```
### 从 GitHub 发布插件
```bash
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
clawhub package publish your-org/your-plugin@v1.0.0
clawhub package publish https://github.com/your-org/your-plugin
```
## 高级详情(技术性)
### 版本管理和标签

View File

@@ -1197,6 +1197,7 @@
"@mariozechner/pi-tui": "0.63.2",
"@modelcontextprotocol/sdk": "1.28.0",
"@mozilla/readability": "^0.6.0",
"@openclaw/plugin-package-contract": "workspace:*",
"@sinclair/typebox": "0.34.49",
"ajv": "^8.18.0",
"chalk": "^5.6.2",

View File

@@ -0,0 +1,9 @@
{
"name": "@openclaw/plugin-package-contract",
"version": "0.0.0-private",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
}
}

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS,
listMissingExternalCodePluginFieldPaths,
normalizeExternalPluginCompatibility,
validateExternalCodePluginPackageJson,
} from "./index.js";
describe("@openclaw/plugin-package-contract", () => {
it("normalizes the OpenClaw compatibility block for external plugins", () => {
expect(
normalizeExternalPluginCompatibility({
version: "1.2.3",
openclaw: {
compat: {
pluginApi: ">=2026.3.24-beta.2",
minGatewayVersion: "2026.3.24-beta.2",
},
build: {
openclawVersion: "2026.3.24-beta.2",
pluginSdkVersion: "0.9.0",
},
},
}),
).toEqual({
pluginApiRange: ">=2026.3.24-beta.2",
builtWithOpenClawVersion: "2026.3.24-beta.2",
pluginSdkVersion: "0.9.0",
minGatewayVersion: "2026.3.24-beta.2",
});
});
it("falls back to install.minHostVersion and package version when compatible", () => {
expect(
normalizeExternalPluginCompatibility({
version: "1.2.3",
openclaw: {
compat: {
pluginApi: ">=1.0.0",
},
install: {
minHostVersion: "2026.3.24-beta.2",
},
},
}),
).toEqual({
pluginApiRange: ">=1.0.0",
builtWithOpenClawVersion: "1.2.3",
minGatewayVersion: "2026.3.24-beta.2",
});
});
it("lists the required external code-plugin fields", () => {
expect(EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS).toEqual([
"openclaw.compat.pluginApi",
"openclaw.build.openclawVersion",
]);
});
it("reports missing required fields with stable field paths", () => {
const packageJson = {
openclaw: {
compat: {},
build: {},
},
};
expect(listMissingExternalCodePluginFieldPaths(packageJson)).toEqual([
"openclaw.compat.pluginApi",
"openclaw.build.openclawVersion",
]);
expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([
{
fieldPath: "openclaw.compat.pluginApi",
message:
"openclaw.compat.pluginApi is required for external code plugins published to ClawHub.",
},
{
fieldPath: "openclaw.build.openclawVersion",
message:
"openclaw.build.openclawVersion is required for external code plugins published to ClawHub.",
},
]);
});
});

View File

@@ -0,0 +1,96 @@
export type JsonObject = Record<string, unknown>;
export type ExternalPluginCompatibility = {
pluginApiRange?: string;
builtWithOpenClawVersion?: string;
pluginSdkVersion?: string;
minGatewayVersion?: string;
};
export type ExternalPluginValidationIssue = {
fieldPath: string;
message: string;
};
export type ExternalCodePluginValidationResult = {
compatibility?: ExternalPluginCompatibility;
issues: ExternalPluginValidationIssue[];
};
export const EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [
"openclaw.compat.pluginApi",
"openclaw.build.openclawVersion",
] as const;
function isRecord(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function getTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readOpenClawBlock(packageJson: unknown) {
const root = isRecord(packageJson) ? packageJson : undefined;
const openclaw = isRecord(root?.openclaw) ? root.openclaw : undefined;
const compat = isRecord(openclaw?.compat) ? openclaw.compat : undefined;
const build = isRecord(openclaw?.build) ? openclaw.build : undefined;
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
return { root, openclaw, compat, build, install };
}
export function normalizeExternalPluginCompatibility(
packageJson: unknown,
): ExternalPluginCompatibility | undefined {
const { root, compat, build, install } = readOpenClawBlock(packageJson);
const version = getTrimmedString(root?.version);
const minHostVersion = getTrimmedString(install?.minHostVersion);
const compatibility: ExternalPluginCompatibility = {};
const pluginApi = getTrimmedString(compat?.pluginApi);
if (pluginApi) {
compatibility.pluginApiRange = pluginApi;
}
const minGatewayVersion = getTrimmedString(compat?.minGatewayVersion) ?? minHostVersion;
if (minGatewayVersion) {
compatibility.minGatewayVersion = minGatewayVersion;
}
const builtWithOpenClawVersion = getTrimmedString(build?.openclawVersion) ?? version;
if (builtWithOpenClawVersion) {
compatibility.builtWithOpenClawVersion = builtWithOpenClawVersion;
}
const pluginSdkVersion = getTrimmedString(build?.pluginSdkVersion);
if (pluginSdkVersion) {
compatibility.pluginSdkVersion = pluginSdkVersion;
}
return Object.keys(compatibility).length > 0 ? compatibility : undefined;
}
export function listMissingExternalCodePluginFieldPaths(packageJson: unknown): string[] {
const { compat, build } = readOpenClawBlock(packageJson);
const missing: string[] = [];
if (!getTrimmedString(compat?.pluginApi)) {
missing.push("openclaw.compat.pluginApi");
}
if (!getTrimmedString(build?.openclawVersion)) {
missing.push("openclaw.build.openclawVersion");
}
return missing;
}
export function validateExternalCodePluginPackageJson(
packageJson: unknown,
): ExternalCodePluginValidationResult {
const issues = listMissingExternalCodePluginFieldPaths(packageJson).map((fieldPath) => ({
fieldPath,
message: `${fieldPath} is required for external code plugins published to ClawHub.`,
}));
return {
compatibility: normalizeExternalPluginCompatibility(packageJson),
issues,
};
}

5
pnpm-lock.yaml generated
View File

@@ -69,6 +69,9 @@ importers:
'@napi-rs/canvas':
specifier: ^0.1.89
version: 0.1.92
'@openclaw/plugin-package-contract':
specifier: workspace:*
version: link:packages/plugin-package-contract
'@sinclair/typebox':
specifier: 0.34.49
version: 0.34.49
@@ -697,6 +700,8 @@ importers:
specifier: workspace:*
version: link:../..
packages/plugin-package-contract: {}
ui:
dependencies:
'@create-markdown/preview':

View File

@@ -8,6 +8,7 @@ const downloadClawHubSkillArchiveMock = vi.fn();
const listClawHubSkillsMock = vi.fn();
const resolveClawHubBaseUrlMock = vi.fn(() => "https://clawhub.ai");
const searchClawHubSkillsMock = vi.fn();
const archiveCleanupMock = vi.fn();
const withExtractedArchiveRootMock = vi.fn();
const installPackageDirMock = vi.fn();
const fileExistsMock = vi.fn();
@@ -42,6 +43,7 @@ describe("skills-clawhub", () => {
listClawHubSkillsMock.mockReset();
resolveClawHubBaseUrlMock.mockReset();
searchClawHubSkillsMock.mockReset();
archiveCleanupMock.mockReset();
withExtractedArchiveRootMock.mockReset();
installPackageDirMock.mockReset();
fileExistsMock.mockReset();
@@ -63,7 +65,9 @@ describe("skills-clawhub", () => {
downloadClawHubSkillArchiveMock.mockResolvedValue({
archivePath: "/tmp/agentreceipt.zip",
integrity: "sha256-test",
cleanup: archiveCleanupMock,
});
archiveCleanupMock.mockResolvedValue(undefined);
searchClawHubSkillsMock.mockResolvedValue([]);
withExtractedArchiveRootMock.mockImplementation(async (params) => {
expect(params.rootMarkers).toEqual(["SKILL.md"]);
@@ -97,6 +101,7 @@ describe("skills-clawhub", () => {
version: "1.0.0",
targetDir: "/tmp/workspace/skills/agentreceipt",
});
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
});
describe("legacy tracked slugs remain updatable", () => {

View File

@@ -331,10 +331,7 @@ async function performClawHubSkillInstall(
detail,
};
} finally {
await fs.rm(archive.archivePath, { force: true }).catch(() => undefined);
await fs
.rm(path.dirname(archive.archivePath), { recursive: true, force: true })
.catch(() => undefined);
await archive.cleanup().catch(() => undefined);
}
} catch (err) {
return {

View File

@@ -0,0 +1,71 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.js";
const DOCS_ROOT = path.join(process.cwd(), "docs");
const pluginDocs = [
path.join(DOCS_ROOT, "tools", "clawhub.md"),
path.join(DOCS_ROOT, "plugins", "building-plugins.md"),
path.join(DOCS_ROOT, "plugins", "sdk-setup.md"),
path.join(DOCS_ROOT, "plugins", "sdk-provider-plugins.md"),
];
function extractNamedJsonBlock(markdown: string, label: string) {
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = markdown.match(
new RegExp(
`^[ \\t]*\\\`\\\`\\\`json ${escapedLabel}\\n([\\s\\S]*?)\\n[ \\t]*\\\`\\\`\\\``,
"m",
),
);
if (!match?.[1]) {
throw new Error(`Missing json code block for ${label}`);
}
return JSON.parse(match[1].trim()) as unknown;
}
describe("ClawHub plugin docs", () => {
it("keeps the canonical plugin-publish snippets contract-valid", async () => {
const packageJson = JSON.parse(
await fs.readFile(
path.join(DOCS_ROOT, "snippets", "plugin-publish", "minimal-package.json"),
"utf8",
),
) as unknown;
const pluginManifest = JSON.parse(
await fs.readFile(
path.join(DOCS_ROOT, "snippets", "plugin-publish", "minimal-openclaw.plugin.json"),
"utf8",
),
) as { id?: unknown; configSchema?: unknown };
expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([]);
expect(typeof pluginManifest.id).toBe("string");
expect(pluginManifest.configSchema).toBeTruthy();
});
it("does not tell plugin authors to use bare clawhub publish", async () => {
for (const docPath of pluginDocs) {
const markdown = await fs.readFile(docPath, "utf8");
expect(markdown).not.toMatch(/(^|[\s`])clawhub publish\b/);
}
});
it("keeps the canonical package snippet embedded in the primary plugin docs", async () => {
const snippet = JSON.parse(
await fs.readFile(
path.join(DOCS_ROOT, "snippets", "plugin-publish", "minimal-package.json"),
"utf8",
),
) as unknown;
const buildingPlugins = await fs.readFile(
path.join(DOCS_ROOT, "plugins", "building-plugins.md"),
"utf8",
);
const sdkSetup = await fs.readFile(path.join(DOCS_ROOT, "plugins", "sdk-setup.md"), "utf8");
expect(extractNamedJsonBlock(buildingPlugins, "package.json")).toEqual(snippet);
expect(extractNamedJsonBlock(sdkSetup, "openclaw-clawhub-package.json")).toEqual(snippet);
});
});

View File

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

View File

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

View File

@@ -1,88 +1,7 @@
import crypto from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
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(/^[._-]+/, "");
if (!token) {
return "";
}
return `.${token}`;
}
function sanitizeFileName(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
);
}
/** Build a unique temp file path with sanitized prefix/extension parts. */
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}`);
}
/** Create a temporary download directory, run the callback, then clean it up best-effort. */
export async function withTempDownloadPath<T>(
params: {
prefix: string;
fileName?: string;
tmpDir?: string;
},
fn: (tmpPath: string) => Promise<T>,
): Promise<T> {
const tempRoot = resolveTempRoot(params.tmpDir);
const prefix = `${sanitizePrefix(params.prefix)}-`;
const dir = await mkdtemp(path.join(tempRoot, prefix));
const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin"));
try {
return await fn(tmpPath);
} finally {
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 {
buildRandomTempFilePath,
createTempDownloadTarget,
resolvePreferredOpenClawTmpDir,
sanitizeTempFileName,
withTempDownloadPath,
} from "../infra/temp-download.js";

View File

@@ -4,6 +4,7 @@ const parseClawHubPluginSpecMock = vi.fn();
const fetchClawHubPackageDetailMock = vi.fn();
const fetchClawHubPackageVersionMock = vi.fn();
const downloadClawHubPackageArchiveMock = vi.fn();
const archiveCleanupMock = vi.fn();
const resolveLatestVersionFromPackageMock = vi.fn();
const resolveCompatibilityHostVersionMock = vi.fn();
const installPluginFromArchiveMock = vi.fn();
@@ -102,6 +103,7 @@ describe("installPluginFromClawHub", () => {
fetchClawHubPackageDetailMock.mockReset();
fetchClawHubPackageVersionMock.mockReset();
downloadClawHubPackageArchiveMock.mockReset();
archiveCleanupMock.mockReset();
resolveLatestVersionFromPackageMock.mockReset();
resolveCompatibilityHostVersionMock.mockReset();
installPluginFromArchiveMock.mockReset();
@@ -137,7 +139,9 @@ describe("installPluginFromClawHub", () => {
downloadClawHubPackageArchiveMock.mockResolvedValue({
archivePath: "/tmp/clawhub-demo/archive.zip",
integrity: "sha256-demo",
cleanup: archiveCleanupMock,
});
archiveCleanupMock.mockResolvedValue(undefined);
resolveCompatibilityHostVersionMock.mockReturnValue("2026.3.22");
installPluginFromArchiveMock.mockResolvedValue({
ok: true,
@@ -171,6 +175,25 @@ describe("installPluginFromClawHub", () => {
"Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0",
);
expect(logger.warn).not.toHaveBeenCalled();
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
});
it("cleans up the downloaded archive even when archive install fails", async () => {
installPluginFromArchiveMock.mockResolvedValueOnce({
ok: false,
error: "bad archive",
});
const result = await installPluginFromClawHub({
spec: "clawhub:demo",
baseUrl: "https://clawhub.ai",
});
expect(result).toMatchObject({
ok: false,
error: "bad archive",
});
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
});
it.each([

View File

@@ -1,5 +1,3 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
ClawHubRequestError,
downloadClawHubPackageArchive,
@@ -343,9 +341,6 @@ export async function installPluginFromClawHub(params: {
},
};
} finally {
await fs.rm(archive.archivePath, { force: true }).catch(() => undefined);
await fs
.rm(path.dirname(archive.archivePath), { recursive: true, force: true })
.catch(() => undefined);
await archive.cleanup().catch(() => undefined);
}
}

View File

@@ -227,6 +227,8 @@ export async function runCommandWithTimeout(
const finalArgv = process.platform === "win32" ? (resolveNpmArgvForWindows(argv) ?? argv) : argv;
const resolvedCommand = finalArgv !== argv ? (finalArgv[0] ?? "") : resolveCommand(argv[0] ?? "");
const useCmdWrapper = isWindowsBatchCommand(resolvedCommand);
const usesWindowsExitCodeShim =
process.platform === "win32" && (useCmdWrapper || finalArgv !== argv);
const child = spawn(
useCmdWrapper ? (process.env.ComSpec ?? "cmd.exe") : resolvedCommand,
useCmdWrapper
@@ -341,8 +343,18 @@ export async function runCommandWithTimeout(
clearTimeout(timer);
clearNoOutputTimer();
clearCloseFallbackTimer();
const resolvedCode = childExitState?.code ?? code ?? child.exitCode ?? null;
const resolvedSignal = childExitState?.signal ?? signal ?? child.signalCode ?? null;
const resolvedCode =
childExitState?.code ??
code ??
child.exitCode ??
(usesWindowsExitCodeShim &&
resolvedSignal == null &&
!timedOut &&
!noOutputTimedOut &&
!child.killed
? 0
: null);
const termination = noOutputTimedOut
? "no-output-timeout"
: timedOut

View File

@@ -143,6 +143,25 @@ describe("windows command wrapper behavior", () => {
}
});
it("treats shimmed Windows commands without a reported exit code as success when they close cleanly", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const child = createMockChild({
closeCode: null,
exitCode: null,
});
spawnMock.mockImplementation(() => child);
try {
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.termination).toBe("exit");
} finally {
platformSpy.mockRestore();
}
});
it("uses cmd.exe wrapper with windowsVerbatimArguments in runExec for .cmd shims", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const expectedComSpec = process.env.ComSpec ?? "cmd.exe";