diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e0013260..7ebd6ee2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,6 +44,41 @@ jobs:
- name: HTTP e2e
run: bun run ci:e2e-http
+ claws-openclaw-contract:
+ name: claws-openclaw-contract
+ runs-on: blacksmith-8vcpu-ubuntu-2404
+ timeout-minutes: 25
+ env:
+ OPENCLAW_CONTRACT_SHA: 59fc573fb9e938a93b93522be6bf4d7bec0dbc6f
+
+ steps:
+ - uses: actions/checkout@v7.0.1
+
+ - uses: ./.github/actions/setup-bun
+
+ - name: Check out the pinned OpenClaw contract source
+ uses: actions/checkout@v7.0.1
+ with:
+ repository: openclaw/openclaw
+ ref: ${{ env.OPENCLAW_CONTRACT_SHA }}
+ path: .artifacts/openclaw-contract
+
+ - name: Set up OpenClaw Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: 24.15.0
+
+ - name: Install pinned OpenClaw dependencies
+ working-directory: .artifacts/openclaw-contract
+ run: |
+ corepack enable
+ corepack pnpm install --frozen-lockfile
+
+ - name: Run ClawHub to OpenClaw contract proof
+ env:
+ OPENCLAW_CLAWS_CHECKOUT: ${{ github.workspace }}/.artifacts/openclaw-contract
+ run: bunx vitest run scripts/claws-feed-openclaw-e2e.test.ts --maxWorkers=1
+
static:
name: static
runs-on: ubuntu-latest
diff --git a/convex/catalogFeed.runtime.test.ts b/convex/catalogFeed.runtime.test.ts
new file mode 100644
index 00000000..a707d4ca
--- /dev/null
+++ b/convex/catalogFeed.runtime.test.ts
@@ -0,0 +1,75 @@
+///
+/* @vitest-environment edge-runtime */
+import { register as registerRateLimiter } from "@convex-dev/rate-limiter/test";
+import { convexTest } from "convex-test";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { internal } from "./_generated/api";
+import schema from "./schema";
+
+const modules = import.meta.glob("./**/*.ts");
+const digest = `sha256:${"a".repeat(64)}`;
+
+describe("experimental Claw feed runtime", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("stores and serves the exact publication only while the gate is enabled", async () => {
+ vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
+ const t = convexTest(schema, modules);
+ registerRateLimiter(t);
+ const stored = await t.mutation(internal.catalogFeed.storeClawPublication, {
+ generatedAt: "2026-07-24T00:00:00.000Z",
+ expiresAt: "2026-07-25T00:00:00.000Z",
+ entries: [
+ {
+ type: "claw",
+ id: "@openclaw/runtime-proof",
+ title: "Runtime proof",
+ version: "1.0.0",
+ state: "available",
+ publisher: { id: "openclaw", trust: "official" },
+ clawManifestSummary: {
+ schemaVersion: 1,
+ agent: { id: "runtime-proof", name: "Runtime proof" },
+ workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 1 },
+ packages: { skillCount: 0, pluginCount: 0 },
+ mcpServerCount: 0,
+ cronJobCount: 0,
+ },
+ install: {
+ candidates: [
+ {
+ sourceRef: "public-clawhub",
+ package: "@openclaw/runtime-proof",
+ version: "1.0.0",
+ integrity: digest,
+ },
+ ],
+ },
+ },
+ ],
+ });
+ expect(stored).toMatchObject({
+ feedId: "clawhub-official-claws",
+ sequence: 1,
+ entryCount: 1,
+ });
+
+ const publication = await t.query(internal.catalogFeed.getLatestPublication, {
+ feedId: "clawhub-official-claws",
+ });
+ expect(publication?.payload).toContain('"id":"@openclaw/runtime-proof"');
+
+ const enabled = await t.fetch("/api/v1/feeds/claws");
+ expect(enabled.status).toBe(200);
+ expect(enabled.headers.get("cache-control")).toBe("no-store");
+ expect(enabled.headers.get("surrogate-control")).toBeNull();
+ expect(await enabled.text()).toBe(publication?.payload);
+
+ vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "0");
+ const disabled = await t.fetch("/api/v1/feeds/claws");
+ expect(disabled.status).toBe(404);
+ expect(disabled.headers.get("cache-control")).toBe("no-store");
+ });
+});
diff --git a/convex/catalogFeed.test.ts b/convex/catalogFeed.test.ts
index 280ba8dd..1f5116cb 100644
--- a/convex/catalogFeed.test.ts
+++ b/convex/catalogFeed.test.ts
@@ -1,6 +1,11 @@
-import { CATALOG_FEED_ID, CATALOG_SKILLS_FEED_ID } from "clawhub-schema";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { listOfficialEntries, listOfficialSkillEntries, publish } from "./catalogFeed";
+import { CATALOG_FEED_ID, CATALOG_SKILLS_FEED_ID, EXPERIMENTAL_CLAW_FEED_ID } from "clawhub-schema";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ listOfficialClawEntries,
+ listOfficialEntries,
+ listOfficialSkillEntries,
+ publish,
+} from "./catalogFeed";
vi.mock("./lib/publishers", () => ({
getOwnerPublisher: vi.fn().mockResolvedValue({ handle: "openclaw" }),
@@ -19,6 +24,9 @@ const listOfficialEntriesHandler = (
unknown[]
>
)._handler;
+const listOfficialClawEntriesHandler = (
+ listOfficialClawEntries as unknown as WrappedHandler, unknown[]>
+)._handler;
const listOfficialSkillEntriesHandler = (
listOfficialSkillEntries as unknown as WrappedHandler<
{ publisherId: string; cursor: string | null },
@@ -189,6 +197,10 @@ describe("catalog feed projection", () => {
vi.clearAllMocks();
});
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
it("projects official releases into ClawHub install candidates", async () => {
const result = await listOfficialEntriesHandler(
makeCtx(
@@ -259,6 +271,55 @@ describe("catalog feed projection", () => {
]);
});
+ it("projects validated Claw releases with only their safe summary", async () => {
+ vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
+ const clawManifestSummary = {
+ schemaVersion: 1,
+ agent: { id: "triage", name: "Triage" },
+ workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 1 },
+ packages: { skillCount: 1, pluginCount: 0 },
+ mcpServerCount: 0,
+ cronJobCount: 1,
+ };
+ const result = await listOfficialClawEntriesHandler(
+ makeCtx([makePackage({ family: "claw" })], {
+ "packageReleases:1": makeRelease({
+ clawManifestSummary,
+ }),
+ }),
+ {},
+ );
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ type: "claw",
+ id: "@openclaw/demo",
+ clawManifestSummary,
+ install: {
+ candidates: [
+ expect.objectContaining({
+ package: "@openclaw/demo",
+ version: "1.2.3",
+ integrity: "sha256:artifact-hash",
+ }),
+ ],
+ },
+ }),
+ ]);
+ });
+
+ it("excludes Claw releases without a validated manifest summary", async () => {
+ vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
+ const result = await listOfficialClawEntriesHandler(
+ makeCtx([makePackage({ family: "claw" })], {
+ "packageReleases:1": makeRelease(),
+ }),
+ {},
+ );
+
+ expect(result).toEqual([]);
+ });
+
it("excludes non-official, blocked, deleted, and undigested releases", async () => {
const result = await listOfficialEntriesHandler(
makeCtx(
@@ -495,6 +556,58 @@ describe("catalog feed projection", () => {
]);
});
+ it("publishes Claws through the separate experimental mutation", async () => {
+ vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
+ const clawEntry = {
+ type: "claw",
+ id: "@openclaw/triage",
+ title: "Triage",
+ version: "1.0.0",
+ state: "available",
+ publisher: { id: "openclaw", trust: "official" },
+ clawManifestSummary: {
+ schemaVersion: 1,
+ agent: { id: "triage" },
+ workspace: { bootstrapFiles: [], fileCount: 0 },
+ packages: { skillCount: 0, pluginCount: 0 },
+ mcpServerCount: 0,
+ cronJobCount: 0,
+ },
+ install: {
+ candidates: [
+ {
+ sourceRef: "public-clawhub",
+ package: "@openclaw/triage",
+ version: "1.0.0",
+ integrity: "sha256:abc",
+ },
+ ],
+ },
+ };
+ const runQuery = vi.fn(async (_ref: unknown, args: Record) => {
+ if ("family" in args) return [];
+ if ("cursor" in args) return { publishers: [], isDone: true, continueCursor: "" };
+ return [clawEntry];
+ });
+ const runMutation = vi.fn(async (_ref: unknown, args: Record) => ({
+ feedId: typeof args.feedId === "string" ? args.feedId : EXPERIMENTAL_CLAW_FEED_ID,
+ entryCount: Array.isArray(args.entries) ? args.entries.length : 0,
+ }));
+
+ const result = await publishHandler(
+ { runQuery, runMutation },
+ { expiresAt: "2026-07-20T00:00:00.000Z" },
+ );
+
+ expect(runMutation).toHaveBeenCalledTimes(3);
+ expect(runMutation).toHaveBeenLastCalledWith(
+ expect.anything(),
+ expect.objectContaining({ entries: [clawEntry] }),
+ );
+ expect(runMutation.mock.calls.at(-1)?.[1]).not.toHaveProperty("feedId");
+ expect(result.at(-1)).toEqual({ feedId: EXPERIMENTAL_CLAW_FEED_ID, entryCount: 1 });
+ });
+
it("projects suspicious current GitHub-backed skills into public GitHub install candidates", async () => {
const result = (await listOfficialSkillEntriesHandler(
makeCtx(
diff --git a/convex/catalogFeed.ts b/convex/catalogFeed.ts
index a5be692d..96efa389 100644
--- a/convex/catalogFeed.ts
+++ b/convex/catalogFeed.ts
@@ -6,9 +6,15 @@ import {
CATALOG_SKILLS_FEED_DESCRIPTION,
CATALOG_SKILLS_FEED_ID,
PROMOTIONS_FEED_ID,
+ EXPERIMENTAL_CLAW_FEED_DESCRIPTION,
+ EXPERIMENTAL_CLAW_FEED_ID,
+ EXPERIMENTAL_CLAW_FEED_SCHEMA_VERSION,
serializeCatalogFeed,
+ serializeExperimentalClawFeed,
type CatalogFeedEntry,
+ type CatalogFeedPluginEntry,
type CatalogFeedSkillEntry,
+ type ExperimentalClawFeedEntry,
} from "clawhub-schema";
import { v } from "convex/values";
import { internal } from "./_generated/api";
@@ -17,6 +23,7 @@ import { internalAction, internalMutation, internalQuery } from "./_generated/se
import type { QueryCtx } from "./_generated/server";
import { isSkillHighlighted } from "./lib/badges";
import { sha256Hex } from "./lib/clawpack";
+import { experimentalClawsEnabled } from "./lib/experimentalClaws";
import { isPublicSkillDoc } from "./lib/globalStats";
import { isOfficialPublisher } from "./lib/officialPublishers";
import { getPackageReleaseArtifactSha256 } from "./lib/packageArtifacts";
@@ -34,6 +41,7 @@ const CATALOG_FEED_DESCRIPTION = "Official OpenClaw plugins published on ClawHub
const CATALOG_FEED_PAGE_SIZE = 100;
const MAX_CATALOG_FEED_ENTRIES = 1000;
const CATALOG_FEED_FAMILIES = ["code-plugin", "bundle-plugin"] as const;
+const CATALOG_CLAW_FAMILY = "claw" as const;
type CatalogQueryCtx = Pick;
type CatalogFeedPublicationResult = {
@@ -94,11 +102,40 @@ const catalogFeedEntryValidator = v.union(
v.object({ type: v.literal("plugin"), ...catalogFeedEntryFields }),
v.object({ type: v.literal("skill"), ...catalogFeedEntryFields }),
);
+const clawFeedEntryValidator = v.object({
+ type: v.literal("claw"),
+ ...catalogFeedEntryFields,
+ install: v.object({
+ candidates: v.array(
+ v.object({
+ sourceRef: v.literal(CATALOG_FEED_SOURCE_REF),
+ package: v.string(),
+ version: v.string(),
+ integrity: v.string(),
+ }),
+ ),
+ }),
+ clawManifestSummary: v.object({
+ schemaVersion: v.literal(1),
+ agent: v.object({
+ id: v.string(),
+ name: v.optional(v.string()),
+ description: v.optional(v.string()),
+ }),
+ workspace: v.object({
+ bootstrapFiles: v.array(v.string()),
+ fileCount: v.number(),
+ }),
+ packages: v.object({ skillCount: v.number(), pluginCount: v.number() }),
+ mcpServerCount: v.number(),
+ cronJobCount: v.number(),
+ }),
+});
async function buildEntry(
ctx: CatalogQueryCtx,
pkg: Doc<"packages">,
-): Promise {
+): Promise {
if (pkg.softDeletedAt || pkg.channel !== "official" || !pkg.latestReleaseId) return null;
const release = await ctx.db.get(pkg.latestReleaseId);
if (!release || release.packageId !== pkg._id || release.softDeletedAt) return null;
@@ -130,6 +167,32 @@ async function buildEntry(
.withIndex("by_package_kind", (q) => q.eq("packageId", pkg._id).eq("kind", "highlighted"))
.unique();
+ if (pkg.family === "claw") {
+ if (!release.clawManifestSummary) return null;
+ return {
+ type: "claw",
+ id,
+ title,
+ version,
+ state: "available",
+ publisher: {
+ id: publisherId,
+ trust: "official",
+ },
+ clawManifestSummary: release.clawManifestSummary,
+ install: {
+ candidates: [
+ {
+ sourceRef: CATALOG_FEED_SOURCE_REF,
+ package: packageName,
+ version,
+ integrity: `sha256:${artifactSha256}`,
+ },
+ ],
+ },
+ } satisfies ExperimentalClawFeedEntry;
+ }
+
return {
type: "plugin",
id,
@@ -159,9 +222,9 @@ async function buildEntry(
async function listFamilyEntries(
ctx: CatalogQueryCtx,
- family: (typeof CATALOG_FEED_FAMILIES)[number],
+ family: (typeof CATALOG_FEED_FAMILIES)[number] | typeof CATALOG_CLAW_FAMILY,
) {
- const entries: CatalogFeedEntry[] = [];
+ const entries: Array = [];
let cursor: string | null = null;
while (true) {
@@ -351,7 +414,25 @@ export const listOfficialEntries = internalQuery({
args: {
family: v.union(v.literal("code-plugin"), v.literal("bundle-plugin")),
},
- handler: async (ctx, args) => await listFamilyEntries(ctx, args.family),
+ handler: async (ctx, args) => {
+ const entries = await listFamilyEntries(ctx, args.family);
+ if (entries.some((entry) => entry.type !== "plugin")) {
+ throw new Error("Plugin feed projection returned a mismatched entry type");
+ }
+ return entries as CatalogFeedPluginEntry[];
+ },
+});
+
+export const listOfficialClawEntries = internalQuery({
+ args: {},
+ handler: async (ctx) => {
+ if (!experimentalClawsEnabled()) return [];
+ const entries = await listFamilyEntries(ctx, CATALOG_CLAW_FAMILY);
+ if (entries.some((entry) => entry.type !== "claw")) {
+ throw new Error("Claw feed projection returned a mismatched entry type");
+ }
+ return entries as ExperimentalClawFeedEntry[];
+ },
});
export const listOfficialSkillEntries = internalQuery({
@@ -442,6 +523,53 @@ export const storePublication = internalMutation({
},
});
+export const storeClawPublication = internalMutation({
+ args: {
+ generatedAt: v.string(),
+ expiresAt: v.string(),
+ entries: v.array(clawFeedEntryValidator),
+ },
+ handler: async (ctx, args) => {
+ if (!experimentalClawsEnabled()) throw new Error("Experimental Claw feeds are disabled");
+ const latest = await ctx.db
+ .query("catalogFeedPublications")
+ .withIndex("by_feed", (q) => q.eq("feedId", EXPERIMENTAL_CLAW_FEED_ID))
+ .unique();
+ const sequence = (latest?.sequence ?? 0) + 1;
+ const payload = serializeExperimentalClawFeed({
+ schemaVersion: EXPERIMENTAL_CLAW_FEED_SCHEMA_VERSION,
+ id: EXPERIMENTAL_CLAW_FEED_ID,
+ generatedAt: args.generatedAt,
+ sequence,
+ expiresAt: args.expiresAt,
+ description: EXPERIMENTAL_CLAW_FEED_DESCRIPTION,
+ entries: args.entries,
+ });
+ const payloadSha256 = await sha256Hex(new TextEncoder().encode(payload));
+ const publishedAt = Date.now();
+ const publication = {
+ feedId: EXPERIMENTAL_CLAW_FEED_ID,
+ sequence,
+ generatedAt: args.generatedAt,
+ expiresAt: args.expiresAt,
+ payload,
+ payloadSha256,
+ publishedAt,
+ };
+ const publicationId = latest
+ ? (await ctx.db.patch(latest._id, publication), latest._id)
+ : await ctx.db.insert("catalogFeedPublications", publication);
+ return {
+ publicationId,
+ feedId: EXPERIMENTAL_CLAW_FEED_ID,
+ sequence,
+ payloadSha256,
+ publishedAt,
+ entryCount: args.entries.length,
+ };
+ },
+});
+
export const publish = internalAction({
args: {
expiresAt: v.string(),
@@ -516,7 +644,25 @@ export const publish = internalAction({
entries: skillEntries.sort((left, right) => left.id.localeCompare(right.id)),
},
);
- return [pluginResult, skillsResult];
+ if (!experimentalClawsEnabled()) {
+ return [pluginResult, skillsResult];
+ }
+ const clawEntries: ExperimentalClawFeedEntry[] = await ctx.runQuery(
+ internal.catalogFeed.listOfficialClawEntries,
+ {},
+ );
+ if (clawEntries.length > MAX_CATALOG_FEED_ENTRIES) {
+ throw new Error(`Catalog feed exceeds ${MAX_CATALOG_FEED_ENTRIES} entries`);
+ }
+ const clawsResult: CatalogFeedPublicationResult = await ctx.runMutation(
+ internal.catalogFeed.storeClawPublication,
+ {
+ generatedAt,
+ expiresAt: args.expiresAt,
+ entries: clawEntries.sort((left, right) => left.id.localeCompare(right.id)),
+ },
+ );
+ return [pluginResult, skillsResult, clawsResult];
},
});
@@ -525,6 +671,7 @@ export const getLatestPublication = internalQuery({
feedId: v.union(
v.literal(CATALOG_FEED_ID),
v.literal(CATALOG_SKILLS_FEED_ID),
+ v.literal(EXPERIMENTAL_CLAW_FEED_ID),
v.literal(PROMOTIONS_FEED_ID),
),
},
diff --git a/convex/http.ts b/convex/http.ts
index 3248bd5d..8f81d7ba 100644
--- a/convex/http.ts
+++ b/convex/http.ts
@@ -53,6 +53,7 @@ import {
promotionsPostRouterV1Http,
catalogFeedV1Http,
catalogSkillsFeedV1Http,
+ catalogClawsFeedV1Http,
promotionsFeedV1Http,
usersGetRouterV1Http,
usersListV1Http,
@@ -191,6 +192,12 @@ http.route({
handler: catalogSkillsFeedV1Http,
});
+http.route({
+ path: ApiRoutes.catalogClawsFeed,
+ method: "GET",
+ handler: catalogClawsFeedV1Http,
+});
+
http.route({
path: ApiRoutes.promotionsFeed,
method: "GET",
diff --git a/convex/httpApiV1.catalogFeed.test.ts b/convex/httpApiV1.catalogFeed.test.ts
index a66199ea..79d4f55a 100644
--- a/convex/httpApiV1.catalogFeed.test.ts
+++ b/convex/httpApiV1.catalogFeed.test.ts
@@ -1,6 +1,6 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { internal } from "./_generated/api";
-import { catalogFeedV1Handler } from "./httpApiV1/catalogFeedV1";
+import { catalogClawsFeedV1Handler, catalogFeedV1Handler } from "./httpApiV1/catalogFeedV1";
type QueryCtx = {
runQuery: ReturnType;
@@ -23,6 +23,10 @@ describe("catalogFeedV1Handler", () => {
ctx = { runQuery: vi.fn().mockResolvedValue(publication) };
});
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
it("serves the exact published payload with edge cache validators", async () => {
const response = await catalogFeedV1Handler(
ctx as never,
@@ -93,4 +97,31 @@ describe("catalogFeedV1Handler", () => {
expect(response.status).toBe(503);
expect(response.headers.get("cache-control")).toBe("no-store");
});
+
+ it("hides the Claws feed while the experiment is disabled", async () => {
+ const response = await catalogClawsFeedV1Handler(
+ ctx as never,
+ new Request("https://clawhub.ai/api/v1/feeds/claws"),
+ );
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ expect(ctx.runQuery).not.toHaveBeenCalled();
+ });
+
+ it("serves the Claws publication while the experiment is enabled", async () => {
+ vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
+ const response = await catalogClawsFeedV1Handler(
+ ctx as never,
+ new Request("https://clawhub.ai/api/v1/feeds/claws"),
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ expect(response.headers.get("surrogate-control")).toBeNull();
+ expect(response.headers.get("etag")).toBe('"sha256:abc123"');
+ expect(ctx.runQuery).toHaveBeenCalledWith(internal.catalogFeed.getLatestPublication, {
+ feedId: "clawhub-official-claws",
+ });
+ });
});
diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts
index 97d36cee..701eff7f 100644
--- a/convex/httpApiV1.ts
+++ b/convex/httpApiV1.ts
@@ -1,5 +1,6 @@
import { httpAction } from "./functions";
import {
+ catalogClawsFeedV1Handler,
catalogFeedV1Handler,
catalogSkillsFeedV1Handler,
promotionsFeedV1Handler,
@@ -75,6 +76,7 @@ export const skillsShCatalogTestV1Http = httpAction(skillsShCatalogTestV1Handler
export const skillsShCatalogPublicV1Http = httpAction(skillsShCatalogPublicV1Handler);
export const catalogFeedV1Http = httpAction(catalogFeedV1Handler);
export const catalogSkillsFeedV1Http = httpAction(catalogSkillsFeedV1Handler);
+export const catalogClawsFeedV1Http = httpAction(catalogClawsFeedV1Handler);
export const promotionsFeedV1Http = httpAction(promotionsFeedV1Handler);
export const searchSkillsV1Http = httpAction(searchSkillsV1Handler);
@@ -126,6 +128,7 @@ export const __handlers = {
skillsShCatalogPublicV1Handler,
catalogFeedV1Handler,
catalogSkillsFeedV1Handler,
+ catalogClawsFeedV1Handler,
searchSkillsV1Handler,
resolveSkillVersionV1Handler,
listSkillsV1Handler,
diff --git a/convex/httpApiV1/catalogFeedV1.ts b/convex/httpApiV1/catalogFeedV1.ts
index b98046cb..5a214f03 100644
--- a/convex/httpApiV1/catalogFeedV1.ts
+++ b/convex/httpApiV1/catalogFeedV1.ts
@@ -1,6 +1,12 @@
-import { CATALOG_FEED_ID, CATALOG_SKILLS_FEED_ID, PROMOTIONS_FEED_ID } from "clawhub-schema";
+import {
+ CATALOG_FEED_ID,
+ CATALOG_SKILLS_FEED_ID,
+ EXPERIMENTAL_CLAW_FEED_ID,
+ PROMOTIONS_FEED_ID,
+} from "clawhub-schema";
import { internal } from "../_generated/api";
import type { ActionCtx } from "../_generated/server";
+import { experimentalClawsEnabled } from "../lib/experimentalClaws";
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
function matchesEtag(request: Request, etag: string) {
@@ -30,8 +36,21 @@ export async function catalogFeedV1Handler(
feedId:
| typeof CATALOG_FEED_ID
| typeof CATALOG_SKILLS_FEED_ID
+ | typeof EXPERIMENTAL_CLAW_FEED_ID
| typeof PROMOTIONS_FEED_ID = CATALOG_FEED_ID,
) {
+ if (feedId === EXPERIMENTAL_CLAW_FEED_ID && !experimentalClawsEnabled()) {
+ return new Response("Not found", {
+ status: 404,
+ headers: mergeHeaders(
+ {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Cache-Control": "no-store",
+ },
+ corsHeaders(),
+ ),
+ });
+ }
const publication = await ctx.runQuery(internal.catalogFeed.getLatestPublication, { feedId });
if (!publication) {
return new Response("Catalog feed is not published", {
@@ -47,11 +66,17 @@ export async function catalogFeedV1Handler(
}
const etag = `"sha256:${publication.payloadSha256}"`;
+ const cacheHeaders: Record =
+ feedId === EXPERIMENTAL_CLAW_FEED_ID
+ ? { "Cache-Control": "no-store" }
+ : {
+ "Cache-Control": "public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
+ "Surrogate-Control": "max-age=300, stale-while-revalidate=86400",
+ };
const headers = mergeHeaders(
{
"Content-Type": "application/json; charset=utf-8",
- "Cache-Control": "public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
- "Surrogate-Control": "max-age=300, stale-while-revalidate=86400",
+ ...cacheHeaders,
ETag: etag,
"Last-Modified": new Date(publication.publishedAt).toUTCString(),
"X-Catalog-Feed-Sequence": String(publication.sequence),
@@ -75,6 +100,10 @@ export async function catalogSkillsFeedV1Handler(ctx: ActionCtx, request: Reques
return await catalogFeedV1Handler(ctx, request, CATALOG_SKILLS_FEED_ID);
}
+export async function catalogClawsFeedV1Handler(ctx: ActionCtx, request: Request) {
+ return await catalogFeedV1Handler(ctx, request, EXPERIMENTAL_CLAW_FEED_ID);
+}
+
export async function promotionsFeedV1Handler(ctx: ActionCtx, request: Request) {
return await catalogFeedV1Handler(ctx, request, PROMOTIONS_FEED_ID);
}
diff --git a/docs/claws.md b/docs/claws.md
index 33349526..7fb9da2e 100644
--- a/docs/claws.md
+++ b/docs/claws.md
@@ -117,3 +117,37 @@ When `CLAWHUB_EXPERIMENTAL_CLAWS` is disabled, explicit `family=claw` filters
are rejected, unscoped list and search results omit Claws, and named Claw reads
return not found. Full manifests remain in exact artifacts and are never
projected through public release responses.
+
+## Consume the experimental feed
+
+Enabled deployments publish eligible official Claws as a separate hosted feed:
+
+```bash
+curl "https://clawhub.ai/v1/feeds/claws"
+```
+
+This uses a dedicated experimental Claw feed contract rather than extending
+the stable plugin/skill catalog feed v1 schema.
+
+Each entry provides the exact package version, artifact SHA-256, publisher
+trust, and `clawManifestSummary`. Consumers resolve and verify that artifact,
+unpack it as a normal Claw package directory, and pass the directory to
+OpenClaw for local inspection or `claws add --dry-run`. ClawHub does not bypass
+OpenClaw's preview or consent boundary.
+
+The route returns `404` while `CLAWHUB_EXPERIMENTAL_CLAWS` is disabled and is
+not advertised in the registry discovery document until the experimental gate
+is removed.
+
+Run the repeatable registry-to-OpenClaw proof against an OpenClaw Claws
+checkout with:
+
+```bash
+OPENCLAW_CLAWS_CHECKOUT=/path/to/openclaw \
+ bunx vitest run scripts/claws-feed-openclaw-e2e.test.ts --maxWorkers=1
+```
+
+The proof serves a deterministic hosted feed and package artifact, verifies the
+feed and downloaded digests, extracts the package, and invokes the actual
+OpenClaw source CLI with `claws add --dry-run --json` in an isolated state
+directory.
diff --git a/fixtures/claws/hosted-e2e/package/CLAW.md b/fixtures/claws/hosted-e2e/package/CLAW.md
new file mode 100644
index 00000000..d1d5581e
--- /dev/null
+++ b/fixtures/claws/hosted-e2e/package/CLAW.md
@@ -0,0 +1,19 @@
+---
+schemaVersion: 1
+agent:
+ id: hosted-e2e
+ name: Hosted E2E
+metadata:
+ openclaw.config: profiles/openclaw.yml
+workspace:
+ bootstrapFiles:
+ SOUL.md:
+ source: workspace/SOUL.md
+packages: []
+mcpServers: {}
+cronJobs: []
+---
+
+# Hosted E2E
+
+Fixture for the ClawHub feed-to-OpenClaw dry-run proof.
diff --git a/fixtures/claws/hosted-e2e/package/package.json b/fixtures/claws/hosted-e2e/package/package.json
new file mode 100644
index 00000000..2cd4ebf3
--- /dev/null
+++ b/fixtures/claws/hosted-e2e/package/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "@openclaw/hosted-e2e",
+ "version": "1.0.0",
+ "openclaw": {
+ "claw": "CLAW.md"
+ }
+}
diff --git a/fixtures/claws/hosted-e2e/package/profiles/openclaw.yml b/fixtures/claws/hosted-e2e/package/profiles/openclaw.yml
new file mode 100644
index 00000000..55042cc2
--- /dev/null
+++ b/fixtures/claws/hosted-e2e/package/profiles/openclaw.yml
@@ -0,0 +1,7 @@
+schemaVersion: 1
+agent:
+ tools:
+ profile: coding
+ fs:
+ workspaceOnly: true
+ humanDelay: { mode: natural }
diff --git a/fixtures/claws/hosted-e2e/package/workspace/SOUL.md b/fixtures/claws/hosted-e2e/package/workspace/SOUL.md
new file mode 100644
index 00000000..60bf3685
--- /dev/null
+++ b/fixtures/claws/hosted-e2e/package/workspace/SOUL.md
@@ -0,0 +1,3 @@
+# Hosted E2E
+
+Use the published Claw package without mutating local state during proof.
diff --git a/packages/schema/dist/experimentalClawFeed.d.ts b/packages/schema/dist/experimentalClawFeed.d.ts
new file mode 100644
index 00000000..6cc31d69
--- /dev/null
+++ b/packages/schema/dist/experimentalClawFeed.d.ts
@@ -0,0 +1,66 @@
+import { type inferred } from "arktype";
+export declare const EXPERIMENTAL_CLAW_FEED_SCHEMA_VERSION = 1;
+export declare const EXPERIMENTAL_CLAW_FEED_ID = "clawhub-official-claws";
+export declare const EXPERIMENTAL_CLAW_FEED_DESCRIPTION = "Claws published by verified OpenClaw publishers on ClawHub.";
+export declare const ExperimentalClawFeedInstallCandidateSchema: import("arktype/internal/variants/object.ts").ObjectType<{
+ sourceRef: "public-clawhub";
+ package: string;
+ version: string;
+ integrity: string;
+}, {}>;
+export declare const ExperimentalClawFeedEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{
+ type: "claw";
+ id: string;
+ title: string;
+ description?: string | undefined;
+ icon?: string | undefined;
+ version: string;
+ state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
+ publisher: {
+ id: string;
+ trust: "community" | "official";
+ };
+ clawManifestSummary: import("./claws.js").ClawManifestSummary;
+ install: {
+ candidates: {
+ sourceRef: "public-clawhub";
+ package: string;
+ version: string;
+ integrity: string;
+ }[];
+ };
+}, {}>;
+export type ExperimentalClawFeedEntry = (typeof ExperimentalClawFeedEntrySchema)[inferred];
+export declare const ExperimentalClawFeedSchema: import("arktype/internal/variants/object.ts").ObjectType<{
+ schemaVersion: number;
+ id: string;
+ generatedAt: string;
+ sequence: number;
+ expiresAt: string;
+ description?: string | undefined;
+ entries: {
+ type: "claw";
+ id: string;
+ title: string;
+ description?: string | undefined;
+ icon?: string | undefined;
+ version: string;
+ state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
+ publisher: {
+ id: string;
+ trust: "community" | "official";
+ };
+ clawManifestSummary: import("./claws.js").ClawManifestSummary;
+ install: {
+ candidates: {
+ sourceRef: "public-clawhub";
+ package: string;
+ version: string;
+ integrity: string;
+ }[];
+ };
+ }[];
+}, {}>;
+export type ExperimentalClawFeed = (typeof ExperimentalClawFeedSchema)[inferred];
+export declare function parseExperimentalClawFeed(value: unknown): ExperimentalClawFeed;
+export declare function serializeExperimentalClawFeed(feed: ExperimentalClawFeed): string;
diff --git a/packages/schema/dist/experimentalClawFeed.js b/packages/schema/dist/experimentalClawFeed.js
new file mode 100644
index 00000000..9ca86cf3
--- /dev/null
+++ b/packages/schema/dist/experimentalClawFeed.js
@@ -0,0 +1,141 @@
+import { type } from "arktype";
+import { CatalogFeedPublisherTrustSchema, CatalogFeedStateSchema } from "./catalogFeed.js";
+import { ClawManifestSummarySchema } from "./claws.js";
+export const EXPERIMENTAL_CLAW_FEED_SCHEMA_VERSION = 1;
+export const EXPERIMENTAL_CLAW_FEED_ID = "clawhub-official-claws";
+export const EXPERIMENTAL_CLAW_FEED_DESCRIPTION = "Claws published by verified OpenClaw publishers on ClawHub.";
+export const ExperimentalClawFeedInstallCandidateSchema = type({
+ "+": "reject",
+ sourceRef: '"public-clawhub"',
+ package: "string",
+ version: "string",
+ integrity: "string",
+});
+export const ExperimentalClawFeedEntrySchema = type({
+ "+": "reject",
+ type: '"claw"',
+ id: "string",
+ title: "string",
+ description: "string?",
+ icon: "string?",
+ version: "string",
+ state: CatalogFeedStateSchema,
+ publisher: {
+ "+": "reject",
+ id: "string",
+ trust: CatalogFeedPublisherTrustSchema,
+ },
+ clawManifestSummary: ClawManifestSummarySchema,
+ install: {
+ "+": "reject",
+ candidates: ExperimentalClawFeedInstallCandidateSchema.array(),
+ },
+});
+export const ExperimentalClawFeedSchema = type({
+ "+": "reject",
+ schemaVersion: "number",
+ id: "string",
+ generatedAt: "string",
+ sequence: "number",
+ expiresAt: "string",
+ description: "string?",
+ entries: ExperimentalClawFeedEntrySchema.array(),
+});
+export function parseExperimentalClawFeed(value) {
+ const feed = ExperimentalClawFeedSchema.assert(value);
+ if (feed.schemaVersion !== EXPERIMENTAL_CLAW_FEED_SCHEMA_VERSION) {
+ throw new Error(`Unsupported experimental Claw feed schema version: ${feed.schemaVersion}`);
+ }
+ if (feed.id !== EXPERIMENTAL_CLAW_FEED_ID) {
+ throw new Error(`Unsupported experimental Claw feed id: ${feed.id}`);
+ }
+ if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) {
+ throw new Error("Experimental Claw feed sequence must be a non-negative integer");
+ }
+ if (!Number.isFinite(Date.parse(feed.generatedAt)) ||
+ !Number.isFinite(Date.parse(feed.expiresAt))) {
+ throw new Error("Experimental Claw feed timestamps must be valid ISO dates");
+ }
+ if (Date.parse(feed.expiresAt) <= Date.parse(feed.generatedAt)) {
+ throw new Error("Experimental Claw feed expiresAt must be after generatedAt");
+ }
+ for (const entry of feed.entries) {
+ if (entry.publisher.trust !== "official") {
+ throw new Error("Experimental Claw feed publisher trust must be official");
+ }
+ if (entry.install.candidates.length !== 1) {
+ throw new Error("Experimental Claw feed entries must have exactly one install candidate");
+ }
+ for (const candidate of entry.install.candidates) {
+ if (candidate.package !== entry.id) {
+ throw new Error("Experimental Claw feed candidate package must match entry id");
+ }
+ if (candidate.version !== entry.version) {
+ throw new Error("Experimental Claw feed candidate version must match entry version");
+ }
+ if (!/^sha256:[0-9a-f]{64}$/.test(candidate.integrity)) {
+ throw new Error("Experimental Claw feed candidate integrity must be lowercase sha256 with 64 hex characters");
+ }
+ }
+ }
+ return feed;
+}
+export function serializeExperimentalClawFeed(feed) {
+ const parsed = parseExperimentalClawFeed(feed);
+ const entries = [...parsed.entries]
+ .sort((left, right) => left.id.localeCompare(right.id))
+ .map((entry) => ({
+ type: entry.type,
+ id: entry.id,
+ title: entry.title,
+ ...(entry.description === undefined ? {} : { description: entry.description }),
+ ...(entry.icon === undefined ? {} : { icon: entry.icon }),
+ version: entry.version,
+ state: entry.state,
+ publisher: { id: entry.publisher.id, trust: entry.publisher.trust },
+ clawManifestSummary: {
+ schemaVersion: entry.clawManifestSummary.schemaVersion,
+ agent: {
+ id: entry.clawManifestSummary.agent.id,
+ ...(entry.clawManifestSummary.agent.name === undefined
+ ? {}
+ : { name: entry.clawManifestSummary.agent.name }),
+ ...(entry.clawManifestSummary.agent.description === undefined
+ ? {}
+ : { description: entry.clawManifestSummary.agent.description }),
+ },
+ workspace: {
+ bootstrapFiles: [...entry.clawManifestSummary.workspace.bootstrapFiles].sort(),
+ fileCount: entry.clawManifestSummary.workspace.fileCount,
+ },
+ packages: {
+ skillCount: entry.clawManifestSummary.packages.skillCount,
+ pluginCount: entry.clawManifestSummary.packages.pluginCount,
+ },
+ mcpServerCount: entry.clawManifestSummary.mcpServerCount,
+ cronJobCount: entry.clawManifestSummary.cronJobCount,
+ },
+ install: {
+ candidates: [...entry.install.candidates]
+ .sort((left, right) => [left.sourceRef, left.package, left.version, left.integrity]
+ .join("\u0000")
+ .localeCompare([right.sourceRef, right.package, right.version, right.integrity].join("\u0000")))
+ .map((candidate) => ({
+ sourceRef: candidate.sourceRef,
+ package: candidate.package,
+ version: candidate.version,
+ integrity: candidate.integrity,
+ })),
+ },
+ }));
+ return JSON.stringify({
+ schemaVersion: parsed.schemaVersion,
+ id: parsed.id,
+ generatedAt: parsed.generatedAt,
+ sequence: parsed.sequence,
+ expiresAt: parsed.expiresAt,
+ ...(parsed.description === undefined ? {} : { description: parsed.description }),
+ entries,
+ });
+}
+//# sourceMappingURL=experimentalClawFeed.js.map
\ No newline at end of file
diff --git a/packages/schema/dist/experimentalClawFeed.js.map b/packages/schema/dist/experimentalClawFeed.js.map
new file mode 100644
index 00000000..9a2788fa
--- /dev/null
+++ b/packages/schema/dist/experimentalClawFeed.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"experimentalClawFeed.js","sourceRoot":"","sources":["../src/experimentalClawFeed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,EAAE,+BAA+B,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC3F,OAAO,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAC;AAEvD,MAAM,CAAC,MAAM,qCAAqC,GAAG,CAAC,CAAC;AACvD,MAAM,CAAC,MAAM,yBAAyB,GAAG,wBAAwB,CAAC;AAClE,MAAM,CAAC,MAAM,kCAAkC,GAC7C,6DAA6D,CAAC;AAEhE,MAAM,CAAC,MAAM,0CAA0C,GAAG,IAAI,CAAC;IAC7D,GAAG,EAAE,QAAQ;IACb,SAAS,EAAE,kBAAkB;IAC7B,OAAO,EAAE,QAAQ;IACjB,OAAO,EAAE,QAAQ;IACjB,SAAS,EAAE,QAAQ;CACpB,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,+BAA+B,GAAG,IAAI,CAAC;IAClD,GAAG,EAAE,QAAQ;IACb,IAAI,EAAE,QAAQ;IACd,EAAE,EAAE,QAAQ;IACZ,KAAK,EAAE,QAAQ;IACf,WAAW,EAAE,SAAS;IACtB,IAAI,EAAE,SAAS;IACf,OAAO,EAAE,QAAQ;IACjB,KAAK,EAAE,sBAAsB;IAC7B,SAAS,EAAE;QACT,GAAG,EAAE,QAAQ;QACb,EAAE,EAAE,QAAQ;QACZ,KAAK,EAAE,+BAA+B;KACvC;IACD,mBAAmB,EAAE,yBAAyB;IAC9C,OAAO,EAAE;QACP,GAAG,EAAE,QAAQ;QACb,UAAU,EAAE,0CAA0C,CAAC,KAAK,EAAE;KAC/D;CACF,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC;IAC7C,GAAG,EAAE,QAAQ;IACb,aAAa,EAAE,QAAQ;IACvB,EAAE,EAAE,QAAQ;IACZ,WAAW,EAAE,QAAQ;IACrB,QAAQ,EAAE,QAAQ;IAClB,SAAS,EAAE,QAAQ;IACnB,WAAW,EAAE,SAAS;IACtB,OAAO,EAAE,+BAA+B,CAAC,KAAK,EAAE;CACjD,CAAC,CAAC;AAGH,MAAM,UAAU,yBAAyB,CAAC,KAAc;IACtD,MAAM,IAAI,GAAG,0BAA0B,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACtD,IAAI,IAAI,CAAC,aAAa,KAAK,qCAAqC,EAAE,CAAC;QACjE,MAAM,IAAI,KAAK,CAAC,sDAAsD,IAAI,CAAC,aAAa,EAAE,CAAC,CAAC;IAC9F,CAAC;IACD,IAAI,IAAI,CAAC,EAAE,KAAK,yBAAyB,EAAE,CAAC;QAC1C,MAAM,IAAI,KAAK,CAAC,0CAA0C,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;IACvE,CAAC;IACD,IAAI,IAAI,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC9D,MAAM,IAAI,KAAK,CAAC,gEAAgE,CAAC,CAAC;IACpF,CAAC;IACD,IACE,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC9C,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,EAC5C,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,2DAA2D,CAAC,CAAC;IAC/E,CAAC;IACD,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;QAC/D,MAAM,IAAI,KAAK,CAAC,4DAA4D,CAAC,CAAC;IAChF,CAAC;IACD,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QACjC,IAAI,KAAK,CAAC,SAAS,CAAC,KAAK,KAAK,UAAU,EAAE,CAAC;YACzC,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;QAC7E,CAAC;QACD,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CAAC,wEAAwE,CAAC,CAAC;QAC5F,CAAC;QACD,KAAK,MAAM,SAAS,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC;YACjD,IAAI,SAAS,CAAC,OAAO,KAAK,KAAK,CAAC,EAAE,EAAE,CAAC;gBACnC,MAAM,IAAI,KAAK,CAAC,8DAA8D,CAAC,CAAC;YAClF,CAAC;YACD,IAAI,SAAS,CAAC,OAAO,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC;gBACxC,MAAM,IAAI,KAAK,CAAC,mEAAmE,CAAC,CAAC;YACvF,CAAC;YACD,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE,CAAC;gBACvD,MAAM,IAAI,KAAK,CACb,4FAA4F,CAC7F,CAAC;YACJ,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,6BAA6B,CAAC,IAA0B;IACtE,MAAM,MAAM,GAAG,yBAAyB,CAAC,IAAI,CAAC,CAAC;IAC/C,MAAM,OAAO,GAAG,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC;SAChC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;SACtD,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QACf,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,GAAG,CAAC,KAAK,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,KAAK,CAAC,WAAW,EAAE,CAAC;QAC9E,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC;QACzD,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,SAAS,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,SAAS,CAAC,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,SAAS,CAAC,KAAK,EAAE;QACnE,mBAAmB,EAAE;YACnB,aAAa,EAAE,KAAK,CAAC,mBAAmB,CAAC,aAAa;YACtD,KAAK,EAAE;gBACL,EAAE,EAAE,KAAK,CAAC,mBAAmB,CAAC,KAAK,CAAC,EAAE;gBACtC,GAAG,CAAC,KAAK,CAAC,mBAAmB,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS;oBACpD,CAAC,CAAC,EAAE;oBACJ,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,mBAAmB,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;gBACnD,GAAG,CAAC,KAAK,CAAC,mBAAmB,CAAC,KAAK,CAAC,WAAW,KAAK,SAAS;oBAC3D,CAAC,CAAC,EAAE;oBACJ,CAAC,CAAC,EAAE,WAAW,EAAE,KAAK,CAAC,mBAAmB,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;aAClE;YACD,SAAS,EAAE;gBACT,cAAc,EAAE,CAAC,GAAG,KAAK,CAAC,mBAAmB,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC,IAAI,EAAE;gBAC9E,SAAS,EAAE,KAAK,CAAC,mBAAmB,CAAC,SAAS,CAAC,SAAS;aACzD;YACD,QAAQ,EAAE;gBACR,UAAU,EAAE,KAAK,CAAC,mBAAmB,CAAC,QAAQ,CAAC,UAAU;gBACzD,WAAW,EAAE,KAAK,CAAC,mBAAmB,CAAC,QAAQ,CAAC,WAAW;aAC5D;YACD,cAAc,EAAE,KAAK,CAAC,mBAAmB,CAAC,cAAc;YACxD,YAAY,EAAE,KAAK,CAAC,mBAAmB,CAAC,YAAY;SACrD;QACD,OAAO,EAAE;YACP,UAAU,EAAE,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC;iBACtC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CACpB,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC;iBACzD,IAAI,CAAC,QAAQ,CAAC;iBACd,aAAa,CACZ,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAChF,CACJ;iBACA,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;gBACnB,SAAS,EAAE,SAAS,CAAC,SAAS;gBAC9B,OAAO,EAAE,SAAS,CAAC,OAAO;gBAC1B,OAAO,EAAE,SAAS,CAAC,OAAO;gBAC1B,SAAS,EAAE,SAAS,CAAC,SAAS;aAC/B,CAAC,CAAC;SACN;KACF,CAAC,CAAC,CAAC;IACN,OAAO,IAAI,CAAC,SAAS,CAAC;QACpB,aAAa,EAAE,MAAM,CAAC,aAAa;QACnC,EAAE,EAAE,MAAM,CAAC,EAAE;QACb,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,GAAG,CAAC,MAAM,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC;QAChF,OAAO;KACR,CAAC,CAAC;AACL,CAAC"}
\ No newline at end of file
diff --git a/packages/schema/dist/index.d.ts b/packages/schema/dist/index.d.ts
index 8d58b85d..1e5749bb 100644
--- a/packages/schema/dist/index.d.ts
+++ b/packages/schema/dist/index.d.ts
@@ -3,6 +3,7 @@ export { formatArkErrors, parseArk } from "./ark.js";
export * from "./claws.js";
export * from "./clawPackage.js";
export * from "./catalogFeed.js";
+export * from "./experimentalClawFeed.js";
export * from "./catalogMetadata.js";
export * from "./docsLinks.js";
export * from "./license.js";
diff --git a/packages/schema/dist/index.js b/packages/schema/dist/index.js
index 6633f43e..76e4bed0 100644
--- a/packages/schema/dist/index.js
+++ b/packages/schema/dist/index.js
@@ -2,6 +2,7 @@ export { formatArkErrors, parseArk } from "./ark.js";
export * from "./claws.js";
export * from "./clawPackage.js";
export * from "./catalogFeed.js";
+export * from "./experimentalClawFeed.js";
export * from "./catalogMetadata.js";
export * from "./docsLinks.js";
export * from "./license.js";
diff --git a/packages/schema/dist/index.js.map b/packages/schema/dist/index.js.map
index bb84e201..97f6c696 100644
--- a/packages/schema/dist/index.js.map
+++ b/packages/schema/dist/index.js.map
@@ -1 +1 @@
-{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
\ No newline at end of file
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
\ No newline at end of file
diff --git a/packages/schema/dist/routes.d.ts b/packages/schema/dist/routes.d.ts
index 28ef10c6..7a90d432 100644
--- a/packages/schema/dist/routes.d.ts
+++ b/packages/schema/dist/routes.d.ts
@@ -26,6 +26,7 @@ export declare const ApiRoutes: {
readonly promotions: "/api/v1/promotions";
readonly catalogFeed: "/api/v1/feeds/plugins";
readonly catalogSkillsFeed: "/api/v1/feeds/skills";
+ readonly catalogClawsFeed: "/api/v1/feeds/claws";
readonly promotionsFeed: "/api/v1/feeds/promotions";
readonly stars: "/api/v1/stars";
readonly transfers: "/api/v1/transfers";
diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js
index 5aa9e326..42f1504b 100644
--- a/packages/schema/dist/routes.js
+++ b/packages/schema/dist/routes.js
@@ -26,6 +26,7 @@ export const ApiRoutes = {
promotions: "/api/v1/promotions",
catalogFeed: "/api/v1/feeds/plugins",
catalogSkillsFeed: "/api/v1/feeds/skills",
+ catalogClawsFeed: "/api/v1/feeds/claws",
promotionsFeed: "/api/v1/feeds/promotions",
stars: "/api/v1/stars",
transfers: "/api/v1/transfers",
diff --git a/packages/schema/dist/routes.js.map b/packages/schema/dist/routes.js.map
index c6169680..d4178461 100644
--- a/packages/schema/dist/routes.js.map
+++ b/packages/schema/dist/routes.js.map
@@ -1 +1 @@
-{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,mBAAmB;IAC7B,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,UAAU,EAAE,oBAAoB;IAChC,WAAW,EAAE,uBAAuB;IACpC,iBAAiB,EAAE,sBAAsB;IACzC,cAAc,EAAE,0BAA0B;IAC1C,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
\ No newline at end of file
+{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,mBAAmB;IAC7B,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,UAAU,EAAE,oBAAoB;IAChC,WAAW,EAAE,uBAAuB;IACpC,iBAAiB,EAAE,sBAAsB;IACzC,gBAAgB,EAAE,qBAAqB;IACvC,cAAc,EAAE,0BAA0B;IAC1C,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
\ No newline at end of file
diff --git a/packages/schema/src/catalogFeed.test.ts b/packages/schema/src/catalogFeed.test.ts
index aa464a27..b9c76883 100644
--- a/packages/schema/src/catalogFeed.test.ts
+++ b/packages/schema/src/catalogFeed.test.ts
@@ -146,6 +146,28 @@ describe("catalog feed schema", () => {
expect(parseCatalogFeed(feed).entries[0]?.type).toBe("skill");
});
+ it("rejects Claw entries from the stable v1 catalog contract", () => {
+ expect(() =>
+ parseCatalogFeed({
+ ...makeFeed(),
+ entries: [
+ {
+ ...makeFeed().entries[0],
+ type: "claw",
+ clawManifestSummary: {
+ schemaVersion: 1,
+ agent: { id: "demo" },
+ workspace: { bootstrapFiles: [], fileCount: 0 },
+ packages: { skillCount: 0, pluginCount: 0 },
+ mcpServerCount: 0,
+ cronJobCount: 0,
+ },
+ },
+ ],
+ }),
+ ).toThrow();
+ });
+
it("round-trips optional featured state without changing schema version 1", () => {
const feed = makeFeed({
entries: makeFeed().entries.map((entry, index) => ({
diff --git a/packages/schema/src/experimentalClawFeed.test.ts b/packages/schema/src/experimentalClawFeed.test.ts
new file mode 100644
index 00000000..49b03374
--- /dev/null
+++ b/packages/schema/src/experimentalClawFeed.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from "vitest";
+import {
+ EXPERIMENTAL_CLAW_FEED_ID,
+ EXPERIMENTAL_CLAW_FEED_SCHEMA_VERSION,
+ parseExperimentalClawFeed,
+ serializeExperimentalClawFeed,
+ type ExperimentalClawFeed,
+} from "./experimentalClawFeed.js";
+
+function makeFeed(): ExperimentalClawFeed {
+ return {
+ schemaVersion: EXPERIMENTAL_CLAW_FEED_SCHEMA_VERSION,
+ id: EXPERIMENTAL_CLAW_FEED_ID,
+ generatedAt: "2026-07-19T00:00:00.000Z",
+ sequence: 1,
+ expiresAt: "2026-07-20T00:00:00.000Z",
+ entries: [
+ {
+ type: "claw",
+ id: "@openclaw/triage",
+ title: "Triage",
+ version: "1.0.0",
+ state: "available",
+ publisher: { id: "openclaw", trust: "official" },
+ clawManifestSummary: {
+ schemaVersion: 1,
+ agent: { id: "triage", name: "Triage" },
+ workspace: { bootstrapFiles: ["SOUL.md", "AGENTS.md"], fileCount: 2 },
+ packages: { skillCount: 1, pluginCount: 0 },
+ mcpServerCount: 0,
+ cronJobCount: 1,
+ },
+ install: {
+ candidates: [
+ {
+ sourceRef: "public-clawhub",
+ package: "@openclaw/triage",
+ version: "1.0.0",
+ integrity: `sha256:${"a".repeat(64)}`,
+ },
+ ],
+ },
+ },
+ ],
+ };
+}
+
+describe("experimental Claw feed schema", () => {
+ it("round-trips bounded Claw summaries with canonical bootstrap ordering", () => {
+ const first = makeFeed();
+ const second = structuredClone(first);
+ second.entries[0]!.clawManifestSummary.workspace.bootstrapFiles.reverse();
+
+ expect(serializeExperimentalClawFeed(first)).toBe(serializeExperimentalClawFeed(second));
+ expect(
+ parseExperimentalClawFeed(JSON.parse(serializeExperimentalClawFeed(first))).entries[0],
+ ).toMatchObject({ type: "claw", clawManifestSummary: { agent: { id: "triage" } } });
+ });
+
+ it("rejects generic feed ids and non-Claw entries", () => {
+ expect(() => parseExperimentalClawFeed({ ...makeFeed(), id: "clawhub-official" })).toThrow(
+ "feed id",
+ );
+ expect(() =>
+ parseExperimentalClawFeed({
+ ...makeFeed(),
+ entries: [{ ...makeFeed().entries[0], type: "plugin" }],
+ }),
+ ).toThrow();
+ });
+
+ it("rejects install candidates outside the public ClawHub source profile", () => {
+ const feed = makeFeed();
+ feed.entries[0]!.install.candidates[0]!.sourceRef = "public-github" as "public-clawhub";
+ expect(() => parseExperimentalClawFeed(feed)).toThrow();
+ });
+
+ it("rejects install candidates that are not bound to their feed entry", () => {
+ const packageMismatch = makeFeed();
+ packageMismatch.entries[0]!.install.candidates[0]!.package = "@openclaw/other";
+ expect(() => parseExperimentalClawFeed(packageMismatch)).toThrow("package must match entry id");
+
+ const versionMismatch = makeFeed();
+ versionMismatch.entries[0]!.install.candidates[0]!.version = "2.0.0";
+ expect(() => parseExperimentalClawFeed(versionMismatch)).toThrow(
+ "version must match entry version",
+ );
+ });
+
+ it("requires exactly one install candidate per entry", () => {
+ const missing = makeFeed();
+ missing.entries[0]!.install.candidates = [];
+ expect(() => parseExperimentalClawFeed(missing)).toThrow("exactly one install candidate");
+
+ const duplicate = makeFeed();
+ duplicate.entries[0]!.install.candidates.push(
+ structuredClone(duplicate.entries[0]!.install.candidates[0]!),
+ );
+ expect(() => parseExperimentalClawFeed(duplicate)).toThrow("exactly one install candidate");
+ });
+
+ it("rejects entries that are not from official publishers", () => {
+ const feed = makeFeed();
+ feed.entries[0]!.publisher.trust = "community";
+ expect(() => parseExperimentalClawFeed(feed)).toThrow("publisher trust must be official");
+ });
+
+ it.each(["sha256:abc", `sha256:${"A".repeat(64)}`, `sha512:${"a".repeat(64)}`])(
+ "rejects non-canonical candidate integrity %s",
+ (integrity) => {
+ const feed = makeFeed();
+ feed.entries[0]!.install.candidates[0]!.integrity = integrity;
+ expect(() => parseExperimentalClawFeed(feed)).toThrow(
+ "integrity must be lowercase sha256 with 64 hex characters",
+ );
+ },
+ );
+});
diff --git a/packages/schema/src/experimentalClawFeed.ts b/packages/schema/src/experimentalClawFeed.ts
new file mode 100644
index 00000000..c36008f6
--- /dev/null
+++ b/packages/schema/src/experimentalClawFeed.ts
@@ -0,0 +1,157 @@
+import { type inferred, type } from "arktype";
+import { CatalogFeedPublisherTrustSchema, CatalogFeedStateSchema } from "./catalogFeed.js";
+import { ClawManifestSummarySchema } from "./claws.js";
+
+export const EXPERIMENTAL_CLAW_FEED_SCHEMA_VERSION = 1;
+export const EXPERIMENTAL_CLAW_FEED_ID = "clawhub-official-claws";
+export const EXPERIMENTAL_CLAW_FEED_DESCRIPTION =
+ "Claws published by verified OpenClaw publishers on ClawHub.";
+
+export const ExperimentalClawFeedInstallCandidateSchema = type({
+ "+": "reject",
+ sourceRef: '"public-clawhub"',
+ package: "string",
+ version: "string",
+ integrity: "string",
+});
+
+export const ExperimentalClawFeedEntrySchema = type({
+ "+": "reject",
+ type: '"claw"',
+ id: "string",
+ title: "string",
+ description: "string?",
+ icon: "string?",
+ version: "string",
+ state: CatalogFeedStateSchema,
+ publisher: {
+ "+": "reject",
+ id: "string",
+ trust: CatalogFeedPublisherTrustSchema,
+ },
+ clawManifestSummary: ClawManifestSummarySchema,
+ install: {
+ "+": "reject",
+ candidates: ExperimentalClawFeedInstallCandidateSchema.array(),
+ },
+});
+export type ExperimentalClawFeedEntry = (typeof ExperimentalClawFeedEntrySchema)[inferred];
+
+export const ExperimentalClawFeedSchema = type({
+ "+": "reject",
+ schemaVersion: "number",
+ id: "string",
+ generatedAt: "string",
+ sequence: "number",
+ expiresAt: "string",
+ description: "string?",
+ entries: ExperimentalClawFeedEntrySchema.array(),
+});
+export type ExperimentalClawFeed = (typeof ExperimentalClawFeedSchema)[inferred];
+
+export function parseExperimentalClawFeed(value: unknown): ExperimentalClawFeed {
+ const feed = ExperimentalClawFeedSchema.assert(value);
+ if (feed.schemaVersion !== EXPERIMENTAL_CLAW_FEED_SCHEMA_VERSION) {
+ throw new Error(`Unsupported experimental Claw feed schema version: ${feed.schemaVersion}`);
+ }
+ if (feed.id !== EXPERIMENTAL_CLAW_FEED_ID) {
+ throw new Error(`Unsupported experimental Claw feed id: ${feed.id}`);
+ }
+ if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) {
+ throw new Error("Experimental Claw feed sequence must be a non-negative integer");
+ }
+ if (
+ !Number.isFinite(Date.parse(feed.generatedAt)) ||
+ !Number.isFinite(Date.parse(feed.expiresAt))
+ ) {
+ throw new Error("Experimental Claw feed timestamps must be valid ISO dates");
+ }
+ if (Date.parse(feed.expiresAt) <= Date.parse(feed.generatedAt)) {
+ throw new Error("Experimental Claw feed expiresAt must be after generatedAt");
+ }
+ for (const entry of feed.entries) {
+ if (entry.publisher.trust !== "official") {
+ throw new Error("Experimental Claw feed publisher trust must be official");
+ }
+ if (entry.install.candidates.length !== 1) {
+ throw new Error("Experimental Claw feed entries must have exactly one install candidate");
+ }
+ for (const candidate of entry.install.candidates) {
+ if (candidate.package !== entry.id) {
+ throw new Error("Experimental Claw feed candidate package must match entry id");
+ }
+ if (candidate.version !== entry.version) {
+ throw new Error("Experimental Claw feed candidate version must match entry version");
+ }
+ if (!/^sha256:[0-9a-f]{64}$/.test(candidate.integrity)) {
+ throw new Error(
+ "Experimental Claw feed candidate integrity must be lowercase sha256 with 64 hex characters",
+ );
+ }
+ }
+ }
+ return feed;
+}
+
+export function serializeExperimentalClawFeed(feed: ExperimentalClawFeed): string {
+ const parsed = parseExperimentalClawFeed(feed);
+ const entries = [...parsed.entries]
+ .sort((left, right) => left.id.localeCompare(right.id))
+ .map((entry) => ({
+ type: entry.type,
+ id: entry.id,
+ title: entry.title,
+ ...(entry.description === undefined ? {} : { description: entry.description }),
+ ...(entry.icon === undefined ? {} : { icon: entry.icon }),
+ version: entry.version,
+ state: entry.state,
+ publisher: { id: entry.publisher.id, trust: entry.publisher.trust },
+ clawManifestSummary: {
+ schemaVersion: entry.clawManifestSummary.schemaVersion,
+ agent: {
+ id: entry.clawManifestSummary.agent.id,
+ ...(entry.clawManifestSummary.agent.name === undefined
+ ? {}
+ : { name: entry.clawManifestSummary.agent.name }),
+ ...(entry.clawManifestSummary.agent.description === undefined
+ ? {}
+ : { description: entry.clawManifestSummary.agent.description }),
+ },
+ workspace: {
+ bootstrapFiles: [...entry.clawManifestSummary.workspace.bootstrapFiles].sort(),
+ fileCount: entry.clawManifestSummary.workspace.fileCount,
+ },
+ packages: {
+ skillCount: entry.clawManifestSummary.packages.skillCount,
+ pluginCount: entry.clawManifestSummary.packages.pluginCount,
+ },
+ mcpServerCount: entry.clawManifestSummary.mcpServerCount,
+ cronJobCount: entry.clawManifestSummary.cronJobCount,
+ },
+ install: {
+ candidates: [...entry.install.candidates]
+ .sort((left, right) =>
+ [left.sourceRef, left.package, left.version, left.integrity]
+ .join("\u0000")
+ .localeCompare(
+ [right.sourceRef, right.package, right.version, right.integrity].join("\u0000"),
+ ),
+ )
+ .map((candidate) => ({
+ sourceRef: candidate.sourceRef,
+ package: candidate.package,
+ version: candidate.version,
+ integrity: candidate.integrity,
+ })),
+ },
+ }));
+ return JSON.stringify({
+ schemaVersion: parsed.schemaVersion,
+ id: parsed.id,
+ generatedAt: parsed.generatedAt,
+ sequence: parsed.sequence,
+ expiresAt: parsed.expiresAt,
+ ...(parsed.description === undefined ? {} : { description: parsed.description }),
+ entries,
+ });
+}
diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts
index 8d58b85d..1e5749bb 100644
--- a/packages/schema/src/index.ts
+++ b/packages/schema/src/index.ts
@@ -3,6 +3,7 @@ export { formatArkErrors, parseArk } from "./ark.js";
export * from "./claws.js";
export * from "./clawPackage.js";
export * from "./catalogFeed.js";
+export * from "./experimentalClawFeed.js";
export * from "./catalogMetadata.js";
export * from "./docsLinks.js";
export * from "./license.js";
diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts
index f20465d2..3ba59a0e 100644
--- a/packages/schema/src/routes.ts
+++ b/packages/schema/src/routes.ts
@@ -27,6 +27,7 @@ export const ApiRoutes = {
promotions: "/api/v1/promotions",
catalogFeed: "/api/v1/feeds/plugins",
catalogSkillsFeed: "/api/v1/feeds/skills",
+ catalogClawsFeed: "/api/v1/feeds/claws",
promotionsFeed: "/api/v1/feeds/promotions",
stars: "/api/v1/stars",
transfers: "/api/v1/transfers",
diff --git a/scripts/claws-feed-openclaw-e2e.test.ts b/scripts/claws-feed-openclaw-e2e.test.ts
new file mode 100644
index 00000000..e1b38bde
--- /dev/null
+++ b/scripts/claws-feed-openclaw-e2e.test.ts
@@ -0,0 +1,275 @@
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { createServer, type Server } from "node:http";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { promisify } from "node:util";
+import { EXPERIMENTAL_CLAW_FEED_ID, serializeExperimentalClawFeed } from "clawhub-schema";
+import { gzipSync, strToU8, zipSync } from "fflate";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import {
+ assertSafeClawArchive,
+ extractSafeClawZip,
+ findExtractedPackageRoot,
+ readResponseBytesBounded,
+ runPublishedClawDryRun,
+ selectPublishedClaw,
+} from "./claws-feed-openclaw-e2e";
+
+const execFileAsync = promisify(execFile);
+const openclawRepo = process.env.OPENCLAW_CLAWS_CHECKOUT;
+const fixtureRoot = resolve("fixtures/claws/hosted-e2e");
+let tempRoot = "";
+let archiveBytes = new Uint8Array();
+let integrity = "";
+let server: Server | undefined;
+let serverPort = 0;
+const TAR_BLOCK_SIZE = 512;
+
+function writeTarString(target: Uint8Array, offset: number, width: number, value: string) {
+ target.set(new TextEncoder().encode(value).subarray(0, width), offset);
+}
+
+function writeTarOctal(target: Uint8Array, offset: number, width: number, value: number) {
+ writeTarString(target, offset, width, `${value.toString(8).padStart(width - 1, "0")}\0`);
+}
+
+function tarEntry(path: string, type: "0" | "2", content = new Uint8Array()) {
+ const header = new Uint8Array(TAR_BLOCK_SIZE);
+ writeTarString(header, 0, 100, path);
+ writeTarOctal(header, 100, 8, type === "0" ? 0o644 : 0o777);
+ writeTarOctal(header, 108, 8, 0);
+ writeTarOctal(header, 116, 8, 0);
+ writeTarOctal(header, 124, 12, content.byteLength);
+ writeTarOctal(header, 136, 12, 0);
+ header.fill(0x20, 148, 156);
+ header[156] = type.charCodeAt(0);
+ if (type === "2") writeTarString(header, 157, 100, "../../outside");
+ writeTarString(header, 257, 6, "ustar");
+ writeTarString(header, 263, 2, "00");
+ writeTarOctal(
+ header,
+ 148,
+ 8,
+ header.reduce((sum, byte) => sum + byte, 0),
+ );
+ const body = new Uint8Array(Math.ceil(content.byteLength / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE);
+ body.set(content);
+ return [header, body];
+}
+
+function deterministicLinkArchive() {
+ const parts = [
+ ...tarEntry(
+ "package/package.json",
+ "0",
+ new TextEncoder().encode('{"name":"@openclaw/hosted-e2e","version":"1.0.0"}\n'),
+ ),
+ ...tarEntry("package/workspace", "2"),
+ new Uint8Array(TAR_BLOCK_SIZE * 2),
+ ];
+ const tar = new Uint8Array(parts.reduce((size, part) => size + part.byteLength, 0));
+ let offset = 0;
+ for (const part of parts) {
+ tar.set(part, offset);
+ offset += part.byteLength;
+ }
+ return gzipSync(tar);
+}
+
+async function npmPackFixture(destination: string) {
+ const { stdout } = await execFileAsync(
+ "npm",
+ [
+ "pack",
+ join(fixtureRoot, "package"),
+ "--json",
+ "--ignore-scripts",
+ "--pack-destination",
+ destination,
+ ],
+ { cwd: destination },
+ );
+ const output = JSON.parse(stdout) as unknown;
+ const filename =
+ Array.isArray(output) && typeof output[0]?.filename === "string"
+ ? output[0].filename
+ : undefined;
+ if (!filename) throw new Error("npm pack did not return a fixture filename");
+ return join(destination, filename);
+}
+
+function feedValue() {
+ const now = Date.now();
+ return JSON.parse(
+ serializeExperimentalClawFeed({
+ schemaVersion: 1,
+ id: EXPERIMENTAL_CLAW_FEED_ID,
+ generatedAt: new Date(now).toISOString(),
+ sequence: 1,
+ expiresAt: new Date(now + 86_400_000).toISOString(),
+ entries: [
+ {
+ type: "claw",
+ id: "@openclaw/hosted-e2e",
+ title: "Hosted E2E",
+ version: "1.0.0",
+ state: "available",
+ publisher: { id: "openclaw", trust: "official" },
+ clawManifestSummary: {
+ schemaVersion: 1,
+ agent: { id: "hosted-e2e", name: "Hosted E2E" },
+ workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 0 },
+ packages: { skillCount: 0, pluginCount: 0 },
+ mcpServerCount: 0,
+ cronJobCount: 0,
+ },
+ install: {
+ candidates: [
+ {
+ sourceRef: "public-clawhub",
+ package: "@openclaw/hosted-e2e",
+ version: "1.0.0",
+ integrity,
+ },
+ ],
+ },
+ },
+ ],
+ }),
+ );
+}
+
+describe("published Claw to OpenClaw dry-run proof", () => {
+ beforeAll(async () => {
+ tempRoot = await mkdtemp(join(tmpdir(), "clawhub-hosted-e2e-fixture-"));
+ const archivePath = await npmPackFixture(tempRoot);
+ archiveBytes = new Uint8Array(await readFile(archivePath));
+ integrity = `sha256:${createHash("sha256").update(archiveBytes).digest("hex")}`;
+ server = createServer((request, response) => {
+ const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
+ if (pathname === "/v1/feeds/claws") {
+ response.setHeader("Content-Type", "application/json");
+ response.end(JSON.stringify(feedValue()));
+ return;
+ }
+ if (pathname === "/api/v1/packages/%40openclaw%2Fhosted-e2e/versions/1.0.0/artifact") {
+ response.setHeader("Content-Type", "application/json");
+ response.end(
+ JSON.stringify({
+ artifact: {
+ kind: "npm-pack",
+ sha256: integrity.slice("sha256:".length),
+ downloadUrl: "/download.tgz",
+ },
+ }),
+ );
+ return;
+ }
+ if (pathname === "/download.tgz") {
+ response.setHeader("Content-Type", "application/gzip");
+ response.end(archiveBytes);
+ return;
+ }
+ response.statusCode = 404;
+ response.end("Not found");
+ });
+ await new Promise((resolveListen) => server!.listen(0, "127.0.0.1", resolveListen));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Fixture server did not bind");
+ serverPort = address.port;
+ });
+
+ afterAll(async () => {
+ if (server) await new Promise((resolveClose) => server!.close(() => resolveClose()));
+ if (tempRoot) await rm(tempRoot, { recursive: true, force: true });
+ });
+
+ it("selects only the exact public ClawHub candidate", () => {
+ const selected = selectPublishedClaw(feedValue(), "@openclaw/hosted-e2e");
+ expect(selected.candidate).toMatchObject({ version: "1.0.0", integrity });
+ expect(() => selectPublishedClaw(feedValue(), "@openclaw/missing")).toThrow("was not present");
+ });
+
+ it("builds a portable production-equivalent ClawPack fixture", async () => {
+ const archivePath = join(tempRoot, "portable-claw.tgz");
+ await writeFile(archivePath, archiveBytes);
+ await expect(assertSafeClawArchive(archivePath)).resolves.toBeUndefined();
+ });
+
+ it("rejects link entries before extracting a published artifact", async () => {
+ const archivePath = join(tempRoot, "linked.tgz");
+ await writeFile(archivePath, deterministicLinkArchive());
+ await expect(assertSafeClawArchive(archivePath)).rejects.toThrow(
+ "only contain regular files and directories",
+ );
+ });
+
+ it("extracts legacy ZIP artifacts without permitting traversal", async () => {
+ const root = await mkdtemp(join(tmpdir(), "clawhub-hosted-e2e-zip-"));
+ try {
+ const archive = zipSync({
+ "package/package.json": strToU8("{}\n"),
+ "package/CLAW.md": strToU8("---\nschemaVersion: 1\n---\n"),
+ });
+ await extractSafeClawZip(archive, root);
+ await expect(readFile(join(root, "package", "package.json"), "utf8")).resolves.toBe("{}\n");
+
+ const unsafeArchive = zipSync({ "../outside": strToU8("unsafe") });
+ await expect(extractSafeClawZip(unsafeArchive, root)).rejects.toThrow("unsafe path");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("discovers legacy ZIP packages extracted directly at the archive root", async () => {
+ const root = await mkdtemp(join(tmpdir(), "clawhub-hosted-e2e-root-zip-"));
+ try {
+ const archive = zipSync({
+ "package.json": strToU8("{}\n"),
+ "CLAW.md": strToU8("---\nschemaVersion: 1\n---\n"),
+ });
+ await extractSafeClawZip(archive, root);
+ await expect(findExtractedPackageRoot(root)).resolves.toBe(root);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects ZIP artifacts whose expanded content exceeds the package limit", async () => {
+ const root = await mkdtemp(join(tmpdir(), "clawhub-hosted-e2e-large-zip-"));
+ try {
+ const archive = zipSync({ "package/large.bin": new Uint8Array(50 * 1024 * 1024 + 1) });
+ await expect(extractSafeClawZip(archive, root)).rejects.toThrow("50MB unpacked limit");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects oversized downloads from metadata before buffering the body", async () => {
+ const response = new Response("", { headers: { "Content-Length": String(65 * 1024 * 1024) } });
+ await expect(readResponseBytesBounded(response)).rejects.toThrow("64MB download limit");
+ });
+
+ it.skipIf(!openclawRepo)(
+ "runs the downloaded package through OpenClaw dry-run",
+ async () => {
+ const origin = `http://127.0.0.1:${serverPort}`;
+ const result = await runPublishedClawDryRun({
+ feedUrl: `${origin}/v1/feeds/claws`,
+ packageName: "@openclaw/hosted-e2e",
+ registryUrl: origin,
+ openclawRepo: openclawRepo!,
+ });
+ expect(result.plan).toMatchObject({
+ schemaVersion: "openclaw.clawAddPlan.v1",
+ dryRun: true,
+ mutationAllowed: false,
+ agent: { finalId: "hosted-e2e" },
+ summary: { blockedActions: 0 },
+ });
+ },
+ 30_000,
+ );
+});
diff --git a/scripts/claws-feed-openclaw-e2e.ts b/scripts/claws-feed-openclaw-e2e.ts
new file mode 100644
index 00000000..9a2652ab
--- /dev/null
+++ b/scripts/claws-feed-openclaw-e2e.ts
@@ -0,0 +1,275 @@
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { promisify } from "node:util";
+import { parseExperimentalClawFeed, type ExperimentalClawFeedEntry } from "clawhub-schema";
+import { unzipSync } from "fflate";
+import { parseClawPack } from "../convex/lib/clawpack";
+import { isSafeClawPackagePath } from "../packages/clawhub/src/schema/clawPackage";
+
+const execFileAsync = promisify(execFile);
+const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
+const MAX_UNPACKED_BYTES = 50 * 1024 * 1024;
+const MAX_ARCHIVE_ENTRIES = 10_000;
+
+type PublishedClawProofOptions = {
+ feedUrl: string;
+ packageName: string;
+ registryUrl: string;
+ openclawRepo: string;
+ keepTemp?: boolean;
+};
+
+function sha256(bytes: Uint8Array): string {
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
+}
+
+function encodePackageName(name: string): string {
+ return encodeURIComponent(name);
+}
+
+export function selectPublishedClaw(feedValue: unknown, packageName: string) {
+ const feed = parseExperimentalClawFeed(feedValue);
+ const entry = feed.entries.find(
+ (candidate): candidate is ExperimentalClawFeedEntry => candidate.id === packageName,
+ );
+ if (!entry) throw new Error(`Claw ${packageName} was not present in the hosted feed`);
+ const candidate = entry.install.candidates.find(
+ (install) =>
+ install.sourceRef === "public-clawhub" &&
+ install.package === packageName &&
+ install.version === entry.version,
+ );
+ if (!candidate) {
+ throw new Error(`Claw ${packageName}@${entry.version} has no exact public-clawhub candidate`);
+ }
+ return { entry, candidate };
+}
+
+export async function findExtractedPackageRoot(root: string): Promise {
+ if (await readFile(join(root, "package.json"), "utf8").catch(() => undefined)) {
+ return root;
+ }
+ const conventional = join(root, "package");
+ if (await readFile(join(conventional, "package.json"), "utf8").catch(() => undefined)) {
+ return conventional;
+ }
+ const candidates: string[] = [];
+ for (const entry of await readdir(root, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const candidate = join(root, entry.name);
+ if (await readFile(join(candidate, "package.json"), "utf8").catch(() => undefined)) {
+ candidates.push(candidate);
+ }
+ }
+ if (candidates.length !== 1) {
+ throw new Error("Downloaded artifact did not contain exactly one package root");
+ }
+ return candidates[0]!;
+}
+
+export async function assertSafeClawArchive(archivePath: string): Promise {
+ const bytes = new Uint8Array(await readFile(archivePath));
+ if (bytes.byteLength > MAX_ARCHIVE_BYTES) throw new Error("Artifact exceeds 64MB download limit");
+ await parseClawPack(bytes);
+}
+
+async function extractSafeClawPack(bytes: Uint8Array, targetDir: string): Promise {
+ const parsed = await parseClawPack(bytes);
+ await mkdir(targetDir, { recursive: true });
+ for (const entry of parsed.entries) {
+ const outputPath = join(targetDir, "package", entry.path);
+ await mkdir(dirname(outputPath), { recursive: true });
+ await writeFile(outputPath, entry.bytes);
+ }
+}
+
+export async function extractSafeClawZip(bytes: Uint8Array, targetDir: string): Promise {
+ if (bytes.byteLength > MAX_ARCHIVE_BYTES) throw new Error("Artifact exceeds 64MB download limit");
+ const portablePaths = new Set();
+ let entryCount = 0;
+ let unpackedBytes = 0;
+ const entries = unzipSync(bytes, {
+ filter: (entry) => {
+ entryCount += 1;
+ if (entryCount > MAX_ARCHIVE_ENTRIES) throw new Error("Artifact exceeds 10000 entry limit");
+ unpackedBytes += entry.originalSize;
+ if (unpackedBytes > MAX_UNPACKED_BYTES) {
+ throw new Error("Artifact exceeds 50MB unpacked limit");
+ }
+ const path = entry.name.replace(/\/+$/, "");
+ if (!path || !isSafeClawPackagePath(path)) {
+ throw new Error(`Artifact contains an unsafe path: ${entry.name}`);
+ }
+ const portablePath = path.normalize("NFC").toLowerCase();
+ if (portablePaths.has(portablePath)) {
+ throw new Error(`Artifact contains a duplicate portable path: ${entry.name}`);
+ }
+ portablePaths.add(portablePath);
+ return true;
+ },
+ });
+ await mkdir(targetDir, { recursive: true });
+ for (const [rawPath, data] of Object.entries(entries)) {
+ const path = rawPath.replace(/\/+$/, "");
+ if (!path) continue;
+ if (rawPath.endsWith("/")) {
+ await mkdir(join(targetDir, path), { recursive: true });
+ continue;
+ }
+ const outputPath = join(targetDir, path);
+ await mkdir(dirname(outputPath), { recursive: true });
+ await writeFile(outputPath, data);
+ }
+}
+
+export async function readResponseBytesBounded(response: Response): Promise {
+ const declaredLength = Number(response.headers.get("content-length"));
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_ARCHIVE_BYTES) {
+ throw new Error("Artifact exceeds 64MB download limit");
+ }
+ if (!response.body) return new Uint8Array();
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ total += value.byteLength;
+ if (total > MAX_ARCHIVE_BYTES) {
+ await reader.cancel();
+ throw new Error("Artifact exceeds 64MB download limit");
+ }
+ chunks.push(value);
+ }
+ const result = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ result.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return result;
+}
+
+export async function runPublishedClawDryRun(options: PublishedClawProofOptions) {
+ const root = await mkdtemp(join(tmpdir(), "clawhub-claw-e2e-"));
+ try {
+ const feedResponse = await fetch(options.feedUrl);
+ if (!feedResponse.ok) {
+ throw new Error(`Claws feed returned HTTP ${feedResponse.status}`);
+ }
+ const { entry, candidate } = selectPublishedClaw(
+ await feedResponse.json(),
+ options.packageName,
+ );
+ const artifactUrl = new URL(
+ `/api/v1/packages/${encodePackageName(candidate.package)}/versions/${encodeURIComponent(candidate.version)}/artifact`,
+ options.registryUrl,
+ );
+ const metadataResponse = await fetch(artifactUrl);
+ if (!metadataResponse.ok) {
+ throw new Error(`Artifact metadata returned HTTP ${metadataResponse.status}`);
+ }
+ const metadata = (await metadataResponse.json()) as {
+ artifact?: { kind?: unknown; sha256?: unknown; downloadUrl?: unknown };
+ };
+ const artifactKind = metadata.artifact?.kind;
+ const metadataSha256 = metadata.artifact?.sha256;
+ const downloadUrl = metadata.artifact?.downloadUrl;
+ if (
+ (artifactKind !== "npm-pack" && artifactKind !== "legacy-zip") ||
+ typeof metadataSha256 !== "string" ||
+ typeof downloadUrl !== "string"
+ ) {
+ throw new Error("Artifact metadata did not include kind, sha256, and downloadUrl");
+ }
+ if (candidate.integrity !== `sha256:${metadataSha256.replace(/^sha256:/, "")}`) {
+ throw new Error("Feed integrity does not match artifact metadata");
+ }
+ const artifactResponse = await fetch(new URL(downloadUrl, options.registryUrl));
+ if (!artifactResponse.ok) {
+ throw new Error(`Artifact download returned HTTP ${artifactResponse.status}`);
+ }
+ const artifactBytes = await readResponseBytesBounded(artifactResponse);
+ if (sha256(artifactBytes) !== candidate.integrity) {
+ throw new Error("Downloaded artifact does not match the feed integrity");
+ }
+
+ const extractRoot = join(root, "extract");
+ if (artifactKind === "npm-pack") {
+ await extractSafeClawPack(artifactBytes, extractRoot);
+ } else {
+ await extractSafeClawZip(artifactBytes, extractRoot);
+ }
+ const packageRoot = await findExtractedPackageRoot(extractRoot);
+ const stateDir = join(root, "openclaw-state");
+ const result = await execFileAsync(
+ process.execPath,
+ ["--import", "tsx", "src/entry.ts", "claws", "add", packageRoot, "--dry-run", "--json"],
+ {
+ cwd: resolve(options.openclawRepo),
+ env: {
+ ...process.env,
+ HOME: stateDir,
+ USERPROFILE: stateDir,
+ OPENCLAW_CONFIG_PATH: join(stateDir, "openclaw.json"),
+ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
+ OPENCLAW_EXPERIMENTAL_CLAWS: "1",
+ OPENCLAW_HOME: stateDir,
+ OPENCLAW_STATE_DIR: stateDir,
+ OPENCLAW_TEST_FAST: "1",
+ VITEST: "",
+ },
+ maxBuffer: 1024 * 1024,
+ },
+ );
+ const plan = JSON.parse(result.stdout.trim()) as Record;
+ if (
+ plan.schemaVersion !== "openclaw.clawAddPlan.v1" ||
+ plan.dryRun !== true ||
+ plan.mutationAllowed !== false
+ ) {
+ throw new Error("OpenClaw did not return a non-mutating Claw add plan");
+ }
+ return { entry, candidate, plan };
+ } finally {
+ if (!options.keepTemp) await rm(root, { recursive: true, force: true });
+ }
+}
+
+function parseArgs(argv: string[]) {
+ const values = new Map();
+ let keepTemp = false;
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index]!;
+ if (arg === "--keep-temp") {
+ keepTemp = true;
+ continue;
+ }
+ const value = argv[index + 1];
+ if (!arg.startsWith("--") || !value || value.startsWith("--")) {
+ throw new Error(`Invalid argument: ${arg}`);
+ }
+ values.set(arg, value);
+ index += 1;
+ }
+ const required = (name: string) => {
+ const value = values.get(name);
+ if (!value) throw new Error(`Missing ${name}`);
+ return value;
+ };
+ return {
+ feedUrl: required("--feed"),
+ packageName: required("--package"),
+ registryUrl: required("--registry"),
+ openclawRepo: required("--openclaw-repo"),
+ keepTemp,
+ };
+}
+
+if (import.meta.main) {
+ const result = await runPublishedClawDryRun(parseArgs(process.argv.slice(2)));
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
+}
diff --git a/scripts/claws-feed-openclaw-workflow.test.ts b/scripts/claws-feed-openclaw-workflow.test.ts
new file mode 100644
index 00000000..4d03e4d7
--- /dev/null
+++ b/scripts/claws-feed-openclaw-workflow.test.ts
@@ -0,0 +1,45 @@
+import { readFile } from "node:fs/promises";
+import { describe, expect, it } from "vitest";
+import { parse as parseYaml } from "yaml";
+
+describe("Claw feed OpenClaw contract workflow", () => {
+ it("runs the real bridge against a pinned OpenClaw source revision", async () => {
+ const workflow = parseYaml(await readFile(".github/workflows/ci.yml", "utf8")) as {
+ jobs: Record<
+ string,
+ {
+ env?: Record;
+ steps?: Array<{
+ uses?: string;
+ with?: Record;
+ env?: Record;
+ run?: string;
+ }>;
+ }
+ >;
+ };
+ const job = workflow.jobs["claws-openclaw-contract"];
+ expect(job?.env?.OPENCLAW_CONTRACT_SHA).toBe("59fc573fb9e938a93b93522be6bf4d7bec0dbc6f");
+ expect(job?.steps).toContainEqual(
+ expect.objectContaining({
+ uses: "actions/checkout@v7.0.1",
+ with: expect.objectContaining({
+ repository: "openclaw/openclaw",
+ ref: "${{ env.OPENCLAW_CONTRACT_SHA }}",
+ path: ".artifacts/openclaw-contract",
+ }),
+ }),
+ );
+ expect(job?.steps?.some((step) => step.run?.includes("pnpm install --frozen-lockfile"))).toBe(
+ true,
+ );
+ expect(
+ job?.steps?.some(
+ (step) =>
+ step.env?.OPENCLAW_CLAWS_CHECKOUT ===
+ "${{ github.workspace }}/.artifacts/openclaw-contract" &&
+ step.run?.includes("claws-feed-openclaw-e2e.test.ts"),
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/specs/claws.md b/specs/claws.md
index 757ca083..d25746ef 100644
--- a/specs/claws.md
+++ b/specs/claws.md
@@ -48,8 +48,13 @@ namespaced key or define their own profile-pointer contract.
([PR #3090](https://github.com/openclaw/clawhub/pull/3090)).
3. Add feature-gated search, detail, and API surfaces
([PR #3091](https://github.com/openclaw/clawhub/pull/3091)).
-4. Add hosted feed export and a published-package end-to-end proof through
- OpenClaw `claws add --dry-run`.
+4. Add a separately gated hosted Claws feed and a repeatable published-package
+ proof through OpenClaw `claws add --dry-run`
+ ([PR #3092](https://github.com/openclaw/clawhub/pull/3092)).
+
+The hosted projection uses the separate
+[experimental Claw feed contract](experimental-claw-feed.md), not an extension
+of the stable plugin/skill catalog feed v1 schema.
The shared validator follows the RFC's strict v1 contract: strings are not
trimmed into validity, MCP package selectors must resolve exact versions,
diff --git a/specs/experimental-claw-feed.md b/specs/experimental-claw-feed.md
new file mode 100644
index 00000000..a1b7783b
--- /dev/null
+++ b/specs/experimental-claw-feed.md
@@ -0,0 +1,66 @@
+---
+summary: "Experimental ClawHub feed contract for versioned Claw package discovery."
+read_when:
+ - Publishing or consuming the experimental Claws feed
+ - Changing Claw feed entries, gating, or package proof
+---
+
+# Experimental Claw Feed
+
+The Claws feed is a separate experimental wire contract. It does not add
+`type: "claw"` to the stable hosted catalog feed schema version 1.
+
+## Contract
+
+- Route: `/api/v1/feeds/claws`, proxied as `/v1/feeds/claws`
+- Feed id: `clawhub-official-claws`
+- Experimental schema version: `1`
+- Gate: `CLAWHUB_EXPERIMENTAL_CLAWS=1`
+- Entry type: `claw` only
+- Install coordinate: canonical package name plus exact release version
+- Integrity: `sha256:`
+- Metadata: bounded `clawManifestSummary`; never the full manifest
+
+The route uses `Cache-Control: no-store` and no surrogate cache in both enabled
+and disabled states, so changing the gate cannot leave an enabled response at
+the edge. Direct responses retain ETag and Last-Modified validators, but the
+experimental feed must not reuse the stable feeds' CDN policy. When disabled,
+it returns `404` before reading stored publication state. It has no unversioned
+Vercel redirect and is not advertised by
+`/.well-known/openclaw-registry.json` while experimental.
+
+Eligible releases must be public, official, unblocked, and retain an immutable
+artifact digest plus the bounded summary derived during publication. The exact
+package artifact remains authoritative; Convex does not retain a second full
+manifest copy.
+
+The experimental parser rejects generic plugin and skill entries, unknown feed
+ids, unknown fields, invalid timestamps, unsupported schema versions,
+non-official publishers, and entries without exactly one install candidate.
+That candidate must match the entry package/version and use lowercase
+`sha256:` plus exactly 64 hexadecimal characters. The serializer provides
+deterministic entry and bootstrap-file ordering.
+
+## Proof Boundary
+
+`scripts/claws-feed-openclaw-e2e.test.ts` is a registry-to-OpenClaw bridge
+proof. It parses the experimental feed, selects one exact ClawHub candidate,
+checks artifact metadata and downloaded bytes against the feed digest, performs
+bounded safe extraction, and passes the resulting package directory to the real
+OpenClaw `claws add --dry-run --json` command in isolated state.
+
+This proves that a package advertised by ClawHub can produce a non-mutating
+OpenClaw plan. It does not claim that OpenClaw itself resolves ClawHub feed URLs;
+that consumer integration is a separate dependent track.
+
+The valid TGZ fixture is produced through the same `npm pack --ignore-scripts`
+path as package publishing. Invalid link/special-entry fixtures use deterministic
+ustar bytes instead of the host `tar` implementation. ClawHub CI checks out the
+declared OpenClaw contract SHA and runs this bridge with OpenClaw's frozen
+dependencies installed; changing that SHA is an explicit compatibility update.
+
+Downloads are capped at 64 MiB. TGZ parsing shares ClawHub's npm-pack path,
+which enforces canonical `package/` paths, regular files/directories only,
+10,000 entries, 50 MiB expanded content, and portable duplicate rejection.
+Legacy ZIP extraction applies the same entry, expanded-size, path, and portable
+collision bounds and supports either `package/` or archive-root package layout.
diff --git a/specs/hosted-catalog-feed.md b/specs/hosted-catalog-feed.md
index 30d45901..9c1cd5c2 100644
--- a/specs/hosted-catalog-feed.md
+++ b/specs/hosted-catalog-feed.md
@@ -1,5 +1,5 @@
---
-summary: "ClawHub publication contract for the OpenClaw hosted plugin, skill, and promotions feeds."
+summary: "ClawHub publication contract for the stable OpenClaw hosted plugin, skill, and promotions feeds."
read_when:
- Publishing an OpenClaw hosted feed
- Changing feed entries, cache headers, or publication workflow
@@ -9,9 +9,9 @@ read_when:
# Hosted Feeds
ClawHub is the canonical producer for the initial OpenClaw plugin and skill
-feeds and the runtime promotions feed. The feeds are projections of existing
-public package, release, skill, and promotion records; they are not second
-catalogs.
+feeds and the runtime promotions feed. The feeds
+are projections of existing public package, release, skill, and promotion
+records; they are not second catalogs.
## Contract
@@ -71,6 +71,10 @@ Until the skills feed has pagination or sharding, it publishes at most 1000
eligible entries per snapshot so a large skills corpus does not block the plugin
feed publication path.
+The experimental Claws feed is deliberately not an additive entry type in this
+stable v1 contract. Its separately gated parser, serializer, and route are
+specified in [Experimental Claw Feed](experimental-claw-feed.md).
+
The promotions feed uses id `clawhub-promotions`, schema version `1`, and the
`/v1/feeds/promotions` route. Entries are declarative promotion records, not
commands or executable content. They may identify providers, auth choices,
@@ -97,8 +101,9 @@ outside the promotion's declared provider.
## Publication
-`convex/catalogFeed.ts` builds both feeds from indexed package/skill queries and
-stores one current publication row per feed in `catalogFeedPublications`.
+`convex/catalogFeed.ts` builds the package, skill, and gated Claws feeds from
+indexed package/skill queries and stores one current publication row per feed
+in `catalogFeedPublications`.
Keeping one row per feed avoids an unbounded publication log while preserving
the sequence and exact payload needed for validators.
@@ -119,9 +124,9 @@ snapshot inside its 24-hour `expiresAt` horizon.
## Edge delivery
-The HTTP endpoints are `/api/v1/feeds/plugins`, `/api/v1/feeds/skills`, and
-`/api/v1/feeds/promotions`. Each returns its stored bytes unchanged and
-provides:
+The stable HTTP endpoints are `/api/v1/feeds/plugins`, `/api/v1/feeds/skills`,
+and `/api/v1/feeds/promotions`. Each enabled endpoint
+returns its stored bytes unchanged and provides:
- `ETag: "sha256:"`
- `Last-Modified`
@@ -131,7 +136,7 @@ provides:
Nitro exposes `/v1/feeds/plugins`, `/v1/feeds/skills`, and
`/v1/feeds/promotions` through the same environment-aware Convex proxy used for
-`/api/*`. The unversioned `/feeds/*` paths permanently redirect to their
+`/api/*`. Their unversioned `/feeds/*` paths permanently redirect to the
versioned paths. The `registry.openclaw.ai` custom domain must point at the same
Vercel project before the public RFC URLs are enabled.
diff --git a/src/__tests__/vercel-preview-config.test.ts b/src/__tests__/vercel-preview-config.test.ts
index 0b0bd456..d598f7d8 100644
--- a/src/__tests__/vercel-preview-config.test.ts
+++ b/src/__tests__/vercel-preview-config.test.ts
@@ -6,6 +6,7 @@ describe("Vercel preview configuration", () => {
const configText = await readFile("vercel.json", "utf8");
const config = JSON.parse(configText) as {
buildCommand?: string;
+ redirects?: Array<{ source?: string; destination?: string; statusCode?: number }>;
rewrites?: unknown[];
};
const packageJson = JSON.parse(await readFile("package.json", "utf8")) as {