Add gated Claw hosted feed and lifecycle proof (#3092)

* feat(claws): publish hosted feed with OpenClaw proof

* test(claws): prove package-local profile feed flow

* fix(claws): encode scoped package artifact routes

* fix(claws): enforce feed rollback and binding

* test(claws): pin hosted OpenClaw contract proof

* test(claws): add Convex feed runtime smoke

* chore(schema): refresh experimental feed declarations

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Gio Della-Libera
2026-07-24 19:29:12 -05:00
committed by GitHub
co-authored by Patrick Erichsen
parent 6efbcb768f
commit 5a3b050751
34 changed files with 1720 additions and 27 deletions
+35
View File
@@ -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
+75
View File
@@ -0,0 +1,75 @@
/// <reference types="vite/client" />
/* @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");
});
});
+116 -3
View File
@@ -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<Record<string, never>, 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<string, unknown>) => {
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<string, unknown>) => ({
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(
+152 -5
View File
@@ -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<QueryCtx, "db">;
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<CatalogFeedEntry | null> {
): Promise<CatalogFeedPluginEntry | ExperimentalClawFeedEntry | null> {
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<CatalogFeedPluginEntry | ExperimentalClawFeedEntry> = [];
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),
),
},
+7
View File
@@ -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",
+33 -2
View File
@@ -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<typeof vi.fn>;
@@ -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",
});
});
});
+3
View File
@@ -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,
+32 -3
View File
@@ -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<string, string> =
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);
}
+34
View File
@@ -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.
+19
View File
@@ -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.
@@ -0,0 +1,7 @@
{
"name": "@openclaw/hosted-e2e",
"version": "1.0.0",
"openclaw": {
"claw": "CLAW.md"
}
}
@@ -0,0 +1,7 @@
schemaVersion: 1
agent:
tools:
profile: coding
fs:
workspaceOnly: true
humanDelay: { mode: natural }
@@ -0,0 +1,3 @@
# Hosted E2E
Use the published Claw package without mutating local state during proof.
+66
View File
@@ -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;
+141
View File
@@ -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
File diff suppressed because one or more lines are too long
+1
View File
@@ -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";
+1
View File
@@ -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";
+1 -1
View File
@@ -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"}
{"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"}
+1
View File
@@ -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";
+1
View File
@@ -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",
+1 -1
View File
@@ -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"}
{"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"}
+22
View File
@@ -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) => ({
@@ -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",
);
},
);
});
+157
View File
@@ -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,
});
}
+1
View File
@@ -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";
+1
View File
@@ -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",
+275
View File
@@ -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<void>((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<void>((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,
);
});
+275
View File
@@ -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<string> {
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<void> {
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<void> {
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<void> {
if (bytes.byteLength > MAX_ARCHIVE_BYTES) throw new Error("Artifact exceeds 64MB download limit");
const portablePaths = new Set<string>();
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<Uint8Array> {
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<string, unknown>;
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<string, string>();
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`);
}
@@ -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<string, string>;
steps?: Array<{
uses?: string;
with?: Record<string, string>;
env?: Record<string, string>;
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);
});
});
+7 -2
View File
@@ -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,
+66
View File
@@ -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:<immutable artifact 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.
+15 -10
View File
@@ -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:<payload hash>"`
- `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.
@@ -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 {