mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
co-authored by
Patrick Erichsen
parent
6efbcb768f
commit
5a3b050751
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
@@ -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",
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user