feat: require exact ClawPack publication (#3359)

Accept artifact-only publication for experimental Claws so ClawHub can attest, retry, and serve the exact stored bytes. Preserve exact actor, owner, and digest identity across staged retries and validate current release state before reuse. Add durable contract documentation and real-stack publish, poll, download, and retry proof.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Gio Della-Libera
2026-08-09 12:37:04 -07:00
committed by GitHub
parent 348851eeb9
commit 82313c2bb1
23 changed files with 1432 additions and 54 deletions
+131 -5
View File
@@ -37,6 +37,7 @@ const {
const { fetchGitHubRepositoryIdentity, verifyGitHubActionsTrustedPublishJwt } = const { fetchGitHubRepositoryIdentity, verifyGitHubActionsTrustedPublishJwt } =
await import("./lib/githubActionsOidc"); await import("./lib/githubActionsOidc");
const { buildBundleFingerprint } = await import("./lib/skillCards"); const { buildBundleFingerprint } = await import("./lib/skillCards");
const { sha256Hex } = await import("./lib/clawpack");
const { publishVersionForUser } = await import("./skills"); const { publishVersionForUser } = await import("./skills");
const { __handlers } = await import("./httpApiV1"); const { __handlers } = await import("./httpApiV1");
@@ -14385,6 +14386,8 @@ describe("httpApiV1 handlers", () => {
it("npm mirror tarball downloads record package installs and download metrics", async () => { it("npm mirror tarball downloads record package installs and download metrics", async () => {
vi.stubEnv("TRUST_FORWARDED_IPS", "true"); vi.stubEnv("TRUST_FORWARDED_IPS", "true");
const tarballBytes = new TextEncoder().encode("tarball");
const artifactSha256 = await sha256Hex(tarballBytes);
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => { const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args && !("paginationOpts" in args)) { if ("name" in args && !("paginationOpts" in args)) {
return { return {
@@ -14418,6 +14421,7 @@ describe("httpApiV1 handlers", () => {
files: [], files: [],
artifactKind: "npm-pack", artifactKind: "npm-pack",
clawpackStorageId: "storage:clawpack", clawpackStorageId: "storage:clawpack",
clawpackSha256: artifactSha256,
npmIntegrity: "sha512-demo", npmIntegrity: "sha512-demo",
npmShasum: "d".repeat(40), npmShasum: "d".repeat(40),
npmTarballName: "demo-plugin-1.0.0.tgz", npmTarballName: "demo-plugin-1.0.0.tgz",
@@ -14436,7 +14440,7 @@ describe("httpApiV1 handlers", () => {
runQuery, runQuery,
runMutation, runMutation,
storage: { storage: {
get: vi.fn(async () => new Blob(["tarball"], { type: "application/octet-stream" })), get: vi.fn(async () => new Blob([tarballBytes], { type: "application/octet-stream" })),
}, },
}), }),
new Request("https://example.com/api/npm/demo-plugin/-/demo-plugin-1.0.0.tgz", { new Request("https://example.com/api/npm/demo-plugin/-/demo-plugin-1.0.0.tgz", {
@@ -14445,6 +14449,9 @@ describe("httpApiV1 handlers", () => {
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(new Uint8Array(await response.arrayBuffer())).toEqual(tarballBytes);
expect(response.headers.get("X-ClawHub-Artifact-Sha256")).toBe(artifactSha256);
expect(response.headers.get("ETag")).toBe(`"sha256:${artifactSha256}"`);
expect(runMutation).toHaveBeenCalledWith( expect(runMutation).toHaveBeenCalledWith(
internal.packages.recordPackageInstallInternal, internal.packages.recordPackageInstallInternal,
expect.objectContaining({ expect.objectContaining({
@@ -15708,6 +15715,40 @@ describe("httpApiV1 handlers", () => {
}, },
); );
it("rejects loose Claw files before multipart storage when the experiment is enabled", async () => {
vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
kind: "user",
userId: "users:1",
user: { _id: "users:1", handle: "p" },
} as never);
const form = packagePublishForm(packagePublishMetadata({ family: "claw" }));
form.append("files", new File(["manifest"], "CLAW.md", { type: "text/markdown" }));
const storageStore = vi.fn();
const runAction = vi.fn();
const response = await __handlers.publishPackageV1Handler(
makeCtx({
runAction,
runMutation: vi.fn().mockResolvedValue(okRate()),
storage: { store: storageStore },
}),
new Request("https://example.com/api/v1/packages", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
body: form,
}),
);
expect(response.status).toBe(400);
expect(await response.text()).toBe(
"Claw publication requires an already-built package tarball (.tgz)",
);
expect(storageStore).not.toHaveBeenCalled();
expect(runAction).not.toHaveBeenCalled();
});
it("package publish rejects browser session auth when token auth is not an API token", async () => { it("package publish rejects browser session auth when token auth is not an API token", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:session" as never); vi.mocked(getAuthUserId).mockResolvedValue("users:session" as never);
vi.mocked(requirePackagePublishAuth).mockRejectedValue(new Error("Unauthorized")); vi.mocked(requirePackagePublishAuth).mockRejectedValue(new Error("Unauthorized"));
@@ -15888,10 +15929,7 @@ describe("httpApiV1 handlers", () => {
user: { _id: "users:1", handle: "p" }, user: { _id: "users:1", handle: "p" },
} as never); } as never);
const runMutation = vi.fn().mockResolvedValue(okRate()); const runMutation = vi.fn().mockResolvedValue(okRate());
const runAction = vi const storageStore = vi.fn(async (_blob: Blob) => `storage:${storageStore.mock.calls.length}`);
.fn()
.mockResolvedValue({ ok: true, packageId: "pkg:claw", releaseId: "rel:claw" });
const storageStore = vi.fn(async () => `storage:${storageStore.mock.calls.length}`);
const pack = npmPackFixture({ const pack = npmPackFixture({
"package/package.json": JSON.stringify({ "package/package.json": JSON.stringify({
name: "demo-claw", name: "demo-claw",
@@ -15901,6 +15939,13 @@ describe("httpApiV1 handlers", () => {
"package/CLAW.md": "package/CLAW.md":
"---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\nYou are a focused demo agent.\n", "---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\nYou are a focused demo agent.\n",
}); });
const artifactSha256 = await sha256Hex(pack);
const runAction = vi.fn().mockResolvedValue({
ok: true,
packageId: "pkg:claw",
releaseId: "rel:claw",
artifactSha256,
});
const form = new FormData(); const form = new FormData();
form.set( form.set(
"payload", "payload",
@@ -15909,6 +15954,7 @@ describe("httpApiV1 handlers", () => {
family: "claw", family: "claw",
version: "1.0.0", version: "1.0.0",
changelog: "init", changelog: "init",
expectedArtifactSha256: artifactSha256,
}), }),
); );
form.append( form.append(
@@ -15928,12 +15974,17 @@ describe("httpApiV1 handlers", () => {
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ artifactSha256 });
expect(storageStore).toHaveBeenCalledTimes(3); expect(storageStore).toHaveBeenCalledTimes(3);
const storedArtifact = storageStore.mock.calls[0]?.[0];
expect(storedArtifact).toBeInstanceOf(Blob);
expect(new Uint8Array(await (storedArtifact as Blob).arrayBuffer())).toEqual(pack);
expect(runAction).toHaveBeenCalledWith( expect(runAction).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.objectContaining({ expect.objectContaining({
payload: expect.objectContaining({ payload: expect.objectContaining({
family: "claw", family: "claw",
expectedArtifactSha256: artifactSha256,
artifact: expect.objectContaining({ kind: "npm-pack", npmFileCount: 2 }), artifact: expect.objectContaining({ kind: "npm-pack", npmFileCount: 2 }),
files: [ files: [
expect.objectContaining({ path: "package.json" }), expect.objectContaining({ path: "package.json" }),
@@ -15944,6 +15995,79 @@ describe("httpApiV1 handlers", () => {
); );
}); });
it.each([
{
label: "package name",
metadata: { name: "other-claw", version: "1.0.0" },
digest: "actual",
message: "Claw package name mismatch",
},
{
label: "package version",
metadata: { name: "demo-claw", version: "2.0.0" },
digest: "actual",
message: "Claw package version mismatch",
},
{
label: "artifact digest",
metadata: { name: "demo-claw", version: "1.0.0" },
digest: "0".repeat(64),
message: "Claw artifact SHA-256 mismatch",
},
])("rejects a Claw tarball with mismatched $label before storing it", async (testCase) => {
vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
kind: "user",
userId: "users:1",
user: { _id: "users:1", handle: "p" },
} as never);
const pack = npmPackFixture({
"package/package.json": JSON.stringify({
name: "demo-claw",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"package/CLAW.md": "---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\nDemo.\n",
});
const expectedArtifactSha256 =
testCase.digest === "actual" ? await sha256Hex(pack) : testCase.digest;
const form = packagePublishForm({
...packagePublishMetadata({
family: "claw",
name: testCase.metadata.name,
version: testCase.metadata.version,
}),
expectedArtifactSha256,
});
form.append(
"clawpack",
new File([bytesToArrayBuffer(pack)], "demo-claw-1.0.0.tgz", {
type: "application/octet-stream",
}),
);
const storageStore = vi.fn();
const runAction = vi.fn();
const response = await __handlers.publishPackageV1Handler(
makeCtx({
runAction,
runMutation: vi.fn().mockResolvedValue(okRate()),
storage: { store: storageStore },
}),
new Request("https://example.com/api/v1/packages", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
body: form,
}),
);
expect(response.status).toBe(400);
expect(await response.text()).toContain(testCase.message);
expect(storageStore).not.toHaveBeenCalled();
expect(runAction).not.toHaveBeenCalled();
});
it("staged ClawPack publish derives artifact metadata from stored bytes", async () => { it("staged ClawPack publish derives artifact metadata from stored bytes", async () => {
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never); vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
vi.mocked(requirePackagePublishAuth).mockResolvedValue({ vi.mocked(requirePackagePublishAuth).mockResolvedValue({
@@ -16237,6 +16361,7 @@ describe("httpApiV1 handlers", () => {
userId: "users:publisher", userId: "users:publisher",
packageId: "packages:demo", packageId: "packages:demo",
releaseId: "packageReleases:demo", releaseId: "packageReleases:demo",
artifactSha256: "a".repeat(64),
name: "@openclaw/demo", name: "@openclaw/demo",
version: "1.0.0", version: "1.0.0",
status: "finalized", status: "finalized",
@@ -16259,6 +16384,7 @@ describe("httpApiV1 handlers", () => {
attemptId: "publishAttempts:demo", attemptId: "publishAttempts:demo",
packageId: "packages:demo", packageId: "packages:demo",
releaseId: "packageReleases:demo", releaseId: "packageReleases:demo",
artifactSha256: "a".repeat(64),
name: "@openclaw/demo", name: "@openclaw/demo",
version: "1.0.0", version: "1.0.0",
status: "finalized", status: "finalized",
+33
View File
@@ -1439,6 +1439,32 @@ async function buildPackagePublishRequestFromClawPack(
return { ...metadata, files, artifact }; return { ...metadata, files, artifact };
} }
function assertClawPackPublicationIdentity(
metadata: PackagePublishMetadata,
parsed: ParsedPackageClawPack,
) {
if (metadata.family !== "claw") return;
const expectedName = tryNormalizePackageName(metadata.name);
if (!expectedName || parsed.packageName !== expectedName) {
throw new Error(
`Claw package name mismatch: expected ${expectedName ?? metadata.name}, got ${parsed.packageName}`,
);
}
const expectedVersion = metadata.version.trim();
if (parsed.packageVersion !== expectedVersion) {
throw new Error(
`Claw package version mismatch: expected ${expectedVersion}, got ${parsed.packageVersion}`,
);
}
const expectedDigest = metadata.expectedArtifactSha256?.trim().toLowerCase();
if (!expectedDigest) throw new Error("Claw publication requires expectedArtifactSha256");
if (expectedDigest !== parsed.artifactSha256) {
throw new Error(
`Claw artifact SHA-256 mismatch: expected ${expectedDigest}, got ${parsed.artifactSha256}`,
);
}
}
const PACKAGE_PUBLISH_FILE_FIELDS = ["files"] as const; const PACKAGE_PUBLISH_FILE_FIELDS = ["files"] as const;
const PACKAGE_PUBLISH_TARBALL_FIELDS = ["clawpack"] as const; const PACKAGE_PUBLISH_TARBALL_FIELDS = ["clawpack"] as const;
const PACKAGE_PUBLISH_FORM_FIELDS = new Set([ const PACKAGE_PUBLISH_FORM_FIELDS = new Set([
@@ -1489,6 +1515,9 @@ async function parseMultipartPackagePublish(
PACKAGE_PUBLISH_FILE_FIELDS, PACKAGE_PUBLISH_FILE_FIELDS,
"Package publish file uploads must be files", "Package publish file uploads must be files",
); );
if (metadata.family === "claw" && !tarballPart) {
throw new Error("Claw publication requires an already-built package tarball (.tgz)");
}
if (tarballPart) { if (tarballPart) {
if (fileParts.length > 0) { if (fileParts.length > 0) {
@@ -1498,6 +1527,7 @@ async function parseMultipartPackagePublish(
await consumePackageTarballUploadTicket(ctx, auth, tarballPart); await consumePackageTarballUploadTicket(ctx, auth, tarballPart);
const artifactBytes = await readStoredPackageTarball(ctx, tarballPart.storageId); const artifactBytes = await readStoredPackageTarball(ctx, tarballPart.storageId);
const parsed = await parseClawPack(artifactBytes); const parsed = await parseClawPack(artifactBytes);
assertClawPackPublicationIdentity(metadata, parsed);
return await buildPackagePublishRequestFromClawPack( return await buildPackagePublishRequestFromClawPack(
ctx, ctx,
metadata, metadata,
@@ -1522,6 +1552,7 @@ async function parseMultipartPackagePublish(
} }
const artifactBytes = new Uint8Array(await tarballEntry.arrayBuffer()); const artifactBytes = new Uint8Array(await tarballEntry.arrayBuffer());
const parsed = await parseClawPack(artifactBytes); const parsed = await parseClawPack(artifactBytes);
assertClawPackPublicationIdentity(metadata, parsed);
const artifactStorageId = await ctx.storage.store( const artifactStorageId = await ctx.storage.store(
new Blob([bytesToArrayBuffer(artifactBytes)], { type: "application/octet-stream" }), new Blob([bytesToArrayBuffer(artifactBytes)], { type: "application/octet-stream" }),
); );
@@ -2426,6 +2457,7 @@ type PackagePublishAttemptStatusResult = {
userId: Id<"users">; userId: Id<"users">;
packageId: Id<"packages">; packageId: Id<"packages">;
releaseId: Id<"packageReleases">; releaseId: Id<"packageReleases">;
artifactSha256?: string;
name: string; name: string;
version: string; version: string;
status: status:
@@ -2482,6 +2514,7 @@ export async function publishAttemptsGetRouterV1Handler(ctx: ActionCtx, request:
attemptId: attempt.attemptId, attemptId: attempt.attemptId,
packageId: attempt.packageId, packageId: attempt.packageId,
releaseId: attempt.releaseId, releaseId: attempt.releaseId,
...(attempt.artifactSha256 ? { artifactSha256: attempt.artifactSha256 } : {}),
name: attempt.name, name: attempt.name,
version: attempt.version, version: attempt.version,
status: attempt.status, status: attempt.status,
+4 -1
View File
@@ -196,7 +196,10 @@ async function classifySuspiciousPublishAttemptPublicState(
) )
.unique(); .unique();
if (!release) return "replay_missing"; if (!release) return "replay_missing";
if (release.softDeletedAt || release.integritySha256 !== attempt.artifactFingerprint) { const matchesArtifact =
release.integritySha256 === attempt.artifactFingerprint ||
(pkg.family === "claw" && release.clawpackSha256 === attempt.artifactFingerprint);
if (release.softDeletedAt || !matchesArtifact) {
return "public_conflict"; return "public_conflict";
} }
return "replay_identical"; return "replay_identical";
+473 -5
View File
@@ -16,6 +16,7 @@ import {
getPackageReleaseScanBackfillBatchInternal, getPackageReleaseScanBackfillBatchInternal,
getByName, getByName,
list, list,
publishRelease,
publishPackageForTrustedPublisherInternal, publishPackageForTrustedPublisherInternal,
publishPackageForUserInternal, publishPackageForUserInternal,
generateChangelogPreview, generateChangelogPreview,
@@ -42,6 +43,7 @@ import {
resolvePackageAppealForUserInternal, resolvePackageAppealForUserInternal,
upsertOfficialPluginMigrationForUserInternal, upsertOfficialPluginMigrationForUserInternal,
getVersionByName, getVersionByName,
getVersionByNameForViewerInternal,
getVersionSecurityByNameForViewerInternal, getVersionSecurityByNameForViewerInternal,
insertReleaseInternal, insertReleaseInternal,
cleanupReassignedPackageReleaseTagsInternal, cleanupReassignedPackageReleaseTagsInternal,
@@ -56,6 +58,7 @@ import {
listPublicNewPluginsPage, listPublicNewPluginsPage,
listPageForViewerInternal, listPageForViewerInternal,
listVersions, listVersions,
listVersionsForViewerInternal,
listVersionsForManager, listVersionsForManager,
updateReleaseLlmAnalysisInternal, updateReleaseLlmAnalysisInternal,
updateReleaseStaticScanInternal, updateReleaseStaticScanInternal,
@@ -135,6 +138,12 @@ const getVersionByNameHandler = (
{ package: { name: string; scanStatus?: string }; version: { version: string } } | null { package: { name: string; scanStatus?: string }; version: { version: string } } | null
> >
)._handler; )._handler;
const getVersionByNameForViewerInternalHandler = (
getVersionByNameForViewerInternal as unknown as WrappedHandler<
{ name: string; version: string; viewerUserId?: string },
{ package: { name: string }; version: { version: string; clawpackStorageId?: string } } | null
>
)._handler;
const getVersionSecurityByNameForViewerInternalHandler = ( const getVersionSecurityByNameForViewerInternalHandler = (
getVersionSecurityByNameForViewerInternal as unknown as WrappedHandler< getVersionSecurityByNameForViewerInternal as unknown as WrappedHandler<
{ name: string; version: string; viewerUserId?: string }, { name: string; version: string; viewerUserId?: string },
@@ -246,6 +255,20 @@ const listVersionsHandler = (
} }
> >
)._handler; )._handler;
const listVersionsForViewerInternalHandler = (
listVersionsForViewerInternal as unknown as WrappedHandler<
{
name: string;
viewerUserId?: string;
paginationOpts: { cursor: string | null; numItems: number };
},
{
page: Array<{ version: string; clawpackStorageId?: string }>;
isDone: boolean;
continueCursor: string;
}
>
)._handler;
const listVersionsForManagerHandler = ( const listVersionsForManagerHandler = (
listVersionsForManager as unknown as WrappedHandler< listVersionsForManager as unknown as WrappedHandler<
{ {
@@ -277,7 +300,7 @@ const insertReleaseInternalHandler = (
}; };
name: string; name: string;
displayName: string; displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin"; family: "skill" | "code-plugin" | "bundle-plugin" | "claw";
version: string; version: string;
publicationStatus?: "pending" | "published"; publicationStatus?: "pending" | "published";
changelog: string; changelog: string;
@@ -328,6 +351,7 @@ const findPackagePublishResultInternalHandler = (
name: string; name: string;
version: string; version: string;
integritySha256: string; integritySha256: string;
clawpackSha256?: string;
ownerUserId: string; ownerUserId: string;
ownerPublisherId?: string; ownerPublisherId?: string;
}, },
@@ -421,6 +445,9 @@ const publishPackageForUserInternalHandler = (
unknown unknown
> >
)._handler; )._handler;
const publishReleaseHandler = (
publishRelease as unknown as WrappedHandler<{ payload: unknown }, unknown>
)._handler;
const publishPackageForTrustedPublisherInternalHandler = ( const publishPackageForTrustedPublisherInternalHandler = (
publishPackageForTrustedPublisherInternal as unknown as WrappedHandler< publishPackageForTrustedPublisherInternal as unknown as WrappedHandler<
{ {
@@ -7122,6 +7149,51 @@ describe("packages public queries", () => {
expect(releaseIndexNames).toContain("by_package_active_created"); expect(releaseIndexNames).toContain("by_package_active_created");
}); });
it("keeps ClawPack storage ids on internal downloadable version projections", async () => {
const release = makeReleaseDoc({
files: [],
artifactKind: "npm-pack",
clawpackStorageId: "storage:clawpack",
clawpackSha256: "a".repeat(64),
});
const { ctx } = makePackageCtx({
pkg: makePackageDoc({ family: "claw" }),
latestRelease: release,
versionRelease: release,
versionsPage: {
page: [
release,
makeReleaseDoc({
_id: "packageReleases:legacy",
version: "0.9.0",
files: [],
}),
],
isDone: true,
continueCursor: "",
},
});
const listed = await listVersionsForViewerInternalHandler(ctx, {
name: "demo-plugin",
paginationOpts: { cursor: null, numItems: 10 },
});
const exact = await getVersionByNameForViewerInternalHandler(ctx, {
name: "demo-plugin",
version: "1.0.0",
});
expect(listed.page[0]).toMatchObject({
version: "1.0.0",
clawpackStorageId: "storage:clawpack",
});
expect(exact?.version).toMatchObject({
version: "1.0.0",
clawpackStorageId: "storage:clawpack",
});
expect(listed.page[1]).not.toHaveProperty("clawpackStorageId");
});
it("fills public package version pages after skipping pending releases", async () => { it("fills public package version pages after skipping pending releases", async () => {
const releases = [ const releases = [
makeReleaseDoc({ makeReleaseDoc({
@@ -8555,6 +8627,40 @@ describe("packages public queries", () => {
}); });
}); });
it("does not recover a Claw publish result for different exact artifact bytes", async () => {
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(
table === "packages"
? makePackageDoc({
family: "claw",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
})
: makeReleaseDoc({
integritySha256: "same-extracted-files",
clawpackSha256: "a".repeat(64),
}),
),
})),
})),
},
};
await expect(
findPackagePublishResultInternalHandler(ctx as never, {
name: "demo-claw",
version: "1.0.0",
integritySha256: "same-extracted-files",
clawpackSha256: "b".repeat(64),
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
}),
).resolves.toBeNull();
});
it("does not recover pending package publish results as public successes", async () => { it("does not recover pending package publish results as public successes", async () => {
const release = makeReleaseDoc({ const release = makeReleaseDoc({
integritySha256: "abc123", integritySha256: "abc123",
@@ -9791,6 +9897,103 @@ describe("packages public queries", () => {
expect(ctx.patch).not.toHaveBeenCalled(); expect(ctx.patch).not.toHaveBeenCalled();
}); });
it("rejects a Claw version retry when the exact artifact digest differs", async () => {
const ctx = makeInsertReleaseCtx(makePackageDoc({ family: "claw" }), [
makeReleaseDoc({
_id: "packageReleases:existing",
version: "1.0.0",
integritySha256: "same-extracted-files",
clawpackSha256: "a".repeat(64),
}),
]);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-claw",
displayName: "Demo Claw",
family: "claw",
version: "1.0.0",
changelog: "retry",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "same-extracted-files",
clawpackSha256: "b".repeat(64),
allowExistingRelease: true,
}),
).rejects.toThrow("Version 1.0.0 already exists. Increment the version number and try again.");
});
it("treats an ordinary-user exact Claw artifact retry as idempotent", async () => {
const artifactSha256 = "a".repeat(64);
const ctx = makeInsertReleaseCtx(makePackageDoc({ family: "claw" }), [
makeReleaseDoc({
_id: "packageReleases:existing",
version: "1.0.0",
integritySha256: "same-extracted-files",
clawpackSha256: artifactSha256,
}),
]);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-claw",
displayName: "Demo Claw",
family: "claw",
version: "1.0.0",
changelog: "retry",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "same-extracted-files",
clawpackSha256: artifactSha256,
}),
).resolves.toMatchObject({
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:existing",
});
});
it.each(["pending", "blocked"] as const)(
"does not report a %s exact Claw release as published",
async (publicationStatus) => {
const artifactSha256 = "a".repeat(64);
const ctx = makeInsertReleaseCtx(makePackageDoc({ family: "claw" }), [
makeReleaseDoc({
_id: "packageReleases:pending",
version: "1.0.0",
publicationStatus,
integritySha256: "same-extracted-files",
clawpackSha256: artifactSha256,
}),
]);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-claw",
displayName: "Demo Claw",
family: "claw",
version: "1.0.0",
changelog: "retry",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "same-extracted-files",
clawpackSha256: artifactSha256,
}),
).rejects.toThrow(
"Version 1.0.0 already exists. Increment the version number and try again.",
);
},
);
it("keeps an initial beta-only package publish off latest", async () => { it("keeps an initial beta-only package publish off latest", async () => {
const ctx = makeInsertReleaseCtx( const ctx = makeInsertReleaseCtx(
makePackageDoc({ makePackageDoc({
@@ -9876,6 +10079,19 @@ describe("packages public queries", () => {
).rejects.toThrow("Skill packages must use the skills publish flow"); ).rejects.toThrow("Skill packages must use the skills publish flow");
}); });
it("requires the exact-artifact HTTP flow for public Claw publishes", async () => {
await expect(
publishReleaseHandler({} as never, {
payload: {
name: "demo-claw",
family: "claw",
version: "1.0.0",
},
}),
).rejects.toThrow("Claw packages must use the exact-artifact HTTP publish flow");
expect(getAuthUserId).not.toHaveBeenCalled();
});
it("rejects Claw publication before mutation when the experimental gate is disabled", async () => { it("rejects Claw publication before mutation when the experimental gate is disabled", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS; const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS; delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
@@ -9933,6 +10149,7 @@ describe("packages public queries", () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS; const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
process.env.CLAWHUB_EXPERIMENTAL_CLAWS = "1"; process.env.CLAWHUB_EXPERIMENTAL_CLAWS = "1";
const longClawDescription = "x".repeat(1_100); const longClawDescription = "x".repeat(1_100);
const artifactSha256 = "a".repeat(64);
const storedFiles = new Map<string, string>([ const storedFiles = new Map<string, string>([
[ [
"storage:package", "storage:package",
@@ -10000,6 +10217,7 @@ describe("packages public queries", () => {
family: "claw", family: "claw",
version: "1.0.0", version: "1.0.0",
changelog: "init", changelog: "init",
expectedArtifactSha256: artifactSha256,
files: [ files: [
{ path: "package.json", size: 1, storageId: "storage:package", sha256: "package" }, { path: "package.json", size: 1, storageId: "storage:package", sha256: "package" },
{ {
@@ -10027,6 +10245,18 @@ describe("packages public queries", () => {
sha256: "bootstrap", sha256: "bootstrap",
}, },
], ],
artifact: {
kind: "npm-pack",
storageId: "storage:archive",
sha256: artifactSha256,
size: 3,
format: "tgz",
npmIntegrity: "sha512-demo",
npmShasum: "b".repeat(40),
npmTarballName: "demo-claw-1.0.0.tgz",
npmUnpackedSize: 3,
npmFileCount: 3,
},
}, },
}), }),
).resolves.toEqual({ ).resolves.toEqual({
@@ -10034,6 +10264,7 @@ describe("packages public queries", () => {
packageId: "packages:claw", packageId: "packages:claw",
releaseId: "releases:claw-1", releaseId: "releases:claw-1",
publicationStatus: "published", publicationStatus: "published",
artifactSha256,
}); });
expect(runMutation).toHaveBeenCalledWith( expect(runMutation).toHaveBeenCalledWith(
@@ -10041,10 +10272,10 @@ describe("packages public queries", () => {
expect.objectContaining({ expect.objectContaining({
family: "claw", family: "claw",
summary: expect.any(String), summary: expect.any(String),
artifactKind: "legacy-zip", artifactKind: "npm-pack",
clawpackStorageId: "storage:legacy-zip", clawpackStorageId: "storage:archive",
clawpackSha256: expect.stringMatching(/^[a-f0-9]{64}$/), clawpackSha256: artifactSha256,
clawpackSize: expect.any(Number), clawpackSize: 3,
clawManifestSummary: expect.objectContaining({ clawManifestSummary: expect.objectContaining({
agent: expect.objectContaining({ agent: expect.objectContaining({
id: "demo-claw", id: "demo-claw",
@@ -10068,6 +10299,243 @@ describe("packages public queries", () => {
} }
}); });
it("reuses an in-flight staged Claw publish for the same user and artifact", async () => {
const previousStage = process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES;
process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = "1";
const artifactSha256 = "a".repeat(64);
const storedFiles = new Map<string, string>([
[
"storage:package",
JSON.stringify({
name: "demo-claw",
version: "1.0.0",
openclaw: { claw: "manifests/CLAW.md" },
}),
],
[
"storage:claw",
"---\nschemaVersion: 1\nagent:\n id: demo-claw\n name: Demo Claw\n---\nRun the demo workflow precisely.\n",
],
["storage:profile", "schemaVersion: 1\nagent:\n tools:\n profile: coding\n"],
]);
const existingPackage = makePackageDoc({
family: "claw",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
});
const existingRelease = makeReleaseDoc({
_id: "packageReleases:pending",
packageId: "packages:demo",
publicationStatus: "pending",
clawpackSha256: artifactSha256,
});
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if (args.minimumRole === "publisher") {
return { publisherId: "publishers:owner", linkedUserId: "users:owner" };
}
throw new Error("retry should not create another release or publish attempt");
});
const runQuery = vi
.fn()
.mockResolvedValueOnce(existingPackage)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
_id: "users:owner",
githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000,
})
.mockResolvedValueOnce({
_id: "users:owner",
role: "user",
githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000,
})
.mockResolvedValueOnce({
_id: "publishers:owner",
kind: "user",
handle: "owner",
linkedUserId: "users:owner",
})
.mockResolvedValueOnce(existingRelease)
.mockResolvedValueOnce({
attemptId: "publishAttempts:pending",
status: "pending_checks",
reusable: true,
packageId: "packages:demo",
releaseId: "packageReleases:pending",
artifactFingerprint: artifactSha256,
});
const ctx = {
runQuery,
runMutation,
scheduler: { runAfter: vi.fn() },
storage: {
get: vi.fn(async (storageId: string) => {
const content = storedFiles.get(storageId);
return content === undefined ? null : new Blob([content]);
}),
store: vi.fn(async () => "storage:legacy-zip"),
},
};
try {
await expect(
publishPackageForUserInternalHandler(ctx as never, {
actorUserId: "users:owner",
payload: {
name: "demo-claw",
displayName: "Demo Claw",
family: "claw",
version: "1.0.0",
changelog: "retry",
ownerHandle: "owner",
expectedArtifactSha256: artifactSha256,
files: [
{ path: "package.json", size: 1, storageId: "storage:package", sha256: "package" },
{
path: "manifests/CLAW.md",
size: 1,
storageId: "storage:claw",
sha256: "claw",
},
{
path: "profiles/openclaw.yml",
size: 1,
storageId: "storage:profile",
sha256: "profile",
},
],
artifact: {
kind: "npm-pack",
storageId: "storage:archive",
sha256: artifactSha256,
size: 3,
format: "tgz",
npmIntegrity: "sha512-demo",
npmShasum: "b".repeat(40),
npmTarballName: "demo-claw-1.0.0.tgz",
npmUnpackedSize: 3,
npmFileCount: 3,
},
},
}),
).resolves.toMatchObject({
ok: true,
status: "pending",
packageId: "packages:demo",
releaseId: "packageReleases:pending",
artifactSha256,
publicationStatus: "pending",
attemptId: "publishAttempts:pending",
});
expect(runMutation).toHaveBeenCalledTimes(1);
expect(runQuery).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
kind: "package",
slug: "demo-claw",
version: "1.0.0",
userId: "users:owner",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
artifactFingerprint: artifactSha256,
}),
);
runQuery
.mockResolvedValueOnce(existingPackage)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
_id: "users:owner",
githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000,
})
.mockResolvedValueOnce({
_id: "users:owner",
role: "user",
githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000,
})
.mockResolvedValueOnce({
_id: "publishers:owner",
kind: "user",
handle: "owner",
linkedUserId: "users:owner",
})
.mockResolvedValueOnce(existingRelease)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
attemptId: "publishAttempts:different-artifact",
});
await expect(
publishPackageForUserInternalHandler(ctx as never, {
actorUserId: "users:owner",
payload: {
name: "demo-claw",
displayName: "Demo Claw",
family: "claw",
version: "1.0.0",
changelog: "different artifact",
ownerHandle: "owner",
expectedArtifactSha256: "c".repeat(64),
files: [
{ path: "package.json", size: 1, storageId: "storage:package", sha256: "package" },
{
path: "manifests/CLAW.md",
size: 1,
storageId: "storage:claw",
sha256: "claw",
},
{
path: "profiles/openclaw.yml",
size: 1,
storageId: "storage:profile",
sha256: "profile",
},
],
artifact: {
kind: "npm-pack",
storageId: "storage:archive",
sha256: "c".repeat(64),
size: 3,
format: "tgz",
npmIntegrity: "sha512-different",
npmShasum: "d".repeat(40),
npmTarballName: "demo-claw-1.0.0.tgz",
npmUnpackedSize: 3,
npmFileCount: 3,
},
},
}),
).rejects.toThrow(
"Version 1.0.0 already exists. Increment the version number and try again.",
);
expect(runQuery).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
kind: "package",
slug: "demo-claw",
version: "1.0.0",
}),
);
expect(runQuery.mock.calls.at(-1)?.[1]).not.toHaveProperty("artifactFingerprint");
expect(runMutation).toHaveBeenCalledTimes(2);
expect(runMutation).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
packageId: existingPackage._id,
releaseId: existingRelease._id,
createdNewParent: expect.anything(),
}),
);
} finally {
if (previousStage === undefined) {
delete process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES;
} else {
process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = previousStage;
}
}
});
it("keeps raw package publishes behind the per-file size limit", async () => { it("keeps raw package publishes behind the per-file size limit", async () => {
const ctx = { const ctx = {
runQuery: vi runQuery: vi
+175 -18
View File
@@ -3215,7 +3215,11 @@ export const listVersionsForViewerInternal = internalQuery({
const result = await paginatePublishedPackageReleases(ctx, pkg._id, args.paginationOpts); const result = await paginatePublishedPackageReleases(ctx, pkg._id, args.paginationOpts);
return { return {
...result, ...result,
page: result.page.map((release) => toPublicPackageRelease(release, pkg.family)), page: result.page.map((release) => ({
...toPublicPackageRelease(release, pkg.family),
// Internal HTTP handlers need the opaque storage id to stream exact ClawPack bytes.
...(release.clawpackStorageId ? { clawpackStorageId: release.clawpackStorageId } : {}),
})),
}; };
}, },
}); });
@@ -3319,7 +3323,11 @@ export const getVersionByNameForViewerInternal = internalQuery({
if (!publicPackage) return null; if (!publicPackage) return null;
return { return {
package: publicPackage, package: publicPackage,
version: toPublicPackageRelease(release, pkg.family), version: {
...toPublicPackageRelease(release, pkg.family),
// Internal HTTP handlers need the opaque storage id to stream exact ClawPack bytes.
...(release.clawpackStorageId ? { clawpackStorageId: release.clawpackStorageId } : {}),
},
}; };
}, },
}); });
@@ -5051,6 +5059,7 @@ export const findPackagePublishResultInternal = internalQuery({
name: v.string(), name: v.string(),
version: v.string(), version: v.string(),
integritySha256: v.string(), integritySha256: v.string(),
clawpackSha256: v.optional(v.string()),
ownerUserId: v.id("users"), ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")), ownerPublisherId: v.optional(v.id("publishers")),
}, },
@@ -5064,7 +5073,14 @@ export const findPackagePublishResultInternal = internalQuery({
q.eq("packageId", pkg._id).eq("version", args.version), q.eq("packageId", pkg._id).eq("version", args.version),
) )
.unique(); .unique();
if (!isPublishedPackageRelease(release) || release.integritySha256 !== args.integritySha256) { const matchesExactClawArtifact =
pkg.family !== "claw" ||
(typeof args.clawpackSha256 === "string" && release?.clawpackSha256 === args.clawpackSha256);
if (
!isPublishedPackageRelease(release) ||
release.integritySha256 !== args.integritySha256 ||
!matchesExactClawArtifact
) {
return null; return null;
} }
return { ok: true as const, packageId: pkg._id, releaseId: release._id }; return { ok: true as const, packageId: pkg._id, releaseId: release._id };
@@ -8272,6 +8288,28 @@ async function publishPackageImpl(
throw new ConvexError(`Claw package name must use canonical form ${name}`); throw new ConvexError(`Claw package name must use canonical form ${name}`);
} }
const version = assertPackageVersion(family, payload.version); const version = assertPackageVersion(family, payload.version);
if (family === "claw") {
if (payload.artifact?.kind !== "npm-pack") {
throw new ConvexError("Claw publication requires an already-built package tarball (.tgz)");
}
const expectedArtifactSha256 = payload.expectedArtifactSha256?.trim().toLowerCase();
if (!expectedArtifactSha256) {
throw new ConvexError("Claw publication requires expectedArtifactSha256");
}
if (!/^[a-f0-9]{64}$/.test(expectedArtifactSha256)) {
throw new ConvexError(
"Claw expectedArtifactSha256 must be a 64-character SHA-256 hex digest",
);
}
if (!/^[a-f0-9]{64}$/.test(payload.artifact.sha256)) {
throw new ConvexError("Claw artifact SHA-256 must be a 64-character lowercase hex digest");
}
if (expectedArtifactSha256 !== payload.artifact.sha256) {
throw new ConvexError(
`Claw artifact SHA-256 mismatch: expected ${expectedArtifactSha256}, got ${payload.artifact.sha256}`,
);
}
}
const existingPackage = await runQueryRef<Doc<"packages"> | null>( const existingPackage = await runQueryRef<Doc<"packages"> | null>(
ctx, ctx,
internalRefs.packages.getPackageByNameInternal, internalRefs.packages.getPackageByNameInternal,
@@ -8720,6 +8758,8 @@ async function publishPackageImpl(
clawManifestSummary: validatedClaw?.summary, clawManifestSummary: validatedClaw?.summary,
source: effectiveSource, source: effectiveSource,
}; };
const publishedArtifactSha256 = family === "claw" ? packageInsertArgs.clawpackSha256 : undefined;
const attemptArtifactFingerprint = publishedArtifactSha256 ?? integritySha256;
const inspectorFindings = const inspectorFindings =
inspectorResult?.warnings.map((finding) => inspectorResult?.warnings.map((finding) =>
@@ -8735,20 +8775,106 @@ async function publishPackageImpl(
{ packageId: existingPackage._id, version }, { packageId: existingPackage._id, version },
); );
} }
const existingAttempt = await runQueryRef<null | { attemptId: Id<"publishAttempts"> }>( const existingAttempt = await runQueryRef<null | {
ctx, attemptId: Id<"publishAttempts">;
internalRefs.publishAttempts.findExistingPublishAttemptForArtifactInternal, status: string;
{ reusable: boolean;
kind: "package", packageId?: Id<"packages">;
slug: name, releaseId?: Id<"packageReleases">;
version, result?: {
}, ok: true;
); packageId: Id<"packages">;
releaseId: Id<"packageReleases">;
};
}>(ctx, internalRefs.publishAttempts.findExistingPublishAttemptForArtifactInternal, {
kind: "package",
slug: name,
version,
...(family === "claw"
? {
userId: actorUserId,
ownerUserId,
ownerPublisherId,
artifactFingerprint: attemptArtifactFingerprint,
}
: {}),
});
if (existingAttempt) { if (existingAttempt) {
const reusableClawAttempt =
family === "claw" &&
existingAttempt.reusable &&
existingPackage !== null &&
!existingPackage.softDeletedAt &&
existingAttempt.packageId !== undefined &&
existingAttempt.packageId === existingPackage._id &&
existingAttempt.releaseId !== undefined &&
existingRelease !== null &&
existingAttempt.releaseId === existingRelease._id &&
!existingRelease.softDeletedAt &&
existingRelease.ownerDeletedAt === undefined &&
existingRelease.manualModeration?.state !== "quarantined" &&
existingRelease.manualModeration?.state !== "revoked" &&
resolvePackageReleaseScanStatus(existingRelease) !== "malicious" &&
(existingAttempt.status === "finalized"
? isPublishedPackageRelease(existingRelease)
: existingRelease.publicationStatus === "pending");
if (reusableClawAttempt) {
if (existingAttempt.status === "finalized") {
if (!existingAttempt.result) {
throw new ConvexError("Finalized publish attempt is missing its package result.");
}
if (auth.kind === "github-actions") {
await runMutationRef(ctx, internalRefs.packagePublishTokens.revokeInternal, {
tokenId: auth.publishToken._id,
});
}
const finalizedResult = {
...existingAttempt.result,
publicationStatus: "published" as const,
artifactSha256: publishedArtifactSha256,
};
return inspectorFindings.length > 0
? { ...finalizedResult, inspectorFindings }
: finalizedResult;
}
if (auth.kind === "github-actions") {
await runMutationRef(ctx, internalRefs.packagePublishTokens.revokeInternal, {
tokenId: auth.publishToken._id,
});
}
return {
ok: true as const,
status: "pending" as const,
packageId: existingAttempt.packageId,
releaseId: existingAttempt.releaseId,
artifactSha256: publishedArtifactSha256,
publicationStatus: "pending" as const,
attemptId: existingAttempt.attemptId,
packageName: name,
version,
...(inspectorFindings.length > 0 ? { inspectorFindings } : {}),
};
}
throw new ConvexError( throw new ConvexError(
`Version ${version} already exists. Increment the version number and try again.`, `Version ${version} already exists. Increment the version number and try again.`,
); );
} }
if (family === "claw") {
const conflictingAttempt = await runQueryRef<null | { attemptId: Id<"publishAttempts"> }>(
ctx,
internalRefs.publishAttempts.findExistingPublishAttemptForArtifactInternal,
{
kind: "package",
slug: name,
version,
},
);
if (conflictingAttempt) {
throw new ConvexError(
`Version ${version} already exists. Increment the version number and try again.`,
);
}
}
if (existingPackage && existingRelease) { if (existingPackage && existingRelease) {
if (!existingRelease.softDeletedAt && existingRelease.publicationStatus === "pending") { if (!existingRelease.softDeletedAt && existingRelease.publicationStatus === "pending") {
await runMutationRef(ctx, internalRefs.packages.discardPendingPackagePublicationInternal, { await runMutationRef(ctx, internalRefs.packages.discardPendingPackagePublicationInternal, {
@@ -8796,9 +8922,9 @@ async function publishPackageImpl(
ownerUserId, ownerUserId,
name, name,
version, version,
integritySha256, artifactFingerprint: attemptArtifactFingerprint,
}), }),
artifactFingerprint: integritySha256, artifactFingerprint: attemptArtifactFingerprint,
files, files,
clawpackStorageId: packageInsertArgs.clawpackStorageId, clawpackStorageId: packageInsertArgs.clawpackStorageId,
scanContext: buildPackagePublishAttemptScanContext(packageInsertArgs), scanContext: buildPackagePublishAttemptScanContext(packageInsertArgs),
@@ -8856,6 +8982,7 @@ async function publishPackageImpl(
const finalizedResult = { const finalizedResult = {
...staged.result, ...staged.result,
publicationStatus: "published" as const, publicationStatus: "published" as const,
...(publishedArtifactSha256 ? { artifactSha256: publishedArtifactSha256 } : {}),
}; };
return inspectorFindings.length > 0 return inspectorFindings.length > 0
? { ...finalizedResult, inspectorFindings } ? { ...finalizedResult, inspectorFindings }
@@ -8867,6 +8994,7 @@ async function publishPackageImpl(
status: "pending" as const, status: "pending" as const,
packageId: pendingResult.packageId, packageId: pendingResult.packageId,
releaseId: pendingResult.releaseId, releaseId: pendingResult.releaseId,
...(publishedArtifactSha256 ? { artifactSha256: publishedArtifactSha256 } : {}),
publicationStatus: "pending" as const, publicationStatus: "pending" as const,
attemptId: staged.attemptId, attemptId: staged.attemptId,
packageName: name, packageName: name,
@@ -8966,6 +9094,7 @@ async function publishPackageImpl(
const publishedResult = { const publishedResult = {
...publishResult, ...publishResult,
publicationStatus: "published" as const, publicationStatus: "published" as const,
...(publishedArtifactSha256 ? { artifactSha256: publishedArtifactSha256 } : {}),
}; };
return inspectorFindings.length > 0 ? { ...publishedResult, inspectorFindings } : publishedResult; return inspectorFindings.length > 0 ? { ...publishedResult, inspectorFindings } : publishedResult;
} }
@@ -9011,6 +9140,14 @@ export const publishRelease: ReturnType<typeof action> = action({
payload: v.any(), payload: v.any(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
if (
args.payload &&
typeof args.payload === "object" &&
!Array.isArray(args.payload) &&
(args.payload as Record<string, unknown>).family === "claw"
) {
throw new ConvexError("Claw packages must use the exact-artifact HTTP publish flow");
}
const { userId } = await requireUserFromAction(ctx); const { userId } = await requireUserFromAction(ctx);
const stagePrePublicationChecks = stagedPrePublicationPublishesEnabled(); const stagePrePublicationChecks = stagedPrePublicationPublishesEnabled();
return await publishPackageImpl(ctx, { kind: "user", actorUserId: userId }, args.payload, { return await publishPackageImpl(ctx, { kind: "user", actorUserId: userId }, args.payload, {
@@ -9071,6 +9208,7 @@ export const finalizePackagePublishAttemptInternal = internalAction({
name?: string; name?: string;
version?: string; version?: string;
integritySha256?: string; integritySha256?: string;
clawpackSha256?: string;
ownerUserId?: Id<"users">; ownerUserId?: Id<"users">;
ownerPublisherId?: Id<"publishers">; ownerPublisherId?: Id<"publishers">;
}; };
@@ -9087,6 +9225,7 @@ export const finalizePackagePublishAttemptInternal = internalAction({
name: insertArgs.name, name: insertArgs.name,
version: insertArgs.version, version: insertArgs.version,
integritySha256: insertArgs.integritySha256, integritySha256: insertArgs.integritySha256,
clawpackSha256: insertArgs.clawpackSha256,
ownerUserId: insertArgs.ownerUserId, ownerUserId: insertArgs.ownerUserId,
ownerPublisherId: insertArgs.ownerPublisherId, ownerPublisherId: insertArgs.ownerPublisherId,
}) })
@@ -9184,7 +9323,7 @@ function buildPackagePublishAttemptIdempotencyKey(args: {
ownerPublisherId?: Id<"publishers">; ownerPublisherId?: Id<"publishers">;
name: string; name: string;
version: string; version: string;
integritySha256: string; artifactFingerprint: string;
}) { }) {
return [ return [
"package", "package",
@@ -9192,7 +9331,7 @@ function buildPackagePublishAttemptIdempotencyKey(args: {
args.ownerPublisherId ?? args.ownerUserId, args.ownerPublisherId ?? args.ownerUserId,
args.name, args.name,
args.version, args.version,
args.integritySha256, args.artifactFingerprint,
].join(":"); ].join(":");
} }
@@ -11186,10 +11325,28 @@ export const insertReleaseInternal = internalMutation({
) )
.unique(); .unique();
if (releaseExists) { if (releaseExists) {
const matchesExactClawArtifact =
args.family !== "claw" ||
(typeof args.clawpackSha256 === "string" &&
releaseExists.clawpackSha256 === args.clawpackSha256);
const matchesExistingOwner =
args.ownerPublisherId !== undefined
? existing.ownerPublisherId === args.ownerPublisherId
: existing.ownerPublisherId === undefined && existing.ownerUserId === args.ownerUserId;
const allowExactClawRetry =
args.family === "claw" && matchesExistingOwner && matchesExactClawArtifact;
const canReuseExistingRelease =
args.allowExistingRelease ||
(allowExactClawRetry &&
isPublishedPackageRelease(releaseExists) &&
releaseExists.manualModeration?.state !== "quarantined" &&
releaseExists.manualModeration?.state !== "revoked" &&
resolvePackageReleaseScanStatus(releaseExists) !== "malicious");
if ( if (
args.allowExistingRelease && canReuseExistingRelease &&
!releaseExists.softDeletedAt && !releaseExists.softDeletedAt &&
releaseExists.integritySha256 === args.integritySha256 releaseExists.integritySha256 === args.integritySha256 &&
matchesExactClawArtifact
) { ) {
return { return {
ok: true as const, ok: true as const,
+161
View File
@@ -7,6 +7,8 @@ import {
completePendingPublishAttemptChecksInternal, completePendingPublishAttemptChecksInternal,
createPackagePublishAttemptInternal, createPackagePublishAttemptInternal,
createSkillPublishAttemptInternal, createSkillPublishAttemptInternal,
findExistingPublishAttemptForArtifactInternal,
getPackagePublishAttemptStatusInternal,
recordSkillPublishAttemptFinalizedInternal, recordSkillPublishAttemptFinalizedInternal,
releasePackagePublishAttemptFinalizationClaimInternal, releasePackagePublishAttemptFinalizationClaimInternal,
releaseSkillPublishAttemptFinalizationClaimInternal, releaseSkillPublishAttemptFinalizationClaimInternal,
@@ -57,8 +59,167 @@ const createPackagePublishAttemptHandler = (
_handler: (ctx: unknown, args: unknown) => Promise<unknown>; _handler: (ctx: unknown, args: unknown) => Promise<unknown>;
} }
)._handler; )._handler;
const getPackagePublishAttemptStatusHandler = (
getPackagePublishAttemptStatusInternal as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
}
)._handler;
const findExistingPublishAttemptForArtifactHandler = (
findExistingPublishAttemptForArtifactInternal as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
}
)._handler;
function makeAttemptLookupCtx(attempts: Array<Record<string, unknown>>) {
let requestedStatus = "";
const indexQuery = {
eq: vi.fn((field: string, value: unknown) => {
if (field === "status") requestedStatus = String(value);
return indexQuery;
}),
};
return {
db: {
query: vi.fn(() => ({
withIndex: vi.fn(
(_indexName: string, buildQuery: (query: typeof indexQuery) => unknown) => {
requestedStatus = "";
buildQuery(indexQuery);
return {
order: vi.fn(() => ({
take: vi.fn(async () =>
attempts.filter((attempt) => attempt.status === requestedStatus),
),
})),
};
},
),
})),
},
};
}
describe("publishAttempts", () => { describe("publishAttempts", () => {
it("returns a finalized package attempt only for the exact actor, owner, and artifact", async () => {
const result = {
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:demo",
};
const attempt = {
_id: "publishAttempts:demo",
kind: "package",
status: "finalized",
userId: "users:publisher",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
packageId: result.packageId,
packageReleaseId: result.releaseId,
slug: "demo-claw",
version: "1.0.0",
artifactFingerprint: "exact-fingerprint",
result,
};
const ctx = makeAttemptLookupCtx([attempt]);
const args = {
kind: "package",
slug: "demo-claw",
version: "1.0.0",
userId: "users:publisher",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
artifactFingerprint: "exact-fingerprint",
};
await expect(findExistingPublishAttemptForArtifactHandler(ctx, args)).resolves.toMatchObject({
attemptId: attempt._id,
status: "finalized",
reusable: true,
packageId: result.packageId,
releaseId: result.releaseId,
result,
});
for (const mismatch of [
{ artifactFingerprint: "different-fingerprint" },
{ userId: "users:different" },
{ ownerUserId: "users:different" },
{ ownerPublisherId: "publishers:different" },
]) {
await expect(
findExistingPublishAttemptForArtifactHandler(ctx, { ...args, ...mismatch }),
).resolves.toBeNull();
}
});
it("reports an exact terminal package attempt as non-reusable", async () => {
const attempt = {
_id: "publishAttempts:blocked",
kind: "package",
status: "blocked",
userId: "users:owner",
ownerUserId: "users:owner",
packageId: "packages:demo",
packageReleaseId: "packageReleases:demo",
slug: "demo-claw",
version: "1.0.0",
artifactFingerprint: "exact-fingerprint",
};
await expect(
findExistingPublishAttemptForArtifactHandler(makeAttemptLookupCtx([attempt]), {
kind: "package",
slug: "demo-claw",
version: "1.0.0",
userId: "users:owner",
ownerUserId: "users:owner",
artifactFingerprint: "exact-fingerprint",
}),
).resolves.toMatchObject({
attemptId: attempt._id,
status: "blocked",
reusable: false,
});
});
it("returns the stored package artifact digest while publication is pending", async () => {
const get = vi
.fn()
.mockResolvedValueOnce({
_id: "publishAttempts:demo",
kind: "package",
userId: "users:publisher",
packageId: "packages:demo",
packageReleaseId: "packageReleases:demo",
slug: "demo-claw",
version: "1.0.0",
status: "pending_checks",
checks: {
trufflehog: { status: "pending" },
clawscan: { status: "pending" },
},
})
.mockResolvedValueOnce({ clawpackSha256: "a".repeat(64) });
await expect(
getPackagePublishAttemptStatusHandler(
{
db: {
normalizeId: vi.fn(() => "publishAttempts:demo"),
get,
},
},
{ attemptId: "publishAttempts:demo" },
),
).resolves.toMatchObject({
attemptId: "publishAttempts:demo",
artifactSha256: "a".repeat(64),
status: "pending_checks",
});
expect(get).toHaveBeenNthCalledWith(2, "packageReleases:demo");
});
it("schedules exact dispatch for fresh skill attempts", async () => { it("schedules exact dispatch for fresh skill attempts", async () => {
vi.stubEnv("SECURITY_SCAN_EVENT_DISPATCH_ENABLED", "1"); vi.stubEnv("SECURITY_SCAN_EVENT_DISPATCH_ENABLED", "1");
vi.stubEnv("GITHUB_APP_ID", "configured"); vi.stubEnv("GITHUB_APP_ID", "configured");
+29 -2
View File
@@ -289,7 +289,9 @@ export const findExistingPublishAttemptForArtifactInternal = internalQuery({
slug: v.string(), slug: v.string(),
version: v.string(), version: v.string(),
userId: v.optional(v.id("users")), userId: v.optional(v.id("users")),
ownerUserId: v.optional(v.id("users")),
ownerPublisherId: v.optional(v.id("publishers")), ownerPublisherId: v.optional(v.id("publishers")),
artifactFingerprint: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
for (const status of PUBLISH_ATTEMPT_STATUSES) { for (const status of PUBLISH_ATTEMPT_STATUSES) {
@@ -305,7 +307,22 @@ export const findExistingPublishAttemptForArtifactInternal = internalQuery({
.order("desc") .order("desc")
.take(25); .take(25);
const match = attempts.find((attempt) => { const match = attempts.find((attempt) => {
if (args.kind === "package") return true; if (args.kind === "package") {
if (args.artifactFingerprint === undefined) return true;
if (attempt.artifactFingerprint !== args.artifactFingerprint) return false;
if (args.ownerPublisherId !== undefined) {
return (
attempt.ownerPublisherId === args.ownerPublisherId &&
attempt.userId === args.userId &&
attempt.ownerUserId === args.ownerUserId
);
}
return (
attempt.ownerPublisherId === undefined &&
attempt.userId === args.userId &&
attempt.ownerUserId === args.ownerUserId
);
}
if (args.ownerPublisherId !== undefined) { if (args.ownerPublisherId !== undefined) {
return attempt.ownerPublisherId === args.ownerPublisherId; return attempt.ownerPublisherId === args.ownerPublisherId;
} }
@@ -318,6 +335,11 @@ export const findExistingPublishAttemptForArtifactInternal = internalQuery({
kind: match.kind, kind: match.kind,
slug: match.slug, slug: match.slug,
version: match.version, version: match.version,
reusable: !isTerminalRetriableAttemptStatus(match.status),
packageId: match.packageId,
releaseId: match.packageReleaseId,
artifactFingerprint: match.artifactFingerprint,
result: match.result,
}; };
} }
} }
@@ -426,11 +448,13 @@ export const getPackagePublishAttemptStatusInternal = internalQuery({
if (!attempt || attempt.kind !== "package" || !attempt.packageId || !attempt.packageReleaseId) { if (!attempt || attempt.kind !== "package" || !attempt.packageId || !attempt.packageReleaseId) {
return null; return null;
} }
const release = await ctx.db.get(attempt.packageReleaseId);
return { return {
attemptId: attempt._id, attemptId: attempt._id,
userId: attempt.userId, userId: attempt.userId,
packageId: attempt.packageId, packageId: attempt.packageId,
releaseId: attempt.packageReleaseId, releaseId: attempt.packageReleaseId,
...(release?.clawpackSha256 ? { artifactSha256: release.clawpackSha256 } : {}),
name: attempt.slug, name: attempt.slug,
version: attempt.version, version: attempt.version,
status: attempt.status, status: attempt.status,
@@ -890,7 +914,10 @@ export const claimPendingPublishAttemptChecksInternal = internalMutation({
} }
} else if (attempt.kind === "package" && attempt.packageReleaseId) { } else if (attempt.kind === "package" && attempt.packageReleaseId) {
const release = await ctx.db.get(attempt.packageReleaseId); const release = await ctx.db.get(attempt.packageReleaseId);
if (release?.integritySha256 === attempt.artifactFingerprint) { const releaseFingerprint = release?.clawManifestSummary
? release.clawpackSha256
: release?.integritySha256;
if (release && releaseFingerprint === attempt.artifactFingerprint) {
existingClawscanAnalysis = reusableClawscanAnalysis(release.llmAnalysis); existingClawscanAnalysis = reusableClawscanAnalysis(release.llmAnalysis);
} }
} }
+18 -7
View File
@@ -124,28 +124,38 @@ exact. Other harnesses may bind the same application needs using their own
native profile model; the portable manifest does not impose a capability-name native profile model; the portable manifest does not impose a capability-name
registry. registry.
## Validate and publish ## Validate, build, and publish
Preview the package without uploading it: Validate the source project and build its deterministic artifact with
OpenClaw:
```bash ```bash
clawhub package publish . --family claw --dry-run openclaw claws validate .
openclaw claws build . --out ./github-triage-1.0.0.tgz
``` ```
When the target ClawHub deployment has experimental Claws enabled, publish Preview that exact artifact without uploading it, then publish it through the
through the existing authenticated package flow: existing authenticated package flow:
```bash ```bash
clawhub package publish . --family claw clawhub package publish ./github-triage-1.0.0.tgz --family claw --dry-run
clawhub package publish ./github-triage-1.0.0.tgz --family claw --wait
``` ```
The CLI detects `family: claw` when `package.json` contains `openclaw.claw`, so The CLI detects `family: claw` when `package.json` contains `openclaw.claw`, so
`--family claw` is optional for a well-formed package. `--family claw` is optional for a well-formed package.
Experimental Claw publication accepts only an already-built npm-pack `.tgz`,
not a source directory or GitHub checkout. The CLI sends the local artifact
SHA-256 with the request; ClawHub verifies it against the uploaded bytes before
publication and returns the same digest through pending and final responses.
Publication rejects: Publication rejects:
- a missing, invalid, or escaping `openclaw.claw` path; - a missing, invalid, or escaping `openclaw.claw` path;
- a source folder instead of a built `.tgz`;
- package identity or version mismatches; - package identity or version mismatches;
- a missing or mismatched expected artifact SHA-256;
- malformed `CLAW.md` frontmatter or manifest fields; - malformed `CLAW.md` frontmatter or manifest fields;
- a non-empty `CLAW.md` body combined with an explicit `SOUL.md` destination; - a non-empty `CLAW.md` body combined with an explicit `SOUL.md` destination;
- missing workspace source files or portable path collisions; - missing workspace source files or portable path collisions;
@@ -156,7 +166,8 @@ Publication rejects:
Accepted packages continue through ClawHub's existing ownership, moderation, Accepted packages continue through ClawHub's existing ownership, moderation,
static scanning, release, and artifact storage pipeline. The stored release static scanning, release, and artifact storage pipeline. The stored release
retains the exact artifact plus a non-sensitive summary for later search and retains the exact artifact plus a non-sensitive summary for later search and
detail surfaces; it does not duplicate the full manifest into Convex storage. detail surfaces; downloads return those same bytes and digest, and ClawHub does
not duplicate the full manifest into Convex storage.
## Discover published Claws ## Discover published Claws
+5 -1
View File
@@ -667,6 +667,10 @@ clawhub publisher create opik --display-name "Opik"
- `.tgz` sources are treated as ClawPack. The CLI uploads the exact npm-pack - `.tgz` sources are treated as ClawPack. The CLI uploads the exact npm-pack
bytes and uses the extracted `package/` contents only for validation and bytes and uses the extracted `package/` contents only for validation and
metadata prefill. metadata prefill.
- Experimental Claws must be published from an already-built `.tgz`. Claw
source folders and GitHub sources are rejected; use `openclaw claws build`
first. The publish request binds the local SHA-256, and ClawHub returns that
digest after accepting the exact bytes.
- Code-plugin folders are packed into a ClawPack npm tarball before upload so - Code-plugin folders are packed into a ClawPack npm tarball before upload so
OpenClaw installs can verify the exact artifact. Bundle-plugin folders still OpenClaw installs can verify the exact artifact. Bundle-plugin folders still
use the extracted-file publish path. use the extracted-file publish path.
@@ -712,7 +716,7 @@ clawhub package publish ./my-plugin-1.2.3.tgz --family code-plugin --wait
#### Local folder flow #### Local folder flow
For code plugins, folder publish builds and uploads a ClawPack artifact from For code plugins, folder publish builds and uploads a ClawPack artifact from
the package folder: the package folder. This convenience does not apply to Claws:
```bash ```bash
clawhub package publish ./my-plugin --family code-plugin --dry-run clawhub package publish ./my-plugin --family code-plugin --dry-run
@@ -0,0 +1,222 @@
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { completeMockPrePublicationChecks } from "./helpers";
test.setTimeout(180_000);
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
"exact Claw artifact proof requires the local dev auth runner",
);
function localConvexDeployment() {
const raw = readFileSync(".convex/local/default/config.json", "utf8");
const parsed = JSON.parse(raw) as { deploymentName?: unknown };
if (typeof parsed.deploymentName !== "string" || !parsed.deploymentName) {
throw new Error("Local Convex deployment name was not available");
}
return `local:${parsed.deploymentName}`;
}
function extractLastJsonObject(output: string) {
const trimmed = output.trim();
for (let index = 0; index < trimmed.length; index += 1) {
if (trimmed[index] !== "{") continue;
const candidate = trimmed.slice(index);
try {
return JSON.parse(candidate) as Record<string, unknown>;
} catch {
// Convex and the CLI may print status lines before their JSON payload.
}
}
throw new Error(`No JSON object in output:\n${output}`);
}
function runDevSeed<T>(functionName: string, args: Record<string, unknown>) {
const result = spawnSync(
"bunx",
[
"convex",
"run",
"--typecheck",
"disable",
"--codegen",
"disable",
functionName,
JSON.stringify(args),
],
{
cwd: process.cwd(),
env: { ...process.env, CONVEX_DEPLOYMENT: localConvexDeployment() },
encoding: "utf8",
},
);
if (result.status !== 0) {
throw new Error(
[`Failed to run ${functionName}.`, result.stdout.trim(), result.stderr.trim()].join("\n"),
);
}
return extractLastJsonObject(result.stdout) as T;
}
function sha256(bytes: Uint8Array) {
return createHash("sha256").update(bytes).digest("hex");
}
function runCli(args: string[], env: NodeJS.ProcessEnv) {
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const child = spawn("bun", ["packages/clawhub/src/cli.ts", ...args], {
cwd: process.cwd(),
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(new Error(`ClawHub CLI exited ${code}.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
});
});
}
test("publishes, polls, retries, and downloads the exact Claw tarball", async ({ request }) => {
const root = mkdtempSync(path.join(tmpdir(), "clawhub-exact-claw-proof-"));
const sourceDir = path.join(root, "source");
const configPath = path.join(root, "config.json");
const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
const packageName = `exact-claw-proof-${suffix}`;
const version = "1.0.0";
const registry = process.env.VITE_CONVEX_SITE_URL;
if (!registry) throw new Error("VITE_CONVEX_SITE_URL is required");
try {
mkdirSync(path.join(sourceDir, "manifests"), { recursive: true });
mkdirSync(path.join(sourceDir, "profiles"), { recursive: true });
writeFileSync(
path.join(sourceDir, "package.json"),
JSON.stringify({
name: packageName,
version,
openclaw: { claw: "manifests/CLAW.md" },
}),
);
writeFileSync(
path.join(sourceDir, "manifests", "CLAW.md"),
`---\nschemaVersion: 1\nagent:\n id: ${packageName}\n name: Exact Claw Proof\n---\nPreserve the exact published artifact.\n`,
);
writeFileSync(
path.join(sourceDir, "profiles", "openclaw.yml"),
"schemaVersion: 1\nagent: {}\n",
);
const pack = spawnSync("npm", ["pack", "--json", "--pack-destination", root], {
cwd: sourceDir,
encoding: "utf8",
});
if (pack.status !== 0) {
throw new Error(`npm pack failed.\n${pack.stdout}\n${pack.stderr}`);
}
const packResult = JSON.parse(pack.stdout) as Array<{ filename: string }>;
const artifactPath = path.join(root, packResult[0]?.filename ?? "");
const artifactBytes = readFileSync(artifactPath);
const artifactSha256 = sha256(artifactBytes);
const fixtures = runDevSeed<{
user: { token: string };
}>("devSeed:seedCliRoleHelpFixtures", {});
const cliEnv = {
...process.env,
CLAWHUB_CONFIG_PATH: configPath,
CLAWHUB_REGISTRY: registry,
CLAWHUB_SITE: process.env.PLAYWRIGHT_BASE_URL ?? registry,
};
const login = spawnSync(
"bun",
[
"packages/clawhub/src/cli.ts",
"--registry",
registry,
"login",
"--token",
fixtures.user.token,
],
{ cwd: process.cwd(), env: cliEnv, encoding: "utf8" },
);
if (login.status !== 0) {
throw new Error(`CLI login failed.\n${login.stdout}\n${login.stderr}`);
}
const publishArgs = [
"--registry",
registry,
"--no-input",
"package",
"publish",
artifactPath,
"--family",
"claw",
"--wait",
"--wait-timeout",
"120",
"--json",
];
const publication = runCli(publishArgs, cliEnv);
const claim = await completeMockPrePublicationChecks({
kind: "package",
slug: packageName,
version,
});
expect(claim.claim.artifactFingerprint).toBe(artifactSha256);
const published = extractLastJsonObject((await publication).stdout);
expect(published).toMatchObject({
publicationStatus: "published",
artifactSha256,
});
const packagePath = encodeURIComponent(packageName);
const download = await request.get(
`${registry}/api/v1/packages/${packagePath}/versions/${version}/artifact/download`,
);
const downloadedBytes = await download.body();
expect(download.status(), downloadedBytes.toString("utf8")).toBe(200);
const downloadedSha256 = sha256(downloadedBytes);
expect(downloadedSha256).toBe(artifactSha256);
const retried = extractLastJsonObject((await runCli(publishArgs, cliEnv)).stdout);
expect(retried).toMatchObject({
publicationStatus: "published",
artifactSha256,
});
console.log(
`EXACT_CLAW_ARTIFACT_PROOF ${JSON.stringify({
packageName,
version,
publicationStatus: published.publicationStatus,
uploadedSha256: artifactSha256,
attemptSha256: claim.claim.artifactFingerprint,
responseSha256: published.artifactSha256,
downloadedSha256,
retrySha256: retried.artifactSha256,
})}`,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+10
View File
@@ -88,6 +88,16 @@ For code plugins, folder publish builds and uploads a ClawPack artifact from
the package folder. Bundle-plugin folders still use the extracted-file publish the package folder. Bundle-plugin folders still use the extracted-file publish
path. path.
Experimental Claws use an exact-artifact flow. Build with OpenClaw, then give
ClawHub the resulting `.tgz`; Claw source-folder publication is rejected:
```bash
openclaw claws validate .
openclaw claws build . --out ./my-claw-1.0.0.tgz
clawhub package publish ./my-claw-1.0.0.tgz --family claw --dry-run
clawhub package publish ./my-claw-1.0.0.tgz --family claw --wait
```
Use `clawhub package download` to resolve the published artifact through Use `clawhub package download` to resolve the published artifact through
ClawHub's explicit artifact route. ClawPack downloads are verified against npm ClawHub's explicit artifact route. ClawPack downloads are verified against npm
integrity/shasum plus ClawHub SHA-256; legacy package versions still download integrity/shasum plus ClawHub SHA-256; legacy package versions still download
@@ -2600,10 +2600,13 @@ describe("package commands", () => {
"package/CLAW.md": "---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\n# Demo Claw\n", "package/CLAW.md": "---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\n# Demo Claw\n",
}), }),
); );
const packBytes = new Uint8Array(await readFile(join(workdir, packName)));
const artifactSha256 = artifactIdentity(packBytes).sha256;
httpMocks.apiRequestForm.mockResolvedValueOnce({ httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true, ok: true,
packageId: "pkg_claw", packageId: "pkg_claw",
releaseId: "rel_claw", releaseId: "rel_claw",
artifactSha256,
}); });
await cmdPublishPackage(makeOpts(workdir), packName); await cmdPublishPackage(makeOpts(workdir), packName);
@@ -2612,6 +2615,7 @@ describe("package commands", () => {
name: "demo-claw", name: "demo-claw",
family: "claw", family: "claw",
version: "1.0.0", version: "1.0.0",
expectedArtifactSha256: artifactSha256,
}); });
const uploaded = getPublishForm().get("clawpack"); const uploaded = getPublishForm().get("clawpack");
expect(uploaded).toBeInstanceOf(File); expect(uploaded).toBeInstanceOf(File);
@@ -2622,6 +2626,81 @@ describe("package commands", () => {
} }
}); });
it("fails closed when ClawHub does not confirm the submitted Claw digest", async () => {
const workdir = await makeTmpWorkdir();
try {
const packName = "demo-claw-1.0.0.tgz";
await writeFile(
join(workdir, packName),
npmPackFixture({
"package/package.json": JSON.stringify({
name: "demo-claw",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"package/CLAW.md": "---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\n# Demo Claw\n",
}),
);
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_claw",
releaseId: "rel_claw",
artifactSha256: "0".repeat(64),
});
await expect(cmdPublishPackage(makeOpts(workdir), packName)).rejects.toThrow(
"ClawHub artifact SHA-256 mismatch",
);
expect(httpMocks.apiRequestForm).toHaveBeenCalledTimes(1);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("fails closed when a finalized Claw publish reports a different digest", async () => {
const workdir = await makeTmpWorkdir();
try {
const packName = "demo-claw-1.0.0.tgz";
const pack = npmPackFixture({
"package/package.json": JSON.stringify({
name: "demo-claw",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"package/CLAW.md": "---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\n# Demo Claw\n",
});
await writeFile(join(workdir, packName), pack);
const artifactSha256 = artifactIdentity(pack).sha256;
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_claw",
releaseId: "rel_pending",
publicationStatus: "pending",
attemptId: "attempt_1",
artifactSha256,
});
httpMocks.apiRequest.mockResolvedValueOnce({
attemptId: "attempt_1",
packageId: "pkg_claw",
releaseId: "rel_published",
name: "demo-claw",
version: "1.0.0",
status: "finalized",
publicationStatus: "published",
terminal: true,
checks: makePublishAttemptChecks(),
artifactSha256: "0".repeat(64),
});
await expect(cmdPublishPackage(makeOpts(workdir), packName, { wait: true })).rejects.toThrow(
"ClawHub artifact SHA-256 mismatch",
);
expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("mints a short-lived publish token from GitHub Actions OIDC in CI", async () => { it("mints a short-lived publish token from GitHub Actions OIDC in CI", async () => {
const workdir = await makeTmpWorkdir(); const workdir = await makeTmpWorkdir();
try { try {
@@ -3175,7 +3254,7 @@ describe("package commands", () => {
} }
}); });
it("detects, validates, and publishes a Claw package", async () => { it("validates a Claw source folder but requires a built tarball for publication", async () => {
const workdir = await makeTmpWorkdir(); const workdir = await makeTmpWorkdir();
try { try {
const folder = join(workdir, "github-triage"); const folder = join(workdir, "github-triage");
@@ -3228,19 +3307,10 @@ describe("package commands", () => {
].join("\n"), ].join("\n"),
"utf8", "utf8",
); );
httpMocks.apiRequestForm.mockResolvedValueOnce({ await expect(cmdPublishPackage(makeOpts(workdir), "github-triage")).rejects.toThrow(
ok: true, "Claw publication requires an already-built package tarball (.tgz)",
packageId: "pkg_claw", );
releaseId: "rel_claw", expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
});
await cmdPublishPackage(makeOpts(workdir), "github-triage");
expect(getPublishPayload()).toMatchObject({
name: "@acme/github-triage",
family: "claw",
version: "1.0.0",
});
} finally { } finally {
await rm(workdir, { recursive: true, force: true }); await rm(workdir, { recursive: true, force: true });
} }
@@ -281,6 +281,7 @@ type PackagePublishPayload = {
family: PublishablePackageFamily; family: PublishablePackageFamily;
version: string; version: string;
changelog: string; changelog: string;
expectedArtifactSha256?: string;
manualOverrideReason?: string; manualOverrideReason?: string;
tags: string[]; tags: string[];
categories?: string[]; categories?: string[];
@@ -310,9 +311,22 @@ type PackagePublishPlan = {
commit?: string; commit?: string;
files: number; files: number;
totalBytes: number; totalBytes: number;
artifactSha256?: string;
}; };
}; };
function assertClawPublishArtifactDigest(
plan: PackagePublishPlan,
result: { artifactSha256?: string },
) {
if (plan.payload.family !== "claw") return;
if (result.artifactSha256 !== plan.payload.expectedArtifactSha256) {
fail(
`ClawHub artifact SHA-256 mismatch: expected ${plan.payload.expectedArtifactSha256}, got ${result.artifactSha256 ?? "missing"}`,
);
}
}
type PackedClawPack = { type PackedClawPack = {
path: string; path: string;
file: PackageFile; file: PackageFile;
@@ -1002,6 +1016,7 @@ export async function cmdPublishPackage(
}, },
ApiV1PackagePublishResponseSchema, ApiV1PackagePublishResponseSchema,
); );
assertClawPublishArtifactDigest(plan, result);
let finalResult: ApiV1PackagePublishResponse | ApiV1PackagePublishAttemptResponse = result; let finalResult: ApiV1PackagePublishResponse | ApiV1PackagePublishAttemptResponse = result;
if (options.wait && result.publicationStatus !== "published") { if (options.wait && result.publicationStatus !== "published") {
@@ -1033,6 +1048,7 @@ export async function cmdPublishPackage(
return publishToken; return publishToken;
}, },
}); });
assertClawPublishArtifactDigest(plan, finalResult);
} }
const publicationStatus = finalResult.publicationStatus; const publicationStatus = finalResult.publicationStatus;
@@ -1049,6 +1065,7 @@ export async function cmdPublishPackage(
...plan.output, ...plan.output,
status: outputStatus, status: outputStatus,
releaseId: finalResult.releaseId, releaseId: finalResult.releaseId,
artifactSha256: finalResult.artifactSha256,
publicationStatus, publicationStatus,
attemptId: finalResult.attemptId, attemptId: finalResult.attemptId,
inspectorFindings: result.inspectorFindings, inspectorFindings: result.inspectorFindings,
@@ -2542,6 +2559,9 @@ async function preparePackagePublishPlan(
if (!validation.ok) { if (!validation.ok) {
fail(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join(" ")); fail(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join(" "));
} }
if (!clawpackOnDisk) {
fail("Claw publication requires an already-built package tarball (.tgz)");
}
} }
if (family === "code-plugin" && !clawpackOnDisk) { if (family === "code-plugin" && !clawpackOnDisk) {
@@ -2587,6 +2607,9 @@ async function preparePackagePublishPlan(
family, family,
version, version,
changelog, changelog,
...(family === "claw" && clawpackOnDisk
? { expectedArtifactSha256: digestHex(clawpackOnDisk.bytes, "sha256") }
: {}),
...(options.manualOverrideReason?.trim() ...(options.manualOverrideReason?.trim()
? { manualOverrideReason: options.manualOverrideReason.trim() } ? { manualOverrideReason: options.manualOverrideReason.trim() }
: {}), : {}),
@@ -2638,6 +2661,9 @@ async function preparePackagePublishPlan(
...(source?.commit ? { commit: source.commit } : {}), ...(source?.commit ? { commit: source.commit } : {}),
files: filesOnDisk.length, files: filesOnDisk.length,
totalBytes, totalBytes,
...(family === "claw" && clawpackOnDisk
? { artifactSha256: digestHex(clawpackOnDisk.bytes, "sha256") }
: {}),
}, },
}; };
} }
+3
View File
@@ -325,6 +325,7 @@ const PackagePublishMetadataFields = {
family: PackageFamilySchema, family: PackageFamilySchema,
version: "string", version: "string",
changelog: "string", changelog: "string",
expectedArtifactSha256: "string?",
manualOverrideReason: "string?", manualOverrideReason: "string?",
channel: PackageChannelSchema.optional(), channel: PackageChannelSchema.optional(),
tags: "string[]?", tags: "string[]?",
@@ -935,6 +936,7 @@ export const ApiV1PackagePublishResponseSchema = type({
ok: "true", ok: "true",
packageId: "string", packageId: "string",
releaseId: "string", releaseId: "string",
artifactSha256: "string?",
publicationStatus: '"pending"|"published"?', publicationStatus: '"pending"|"published"?',
attemptId: "string?", attemptId: "string?",
inspectorFindings: type({ inspectorFindings: type({
@@ -976,6 +978,7 @@ export const ApiV1PackagePublishAttemptResponseSchema = type({
attemptId: "string", attemptId: "string",
packageId: "string", packageId: "string",
releaseId: "string", releaseId: "string",
artifactSha256: "string?",
name: "string", name: "string",
version: "string", version: "string",
status: PackagePublishAttemptStatusSchema, status: PackagePublishAttemptStatusSchema,
@@ -140,6 +140,7 @@ describe("packages/clawhub skill metadata schema", () => {
ok: true, ok: true,
packageId: "packages:demo", packageId: "packages:demo",
releaseId: "packageReleases:demo", releaseId: "packageReleases:demo",
artifactSha256: "a".repeat(64),
publicationStatus: "pending", publicationStatus: "pending",
attemptId: "publishAttempts:demo", attemptId: "publishAttempts:demo",
}, },
@@ -147,6 +148,7 @@ describe("packages/clawhub skill metadata schema", () => {
); );
expect(parsed.releaseId).toBe("packageReleases:demo"); expect(parsed.releaseId).toBe("packageReleases:demo");
expect(parsed.artifactSha256).toBe("a".repeat(64));
expect(parsed.publicationStatus).toBe("pending"); expect(parsed.publicationStatus).toBe("pending");
expect(parsed.attemptId).toBe("publishAttempts:demo"); expect(parsed.attemptId).toBe("publishAttempts:demo");
}); });
@@ -158,6 +160,7 @@ describe("packages/clawhub skill metadata schema", () => {
attemptId: "publishAttempts:demo", attemptId: "publishAttempts:demo",
packageId: "packages:demo", packageId: "packages:demo",
releaseId: "packageReleases:demo", releaseId: "packageReleases:demo",
artifactSha256: "a".repeat(64),
name: "@openclaw/demo", name: "@openclaw/demo",
version: "1.0.0", version: "1.0.0",
status: "finalized", status: "finalized",
@@ -172,6 +175,7 @@ describe("packages/clawhub skill metadata schema", () => {
); );
expect(parsed.publicationStatus).toBe("published"); expect(parsed.publicationStatus).toBe("published");
expect(parsed.artifactSha256).toBe("a".repeat(64));
expect(parsed.terminal).toBe(true); expect(parsed.terminal).toBe(true);
expect(parsed.checks.clawscan.status).toBe("clean"); expect(parsed.checks.clawscan.status).toBe("clean");
}); });
+4
View File
@@ -268,6 +268,7 @@ export declare const PackagePublishMetadataSchema: import("arktype/internal/vari
family: "bundle-plugin" | "claw" | "code-plugin" | "skill"; family: "bundle-plugin" | "claw" | "code-plugin" | "skill";
version: string; version: string;
changelog: string; changelog: string;
expectedArtifactSha256?: string | undefined;
manualOverrideReason?: string | undefined; manualOverrideReason?: string | undefined;
channel?: "community" | "official" | "private" | undefined; channel?: "community" | "official" | "private" | undefined;
tags?: string[] | undefined; tags?: string[] | undefined;
@@ -296,6 +297,7 @@ export declare const ServerPackagePublishRequestSchema: import("arktype/internal
family: "bundle-plugin" | "claw" | "code-plugin" | "skill"; family: "bundle-plugin" | "claw" | "code-plugin" | "skill";
version: string; version: string;
changelog: string; changelog: string;
expectedArtifactSha256?: string | undefined;
manualOverrideReason?: string | undefined; manualOverrideReason?: string | undefined;
channel?: "community" | "official" | "private" | undefined; channel?: "community" | "official" | "private" | undefined;
tags?: string[] | undefined; tags?: string[] | undefined;
@@ -1217,6 +1219,7 @@ export declare const ApiV1PackagePublishResponseSchema: import("arktype/internal
ok: true; ok: true;
packageId: string; packageId: string;
releaseId: string; releaseId: string;
artifactSha256?: string | undefined;
publicationStatus?: "pending" | "published" | undefined; publicationStatus?: "pending" | "published" | undefined;
attemptId?: string | undefined; attemptId?: string | undefined;
inspectorFindings?: { inspectorFindings?: {
@@ -1248,6 +1251,7 @@ export declare const ApiV1PackagePublishAttemptResponseSchema: import("arktype/i
attemptId: string; attemptId: string;
packageId: string; packageId: string;
releaseId: string; releaseId: string;
artifactSha256?: string | undefined;
name: string; name: string;
version: string; version: string;
status: "blocked" | "expired" | "failed" | "finalized" | "finalizing" | "pending_checks" | "ready_to_finalize"; status: "blocked" | "expired" | "failed" | "finalized" | "finalizing" | "pending_checks" | "ready_to_finalize";
+3
View File
@@ -260,6 +260,7 @@ const PackagePublishMetadataFields = {
family: PackagePublishFamilySchema, family: PackagePublishFamilySchema,
version: "string", version: "string",
changelog: "string", changelog: "string",
expectedArtifactSha256: "string?",
manualOverrideReason: "string?", manualOverrideReason: "string?",
channel: PackageChannelSchema.optional(), channel: PackageChannelSchema.optional(),
tags: "string[]?", tags: "string[]?",
@@ -763,6 +764,7 @@ export const ApiV1PackagePublishResponseSchema = type({
ok: "true", ok: "true",
packageId: "string", packageId: "string",
releaseId: "string", releaseId: "string",
artifactSha256: "string?",
publicationStatus: '"pending"|"published"?', publicationStatus: '"pending"|"published"?',
attemptId: "string?", attemptId: "string?",
inspectorFindings: type({ inspectorFindings: type({
@@ -792,6 +794,7 @@ export const ApiV1PackagePublishAttemptResponseSchema = type({
attemptId: "string", attemptId: "string",
packageId: "string", packageId: "string",
releaseId: "string", releaseId: "string",
artifactSha256: "string?",
name: "string", name: "string",
version: "string", version: "string",
status: PackagePublishAttemptStatusSchema, status: PackagePublishAttemptStatusSchema,
File diff suppressed because one or more lines are too long
+3
View File
@@ -354,6 +354,7 @@ const PackagePublishMetadataFields = {
family: PackagePublishFamilySchema, family: PackagePublishFamilySchema,
version: "string", version: "string",
changelog: "string", changelog: "string",
expectedArtifactSha256: "string?",
manualOverrideReason: "string?", manualOverrideReason: "string?",
channel: PackageChannelSchema.optional(), channel: PackageChannelSchema.optional(),
tags: "string[]?", tags: "string[]?",
@@ -971,6 +972,7 @@ export const ApiV1PackagePublishResponseSchema = type({
ok: "true", ok: "true",
packageId: "string", packageId: "string",
releaseId: "string", releaseId: "string",
artifactSha256: "string?",
publicationStatus: '"pending"|"published"?', publicationStatus: '"pending"|"published"?',
attemptId: "string?", attemptId: "string?",
inspectorFindings: type({ inspectorFindings: type({
@@ -1012,6 +1014,7 @@ export const ApiV1PackagePublishAttemptResponseSchema = type({
attemptId: "string", attemptId: "string",
packageId: "string", packageId: "string",
releaseId: "string", releaseId: "string",
artifactSha256: "string?",
name: "string", name: "string",
version: "string", version: "string",
status: PackagePublishAttemptStatusSchema, status: PackagePublishAttemptStatusSchema,
+4
View File
@@ -131,6 +131,7 @@ describe("clawhub-schema", () => {
ok: true, ok: true,
packageId: "packages:demo", packageId: "packages:demo",
releaseId: "packageReleases:demo", releaseId: "packageReleases:demo",
artifactSha256: "a".repeat(64),
publicationStatus: "pending", publicationStatus: "pending",
attemptId: "publishAttempts:demo", attemptId: "publishAttempts:demo",
}, },
@@ -138,6 +139,7 @@ describe("clawhub-schema", () => {
); );
expect(response.releaseId).toBe("packageReleases:demo"); expect(response.releaseId).toBe("packageReleases:demo");
expect(response.artifactSha256).toBe("a".repeat(64));
expect(response.publicationStatus).toBe("pending"); expect(response.publicationStatus).toBe("pending");
expect(response.attemptId).toBe("publishAttempts:demo"); expect(response.attemptId).toBe("publishAttempts:demo");
}); });
@@ -149,6 +151,7 @@ describe("clawhub-schema", () => {
attemptId: "publishAttempts:demo", attemptId: "publishAttempts:demo",
packageId: "packages:demo", packageId: "packages:demo",
releaseId: "packageReleases:demo", releaseId: "packageReleases:demo",
artifactSha256: "a".repeat(64),
name: "@openclaw/demo", name: "@openclaw/demo",
version: "1.0.0", version: "1.0.0",
status: "blocked", status: "blocked",
@@ -164,6 +167,7 @@ describe("clawhub-schema", () => {
); );
expect(response.publicationStatus).toBe("blocked"); expect(response.publicationStatus).toBe("blocked");
expect(response.artifactSha256).toBe("a".repeat(64));
expect(response.terminal).toBe(true); expect(response.terminal).toBe(true);
expect(response.checks.clawscan.status).toBe("blocked"); expect(response.checks.clawscan.status).toBe("blocked");
}); });
+2
View File
@@ -506,6 +506,7 @@ async function main() {
`AUTH_GITHUB_ID=${e2eEnv.AUTH_GITHUB_ID}`, `AUTH_GITHUB_ID=${e2eEnv.AUTH_GITHUB_ID}`,
`AUTH_GITHUB_SECRET=${e2eEnv.AUTH_GITHUB_SECRET}`, `AUTH_GITHUB_SECRET=${e2eEnv.AUTH_GITHUB_SECRET}`,
"CLAWHUB_DISABLE_CRONS=1", "CLAWHUB_DISABLE_CRONS=1",
"CLAWHUB_EXPERIMENTAL_CLAWS=1",
"CLAWHUB_SKILLS_SH_ROLLOUT_MODE=test", "CLAWHUB_SKILLS_SH_ROLLOUT_MODE=test",
`CLAWHUB_EMAIL_CAPTURE_FILE=${e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE}`, `CLAWHUB_EMAIL_CAPTURE_FILE=${e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE}`,
...(deployment ? [`CONVEX_DEPLOYMENT=${deployment}`] : []), ...(deployment ? [`CONVEX_DEPLOYMENT=${deployment}`] : []),
@@ -555,6 +556,7 @@ async function main() {
{ name: "AUTH_GITHUB_ID", value: e2eEnv.AUTH_GITHUB_ID ?? "local-dev" }, { name: "AUTH_GITHUB_ID", value: e2eEnv.AUTH_GITHUB_ID ?? "local-dev" },
{ name: "AUTH_GITHUB_SECRET", value: e2eEnv.AUTH_GITHUB_SECRET ?? "local-dev" }, { name: "AUTH_GITHUB_SECRET", value: e2eEnv.AUTH_GITHUB_SECRET ?? "local-dev" },
{ name: "CLAWHUB_DISABLE_CRONS", value: "1" }, { name: "CLAWHUB_DISABLE_CRONS", value: "1" },
{ name: "CLAWHUB_EXPERIMENTAL_CLAWS", value: "1" },
{ name: "CLAWHUB_SKILLS_SH_ROLLOUT_MODE", value: "test" }, { name: "CLAWHUB_SKILLS_SH_ROLLOUT_MODE", value: "test" },
{ name: "CLAWHUB_EMAIL_CAPTURE_FILE", value: e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE ?? "" }, { name: "CLAWHUB_EMAIL_CAPTURE_FILE", value: e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE ?? "" },
{ name: "CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES", value: "1" }, { name: "CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES", value: "1" },
+20
View File
@@ -118,3 +118,23 @@ Convex storage. The server rejects
`family: claw` before mutation when the experimental gate is disabled; the gate `family: claw` before mutation when the experimental gate is disabled; the gate
does not bypass ownership, moderation, scanning, or release invariants when does not bypass ownership, moderation, scanning, or release invariants when
enabled. enabled.
Experimental Claw publication is exact-artifact-only:
- The publisher must submit an already-built npm-pack `.tgz`; source folders,
GitHub checkouts, extracted-file payloads, and the legacy public-action path
cannot publish a Claw release.
- The publisher supplies the canonical lowercase SHA-256 of that tarball.
ClawHub recomputes the digest from the uploaded bytes before mutation and
rejects a missing or mismatched digest.
- The verified tarball digest remains the artifact identity through staged
scanning, finalization, publication status responses, and exact-byte
download.
- Staged retry identity includes the actor, owner, package name, version, and
verified artifact digest. Only an exact retry may reuse the same active
pending attempt or active published release. A different actor, owner, or
digest, a terminal attempt, or a deleted, blocked, quarantined, revoked, or
malicious release remains a version conflict.
- Retry compatibility is scoped to Claws. Existing non-Claw package behavior
and historical attempt recovery remain unchanged unless they already carry
the exact artifact metadata required by their own contract.
+17
View File
@@ -303,6 +303,23 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
- For tarball uploads, ClawHub stores the uploaded tarball, derives its - For tarball uploads, ClawHub stores the uploaded tarball, derives its
artifact hashes and npm metadata, and derives package file metadata from the artifact hashes and npm metadata, and derives package file metadata from the
tarball contents. tarball contents.
- Experimental `family: claw` publication requires one already-built npm-pack
`.tgz` plus the publisher-computed canonical lowercase SHA-256. It must not
accept source directories, GitHub checkout sources, extracted-file
publication, or the legacy public-action path. The HTTP boundary recomputes
the digest from the accepted tarball bytes before creating or reusing any
release state.
- A Claw staged-attempt key binds actor, owner, normalized package name,
version, and verified tarball digest. Only that exact tuple may reuse an
active pending attempt or active published release. Different artifacts or
owners and terminal, deleted, blocked, quarantined, revoked, or malicious
state must preserve the version conflict rather than being reused or
discarded.
- Claw pending and final publication responses expose the verified artifact
SHA-256. Polling must preserve the same digest, and the package artifact
download must serve the exact stored tarball bytes whose SHA-256 matches that
value. Trusted publish tokens accepted by an idempotent retry are revoked on
the same success boundary as a newly created attempt.
- A staged publish attempt is not a successful release. ClawHub dispatches an - A staged publish attempt is not a successful release. ClawHub dispatches an
exact pre-publication worker through the production GitHub App as soon as the exact pre-publication worker through the production GitHub App as soon as the
attempt becomes pending; the scheduled worker remains recovery for missed attempt becomes pending; the scheduled worker remains recovery for missed