diff --git a/CHANGELOG.md b/CHANGELOG.md index dc11acc1884..2d1db822c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Codex/app-server: resolve managed binaries from bundled `dist` chunks and from the `@openai/codex` package bin when installs do not provide a nearby `.bin/codex` shim, avoiding false missing-binary startup failures. +- Plugins/ClawHub: use the ClawHub artifact resolver response as the install decision before downloading, keeping legacy ZIP fallback and future ClawPack npm-pack installs on the same explicit resolver path. Thanks @vincentkoc. - Plugins/ClawHub: gate bare plugin specs on ClawHub readiness before preferring ClawHub, so packages without deployed ClawPack readiness keep the npm fallback path instead of failing through a half-ready registry route. Thanks @vincentkoc. - Plugins/source checkout: discover source-only plugins such as Codex from the `extensions/*` workspace while using npm package excludes as the packaged-core boundary, removing the stale core-bundle metadata path. - Plugins/ClawHub: install ClawPack artifacts from the explicit npm-pack `.tgz` resolver path and persist artifact kind, npm integrity, shasum, and tarball metadata for update and diagnostics flows. Thanks @vincentkoc. diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 5bf5cd7e5d0..fb7590c7775 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -94,8 +94,30 @@ export type ClawHubResolvedArtifact = moderationState?: ClawHubArtifactModerationState | null; }; export type ClawHubPackageArtifactResolverResponse = { - package?: { name?: string | null } | null; - version?: { version?: string | null } | string | null; + package?: { + name?: string | null; + displayName?: string | null; + family?: ClawHubPackageFamily | (string & {}) | null; + } | null; + version?: + | ({ + version?: string | null; + createdAt?: number | null; + changelog?: string | null; + distTags?: string[]; + files?: Array<{ + path: string; + size?: number; + sha256: string; + contentType?: string; + }>; + sha256hash?: string | null; + compatibility?: ClawHubPackageCompatibility | null; + artifact?: ClawHubPackageArtifactSummary | null; + clawpack?: ClawHubPackageClawPackSummary | null; + } & Record) + | string + | null; artifact?: ClawHubResolvedArtifact | null; }; export type ClawHubPackageSecurityResponse = { diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 13eb4103d86..86f2374f860 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const parseClawHubPluginSpecMock = vi.fn(); const fetchClawHubPackageDetailMock = vi.fn(); +const fetchClawHubPackageArtifactMock = vi.fn(); const fetchClawHubPackageVersionMock = vi.fn(); const downloadClawHubPackageArchiveMock = vi.fn(); const archiveCleanupMock = vi.fn(); @@ -21,6 +22,7 @@ vi.mock("../infra/clawhub.js", async () => { ...actual, parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpecMock(...args), fetchClawHubPackageDetail: (...args: unknown[]) => fetchClawHubPackageDetailMock(...args), + fetchClawHubPackageArtifact: (...args: unknown[]) => fetchClawHubPackageArtifactMock(...args), fetchClawHubPackageVersion: (...args: unknown[]) => fetchClawHubPackageVersionMock(...args), downloadClawHubPackageArchive: (...args: unknown[]) => downloadClawHubPackageArchiveMock(...args), @@ -143,6 +145,12 @@ function expectClawHubInstallFlow(params: { version: params.version, }), ); + expect(fetchClawHubPackageArtifactMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "demo", + version: params.version, + }), + ); expect(installPluginFromArchiveMock).toHaveBeenCalledWith( expect.objectContaining({ archivePath: params.archivePath, @@ -175,6 +183,7 @@ describe("installPluginFromClawHub", () => { beforeEach(() => { parseClawHubPluginSpecMock.mockReset(); fetchClawHubPackageDetailMock.mockReset(); + fetchClawHubPackageArtifactMock.mockReset(); fetchClawHubPackageVersionMock.mockReset(); downloadClawHubPackageArchiveMock.mockReset(); archiveCleanupMock.mockReset(); @@ -211,6 +220,9 @@ describe("installPluginFromClawHub", () => { }, }, }); + fetchClawHubPackageArtifactMock.mockImplementation((params) => + fetchClawHubPackageVersionMock(params), + ); downloadClawHubPackageArchiveMock.mockResolvedValue({ archivePath: "/tmp/clawhub-demo/archive.zip", integrity: DEMO_ARCHIVE_INTEGRITY, @@ -384,6 +396,73 @@ describe("installPluginFromClawHub", () => { ); }); + it("uses the artifact resolver response as the install decision", async () => { + fetchClawHubPackageVersionMock.mockClear(); + fetchClawHubPackageArtifactMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + version: { + version: "2026.3.22", + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + }, + artifact: { + source: "clawhub", + artifactKind: "npm-pack", + packageName: "demo", + version: "2026.3.22", + artifactSha256: DEMO_CLAWPACK_SHA256, + npmIntegrity: "sha512-clawpack", + npmShasum: "1".repeat(40), + }, + }); + downloadClawHubPackageArchiveMock.mockResolvedValueOnce({ + archivePath: "/tmp/clawhub-demo/demo-2026.3.22.tgz", + integrity: DEMO_CLAWPACK_INTEGRITY, + sha256Hex: DEMO_CLAWPACK_SHA256, + artifact: "clawpack", + clawpackHeaderSha256: DEMO_CLAWPACK_SHA256, + npmIntegrity: "sha512-clawpack", + npmShasum: "1".repeat(40), + cleanup: archiveCleanupMock, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + expect(result).toMatchObject({ + ok: true, + clawhub: { + artifactKind: "npm-pack", + artifactFormat: "tgz", + npmIntegrity: "sha512-clawpack", + npmShasum: "1".repeat(40), + clawpackSha256: DEMO_CLAWPACK_SHA256, + }, + }); + expect(fetchClawHubPackageArtifactMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "demo", + version: "2026.3.22", + }), + ); + expect(fetchClawHubPackageVersionMock).not.toHaveBeenCalled(); + expect(downloadClawHubPackageArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: "clawpack", + name: "demo", + version: "2026.3.22", + }), + ); + }); + it("installs ClawPack artifacts when version metadata has no legacy archive hash", async () => { fetchClawHubPackageVersionMock.mockResolvedValueOnce({ version: { diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 52ab4cedd61..8fef33f8ebc 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -13,8 +13,8 @@ import { import { ClawHubRequestError, downloadClawHubPackageArchive, + fetchClawHubPackageArtifact, fetchClawHubPackageDetail, - fetchClawHubPackageVersion, normalizeClawHubSha256Integrity, normalizeClawHubSha256Hex, parseClawHubPluginSpec, @@ -22,9 +22,11 @@ import { satisfiesGatewayMinimum, satisfiesPluginApiRange, type ClawHubPackageArtifactSummary, + type ClawHubPackageArtifactResolverResponse, type ClawHubPackageCompatibility, type ClawHubPackageDetail, type ClawHubPackageClawPackSummary, + type ClawHubResolvedArtifact, type ClawHubPackageVersion, } from "../infra/clawhub.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -89,6 +91,17 @@ type ClawHubArchiveVerificationResolution = } | ClawHubInstallFailure; +type ClawHubArtifactResolverVersion = NonNullable< + Exclude +>; + +type ClawHubInstallArtifactDecision = { + version: string; + compatibility?: ClawHubPackageCompatibility | null; + verification: ClawHubArchiveVerification | null; + clawpack?: ClawHubPackageArtifactSummary | ClawHubPackageClawPackSummary | null; +}; + type ClawHubArchiveFileVerificationResult = | { ok: true; @@ -219,6 +232,49 @@ function resolveClawHubNpmPackArtifact( return null; } +function readArtifactResolverVersion( + response: ClawHubPackageArtifactResolverResponse, + requestedVersion: string, +): ClawHubArtifactResolverVersion { + if ( + response.version && + typeof response.version === "object" && + !Array.isArray(response.version) + ) { + return response.version; + } + if (typeof response.version === "string" && response.version.trim().length > 0) { + return { version: response.version.trim() }; + } + return { version: requestedVersion }; +} + +function resolveTopLevelNpmPackArtifact( + artifact: ClawHubResolvedArtifact | null | undefined, +): ClawHubPackageArtifactSummary | null { + if (artifact?.artifactKind !== "npm-pack") { + return null; + } + return { + kind: "npm-pack", + format: "tgz", + sha256: artifact.artifactSha256 ?? null, + npmIntegrity: artifact.npmIntegrity, + npmShasum: artifact.npmShasum ?? null, + downloadUrl: artifact.downloadUrl ?? null, + }; +} + +function resolveTopLevelLegacyArchiveVerification( + artifact: ClawHubResolvedArtifact | null | undefined, +): ClawHubArchiveVerification | null { + if (artifact?.artifactKind !== "legacy-zip" || typeof artifact.artifactSha256 !== "string") { + return null; + } + const integrity = normalizeClawHubSha256Integrity(artifact.artifactSha256); + return integrity ? { kind: "archive-integrity", integrity } : null; +} + export function formatClawHubSpecifier(params: { name: string; version?: string }): string { return `clawhub:${params.name}${params.version ? `@${params.version}` : ""}`; } @@ -692,16 +748,7 @@ async function resolveCompatiblePackageVersion(params: { baseUrl?: string; token?: string; timeoutMs?: number; -}): Promise< - | { - ok: true; - version: string; - compatibility?: ClawHubPackageCompatibility | null; - verification: ClawHubArchiveVerification | null; - clawpack?: ClawHubPackageArtifactSummary | ClawHubPackageClawPackSummary | null; - } - | ClawHubInstallFailure -> { +}): Promise<({ ok: true } & ClawHubInstallArtifactDecision) | ClawHubInstallFailure> { const requestedVersion = resolveRequestedVersion(params); if (!requestedVersion) { return buildClawHubInstallFailure( @@ -709,9 +756,9 @@ async function resolveCompatiblePackageVersion(params: { CLAWHUB_INSTALL_ERROR_CODE.NO_INSTALLABLE_VERSION, ); } - let versionDetail; + let artifactResponse; try { - versionDetail = await fetchClawHubPackageVersion({ + artifactResponse = await fetchClawHubPackageArtifact({ name: params.detail.package?.name ?? "", version: requestedVersion, baseUrl: params.baseUrl, @@ -725,20 +772,47 @@ async function resolveCompatiblePackageVersion(params: { version: requestedVersion, }); } - const resolvedVersion = versionDetail.version?.version ?? requestedVersion; + const artifactVersion = readArtifactResolverVersion(artifactResponse, requestedVersion); + const resolvedVersion = normalizeOptionalString(artifactVersion.version) ?? requestedVersion; if (params.detail.package?.family === "skill") { return { ok: true, version: resolvedVersion, - compatibility: - versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null, + compatibility: artifactVersion.compatibility ?? params.detail.package?.compatibility ?? null, verification: null, - clawpack: versionDetail.version?.clawpack ?? null, + clawpack: + artifactVersion.clawpack ?? resolveTopLevelNpmPackArtifact(artifactResponse.artifact), }; } - const clawpack = versionDetail.version - ? resolveClawHubNpmPackArtifact(versionDetail.version) - : null; + const versionDetail: ClawHubPackageVersion = { + package: artifactResponse.package + ? { + name: artifactResponse.package.name ?? params.detail.package?.name ?? "", + displayName: + artifactResponse.package.displayName ?? params.detail.package?.displayName ?? "", + family: + artifactResponse.package.family === "code-plugin" || + artifactResponse.package.family === "bundle-plugin" || + artifactResponse.package.family === "skill" + ? artifactResponse.package.family + : (params.detail.package?.family ?? "code-plugin"), + } + : null, + version: { + version: resolvedVersion, + createdAt: typeof artifactVersion.createdAt === "number" ? artifactVersion.createdAt : 0, + changelog: typeof artifactVersion.changelog === "string" ? artifactVersion.changelog : "", + distTags: artifactVersion.distTags, + files: artifactVersion.files, + sha256hash: artifactVersion.sha256hash, + compatibility: artifactVersion.compatibility, + artifact: artifactVersion.artifact, + clawpack: artifactVersion.clawpack ?? undefined, + }, + }; + const clawpack = + resolveClawHubNpmPackArtifact(versionDetail.version) ?? + resolveTopLevelNpmPackArtifact(artifactResponse.artifact); const verificationState = resolveClawHubArchiveVerification( versionDetail, params.detail.package?.name ?? "unknown", @@ -757,12 +831,15 @@ async function resolveCompatiblePackageVersion(params: { clawpack, }; } + const topLevelLegacyVerification = resolveTopLevelLegacyArchiveVerification( + artifactResponse.artifact, + ); return { ok: true, version: resolvedVersion, compatibility: versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null, - verification: verificationState.verification, + verification: verificationState.verification ?? topLevelLegacyVerification, clawpack, }; }