diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md
index 0c91597c62a..8b4375bf1a7 100644
--- a/docs/plugins/building-plugins.md
+++ b/docs/plugins/building-plugins.md
@@ -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.
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/`.
@@ -107,13 +116,16 @@ and provider plugins have dedicated guides linked above.
- **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.
diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md
index e53869114a4..e6a5aac2eb2 100644
--- a/docs/plugins/sdk-provider-plugins.md
+++ b/docs/plugins/sdk-provider-plugins.md
@@ -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.
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`.
@@ -383,6 +393,18 @@ API key auth, and dynamic model resolution.
+## 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
```
diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md
index 27bd4db39b5..9be078b9414 100644
--- a/docs/plugins/sdk-setup.md
+++ b/docs/plugins/sdk-setup.md
@@ -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
diff --git a/docs/snippets/plugin-publish/minimal-openclaw.plugin.json b/docs/snippets/plugin-publish/minimal-openclaw.plugin.json
new file mode 100644
index 00000000000..e936c8d612e
--- /dev/null
+++ b/docs/snippets/plugin-publish/minimal-openclaw.plugin.json
@@ -0,0 +1,9 @@
+{
+ "id": "my-plugin",
+ "name": "My Plugin",
+ "description": "Adds a custom tool to OpenClaw",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false
+ }
+}
diff --git a/docs/snippets/plugin-publish/minimal-package.json b/docs/snippets/plugin-publish/minimal-package.json
new file mode 100644
index 00000000000..45c7b27830d
--- /dev/null
+++ b/docs/snippets/plugin-publish/minimal-package.json
@@ -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"
+ }
+ }
+}
diff --git a/docs/tools/clawhub.md b/docs/tools/clawhub.md
index 255ae68794b..3ed3b442b97 100644
--- a/docs/tools/clawhub.md
+++ b/docs/tools/clawhub.md
@@ -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 `
+- `clawhub skill publish `
- `--slug `: Skill slug.
- `--name `: Display name.
- `--version `: Semver version.
- `--changelog `: Changelog text (can be empty).
- `--tags `: Comma-separated tags (default: `latest`).
+Publish plugins:
+
+- `clawhub package publish `
+- `` 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 --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
diff --git a/docs/zh-CN/tools/clawhub.md b/docs/zh-CN/tools/clawhub.md
index 8ded0963462..8eadbbd726f 100644
--- a/docs/zh-CN/tools/clawhub.md
+++ b/docs/zh-CN/tools/clawhub.md
@@ -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 `
+- `clawhub skill publish `
- `--slug `:Skills 标识符。
- `--name `:显示名称。
- `--version `:语义化版本号。
- `--changelog `:变更日志文本(可以为空)。
- `--tags `:逗号分隔的标签(默认:`latest`)。
+发布插件:
+
+- `clawhub package publish `
+- `` 可以是本地文件夹、`owner/repo`、`owner/repo@ref` 或 GitHub URL。
+- `--dry-run`:只生成发布计划,不实际上传。
+- `--json`:为 CI 输出结构化 JSON。
+
删除/恢复(仅所有者/管理员):
- `clawhub delete --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
+```
+
## 高级详情(技术性)
### 版本管理和标签
diff --git a/package.json b/package.json
index 66a69a1efcf..6acf4ac67d2 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/plugin-package-contract/package.json b/packages/plugin-package-contract/package.json
new file mode 100644
index 00000000000..74ec4fa16ca
--- /dev/null
+++ b/packages/plugin-package-contract/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@openclaw/plugin-package-contract",
+ "version": "0.0.0-private",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ }
+}
diff --git a/packages/plugin-package-contract/src/index.test.ts b/packages/plugin-package-contract/src/index.test.ts
new file mode 100644
index 00000000000..413e0bb39e6
--- /dev/null
+++ b/packages/plugin-package-contract/src/index.test.ts
@@ -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.",
+ },
+ ]);
+ });
+});
diff --git a/packages/plugin-package-contract/src/index.ts b/packages/plugin-package-contract/src/index.ts
new file mode 100644
index 00000000000..bb8e6000ccb
--- /dev/null
+++ b/packages/plugin-package-contract/src/index.ts
@@ -0,0 +1,96 @@
+export type JsonObject = Record;
+
+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,
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7c21a984bdb..2069571ab96 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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':
diff --git a/src/agents/skills-clawhub.test.ts b/src/agents/skills-clawhub.test.ts
index d6f6ff03d90..85c09e732c7 100644
--- a/src/agents/skills-clawhub.test.ts
+++ b/src/agents/skills-clawhub.test.ts
@@ -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", () => {
diff --git a/src/agents/skills-clawhub.ts b/src/agents/skills-clawhub.ts
index 7952cb7f758..76d978a8c71 100644
--- a/src/agents/skills-clawhub.ts
+++ b/src/agents/skills-clawhub.ts
@@ -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 {
diff --git a/src/docs/clawhub-plugin-docs.test.ts b/src/docs/clawhub-plugin-docs.test.ts
new file mode 100644
index 00000000000..0e53f5ea2ce
--- /dev/null
+++ b/src/docs/clawhub-plugin-docs.test.ts
@@ -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);
+ });
+});
diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts
index 6b7d5eb0a4f..ee340f7757d 100644
--- a/src/infra/clawhub.test.ts
+++ b/src/infra/clawhub.test.ts
@@ -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();
}
});
});
diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts
index ef1bc6e86be..31e7af6900f 100644
--- a/src/infra/clawhub.ts
+++ b/src/infra/clawhub.ts
@@ -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;
};
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise;
@@ -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,
};
}
diff --git a/src/infra/temp-download.ts b/src/infra/temp-download.ts
new file mode 100644
index 00000000000..6f7f9ac6ad2
--- /dev/null
+++ b/src/infra/temp-download.ts
@@ -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;
+};
+
+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 {
+ 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(
+ params: {
+ prefix: string;
+ fileName?: string;
+ tmpDir?: string;
+ },
+ fn: (tmpPath: string) => Promise,
+): Promise {
+ const target = await createTempDownloadTarget(params);
+ try {
+ return await fn(target.path);
+ } finally {
+ await target.cleanup();
+ }
+}
diff --git a/src/plugin-sdk/temp-path.ts b/src/plugin-sdk/temp-path.ts
index 4022f2b7e28..5deb49e021d 100644
--- a/src/plugin-sdk/temp-path.ts
+++ b/src/plugin-sdk/temp-path.ts
@@ -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(
- params: {
- prefix: string;
- fileName?: string;
- tmpDir?: string;
- },
- fn: (tmpPath: string) => Promise,
-): Promise {
- 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";
diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts
index 88873e2f487..97fd4d904c0 100644
--- a/src/plugins/clawhub.test.ts
+++ b/src/plugins/clawhub.test.ts
@@ -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([
diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts
index 5dc36b2a504..0b6a1a73cb3 100644
--- a/src/plugins/clawhub.ts
+++ b/src/plugins/clawhub.ts
@@ -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);
}
}
diff --git a/src/process/exec.ts b/src/process/exec.ts
index 1b19bdecccc..6e674561656 100644
--- a/src/process/exec.ts
+++ b/src/process/exec.ts
@@ -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
diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts
index 69d469f215a..bd78fd32956 100644
--- a/src/process/exec.windows.test.ts
+++ b/src/process/exec.windows.test.ts
@@ -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";