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";