feat(claws): add gated publication and profile validation (#3090)

Gate Claw publication before storage access or mutation, validate bounded exact package/profile bytes and archive hierarchy, implement the managed CLAW.md body envelope, and prevent disabled-family list/search starvation.

Validated at exact head a9f1bb419f with all repository CI, CodeQL, secret scanning, focused tests, real local Convex schema/function validation, and clean final autoreview.

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
This commit is contained in:
Gio Della-Libera
2026-07-24 18:14:12 -05:00
committed by GitHub
parent cfcb6bf0a6
commit 85a3fde608
41 changed files with 5588 additions and 228 deletions
+6 -2
View File
@@ -111,6 +111,7 @@
"@openclaw/plugin-inspector": "0.3.17",
"arktype": "2.2.3",
"commander": "15.0.0",
"croner": "10.0.1",
"fflate": "0.8.3",
"ignore": "7.0.6",
"json5": "2.2.3",
@@ -119,6 +120,8 @@
"p-retry": "8.0.0",
"semver": "7.8.5",
"undici": "7.28.0",
"unicode-case-folding": "1.1.1",
"yaml": "2.9.0",
},
"devDependencies": {
"@types/node": "26.1.1",
@@ -158,6 +161,7 @@
"arktype": "2.2.3",
"croner": "10.0.1",
"unicode-case-folding": "1.1.1",
"yaml": "2.9.0",
},
"devDependencies": {
"@typescript/native": "npm:typescript@7.0.2",
@@ -167,7 +171,7 @@
},
"overrides": {
"ast-v8-to-istanbul": "1.0.4",
"brace-expansion": "5.0.7",
"brace-expansion": "5.0.8",
"dompurify": "3.4.12",
"esbuild": "0.28.1",
"fast-uri": "3.1.4",
@@ -1070,7 +1074,7 @@
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
+4
View File
@@ -69,6 +69,7 @@ import type * as lib_emailRendering from "../lib/emailRendering.js";
import type * as lib_emails from "../lib/emails.js";
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_experimentalClaws from "../lib/experimentalClaws.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js";
import type * as lib_githubAuth from "../lib/githubAuth.js";
@@ -107,6 +108,7 @@ import type * as lib_publisherAbuseScoring from "../lib/publisherAbuseScoring.js
import type * as lib_publisherCatalogDisplay from "../lib/publisherCatalogDisplay.js";
import type * as lib_publisherStats from "../lib/publisherStats.js";
import type * as lib_publishers from "../lib/publishers.js";
import type * as lib_rankingMetricsImportLock from "../lib/rankingMetricsImportLock.js";
import type * as lib_recommendationScore from "../lib/recommendationScore.js";
import type * as lib_reporting from "../lib/reporting.js";
import type * as lib_reservedHandles from "../lib/reservedHandles.js";
@@ -251,6 +253,7 @@ declare const fullApi: ApiFromModules<{
"lib/emails": typeof lib_emails;
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
"lib/embeddings": typeof lib_embeddings;
"lib/experimentalClaws": typeof lib_experimentalClaws;
"lib/githubAccount": typeof lib_githubAccount;
"lib/githubActionsOidc": typeof lib_githubActionsOidc;
"lib/githubAuth": typeof lib_githubAuth;
@@ -289,6 +292,7 @@ declare const fullApi: ApiFromModules<{
"lib/publisherCatalogDisplay": typeof lib_publisherCatalogDisplay;
"lib/publisherStats": typeof lib_publisherStats;
"lib/publishers": typeof lib_publishers;
"lib/rankingMetricsImportLock": typeof lib_rankingMetricsImportLock;
"lib/recommendationScore": typeof lib_recommendationScore;
"lib/reporting": typeof lib_reporting;
"lib/reservedHandles": typeof lib_reservedHandles;
+246
View File
@@ -12191,6 +12191,7 @@ describe("httpApiV1 handlers", () => {
artifactKind: "legacy-zip",
integritySha256: "a".repeat(64),
sha256hash: "b".repeat(64),
clawpackSize: 321,
},
};
}
@@ -12214,6 +12215,7 @@ describe("httpApiV1 handlers", () => {
source: "clawhub",
artifactKind: "legacy-zip",
artifactSha256: "b".repeat(64),
size: 321,
packageName: "demo-plugin",
version: "1.0.0",
downloadUrl: "https://example.com/api/v1/packages/demo-plugin/download?version=1.0.0",
@@ -14444,6 +14446,126 @@ describe("httpApiV1 handlers", () => {
expect(storageGet).toHaveBeenCalledWith("storage:1");
});
it("serves the immutable stored legacy archive without reconstructing it", async () => {
const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0xaa, 0xbb]);
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) {
return {
package: {
_id: "packages:1",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 1,
},
latestRelease: null,
owner: null,
};
}
if ("releaseId" in args) {
return {
_id: "packageReleases:1",
version: "1.0.0",
createdAt: 1,
changelog: "init",
artifactKind: "legacy-zip",
clawpackStorageId: "storage:archive",
files: [
{
path: "package.json",
size: 2,
sha256: "a".repeat(64),
storageId: "storage:file-that-must-not-be-read",
},
],
};
}
return null;
});
const storageGet = vi.fn(async (id: string) =>
id === "storage:archive" ? new Blob([archive], { type: "application/zip" }) : null,
);
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation, storage: { get: storageGet } }),
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
);
expect(response.status).toBe(200);
expect(new Uint8Array(await response.arrayBuffer())).toEqual(archive);
expect(storageGet).toHaveBeenCalledTimes(1);
expect(storageGet).toHaveBeenCalledWith("storage:archive");
});
it("reconstructs compatibility ZIP downloads instead of serving stored npm tarballs", async () => {
const storedTarball = gzipSync(new TextEncoder().encode("not a zip"));
const packageJson = new TextEncoder().encode('{"name":"demo-plugin"}');
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) {
return {
package: {
_id: "packages:1",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 1,
},
latestRelease: null,
owner: null,
};
}
if ("releaseId" in args) {
return {
_id: "packageReleases:1",
version: "1.0.0",
createdAt: 1,
changelog: "init",
artifactKind: "npm-pack",
clawpackStorageId: "storage:tarball",
files: [
{
path: "package.json",
size: packageJson.byteLength,
sha256: "a".repeat(64),
storageId: "storage:package-json",
},
],
};
}
return null;
});
const storageGet = vi.fn(async (id: string) => {
if (id === "storage:tarball") return new Blob([storedTarball], { type: "application/gzip" });
if (id === "storage:package-json") return new Blob([packageJson]);
return null;
});
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation, storage: { get: storageGet } }),
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/zip");
expect(
strFromU8(unzipSync(new Uint8Array(await response.arrayBuffer()))["package/package.json"]!),
).toBe('{"name":"demo-plugin"}');
expect(storageGet).not.toHaveBeenCalledWith("storage:tarball");
expect(storageGet).toHaveBeenCalledWith("storage:package-json");
});
it("allows package downloads when verification is clean even without cached vtAnalysis", async () => {
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
@@ -14750,6 +14872,65 @@ describe("httpApiV1 handlers", () => {
expect(runAction).not.toHaveBeenCalled();
});
it.each(["loose", "direct-tgz", "staged-tgz"] as const)(
"disabled Claw publication rejects %s before multipart storage or ticket mutation",
async (mode) => {
vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "0");
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
kind: "user",
userId: "users:1",
user: { _id: "users:1", handle: "p" },
} as never);
const runMutation = vi.fn().mockResolvedValue(okRate());
const runAction = vi.fn();
const storageGet = vi.fn();
const storageStore = vi.fn();
const form = packagePublishForm(packagePublishMetadata({ family: "claw" }));
if (mode === "loose") {
form.append("files", new File(["manifest"], "CLAW.md", { type: "text/markdown" }));
} else if (mode === "direct-tgz") {
const pack = npmPackFixture({
"package/package.json": JSON.stringify({ name: "demo-claw", version: "1.0.0" }),
"package/CLAW.md": "manifest",
});
form.append(
"clawpack",
new File([bytesToArrayBuffer(pack)], "demo-claw-1.0.0.tgz", {
type: "application/octet-stream",
}),
);
} else {
form.set("clawpack", "storage:clawpack");
form.set("clawpackUploadTicket", "packagePublishUploadTickets:1");
}
const response = await __handlers.publishPackageV1Handler(
makeCtx({
runAction,
runMutation,
storage: { get: storageGet, store: storageStore },
}),
new Request("https://example.com/api/v1/packages", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
body: form,
}),
);
expect(response.status).toBe(400);
expect(await response.text()).toBe("Experimental Claw publication is disabled");
expect(storageGet).not.toHaveBeenCalled();
expect(storageStore).not.toHaveBeenCalled();
expect(runAction).not.toHaveBeenCalled();
expect(
runMutation.mock.calls.some(([, args]) =>
Boolean(args && typeof args === "object" && "uploadTicket" in args),
),
).toBe(false);
},
);
it("package publish rejects browser session auth when token auth is not an API token", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:session" as never);
vi.mocked(requirePackagePublishAuth).mockRejectedValue(new Error("Unauthorized"));
@@ -14921,6 +15102,71 @@ describe("httpApiV1 handlers", () => {
expect(payload?.files?.map((file) => file.path)).toContain("dist/index.js");
});
it("multipart Claw publish accepts an npm pack without a plugin manifest", async () => {
vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
kind: "user",
userId: "users:1",
user: { _id: "users:1", handle: "p" },
} as never);
const runMutation = vi.fn().mockResolvedValue(okRate());
const runAction = vi
.fn()
.mockResolvedValue({ ok: true, packageId: "pkg:claw", releaseId: "rel:claw" });
const storageStore = vi.fn(async () => `storage:${storageStore.mock.calls.length}`);
const pack = npmPackFixture({
"package/package.json": JSON.stringify({
name: "demo-claw",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"package/CLAW.md":
"---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\nYou are a focused demo agent.\n",
});
const form = new FormData();
form.set(
"payload",
JSON.stringify({
name: "demo-claw",
family: "claw",
version: "1.0.0",
changelog: "init",
}),
);
form.append(
"clawpack",
new File([bytesToArrayBuffer(pack)], "demo-claw-1.0.0.tgz", {
type: "application/octet-stream",
}),
);
const response = await __handlers.publishPackageV1Handler(
makeCtx({ runAction, runMutation, storage: { store: storageStore } }),
new Request("https://example.com/api/v1/packages", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
body: form,
}),
);
expect(response.status, await response.clone().text()).toBe(200);
expect(storageStore).toHaveBeenCalledTimes(3);
expect(runAction).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
payload: expect.objectContaining({
family: "claw",
artifact: expect.objectContaining({ kind: "npm-pack", npmFileCount: 2 }),
files: [
expect.objectContaining({ path: "package.json" }),
expect.objectContaining({ path: "CLAW.md" }),
],
}),
}),
);
});
it("staged ClawPack publish derives artifact metadata from stored bytes", async () => {
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
+32 -10
View File
@@ -34,6 +34,7 @@ import { buildDownloadMetricArgs, getDownloadIdentity } from "../downloadMetrics
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
import { parseClawPack, sha256Base64, sha256Hex } from "../lib/clawpack";
import { experimentalClawsEnabled } from "../lib/experimentalClaws";
import {
fetchGitHubRepositoryIdentity,
verifyGitHubActionsTrustedPublishJwt,
@@ -650,6 +651,7 @@ function toReleaseArtifact(release: ReleaseLike, packageName?: string) {
return {
kind: "legacy-zip" as const,
...(sha256 ? { sha256 } : {}),
...(release.clawpackSize !== undefined ? { size: release.clawpackSize } : {}),
format: "zip",
source: "clawhub" as const,
artifactKind: "legacy-zip" as const,
@@ -1461,6 +1463,9 @@ async function parseMultipartPackagePublish(
parsedPayload,
"Package publish payload",
);
if (metadata.family === "claw" && !experimentalClawsEnabled()) {
throw new Error("Experimental Claw publication is disabled");
}
const tarballPart = getTarballPart(form);
const fileParts = getFileParts(
@@ -4137,16 +4142,29 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
if (!release) return text("Version not found", 404, rate.headers);
const securityBlock = getReleaseSecurityBlock(release);
if (securityBlock) return text(securityBlock.message, securityBlock.status, rate.headers);
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
for (const file of release.files) {
const blob = await ctx.storage.get(file.storageId);
if (!blob) return text(`Missing stored file: ${file.path}`, 500, rate.headers);
entries.push({
path: file.path,
bytes: new Uint8Array(await blob.arrayBuffer()),
});
const storedZip =
release.artifactKind === "legacy-zip" && release.clawpackStorageId
? await ctx.storage.get(release.clawpackStorageId)
: null;
if (release.artifactKind === "legacy-zip" && release.clawpackStorageId && !storedZip) {
return text("Missing stored package artifact", 500, rate.headers);
}
let zip: Uint8Array;
if (storedZip) {
zip = new Uint8Array(await storedZip.arrayBuffer());
} else {
// Historical legacy releases and npm packs need a compatibility ZIP projection.
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
for (const file of release.files) {
const blob = await ctx.storage.get(file.storageId);
if (!blob) return text(`Missing stored file: ${file.path}`, 500, rate.headers);
entries.push({
path: file.path,
bytes: new Uint8Array(await blob.arrayBuffer()),
});
}
zip = buildDeterministicPackageZip(entries);
}
const zip = buildDeterministicPackageZip(entries);
const [zipSha256, zipSha256Base64] = await Promise.all([sha256Hex(zip), sha256Base64(zip)]);
try {
const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null);
@@ -4164,7 +4182,11 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
} catch {
// Best-effort metric path; never fail package downloads.
}
return new Response(new Blob([zip], { type: "application/zip" }), {
const zipBody = zip.buffer.slice(
zip.byteOffset,
zip.byteOffset + zip.byteLength,
) as ArrayBuffer;
return new Response(new Blob([zipBody], { type: "application/zip" }), {
status: 200,
headers: mergeHeaders(
rate.headers,
+81 -9
View File
@@ -13,8 +13,8 @@ function writeString(target: Uint8Array, offset: number, width: number, value: s
target.set(encoded.subarray(0, width), offset);
}
function tarFile(path: string, content: string) {
const bytes = new TextEncoder().encode(content);
function tarFile(path: string, content: string | Uint8Array) {
const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
const header = new Uint8Array(BLOCK_SIZE);
writeString(header, 0, 100, path);
writeString(header, 100, 8, octal(0o644, 8));
@@ -37,7 +37,7 @@ function tarFile(path: string, content: string) {
return [header, body];
}
function npmPackFixtureEntries(files: Array<[string, string]>) {
function npmPackFixtureEntries(files: Array<[string, string | Uint8Array]>) {
const parts: Uint8Array[] = [];
for (const [path, content] of files) {
parts.push(...tarFile(path, content));
@@ -53,7 +53,7 @@ function npmPackFixtureEntries(files: Array<[string, string]>) {
return gzipSync(tar);
}
function npmPackFixture(files: Record<string, string>) {
function npmPackFixture(files: Record<string, string | Uint8Array>) {
return npmPackFixtureEntries(Object.entries(files));
}
@@ -81,14 +81,62 @@ describe("clawpack", () => {
]);
});
it("rejects plugin tarballs without openclaw.plugin.json", async () => {
it("accepts Claw tarballs without a plugin manifest", async () => {
const pack = npmPackFixture({
"package/package.json": JSON.stringify({ name: "demo", version: "1.0.0" }),
"package/package.json": JSON.stringify({
name: "demo",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"package/CLAW.md": "---\nschemaVersion: 1\nagent: { id: demo }\n---\n",
});
await expect(parseClawPack(pack)).rejects.toThrow(
"ClawPack must contain package/openclaw.plugin.json",
);
const parsed = await parseClawPack(pack);
expect(parsed.pluginManifest).toBeUndefined();
expect(parsed.entries.map((entry) => entry.path)).toContain("CLAW.md");
});
it("preserves and rejects padded tar path identity", async () => {
const pack = npmPackFixture({
"package/package.json": JSON.stringify({ name: "demo", version: "1.0.0" }),
"package/CLAW.md ": "padded",
});
await expect(parseClawPack(pack)).rejects.toThrow("unsafe tar path");
});
it("rejects empty tar path segments", async () => {
const pack = npmPackFixture({
"package/package.json": JSON.stringify({ name: "demo", version: "1.0.0" }),
"package/workspace//SOUL.md": "unsafe",
});
await expect(parseClawPack(pack)).rejects.toThrow("unsafe tar path");
});
it.each(["\u007f", "\u0085"])("rejects control character U+%s in tar paths", async (control) => {
const pack = npmPackFixture({
"package/package.json": JSON.stringify({ name: "demo", version: "1.0.0" }),
[`package/workspace/a${control}.md`]: "unsafe",
});
await expect(parseClawPack(pack)).rejects.toThrow("unsafe tar path");
});
it("rejects oversized package metadata before JSON parsing", async () => {
const pack = npmPackFixture({
"package/package.json": new Uint8Array(256 * 1024 + 1).fill(0x20),
});
await expect(parseClawPack(pack)).rejects.toThrow("package.json exceeds 256KB limit");
});
it("rejects invalid UTF-8 package metadata", async () => {
const pack = npmPackFixture({
"package/package.json": new Uint8Array([0xc3, 0x28]),
});
await expect(parseClawPack(pack)).rejects.toThrow("package.json is invalid JSON");
});
it("rejects archives that are not rooted under package/", async () => {
@@ -111,6 +159,30 @@ describe("clawpack", () => {
);
});
it.each([
[
"ancestor first",
[
["package/a", "file"],
["package/a/b", "child"],
],
],
[
"descendant first",
[
["package/a/b", "child"],
["package/a", "file"],
],
],
] as const)("rejects tar file/ancestor collisions with %s", async (_label, entries) => {
const pack = npmPackFixtureEntries([
["package/package.json", JSON.stringify({ name: "demo", version: "1.0.0" })],
...entries.map(([path, content]) => [path, content] as [string, string]),
]);
await expect(parseClawPack(pack)).rejects.toThrow("file/ancestor path collision");
});
it("uses npm-style tarball names", () => {
expect(npmTarballName("demo", "1.0.0")).toBe("demo-1.0.0.tgz");
expect(npmTarballName("@scope/demo", "1.0.0")).toBe("scope-demo-1.0.0.tgz");
+80 -27
View File
@@ -1,4 +1,5 @@
import { gunzipSync } from "fflate";
import { findClawPackagePathHierarchyCollision } from "clawhub-schema";
import { Gunzip } from "fflate";
type ClawPackEntry = {
path: string;
@@ -16,23 +17,29 @@ type ParsedClawPack = {
fileCount: number;
entries: ClawPackEntry[];
packageJson: Record<string, unknown>;
pluginManifest: Record<string, unknown>;
pluginManifest?: Record<string, unknown>;
};
const TAR_BLOCK_SIZE = 512;
const MAX_UNPACKED_BYTES = 50 * 1024 * 1024;
const MAX_TAR_BYTES = 64 * 1024 * 1024;
const MAX_FILE_BYTES = MAX_UNPACKED_BYTES;
const MAX_PACKAGE_JSON_BYTES = 256 * 1024;
const MAX_FILE_COUNT = 10_000;
const GZIP_INPUT_CHUNK_BYTES = 1_024;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function textFromBytes(bytes: Uint8Array) {
return new TextDecoder().decode(bytes);
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
function readTarString(block: Uint8Array, offset: number, length: number) {
const slice = block.subarray(offset, offset + length);
const end = slice.indexOf(0);
return textFromBytes(end === -1 ? slice : slice.subarray(0, end)).trim();
return textFromBytes(end === -1 ? slice : slice.subarray(0, end));
}
function readTarSize(block: Uint8Array) {
@@ -44,15 +51,49 @@ function readTarSize(block: Uint8Array) {
}
function normalizeTarPath(path: string) {
const normalized = path.replaceAll("\\", "/").replace(/^\.\/+/, "");
if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) return null;
const segments = normalized.split("/").filter(Boolean);
if (segments.length === 0 || segments.some((segment) => segment === "." || segment === "..")) {
if (
!path ||
path !== path.trim() ||
path.includes("\\") ||
path.startsWith("/") ||
/\p{Cc}/u.test(path)
) {
return null;
}
const segments = path.split("/");
if (
segments.length === 0 ||
segments.some(
(segment, index) =>
segment === "." || segment === ".." || (segment === "" && index !== segments.length - 1),
)
) {
return null;
}
return segments.join("/");
}
function gunzipBounded(bytes: Uint8Array): Uint8Array {
const chunks: Uint8Array[] = [];
let total = 0;
const gunzip = new Gunzip((chunk) => {
total += chunk.byteLength;
if (total > MAX_TAR_BYTES) throw new Error("ClawPack unpacked archive exceeds 64MB limit");
chunks.push(Uint8Array.from(chunk));
});
for (let offset = 0; offset < bytes.byteLength; offset += GZIP_INPUT_CHUNK_BYTES) {
const end = Math.min(offset + GZIP_INPUT_CHUNK_BYTES, bytes.byteLength);
gunzip.push(bytes.subarray(offset, end), end === bytes.byteLength);
}
const result = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function tarEntryPayload(bytes: Uint8Array, offset: number, size: number) {
return bytes.subarray(offset, offset + size);
}
@@ -80,6 +121,7 @@ function parseTarEntries(bytes: Uint8Array): ClawPackEntry[] {
if (!path) throw new Error("ClawPack contains an unsafe tar path");
const size = readTarSize(header);
if (size > MAX_FILE_BYTES) throw new Error("ClawPack file exceeds 50MB limit");
const payloadOffset = offset + TAR_BLOCK_SIZE;
const payloadEnd = payloadOffset + size;
if (payloadEnd > bytes.byteLength) throw new Error("ClawPack tar entry is truncated");
@@ -90,14 +132,13 @@ function parseTarEntries(bytes: Uint8Array): ClawPackEntry[] {
throw new Error("ClawPack entries must be rooted under package/");
}
const relPath = path.slice("package/".length);
if (!relPath || relPath.endsWith("/")) {
offset = nextTarOffset(payloadOffset, size);
continue;
}
if (!relPath || relPath.endsWith("/"))
throw new Error("ClawPack contains an unsafe tar path");
if (paths.has(relPath)) {
throw new Error(`ClawPack contains duplicate path: ${relPath}`);
}
paths.add(relPath);
if (entries.length >= MAX_FILE_COUNT) throw new Error("ClawPack exceeds 10000 file limit");
entries.push({
path: relPath,
bytes: Uint8Array.from(tarEntryPayload(bytes, payloadOffset, size)),
@@ -110,6 +151,16 @@ function parseTarEntries(bytes: Uint8Array): ClawPackEntry[] {
}
if (entries.length === 0) throw new Error("ClawPack contains no files");
const hierarchyCollision = findClawPackagePathHierarchyCollision(
entries.map((entry) => entry.path),
);
if (hierarchyCollision) {
throw new Error(
`ClawPack contains file/ancestor path collision: ${hierarchyCollision.ancestor} and ${hierarchyCollision.descendant}`,
);
}
const unpackedSize = entries.reduce((sum, entry) => sum + entry.bytes.byteLength, 0);
if (unpackedSize > MAX_UNPACKED_BYTES) throw new Error("ClawPack package exceeds 50MB limit");
return entries;
}
@@ -152,18 +203,19 @@ export async function parseClawPack(bytes: Uint8Array): Promise<ParsedClawPack>
let tarBytes: Uint8Array;
try {
tarBytes = gunzipSync(bytes);
} catch {
throw new Error("ClawPack must be a gzip-compressed npm pack tarball");
tarBytes = gunzipBounded(bytes);
} catch (error) {
if (error instanceof Error && error.message.startsWith("ClawPack ")) throw error;
throw new Error("ClawPack must be a gzip-compressed npm pack tarball", { cause: error });
}
const entries = parseTarEntries(tarBytes);
const packageJsonEntry = entries.find((entry) => entry.path === "package.json");
if (!packageJsonEntry) throw new Error("ClawPack must contain package/package.json");
const pluginManifestEntry = entries.find((entry) => entry.path === "openclaw.plugin.json");
if (!pluginManifestEntry) {
throw new Error("ClawPack must contain package/openclaw.plugin.json");
if (packageJsonEntry.bytes.byteLength > MAX_PACKAGE_JSON_BYTES) {
throw new Error("ClawPack package.json exceeds 256KB limit");
}
const pluginManifestEntry = entries.find((entry) => entry.path === "openclaw.plugin.json");
let packageJson: unknown;
try {
@@ -178,14 +230,15 @@ export async function parseClawPack(bytes: Uint8Array): Promise<ParsedClawPack>
if (!packageName) throw new Error("ClawPack package.json must declare a name");
if (!packageVersion) throw new Error("ClawPack package.json must declare a version");
let pluginManifest: unknown;
try {
pluginManifest = JSON.parse(textFromBytes(pluginManifestEntry.bytes));
} catch {
throw new Error("ClawPack openclaw.plugin.json is invalid JSON");
}
if (!isRecord(pluginManifest)) {
throw new Error("ClawPack openclaw.plugin.json must be an object");
let pluginManifest: Record<string, unknown> | undefined;
if (pluginManifestEntry) {
try {
const parsed = JSON.parse(textFromBytes(pluginManifestEntry.bytes)) as unknown;
if (!isRecord(parsed)) throw new Error();
pluginManifest = parsed;
} catch {
throw new Error("ClawPack openclaw.plugin.json is invalid JSON object");
}
}
return {
@@ -199,6 +252,6 @@ export async function parseClawPack(bytes: Uint8Array): Promise<ParsedClawPack>
fileCount: entries.length,
entries,
packageJson,
pluginManifest,
...(pluginManifest ? { pluginManifest } : {}),
};
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { experimentalClawsEnabled, isClawFamilyPubliclyVisible } from "./experimentalClaws";
describe("experimental Claw visibility", () => {
it("requires the exact deployment gate value", () => {
expect(experimentalClawsEnabled({})).toBe(false);
expect(experimentalClawsEnabled({ CLAWHUB_EXPERIMENTAL_CLAWS: "true" })).toBe(false);
expect(experimentalClawsEnabled({ CLAWHUB_EXPERIMENTAL_CLAWS: "1" })).toBe(true);
});
it("keeps existing package families visible while gating Claws", () => {
expect(isClawFamilyPubliclyVisible("code-plugin", {})).toBe(true);
expect(isClawFamilyPubliclyVisible("claw", {})).toBe(false);
expect(isClawFamilyPubliclyVisible("claw", { CLAWHUB_EXPERIMENTAL_CLAWS: "1" })).toBe(true);
});
});
+10
View File
@@ -0,0 +1,10 @@
export function experimentalClawsEnabled(env: Record<string, string | undefined> = process.env) {
return env.CLAWHUB_EXPERIMENTAL_CLAWS === "1";
}
export function isClawFamilyPubliclyVisible(
family: string,
env: Record<string, string | undefined> = process.env,
) {
return family !== "claw" || experimentalClawsEnabled(env);
}
+27 -6
View File
@@ -355,13 +355,17 @@ export function normalizePublishFiles(files: PublishFile[]) {
}
export function assertPackageVersion(
family: "code-plugin" | "bundle-plugin" | "skill",
family: "code-plugin" | "bundle-plugin" | "skill" | "claw",
version: string,
) {
const trimmed = version.trim();
if (!trimmed) throw new ConvexError("Version required");
if (family === "code-plugin" && !semver.valid(trimmed)) {
throw new ConvexError("Code plugin versions must be valid semver");
if ((family === "code-plugin" || family === "claw") && !semver.valid(trimmed)) {
throw new ConvexError(
family === "claw"
? "Claw versions must be valid semver"
: "Code plugin versions must be valid semver",
);
}
return trimmed;
}
@@ -369,22 +373,39 @@ export function assertPackageVersion(
export async function readStorageText(
ctx: Pick<ActionCtx, "storage">,
storageId: string,
options: { maxBytes?: number; label?: string; strictUtf8?: boolean } = {},
): Promise<string> {
const blob = await ctx.storage.get(storageId as never);
if (!blob) throw new ConvexError("Uploaded file no longer exists");
return await blob.text();
if (options.maxBytes !== undefined && blob.size > options.maxBytes) {
throw new ConvexError(`${options.label ?? "Uploaded text file"} exceeds byte limit`);
}
if (!options.strictUtf8) return await blob.text();
try {
return new TextDecoder("utf-8", { fatal: true }).decode(await blob.arrayBuffer());
} catch {
throw new ConvexError(`${options.label ?? "Uploaded text file"} must be valid UTF-8`);
}
}
export async function readOptionalTextFile(
ctx: Pick<ActionCtx, "storage">,
files: PublishFile[],
pathMatch: (path: string) => boolean,
options: {
exactPath?: boolean;
maxBytes?: number;
label?: string;
strictUtf8?: boolean;
} = {},
) {
const file = files.find((entry) => pathMatch(entry.path.toLowerCase()));
const file = files.find((entry) =>
pathMatch(options.exactPath ? entry.path : entry.path.toLowerCase()),
);
if (!file) return null;
return {
file,
text: await readStorageText(ctx, file.storageId),
text: await readStorageText(ctx, file.storageId, options),
};
}
+26
View File
@@ -156,5 +156,31 @@ describe("skillZip", () => {
]);
expect(unzipped["_meta.json"]).toBeUndefined();
});
it("rejects file/ancestor collisions before creating the legacy ZIP", () => {
expect(() =>
buildDeterministicPackageZip([
{ path: "workspace", bytes: new TextEncoder().encode("file") },
{ path: "workspace/SOUL.md", bytes: new TextEncoder().encode("child") },
]),
).toThrow("file/ancestor path collision");
});
it("rejects Unicode-folded hierarchy collisions before creating the legacy ZIP", () => {
expect(() =>
buildDeterministicPackageZip([
{ path: "Straße", bytes: new TextEncoder().encode("file") },
{ path: "STRASSE/child", bytes: new TextEncoder().encode("child") },
]),
).toThrow("file/ancestor path collision");
});
it("rejects unsafe package paths before creating the legacy ZIP", () => {
expect(() =>
buildDeterministicPackageZip([
{ path: "workspace/a\u0085.md", bytes: new TextEncoder().encode("unsafe") },
]),
).toThrow("unsafe package path");
});
});
});
+13
View File
@@ -1,3 +1,4 @@
import { findClawPackagePathHierarchyCollision, isSafeClawPackagePath } from "clawhub-schema";
import { zipSync } from "fflate";
type ZipEntry = {
@@ -68,6 +69,18 @@ export function buildDeterministicZip(entries: ZipEntry[], meta?: SkillZipMeta)
}
export function buildDeterministicPackageZip(entries: ZipEntry[]) {
const unsafeEntry = entries.find((entry) => !isSafeClawPackagePath(entry.path));
if (unsafeEntry) {
throw new Error(`Package contains unsafe package path: ${unsafeEntry.path}`);
}
const hierarchyCollision = findClawPackagePathHierarchyCollision(
entries.map((entry) => entry.path),
);
if (hierarchyCollision) {
throw new Error(
`Package contains file/ancestor path collision: ${hierarchyCollision.ancestor} and ${hierarchyCollision.descendant}`,
);
}
const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path));
const zipData: ZipInput = {};
+734 -12
View File
@@ -1,7 +1,7 @@
/* @vitest-environment node */
import { getAuthUserId } from "@convex-dev/auth/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sha256Hex } from "./lib/clawpack";
import { MAX_PUBLISH_FILE_BYTES } from "./lib/publishLimits";
import {
@@ -408,6 +408,7 @@ function makePackageManifestStorage() {
get: vi.fn(async (id: string) =>
id === "storage:manifest" ? new Blob([JSON.stringify({ id: "demo.plugin" })]) : null,
),
store: vi.fn(async () => "storage:legacy-zip"),
};
}
@@ -975,6 +976,10 @@ const repairPackageIdentityInternalHandler = (
>
)._handler;
beforeEach(() => {
process.env.CLAWHUB_EXPERIMENTAL_CLAWS = "1";
});
afterEach(() => {
vi.mocked(getAuthUserId).mockReset();
vi.mocked(getAuthUserId).mockResolvedValue(null);
@@ -1184,6 +1189,16 @@ function makeDigestCtx(options: {
isDone: boolean;
continueCursor: string;
}>;
topicPagesByFamily?: Partial<
Record<
"skill" | "code-plugin" | "bundle-plugin",
Array<{
page: Array<Record<string, unknown>>;
isDone: boolean;
continueCursor: string;
}>
>
>;
categoryPages?: Array<{
page: Array<Record<string, unknown>>;
isDone: boolean;
@@ -1213,6 +1228,20 @@ function makeDigestCtx(options: {
>
>();
const rowsByTable = new Map<string, Array<Record<string, unknown>>>();
const familyPagesByTable = new Map<
string,
Map<
string,
Map<
string | null,
{
page: Array<Record<string, unknown>>;
isDone: boolean;
continueCursor: string;
}
>
>
>();
const indexNames: string[] = [];
const indexFilters: Array<{
indexName: string;
@@ -1252,6 +1281,36 @@ function makeDigestCtx(options: {
setPages("packageSearchDigest", options.pages ?? []);
setPages("packageCapabilitySearchDigest", options.capabilityPages ?? []);
setPages("packageTopicSearchDigest", options.topicPages ?? []);
if (options.topicPagesByFamily) {
const pagesByFamily = new Map<
string,
Map<
string | null,
{
page: Array<Record<string, unknown>>;
isDone: boolean;
continueCursor: string;
}
>
>();
for (const [family, pages] of Object.entries(options.topicPagesByFamily)) {
const pagesByCursor = new Map<
string | null,
{
page: Array<Record<string, unknown>>;
isDone: boolean;
continueCursor: string;
}
>();
let cursor: string | null = null;
for (const page of pages ?? []) {
pagesByCursor.set(cursor, page);
cursor = page.continueCursor || null;
}
pagesByFamily.set(family, pagesByCursor);
}
familyPagesByTable.set("packageTopicSearchDigest", pagesByFamily);
}
setPages("packagePluginCategorySearchDigest", options.categoryPages ?? []);
if (options.categoryRows) {
rowsByTable.set("packagePluginCategorySearchDigest", options.categoryRows);
@@ -1260,10 +1319,14 @@ function makeDigestCtx(options: {
const paginate = vi.fn();
const take = vi.fn();
const paginateForTable = (table: string) =>
const paginateForTable = (table: string, family?: string) =>
vi.fn(async (args: { cursor: string | null }) => {
paginate(args);
return (
familyPagesByTable
.get(table)
?.get(family ?? "")
?.get(args.cursor ?? null) ??
pageByTable.get(table)?.get(args.cursor ?? null) ?? {
page: [],
isDone: true,
@@ -1272,11 +1335,12 @@ function makeDigestCtx(options: {
);
});
const paginateByTable = new Map<string, ReturnType<typeof vi.fn>>();
const getPaginate = (table: string) => {
const existing = paginateByTable.get(table);
const getPaginate = (table: string, family?: string) => {
const key = `${table}:${family ?? ""}`;
const existing = paginateByTable.get(key);
if (existing) return existing;
const next = paginateForTable(table);
paginateByTable.set(table, next);
const next = paginateForTable(table, family);
paginateByTable.set(key, next);
return next;
};
const takeForTable = (table: string) =>
@@ -1548,6 +1612,51 @@ function makeDigestCtx(options: {
take: vi.fn().mockResolvedValue(matches),
};
}
if (indexName.includes("_family_")) {
indexNames.push(indexName);
let family = "";
let lowerField = "";
let lowerBound = "";
let upperBound = "";
const queryBuilder = {
eq: (field: string, value: string | undefined) => {
if (field === "family") family = value ?? "";
return queryBuilder;
},
gte: (field: string, value: string) => {
lowerField = field;
lowerBound = value;
return queryBuilder;
},
lt: (_field: string, value: string) => {
upperBound = value;
return queryBuilder;
},
};
builder?.(queryBuilder);
const takeFamilyRows = async (limit: number) => {
take(limit);
return (rowsByTable.get(table) ?? [])
.filter((row) => row.family === family)
.filter((row) => {
if (!lowerField) return true;
const value = readTestField(row, lowerField);
return (
typeof value === "string" &&
value >= lowerBound &&
(!upperBound || value < upperBound)
);
})
.slice(0, limit);
};
return {
take: vi.fn(takeFamilyRows),
order: vi.fn(() => ({
paginate: getPaginate(table),
take: vi.fn(takeFamilyRows),
})),
};
}
return withIndex(table, indexName);
},
withSearchIndex: (
@@ -1565,12 +1674,11 @@ function makeDigestCtx(options: {
let searchField = "";
let query = "";
const queryBuilder = {
eq: () => queryBuilder,
search: (field: string, value: string) => {
searchField = field;
query = value;
return {
eq: () => queryBuilder,
};
return queryBuilder;
},
};
builder?.(queryBuilder);
@@ -1595,6 +1703,52 @@ function makeDigestCtx(options: {
lt: (field: string, value: string) => unknown;
}) => unknown,
) => {
if (indexName.includes("_family_")) {
indexNames.push(indexName);
let family = "";
let exactTopic = "";
let exactCategory = "";
let lowerTopic = "";
let upperTopic = "";
const queryBuilder = {
eq: (field: string, value: unknown) => {
if (field === "family" && typeof value === "string") family = value;
if (field === "topic" && typeof value === "string") exactTopic = value;
if (field === "pluginCategory" && typeof value === "string") {
exactCategory = value;
}
return queryBuilder;
},
gte: (field: string, value: string) => {
if (field === "topic") lowerTopic = value;
return queryBuilder;
},
lt: (field: string, value: string) => {
if (field === "topic") upperTopic = value;
return queryBuilder;
},
};
builder?.(queryBuilder);
const takeFamilyRows = async (limit: number) => {
take(limit);
return (rowsByTable.get(table) ?? [])
.filter((row) => row.family === family)
.filter((row) => !exactTopic || row.topic === exactTopic)
.filter((row) => !exactCategory || row.pluginCategory === exactCategory)
.filter((row) => {
if (!lowerTopic && !upperTopic) return true;
const topic = typeof row.topic === "string" ? row.topic : "";
return topic >= lowerTopic && topic < upperTopic;
})
.slice(0, limit);
};
return {
order: vi.fn(() => ({
paginate: getPaginate(table, family),
take: vi.fn(takeFamilyRows),
})),
};
}
if (table !== "packageTopicSearchDigest" || indexName !== "by_active_topic_updated") {
return withIndex(table, indexName);
}
@@ -1671,7 +1825,10 @@ function makePluginExportIndexKey(row: Record<string, unknown>) {
return [row.softDeletedAt, row.family, row.updatedAt, row._creationTime, row._id] as unknown[];
}
function makePluginExportCtx(digests: Array<Record<string, unknown>>) {
function makePluginExportCtx(
digests: Array<Record<string, unknown>>,
options?: { recommendationScoresMissing?: () => boolean },
) {
const packagesById = new Map(
digests.map((digest) => [
String(digest.packageId),
@@ -1697,6 +1854,15 @@ function makePluginExportCtx(digests: Array<Record<string, unknown>>) {
db: {
get: vi.fn(async (id: string) => packagesById.get(id) ?? null),
query: vi.fn((table: string) => {
if (table === "packages") {
return {
withIndex: vi.fn(() => ({
first: vi.fn(async () =>
options?.recommendationScoresMissing?.() ? { _id: "packages:missing-score" } : null,
),
})),
};
}
if (table !== "packageSearchDigest") throw new Error(`Unexpected table ${table}`);
return {
withIndex: vi.fn(
@@ -1757,7 +1923,8 @@ function makePluginExportCtx(digests: Array<Record<string, unknown>>) {
.sort((a, b) => {
const updatedDiff = Number(a.updatedAt) - Number(b.updatedAt);
if (updatedDiff !== 0) return order === "desc" ? -updatedDiff : updatedDiff;
return String(a._id).localeCompare(String(b._id));
const idDiff = String(a._id).localeCompare(String(b._id));
return order === "desc" ? -idDiff : idDiff;
});
return {
async *[Symbol.asyncIterator]() {
@@ -4211,7 +4378,7 @@ describe("packages public queries", () => {
expect(result.map((entry) => entry.package.name)).toEqual(["needle-plugin"]);
expect(paginate).not.toHaveBeenCalled();
expect(take).toHaveBeenCalledTimes(3);
expect(take.mock.calls.length).toBeLessThanOrEqual(8);
expect(take).toHaveBeenCalledWith(20);
expect(take).toHaveBeenCalledWith(50);
});
@@ -5418,6 +5585,159 @@ describe("packages public queries", () => {
expect(paginate).toHaveBeenCalledTimes(2);
});
it("ranks bounded candidates from every stable family before applying the search limit", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const { ctx } = makeDigestCtx({
pages: [
{
page: [
makeDigest("skill-helper", {
family: "skill",
summary: "Calendar integration",
}),
makeDigest("official-plugin", {
family: "code-plugin",
isOfficial: true,
summary: "Calendar integration",
}),
],
isDone: true,
continueCursor: "",
},
],
});
try {
const result = await searchPublicHandler(ctx, { query: "calendar", limit: 1 });
expect(result.map((entry) => entry.package.name)).toEqual(["official-plugin"]);
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("gives every stable family a fair combined-filter scan budget", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const noisePage = (family: "skill" | "code-plugin" | "bundle-plugin", page: number) =>
Array.from({ length: 50 }, (_, index) =>
makeDigest(`${family}-noise-${page}-${index}`, {
family,
topic: "calendar",
topics: ["calendar"],
pluginCategoryTags: ["channels"],
}),
);
const { ctx } = makeDigestCtx({
topicPagesByFamily: {
skill: [
{ page: noisePage("skill", 1), isDone: false, continueCursor: "skill:2" },
{ page: noisePage("skill", 2), isDone: false, continueCursor: "skill:3" },
{ page: noisePage("skill", 3), isDone: true, continueCursor: "" },
],
"code-plugin": [
{
page: noisePage("code-plugin", 1),
isDone: false,
continueCursor: "code:2",
},
{
page: noisePage("code-plugin", 2),
isDone: false,
continueCursor: "code:3",
},
{ page: noisePage("code-plugin", 3), isDone: true, continueCursor: "" },
],
"bundle-plugin": [
{
page: noisePage("bundle-plugin", 1),
isDone: false,
continueCursor: "bundle:2",
},
{
page: noisePage("bundle-plugin", 2),
isDone: false,
continueCursor: "bundle:3",
},
{
page: [
makeDigest("calendar-bundle-api", {
family: "bundle-plugin",
topic: "calendar",
topics: ["calendar"],
pluginCategoryTags: ["tools"],
}),
],
isDone: true,
continueCursor: "",
},
],
},
});
try {
const result = await searchPublicHandler(ctx, {
query: "calendar",
topic: "calendar",
category: "tools",
limit: 1,
});
expect(result.map((entry) => entry.package.name)).toEqual(["calendar-bundle-api"]);
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("scans each stable family past the first combined-filter window while Claws are disabled", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const { ctx, paginate } = makeDigestCtx({
topicPages: [
{
page: Array.from({ length: 50 }, (_, index) =>
makeDigest(`calendar-skill-noise-${index}`, {
family: "skill",
topic: "calendar",
topics: ["calendar"],
pluginCategoryTags: ["channels"],
}),
),
isDone: false,
continueCursor: "later",
},
{
page: [
makeDigest("calendar-skill-api", {
family: "skill",
topic: "calendar",
topics: ["calendar"],
pluginCategoryTags: ["tools"],
}),
],
isDone: true,
continueCursor: "",
},
],
});
try {
const result = await searchPublicHandler(ctx, {
query: "calendar",
topic: "calendar",
category: "tools",
limit: 1,
});
expect(result.map((entry) => entry.package.name)).toEqual(["calendar-skill-api"]);
expect(paginate).toHaveBeenCalled();
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("bounds sparse combined-filter search scans", async () => {
const topicPages = Array.from({ length: 7 }, (_page, pageIndex) => ({
page: Array.from({ length: 50 }, (_digest, digestIndex) =>
@@ -8863,6 +9183,182 @@ describe("packages public queries", () => {
).rejects.toThrow("Skill packages must use the skills publish flow");
});
it("rejects Claw publication before mutation when the experimental gate is disabled", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
try {
await expect(
publishPackageForUserInternalHandler({} as never, {
actorUserId: "users:owner",
payload: {
name: "demo-claw",
family: "claw",
version: "1.0.0",
changelog: "init",
files: [],
},
}),
).rejects.toThrow("Experimental Claw publication is disabled");
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("rechecks the Claw gate at release insertion for staged publications", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const dbGet = vi.fn();
try {
await expect(
insertReleaseInternalHandler(
{
db: {
system: {},
get: dbGet,
insert: vi.fn(),
patch: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
query: vi.fn(),
normalizeId: vi.fn(),
},
} as never,
{
family: "claw",
} as never,
),
).rejects.toThrow("Experimental Claw publication is disabled");
expect(dbGet).not.toHaveBeenCalled();
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("publishes a profile-bearing Claw through the existing release pipeline when enabled", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
process.env.CLAWHUB_EXPERIMENTAL_CLAWS = "1";
const longClawDescription = "x".repeat(1_100);
const storedFiles = new Map<string, string>([
[
"storage:package",
JSON.stringify({
name: "demo-claw",
version: "1.0.0",
openclaw: { claw: "manifests/CLAW.md" },
}),
],
[
"storage:claw",
`---\nschemaVersion: 1\nagent:\n id: demo-claw\n name: Demo Claw\n description: ${longClawDescription}\nmetadata:\n openclaw.config: profiles/openclaw.yml\n---\n# Demo Claw\n`,
],
["storage:profile", "schemaVersion: 1\nagent:\n tools:\n profile: coding\n"],
]);
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if (args.minimumRole === "publisher") {
return { publisherId: "publishers:owner", linkedUserId: "users:owner" };
}
if (args.family === "claw") {
return { ok: true, packageId: "packages:claw", releaseId: "releases:claw-1" };
}
return null;
});
const ctx = {
runQuery: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
_id: "users:owner",
role: "user",
githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000,
})
.mockResolvedValueOnce({
_id: "users:owner",
role: "user",
githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000,
})
.mockResolvedValueOnce({
_id: "publishers:owner",
kind: "user",
handle: "owner",
linkedUserId: "users:owner",
}),
runMutation,
scheduler: { runAfter: vi.fn() },
storage: {
get: vi.fn(async (storageId: string) => {
const content = storedFiles.get(storageId);
return content === undefined ? null : new Blob([content]);
}),
store: vi.fn(async () => "storage:legacy-zip"),
},
};
try {
await expect(
publishPackageForUserInternalHandler(ctx as never, {
actorUserId: "users:owner",
payload: {
name: "demo-claw",
displayName: "Demo Claw",
family: "claw",
version: "1.0.0",
changelog: "init",
files: [
{ path: "package.json", size: 1, storageId: "storage:package", sha256: "package" },
{
path: "manifests/CLAW.md",
size: 1,
storageId: "storage:claw",
sha256: "claw",
},
{
path: "profiles/openclaw.yml",
size: 1,
storageId: "storage:profile",
sha256: "profile",
},
],
},
}),
).resolves.toEqual({
ok: true,
packageId: "packages:claw",
releaseId: "releases:claw-1",
publicationStatus: "published",
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
family: "claw",
summary: expect.any(String),
artifactKind: "legacy-zip",
clawpackStorageId: "storage:legacy-zip",
clawpackSha256: expect.stringMatching(/^[a-f0-9]{64}$/),
clawpackSize: expect.any(Number),
clawManifestSummary: expect.objectContaining({
agent: expect.objectContaining({
id: "demo-claw",
description: "x".repeat(1_024),
}),
workspace: expect.objectContaining({ bootstrapFiles: ["SOUL.md"] }),
}),
pluginManifestSummary: undefined,
}),
);
const publishMutationArgs = runMutation.mock.calls.find(
([, args]) => args.family === "claw",
)?.[1];
expect(publishMutationArgs?.summary).toBe("x".repeat(1_024));
expect(publishMutationArgs).not.toHaveProperty("extractedClawManifest");
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("keeps raw package publishes behind the per-file size limit", async () => {
const ctx = {
runQuery: vi
@@ -8958,6 +9454,7 @@ describe("packages public queries", () => {
const content = files.get(storageId);
return content ? new Blob([content]) : null;
}),
store: vi.fn(async () => "storage:legacy-zip"),
},
};
@@ -9971,6 +10468,7 @@ describe("packages public queries", () => {
const content = storedFiles.get(storageId);
return content ? new Blob([content]) : null;
}),
store: vi.fn(async () => "storage:legacy-zip"),
},
};
@@ -10090,6 +10588,7 @@ describe("packages public queries", () => {
const content = storedFiles.get(storageId);
return content ? new Blob([content]) : null;
}),
store: vi.fn(async () => "storage:legacy-zip"),
},
};
@@ -10228,6 +10727,7 @@ describe("packages public queries", () => {
const content = storedFiles.get(storageId);
return content ? new Blob([content]) : null;
}),
store: vi.fn(async () => "storage:legacy-zip"),
},
runAction: vi.fn(async () => ({
status: "pass",
@@ -10380,6 +10880,7 @@ describe("packages public queries", () => {
const content = files.get(storageId);
return content ? new Blob([content]) : null;
}),
store: vi.fn(async () => "storage:legacy-zip"),
},
};
@@ -10504,6 +11005,7 @@ describe("packages public queries", () => {
const content = files.get(storageId);
return content ? new Blob([content]) : null;
}),
store: vi.fn(async () => "storage:legacy-zip"),
},
};
@@ -16361,4 +16863,224 @@ describe("restorePackageInternal", () => {
).rejects.toThrow("Forbidden");
expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything());
});
it("fails closed on Claw reads and never exposes the extracted manifest", async () => {
vi.mocked(getAuthUserId).mockResolvedValue(null);
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const clawManifestSummary = {
schemaVersion: 1,
agent: { id: "demo-claw" },
workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 0 },
packages: { skillCount: 0, pluginCount: 0 },
mcpServerCount: 0,
cronJobCount: 0,
};
const { ctx } = makePackageCtx({
pkg: makePackageDoc({ family: "claw" }),
latestRelease: makeReleaseDoc({
clawManifestSummary,
extractedClawManifest: {
schemaVersion: 1,
agent: { id: "demo-claw" },
workspace: { bootstrapFiles: { "SOUL.md": { source: "workspace/SOUL.md" } } },
},
}),
});
try {
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
await expect(getByNameHandler(ctx, { name: "demo-plugin" })).resolves.toBeNull();
await expect(
getVersionByNameHandler(ctx, { name: "demo-plugin", version: "1.0.0" }),
).resolves.toBeNull();
process.env.CLAWHUB_EXPERIMENTAL_CLAWS = "1";
const detail = await getByNameHandler(ctx, { name: "demo-plugin" });
expect(detail?.latestRelease).toMatchObject({ clawManifestSummary });
expect(detail?.latestRelease).not.toHaveProperty("extractedClawManifest");
const version = await getVersionByNameHandler(ctx, {
name: "demo-plugin",
version: "1.0.0",
});
expect(version?.version).toMatchObject({ clawManifestSummary });
expect(version?.version).not.toHaveProperty("extractedClawManifest");
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("omits stored Claws from unfiltered public lists while disabled", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const ctx = makePluginExportCtx([
makeDigest("demo-claw", { family: "claw", updatedAt: 2, _creationTime: 2 }),
makeDigest("demo-plugin", { updatedAt: 1, _creationTime: 1 }),
]);
try {
const result = await listPublicPageHandler(ctx, {
paginationOpts: { cursor: null, numItems: 25 },
});
expect(result.page.map((entry) => entry.name)).toEqual(["demo-plugin"]);
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("does not let disabled Claws starve unfiltered public list pages", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const hiddenClaws = Array.from({ length: 50 }, (_, index) =>
makeDigest(`demo-claw-${index}`, {
family: "claw",
updatedAt: 100 - index,
_creationTime: 100 - index,
}),
);
const ctx = makePluginExportCtx([
...hiddenClaws,
makeDigest("visible-plugin", { updatedAt: 1, _creationTime: 1 }),
]);
try {
const result = await listPublicPageHandler(ctx, {
paginationOpts: { cursor: null, numItems: 1 },
});
expect(result.page.map((entry) => entry.name)).toEqual(["visible-plugin"]);
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("paginates stable families beyond the per-query read window while Claws are disabled", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const digests = Array.from({ length: 250 }, (_, index) =>
makeDigest(`stable-${index.toString().padStart(3, "0")}`, {
updatedAt: 250 - index,
_creationTime: 250 - index,
}),
);
const ctx = makePluginExportCtx(digests);
try {
const first = await listPublicPageHandler(ctx, {
paginationOpts: { cursor: null, numItems: 200 },
});
const second = await listPublicPageHandler(ctx, {
paginationOpts: { cursor: first.continueCursor, numItems: 200 },
});
expect(first.page).toHaveLength(200);
expect(first.isDone).toBe(false);
expect(second.page).toHaveLength(50);
expect(second.isDone).toBe(true);
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("preserves each stable family's index order across tied public list pages", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const ctx = makePluginExportCtx([
makeDigest("zulu-plugin", {
_id: "packageSearchDigest:zulu",
packageId: "packages:zulu",
updatedAt: 1,
_creationTime: 1,
}),
makeDigest("alpha-plugin", {
_id: "packageSearchDigest:alpha",
packageId: "packages:alpha",
updatedAt: 1,
_creationTime: 1,
}),
]);
try {
const first = await listPublicPageHandler(ctx, {
paginationOpts: { cursor: null, numItems: 1 },
});
const second = await listPublicPageHandler(ctx, {
paginationOpts: { cursor: first.continueCursor, numItems: 1 },
});
expect([...first.page, ...second.page].map((entry) => entry.name)).toEqual([
"zulu-plugin",
"alpha-plugin",
]);
expect(second.isDone).toBe(true);
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("keeps the cursor's effective sort when recommendation backfill completes between pages", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
let recommendationScoresMissing = true;
const ctx = makePluginExportCtx(
[
makeDigest("newer-plugin", { updatedAt: 2, _creationTime: 2 }),
makeDigest("older-plugin", { updatedAt: 1, _creationTime: 1 }),
],
{ recommendationScoresMissing: () => recommendationScoresMissing },
);
try {
const first = await listPublicPageHandler(ctx, {
sort: "recommended",
paginationOpts: { cursor: null, numItems: 1 },
});
recommendationScoresMissing = false;
const second = await listPublicPageHandler(ctx, {
sort: "recommended",
paginationOpts: { cursor: first.continueCursor, numItems: 1 },
});
expect(first.page.map((entry) => entry.name)).toEqual(["newer-plugin"]);
expect(second.page.map((entry) => entry.name)).toEqual(["older-plugin"]);
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
it("does not let disabled Claws starve unfiltered public search", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
const hiddenClaws = Array.from({ length: 50 }, (_, index) =>
makeDigest(`matching-claw-${index}`, {
family: "claw",
displayName: `Matching Claw ${index}`,
updatedAt: 100 - index,
}),
);
const { ctx } = makeDigestCtx({
pages: [
{
page: hiddenClaws,
isDone: false,
continueCursor: "after-claws",
},
{
page: [makeDigest("matching-plugin", { displayName: "Matching Plugin" })],
isDone: true,
continueCursor: "",
},
],
});
try {
const result = await searchPublicHandler(ctx, { query: "matching", limit: 1 });
expect(result.map((entry) => entry.package.name)).toEqual(["matching-plugin"]);
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
});
});
+557 -88
View File
@@ -1,5 +1,6 @@
import {
ServerPackagePublishRequestSchema,
validateClawPackageContents,
derivePluginCategoryTags,
getCatalogTopicSlugs,
getPackageScopeOwnerMismatch,
@@ -60,6 +61,7 @@ import {
getActivityTrendRangeForEndDay,
} from "./lib/downloadTrend";
import { buildPackageInspectorFindingsEmail } from "./lib/emails";
import { experimentalClawsEnabled, isClawFamilyPubliclyVisible } from "./lib/experimentalClaws";
import { requireGitHubAccountAge } from "./lib/githubAccount";
import { normalizeGitHubRepository } from "./lib/githubActionsOidc";
import { readGlobalPublicPluginsCount } from "./lib/globalStats";
@@ -135,6 +137,7 @@ const MAX_PLUGIN_EXPORT_LIST_LIMIT = 250;
const MAX_SEARCH_PAGE_SIZE = 200;
const MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES = 20;
const MAX_DIRECT_PACKAGE_FULL_TEXT_CANDIDATES = 40;
const STABLE_PACKAGE_FAMILIES = ["skill", "code-plugin", "bundle-plugin"] as const;
const MAX_PACKAGE_VERSION_DELETE_LOOKUP_CANDIDATES = 4;
const MAX_POINTERLESS_RELEASE_SURVIVOR_SCAN = 100;
// Release rows can approach 1 MiB, and trigger-wrapped patches materialize full documents.
@@ -916,6 +919,7 @@ type PackageDigestLike = Pick<
| "pluginCategoryTags"
| "verificationTier"
| "stats"
| "recommendedScore"
| "scanStatus"
| "softDeletedAt"
> & { pluginCategory?: string };
@@ -940,6 +944,13 @@ type PublicPackageListPage = {
isDone: boolean;
continueCursor: string;
};
const STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX = "pkgstable:";
type StablePackageFamily = (typeof STABLE_PACKAGE_FAMILIES)[number];
type StablePackageDiscoverySourceState = { key: IndexKey | null; done: boolean };
type StablePackageDiscoveryCursorState = {
sort: "updated" | "downloads" | "recommended" | "installs";
sources: Record<StablePackageFamily, StablePackageDiscoverySourceState>;
};
const OFFICIAL_FIRST_PACKAGE_CATEGORY_CURSOR_PREFIX = "pkgofficialfirst:";
async function runQueryRef<T>(
@@ -1210,8 +1221,13 @@ function toPublicPackage(
}
function toPublicPackageRelease(release: Doc<"packageReleases">) {
const { capabilities: _capabilities, ...publicRelease } = release as Doc<"packageReleases"> & {
const {
capabilities: _capabilities,
extractedClawManifest: _extractedClawManifest,
...publicRelease
} = release as Doc<"packageReleases"> & {
capabilities?: unknown;
extractedClawManifest?: unknown;
};
const sourcePath = release.verification?.sourcePath ?? getReleaseSourcePath(release);
if (!release.verification || !sourcePath) return publicRelease;
@@ -1319,6 +1335,7 @@ function digestMatchesFilters(
excludedScanStatuses?: PackageListScanStatus[];
},
) {
if (!isClawFamilyPubliclyVisible(digest.family)) return false;
if (digest.scanStatus && args.excludedScanStatuses?.includes(digest.scanStatus)) return false;
if (args.category) {
if (digest.pluginCategory) {
@@ -1365,6 +1382,7 @@ function packageMatchesListFilters(
excludedScanStatuses?: PackageListScanStatus[];
},
) {
if (!isClawFamilyPubliclyVisible(pkg.family)) return false;
if (pkg.scanStatus && args.excludedScanStatuses?.includes(pkg.scanStatus)) return false;
if (args.family && pkg.family !== args.family) return false;
if (args.channel && pkg.channel !== args.channel) return false;
@@ -1875,6 +1893,7 @@ function maybeNormalizePackageQuery(value: string) {
async function resolveDirectPackageSearchDigests(
ctx: DbReaderCtx,
queryText: string,
family?: PackageFamily,
): Promise<PackageDigestLike[]> {
const normalizedQuery = maybeNormalizePackageQuery(queryText);
const topicQuery = normalizeCatalogTopic(queryText);
@@ -1894,49 +1913,85 @@ async function resolveDirectPackageSearchDigests(
categoryDigests,
] = await Promise.all([
normalizedQuery
? ctx.db
.query("packageSearchDigest")
.withIndex("by_active_normalized_name", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedName", normalizedQuery)
.lt("normalizedName", prefixUpperBound(normalizedQuery)),
)
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
? family
? ctx.db
.query("packageSearchDigest")
.withIndex("by_active_family_normalized_name", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("family", family)
.gte("normalizedName", normalizedQuery)
.lt("normalizedName", prefixUpperBound(normalizedQuery)),
)
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
: ctx.db
.query("packageSearchDigest")
.withIndex("by_active_normalized_name", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedName", normalizedQuery)
.lt("normalizedName", prefixUpperBound(normalizedQuery)),
)
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
: Promise.resolve([]),
runtimePrefix
? ctx.db
.query("packageSearchDigest")
.withIndex("by_active_runtime_id", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("runtimeId", runtimePrefix)
.lt("runtimeId", prefixUpperBound(runtimePrefix)),
)
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
? family
? ctx.db
.query("packageSearchDigest")
.withIndex("by_active_family_runtime_id", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("family", family)
.gte("runtimeId", runtimePrefix)
.lt("runtimeId", prefixUpperBound(runtimePrefix)),
)
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
: ctx.db
.query("packageSearchDigest")
.withIndex("by_active_runtime_id", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("runtimeId", runtimePrefix)
.lt("runtimeId", prefixUpperBound(runtimePrefix)),
)
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
: Promise.resolve([]),
ctx.db
.query("packageSearchDigest")
.withSearchIndex("search_by_display_name", (q) =>
q.search("displayName", queryText).eq("softDeletedAt", undefined),
)
.withSearchIndex("search_by_display_name", (q) => {
const search = q.search("displayName", queryText).eq("softDeletedAt", undefined);
return family ? search.eq("family", family) : search;
})
.take(MAX_DIRECT_PACKAGE_FULL_TEXT_CANDIDATES),
ownerHandlePrefix
? ctx.db
.query("packageSearchDigest")
.withIndex("by_active_owner_handle", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("ownerHandle", ownerHandlePrefix)
.lt("ownerHandle", prefixUpperBound(ownerHandlePrefix)),
)
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
? family
? ctx.db
.query("packageSearchDigest")
.withIndex("by_active_family_owner_handle", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("family", family)
.gte("ownerHandle", ownerHandlePrefix)
.lt("ownerHandle", prefixUpperBound(ownerHandlePrefix)),
)
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
: ctx.db
.query("packageSearchDigest")
.withIndex("by_active_owner_handle", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("ownerHandle", ownerHandlePrefix)
.lt("ownerHandle", prefixUpperBound(ownerHandlePrefix)),
)
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
: Promise.resolve([]),
topicQuery
? ctx.db
.query("packageTopicSearchDigest")
.withIndex("by_active_topic_updated", (q) =>
q.eq("softDeletedAt", undefined).eq("topic", topicQuery),
.withIndex(family ? "by_active_family_topic_updated" : "by_active_topic_updated", (q) =>
family
? q.eq("softDeletedAt", undefined).eq("family", family).eq("topic", topicQuery)
: q.eq("softDeletedAt", undefined).eq("topic", topicQuery),
)
.order("desc")
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
@@ -1944,8 +1999,15 @@ async function resolveDirectPackageSearchDigests(
categoryQuery
? ctx.db
.query("packagePluginCategorySearchDigest")
.withIndex("by_active_category_updated", (q) =>
q.eq("softDeletedAt", undefined).eq("pluginCategory", categoryQuery),
.withIndex(
family ? "by_active_family_category_updated" : "by_active_category_updated",
(q) =>
family
? q
.eq("softDeletedAt", undefined)
.eq("family", family)
.eq("pluginCategory", categoryQuery)
: q.eq("softDeletedAt", undefined).eq("pluginCategory", categoryQuery),
)
.order("desc")
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
@@ -1955,11 +2017,17 @@ async function resolveDirectPackageSearchDigests(
topicQuery && exactTopicDigests.length < MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES
? await ctx.db
.query("packageTopicSearchDigest")
.withIndex("by_active_topic_updated", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("topic", topicQuery)
.lt("topic", prefixUpperBound(topicQuery)),
.withIndex(family ? "by_active_family_topic_updated" : "by_active_topic_updated", (q) =>
family
? q
.eq("softDeletedAt", undefined)
.eq("family", family)
.gte("topic", topicQuery)
.lt("topic", prefixUpperBound(topicQuery))
: q
.eq("softDeletedAt", undefined)
.gte("topic", topicQuery)
.lt("topic", prefixUpperBound(topicQuery)),
)
.order("desc")
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES - exactTopicDigests.length)
@@ -2634,6 +2702,7 @@ async function getReadablePackageByName(
const normalizedName = normalizePackageName(name);
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
if (!pkg || pkg.softDeletedAt) return null;
if (!isClawFamilyPubliclyVisible(pkg.family)) return null;
if (pkg.channel === "private" || isPackageBlockedFromPublic(pkg.scanStatus)) {
const canAccessOwner = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
if (pkg.channel === "private" && !canAccessOwner) return null;
@@ -2654,6 +2723,7 @@ async function getPackageReadableForPublicTrust(
) {
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(name));
if (!pkg || pkg.softDeletedAt) return null;
if (!isClawFamilyPubliclyVisible(pkg.family)) return null;
if (pkg.channel === "private" && !(await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId))) {
return null;
}
@@ -3256,7 +3326,7 @@ export const listAuditPage = query({
const page = [];
const membershipCache = new Map<string, Promise<boolean>>();
for (const pkg of result.page) {
if (pkg.family === "skill") continue;
if (pkg.family !== "code-plugin" && pkg.family !== "bundle-plugin") continue;
if (!(await canViewerReadPackage(ctx, pkg, undefined, membershipCache))) continue;
const owner = toPublicPublisher(
@@ -3376,6 +3446,91 @@ function decodePackageIndexKeyValue(val: unknown): Value | undefined {
return val as Value;
}
function emptyStablePackageDiscoveryCursor(
sort: StablePackageDiscoveryCursorState["sort"],
): StablePackageDiscoveryCursorState {
return {
sort,
sources: {
skill: { key: null, done: false },
"code-plugin": { key: null, done: false },
"bundle-plugin": { key: null, done: false },
},
};
}
function encodeStablePackageDiscoveryCursor(state: StablePackageDiscoveryCursorState) {
const sources = Object.fromEntries(
STABLE_PACKAGE_FAMILIES.map((family) => [
family,
{
key: state.sources[family].key?.map(encodePackageIndexKeyValue) ?? null,
done: state.sources[family].done,
},
]),
);
return `${STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX}${JSON.stringify({
v: 1,
sort: state.sort,
sources,
})}`;
}
function readStablePackageDiscoveryCursorSort(
raw: string | null | undefined,
): "updated" | "recommended" | null {
if (!raw?.startsWith(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX)) return null;
try {
const parsed = JSON.parse(raw.slice(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX.length)) as {
v?: unknown;
sort?: unknown;
};
return parsed.v === 1 && (parsed.sort === "updated" || parsed.sort === "recommended")
? parsed.sort
: null;
} catch {
return null;
}
}
function decodeStablePackageDiscoveryCursor(
raw: string | null | undefined,
sort: StablePackageDiscoveryCursorState["sort"],
): StablePackageDiscoveryCursorState {
const fallback = emptyStablePackageDiscoveryCursor(sort);
if (!raw?.startsWith(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX)) return fallback;
try {
const parsed = JSON.parse(raw.slice(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX.length)) as {
v?: unknown;
sort?: unknown;
sources?: unknown;
};
if (
parsed.v !== 1 ||
parsed.sort !== sort ||
!parsed.sources ||
typeof parsed.sources !== "object"
) {
return fallback;
}
const sources = parsed.sources as Record<string, unknown>;
const decoded = emptyStablePackageDiscoveryCursor(sort);
for (const family of STABLE_PACKAGE_FAMILIES) {
const value = sources[family];
if (!value || typeof value !== "object") return fallback;
const source = value as { key?: unknown; done?: unknown };
if (source.key !== null && !Array.isArray(source.key)) return fallback;
decoded.sources[family] = {
key: Array.isArray(source.key) ? source.key.map(decodePackageIndexKeyValue) : null,
done: source.done === true,
};
}
return decoded;
} catch {
return fallback;
}
}
function encodePackageIndexCursor(indexName: string, key: IndexKey): string {
return JSON.stringify({
v: 1,
@@ -3754,6 +3909,146 @@ export const countPublicPlugins = query({
},
});
function compareStablePackageDiscoveryCandidates(
a: PackageDigestLike,
b: PackageDigestLike,
sort: "updated" | "downloads" | "recommended" | "installs",
) {
const metric = (candidate: PackageDigestLike) => {
if (sort === "downloads") return candidate.stats?.downloads ?? 0;
if (sort === "installs") return candidate.stats?.installs ?? 0;
if (sort === "recommended") return candidate.recommendedScore ?? 0;
return candidate.updatedAt;
};
const metricDiff = metric(b) - metric(a);
if (metricDiff !== 0) return metricDiff;
const updatedDiff = b.updatedAt - a.updatedAt;
if (updatedDiff !== 0) return updatedDiff;
const familyDiff = a.family.localeCompare(b.family);
if (familyDiff !== 0) return familyDiff;
return a.name.localeCompare(b.name);
}
async function listStablePackageDiscoveryPage(
ctx: DbReaderCtx,
args: {
channel?: PackageChannel;
isOfficial?: boolean;
category?: PluginCategorySlug;
topic?: string;
excludedScanStatuses?: PackageListScanStatus[];
sort?: "updated" | "downloads" | "recommended" | "installs";
viewerUserId?: Id<"users">;
paginationOpts: { cursor: string | null; numItems: number };
},
): Promise<PublicPackageListPage> {
const targetCount = Math.max(
1,
Math.min(args.paginationOpts.numItems, MAX_PUBLIC_LIST_PAGE_SIZE),
);
const requestedSort = args.sort ?? "updated";
const cursorSort =
requestedSort === "recommended"
? readStablePackageDiscoveryCursorSort(args.paginationOpts.cursor)
: null;
let sort = cursorSort ?? requestedSort;
if (!cursorSort && requestedSort === "recommended") {
const recommendationScoresMissing = (
await Promise.all(
STABLE_PACKAGE_FAMILIES.map(
async (family) => await hasMissingPackageRecommendedScore(ctx, family),
),
)
).some(Boolean);
if (recommendationScoresMissing) sort = "updated";
}
const cursor = decodeStablePackageDiscoveryCursor(args.paginationOpts.cursor, sort);
const scanLimit = Math.min(MAX_PUBLIC_LIST_PAGE_SIZE, Math.max(targetCount * 5, 50));
const loadFamily = async (family: StablePackageFamily) => {
const state = cursor.sources[family];
if (state.done) {
return { family, rows: [] as PackageDigestLike[], keys: [] as IndexKey[], hasMore: false };
}
const eqPrefix: IndexKey = [undefined, family];
if (sort === "updated") {
const result = await getPage(ctx, {
table: "packageSearchDigest",
index: "by_active_family_updated",
startIndexKey: state.key ?? eqPrefix,
startInclusive: state.key === null,
endIndexKey: eqPrefix,
endInclusive: true,
order: "desc",
absoluteMaxRows: scanLimit,
schema,
});
return {
family,
rows: result.page as PackageDigestLike[],
keys: result.indexKeys,
hasMore: result.hasMore,
};
}
const index =
sort === "downloads"
? "by_active_family_downloads"
: sort === "installs"
? "by_active_family_installs"
: "by_active_family_recommended_score";
const result = await getPage(ctx, {
table: "packages",
index,
startIndexKey: state.key ?? eqPrefix,
startInclusive: state.key === null,
endIndexKey: eqPrefix,
endInclusive: true,
order: "desc",
absoluteMaxRows: scanLimit,
schema,
});
return {
family,
rows: result.page.map(extractPackageDigestFields),
keys: result.indexKeys,
hasMore: result.hasMore,
};
};
const familyPages = await Promise.all(
STABLE_PACKAGE_FAMILIES.map(async (family) => await loadFamily(family)),
);
const membershipCache = new Map<string, Promise<boolean>>();
const page: PublicPackageListItem[] = [];
const consumedByFamily = new Map<StablePackageFamily, number>();
while (page.length < targetCount) {
// Merge only the current head from each source so every persisted source
// cursor advances through a contiguous prefix of that source's index order.
const candidate = familyPages
.flatMap((source) => {
const index = consumedByFamily.get(source.family) ?? 0;
const row = source.rows[index];
return row ? [{ family: source.family, row, key: source.keys[index] }] : [];
})
.sort((a, b) => compareStablePackageDiscoveryCandidates(a.row, b.row, sort))[0];
if (!candidate) break;
const digest = candidate.row;
consumedByFamily.set(candidate.family, (consumedByFamily.get(candidate.family) ?? 0) + 1);
cursor.sources[candidate.family].key = candidate.key;
if (!digestMatchesSearchFilters(digest, args)) continue;
if (!(await canViewerReadPackage(ctx, digest, args.viewerUserId, membershipCache))) continue;
page.push(await toPublicPackageListItem(ctx, digest));
}
for (const source of familyPages) {
const consumed = consumedByFamily.get(source.family) ?? 0;
cursor.sources[source.family].done = consumed === source.rows.length && !source.hasMore;
}
const hasMore = STABLE_PACKAGE_FAMILIES.some((family) => !cursor.sources[family].done);
return {
page,
isDone: !hasMore,
continueCursor: hasMore ? encodeStablePackageDiscoveryCursor(cursor) : "",
};
}
async function listPackagePageImpl(
ctx: DbReaderCtx,
args: {
@@ -3849,6 +4144,19 @@ async function listPackagePageImpl(
return { page, isDone: true, continueCursor: "" };
}
if (!args.family && !experimentalClawsEnabled()) {
return await listStablePackageDiscoveryPage(ctx, {
channel: args.channel,
isOfficial: args.isOfficial,
category,
topic,
excludedScanStatuses: args.excludedScanStatuses,
sort: args.sort,
viewerUserId: args.viewerUserId,
paginationOpts: args.paginationOpts,
});
}
const collected: PublicPackageListItem[] = [];
const family = args.family;
const channel = args.channel;
@@ -4311,6 +4619,7 @@ async function searchPackagesImpl(
topic,
});
const entries = highlightedEntries
.filter(({ digest }) => isClawFamilyPubliclyVisible(digest.family))
.filter(
({ digest }) =>
!digest.scanStatus || !args.excludedScanStatuses?.includes(digest.scanStatus),
@@ -4340,30 +4649,42 @@ async function searchPackagesImpl(
return results;
}
const buildSearchDigestQuery = () =>
const searchFamilies =
!args.family && !experimentalClawsEnabled()
? ([...STABLE_PACKAGE_FAMILIES] as PackageFamily[])
: [args.family];
const buildSearchDigestQuery = (family: PackageFamily | undefined) =>
topic
? buildPackageTopicDigestQuery(ctx, {
topic,
family: args.family,
family,
channel: args.channel,
isOfficial: args.isOfficial,
})
: category
? buildPackagePluginCategoryDigestQuery(ctx, {
category,
family: args.family,
family,
channel: args.channel,
isOfficial: args.isOfficial,
})
: buildPackageDigestQuery(ctx, {
family: args.family,
family,
channel: args.channel,
isOfficial: args.isOfficial,
});
const matches: Array<PackageSearchMatch & { package: PublicPackageListItem }> = [];
const seen = new Set<string>();
const directDigests =
category && !topic ? [] : await resolveDirectPackageSearchDigests(ctx, queryText);
category && !topic
? []
: (
await Promise.all(
searchFamilies.map(
async (family) => await resolveDirectPackageSearchDigests(ctx, queryText, family),
),
)
).flat();
for (const digest of directDigests) {
if (!(await canViewPackage(digest))) continue;
if (!digestMatchesSearchFilters(digest, { ...args, topic })) continue;
@@ -4396,37 +4717,60 @@ async function searchPackagesImpl(
...match,
package: await toPublicPackageListItem(ctx, digest),
});
if (authoritativeMatchCount() >= targetCount) break;
}
};
if (topic && category) {
let cursor: string | null = null;
let isDone = false;
let scanPages = 0;
const scanStates = searchFamilies.map((family) => ({
family,
cursor: null as string | null,
isDone: false,
pagesScanned: 0,
}));
let remainingScanBudget = MAX_PUBLIC_LIST_FILTER_SCAN_DOCUMENTS;
while (
authoritativeMatchCount() < targetCount &&
!isDone &&
scanPages < MAX_PUBLIC_LIST_FILTER_SCAN_PAGES &&
scanStates.some(
(state) => !state.isDone && state.pagesScanned < MAX_PUBLIC_LIST_FILTER_SCAN_PAGES,
) &&
remainingScanBudget > 0
) {
const pageSize = Math.min(scanLimit, remainingScanBudget);
const page: {
page: PackageDigestLike[];
isDone: boolean;
continueCursor: string;
} = await buildSearchDigestQuery().order("desc").paginate({ cursor, numItems: pageSize });
scanPages += 1;
remainingScanBudget -= pageSize;
await collectDigestMatches(page.page);
cursor = page.continueCursor;
isDone = page.isDone;
// Finish each round before checking the match quota so the fixed family
// order cannot decide global relevance or consume another family's cap.
for (const state of scanStates) {
if (
state.isDone ||
state.pagesScanned >= MAX_PUBLIC_LIST_FILTER_SCAN_PAGES ||
remainingScanBudget <= 0
) {
continue;
}
const pageSize = Math.min(scanLimit, remainingScanBudget);
const page: {
page: PackageDigestLike[];
isDone: boolean;
continueCursor: string;
} = await buildSearchDigestQuery(state.family)
.order("desc")
.paginate({ cursor: state.cursor, numItems: pageSize });
state.pagesScanned += 1;
remainingScanBudget -= pageSize;
await collectDigestMatches(page.page);
state.cursor = page.continueCursor;
state.isDone = page.isDone;
}
}
} else {
const digests: PackageDigestLike[] = await buildSearchDigestQuery()
.order("desc")
.take(scanLimit);
const digests = (
await Promise.all(
searchFamilies.map(
async (family) =>
(await buildSearchDigestQuery(family)
.order("desc")
.take(scanLimit)) as PackageDigestLike[],
),
)
).flat();
await collectDigestMatches(digests);
}
}
@@ -7581,11 +7925,17 @@ async function publishPackageImpl(
rawPayload,
"Package publish payload",
);
if (payload.family === "claw" && !experimentalClawsEnabled()) {
throw new ConvexError("Experimental Claw publication is disabled");
}
if (payload.family === "skill") {
throw new ConvexError("Skill packages must use the skills publish flow");
}
const family = payload.family;
const name = normalizePackageName(payload.name);
if (payload.family === "claw" && payload.name !== name) {
throw new ConvexError(`Claw package name must use canonical form ${name}`);
}
const version = assertPackageVersion(family, payload.version);
const existingPackage = await runQueryRef<Doc<"packages"> | null>(
ctx,
@@ -7713,7 +8063,8 @@ async function publishPackageImpl(
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
throw new ConvexError(getPublishTotalSizeError("package"));
}
const legacyZipSha256 = await sha256Hex(buildDeterministicPackageZip(legacyZipEntries));
const legacyZipBytes = buildDeterministicPackageZip(legacyZipEntries);
const legacyZipSha256 = await sha256Hex(legacyZipBytes);
if (family === "code-plugin" && (!effectiveSource?.repo || !effectiveSource?.commit)) {
throw new ConvexError("Code plugins require source repo and commit metadata");
@@ -7723,6 +8074,14 @@ async function publishPackageImpl(
ctx,
files,
(path) => path === "package.json",
family === "claw"
? {
exactPath: true,
maxBytes: 256 * 1024,
label: "Claw package.json",
strictUtf8: true,
}
: undefined,
);
const pluginManifestEntry = await readOptionalTextFile(
ctx,
@@ -7746,6 +8105,23 @@ async function publishPackageImpl(
);
const packageJson = maybeParseJson(packageJsonEntry?.text);
const declaredClawManifestPath =
family === "claw" &&
packageJson &&
typeof packageJson.openclaw === "object" &&
packageJson.openclaw !== null &&
!Array.isArray(packageJson.openclaw) &&
typeof (packageJson.openclaw as Record<string, unknown>).claw === "string"
? ((packageJson.openclaw as Record<string, unknown>).claw as string)
: undefined;
const clawManifestEntry = declaredClawManifestPath
? await readOptionalTextFile(ctx, files, (path) => path === declaredClawManifestPath, {
exactPath: true,
maxBytes: 1024 * 1024,
label: "Claw manifest",
strictUtf8: true,
})
: null;
const pluginManifest = maybeParseJson(pluginManifestEntry?.text);
const bundleManifest = maybeParseJson(bundleManifestEntry?.text);
const storedPackageJson = toConvexSafeJsonValue(packageJson, {
@@ -7757,11 +8133,55 @@ async function publishPackageImpl(
const storedBundleManifest = toConvexSafeJsonValue(bundleManifest, {
maxDepth: MAX_STORED_PACKAGE_METADATA_DEPTH,
});
if (packageJson) ensurePluginNameMatchesPackage(name, packageJson);
if (!pluginManifest) {
if (family !== "claw" && packageJson) ensurePluginNameMatchesPackage(name, packageJson);
if (family !== "claw" && !pluginManifest) {
throw new ConvexError("openclaw.plugin.json is required for plugin packages");
}
const icon = normalizePluginManifestIcon(pluginManifest);
const clawValidationFiles = files.map((file) => ({
path: file.path,
...(clawManifestEntry?.file.path === file.path ? { text: clawManifestEntry.text } : {}),
}));
let clawPackage =
family === "claw"
? validateClawPackageContents({
packageName: name,
version,
packageJson,
files: clawValidationFiles,
})
: null;
if (clawPackage && !clawPackage.ok) {
const profilePath = clawPackage.issues.find(
(entry) => entry.code === "missing_openclaw_profile",
)?.path;
if (profilePath) {
const profileEntry = await readOptionalTextFile(ctx, files, (path) => path === profilePath, {
exactPath: true,
maxBytes: 256 * 1024,
label: "OpenClaw profile",
strictUtf8: true,
});
if (profileEntry) {
clawPackage = validateClawPackageContents({
packageName: name,
version,
packageJson,
files: clawValidationFiles.map((file) =>
file.path === profileEntry.file.path ? { ...file, text: profileEntry.text } : file,
),
});
}
}
}
if (clawPackage && !clawPackage.ok) {
throw new ConvexError(
`Invalid Claw package: ${clawPackage.issues
.map((entry) => `${entry.path}: ${entry.message}`)
.join(" ")}`,
);
}
const validatedClaw = clawPackage?.ok ? clawPackage.value : undefined;
const icon = family === "claw" ? undefined : normalizePluginManifestIcon(pluginManifest);
if (family === "code-plugin") {
const validation = validateOpenClawExternalCodePluginPackageContents(
packageJson,
@@ -7785,7 +8205,11 @@ async function publishPackageImpl(
? extractBundlePluginArtifacts({
packageName: name,
packageJson,
pluginManifest,
pluginManifest:
pluginManifest ??
(() => {
throw new ConvexError("openclaw.plugin.json is required for plugin packages");
})(),
bundleManifest,
bundleMetadata:
payload.bundle || detectedBundleFormat
@@ -7807,22 +8231,32 @@ async function publishPackageImpl(
(() => {
throw new ConvexError("package.json is required for code plugins");
})(),
pluginManifest,
pluginManifest:
pluginManifest ??
(() => {
throw new ConvexError("openclaw.plugin.json is required for plugin packages");
})(),
source: effectiveSource,
})
: null;
const summary = summarizePackageForSearch({
packageName: name,
packageJson,
readmeText: readmeEntry?.text ?? null,
});
const summary =
validatedClaw?.summary.agent.description ||
validatedClaw?.summary.agent.name ||
summarizePackageForSearch({
packageName: name,
packageJson,
readmeText: readmeEntry?.text ?? null,
});
let categories: string[];
let normalizedTopics: string[];
try {
const declaredCategories =
payload.categories ?? normalizeStoredPluginCategoryOverride(existingPackage?.categories);
categories = resolvePluginCategories({ declared: declaredCategories });
categories =
family === "claw"
? (declaredCategories ?? [])
: resolvePluginCategories({ declared: declaredCategories });
normalizedTopics = normalizeCatalogTopics(payload.topics ?? existingPackage?.topics);
} catch (error) {
throw new ConvexError(error instanceof Error ? error.message : "Invalid catalog metadata");
@@ -7836,6 +8270,7 @@ async function publishPackageImpl(
packageJson,
pluginManifest,
bundleManifest,
clawManifest: validatedClaw?.manifest,
source: effectiveSource,
},
files,
@@ -7870,12 +8305,34 @@ async function publishPackageImpl(
const integritySha256 = await hashSkillFiles(
files.map((file) => ({ path: file.path, sha256: file.sha256 })),
);
const pluginManifestSummary = derivePluginManifestSummary({
pluginManifest,
...(bundleManifest ? { skillManifest: bundleManifest } : {}),
compatibility: codeArtifacts?.compatibility ?? bundleArtifacts?.compatibility,
files: await withSkillMarkdownTextsForManifestSummary(ctx, files),
});
const pluginManifestSummary =
family === "claw"
? undefined
: derivePluginManifestSummary({
pluginManifest:
pluginManifest ??
(() => {
throw new ConvexError("openclaw.plugin.json is required for plugin packages");
})(),
...(bundleManifest ? { skillManifest: bundleManifest } : {}),
compatibility: codeArtifacts?.compatibility ?? bundleArtifacts?.compatibility,
files: await withSkillMarkdownTextsForManifestSummary(ctx, files),
});
const legacyZipStorageId =
payload.artifact?.kind === "npm-pack"
? undefined
: await ctx.storage.store(
new Blob(
[
legacyZipBytes.buffer.slice(
legacyZipBytes.byteOffset,
legacyZipBytes.byteOffset + legacyZipBytes.byteLength,
) as ArrayBuffer,
],
{ type: "application/zip" },
),
);
const packageInsertArgs = {
actorUserId,
@@ -7902,9 +8359,10 @@ async function publishPackageImpl(
integritySha256,
sha256hash: legacyZipSha256,
artifactKind: payload.artifact?.kind ?? "legacy-zip",
clawpackStorageId: payload.artifact?.storageId as Id<"_storage"> | undefined,
clawpackSha256: payload.artifact?.sha256,
clawpackSize: payload.artifact?.size,
clawpackStorageId:
(payload.artifact?.storageId as Id<"_storage"> | undefined) ?? legacyZipStorageId,
clawpackSha256: payload.artifact?.sha256 ?? legacyZipSha256,
clawpackSize: payload.artifact?.size ?? legacyZipBytes.byteLength,
clawpackFormat: payload.artifact?.format,
npmIntegrity: payload.artifact?.npmIntegrity,
npmShasum: payload.artifact?.npmShasum,
@@ -7918,6 +8376,7 @@ async function publishPackageImpl(
extractedPluginManifest: storedPluginManifest,
normalizedBundleManifest: family === "bundle-plugin" ? storedBundleManifest : undefined,
pluginManifestSummary,
clawManifestSummary: validatedClaw?.summary,
source: effectiveSource,
};
@@ -9807,7 +10266,12 @@ export const insertReleaseInternal = internalMutation({
),
name: v.string(),
displayName: v.string(),
family: v.union(v.literal("skill"), v.literal("code-plugin"), v.literal("bundle-plugin")),
family: v.union(
v.literal("skill"),
v.literal("code-plugin"),
v.literal("bundle-plugin"),
v.literal("claw"),
),
version: v.string(),
publicationStatus: v.optional(v.union(v.literal("pending"), v.literal("published"))),
changelog: v.string(),
@@ -9851,9 +10315,13 @@ export const insertReleaseInternal = internalMutation({
extractedPluginManifest: v.optional(v.any()),
normalizedBundleManifest: v.optional(v.any()),
pluginManifestSummary: v.optional(v.any()),
clawManifestSummary: v.optional(v.any()),
source: v.optional(v.any()),
},
handler: async (ctx, args) => {
if (args.family === "claw" && !experimentalClawsEnabled()) {
throw new ConvexError("Experimental Claw publication is disabled");
}
const now = Date.now();
const publicationStatus = args.publicationStatus ?? "published";
const pendingPublication = publicationStatus === "pending";
@@ -10086,6 +10554,7 @@ export const insertReleaseInternal = internalMutation({
extractedPluginManifest: args.extractedPluginManifest,
normalizedBundleManifest: args.normalizedBundleManifest,
pluginManifestSummary: args.pluginManifestSummary,
clawManifestSummary: args.clawManifestSummary,
compatibility: args.compatibility,
runtimeId: args.runtimeId,
sourceRepo: args.sourceRepo,
+9 -1
View File
@@ -2167,12 +2167,20 @@ const packageSearchDigest = defineTable({
"updatedAt",
])
.index("by_active_normalized_name", ["softDeletedAt", "normalizedName", "updatedAt"])
.index("by_active_family_normalized_name", [
"softDeletedAt",
"family",
"normalizedName",
"updatedAt",
])
.index("by_active_runtime_id", ["softDeletedAt", "runtimeId", "updatedAt"])
.index("by_active_family_runtime_id", ["softDeletedAt", "family", "runtimeId", "updatedAt"])
.index("by_active_owner_handle", ["softDeletedAt", "ownerHandle", "updatedAt"])
.index("by_active_family_owner_handle", ["softDeletedAt", "family", "ownerHandle", "updatedAt"])
.index("by_active_name", ["softDeletedAt", "displayName"])
.searchIndex("search_by_display_name", {
searchField: "displayName",
filterFields: ["softDeletedAt"],
filterFields: ["softDeletedAt", "family"],
});
const packageTopicSearchDigest = defineTable({
+5 -4
View File
@@ -28,10 +28,11 @@ Reading order:
4. `docs/publishing.md`: owner-scoped skill/plugin publishing flow.
5. `docs/cli.md`: ClawHub CLI reference.
6. `docs/skill-format.md`: skill bundle metadata and package shape.
7. `docs/auth.md`: GitHub OAuth, API tokens, and CLI login.
8. `docs/telemetry.md`: install telemetry and how to opt out.
9. `docs/namespace-claims.md`: org, brand, owner-handle, package-scope, skill-slug, and namespace ownership disputes.
10. `docs/troubleshooting.md`: user-facing CLI, install, publish, update, and API fixes.
7. `docs/claws.md`: experimental Claw package authoring and publication.
8. `docs/auth.md`: GitHub OAuth, API tokens, and CLI login.
9. `docs/telemetry.md`: install telemetry and how to opt out.
10. `docs/namespace-claims.md`: org, brand, owner-handle, package-scope, skill-slug, and namespace ownership disputes.
11. `docs/troubleshooting.md`: user-facing CLI, install, publish, update, and API fixes.
Policy, API, and trust docs:
+99
View File
@@ -0,0 +1,99 @@
---
summary: "How to author, validate, and publish experimental Claw packages."
read_when:
- Authoring a Claw package
- Publishing a Claw to an experimental ClawHub deployment
---
# Experimental Claw packages
A Claw is a versioned package that describes one complete OpenClaw agent and
the reusable resources it needs. ClawHub stores and scans the package;
OpenClaw owns local preview, consent, apply, update, and removal.
Claw publication is experimental. The ClawHub deployment must set
`CLAWHUB_EXPERIMENTAL_CLAWS=1`; otherwise the server rejects publication.
## Package shape
A publishable Claw is a normal package directory with a `package.json` that
points to its manifest:
```json
{
"name": "@acme/github-triage",
"version": "1.0.0",
"openclaw": {
"claw": "CLAW.md"
}
}
```
`CLAW.md` starts with the grouped Claw manifest as YAML frontmatter. Its
non-empty Markdown body becomes the managed `SOUL.md` for the new agent, so do
not also declare an explicit `SOUL.md` bootstrap or supporting-file destination.
```markdown
---
schemaVersion: 1
agent:
id: github-triage
name: GitHub Triage
description: Reviews incoming issues.
workspace:
files:
- source: workspace/reference.md
path: reference.md
packages:
- kind: skill
source: clawhub
ref: "@acme/triage"
version: 1.2.0
mcpServers: {}
cronJobs: []
---
# GitHub Triage
Reviews and classifies incoming GitHub issues.
```
JSON manifests remain compatible. Set `openclaw.claw` to a package-relative
JSON file such as `openclaw.claw.json`. JSON has no implicit body file and may
declare an explicit `SOUL.md` source.
Every `workspace.*.source` must name a file in the same package. Package names
and versions must match `package.json`, dependency versions must be exact, and
MCP environment values must remain unresolved `${ENV_VAR}` references.
## Validate and publish
Preview the package without uploading it:
```bash
clawhub package publish . --family claw --dry-run
```
When the target ClawHub deployment has experimental Claws enabled, publish
through the existing authenticated package flow:
```bash
clawhub package publish . --family claw
```
The CLI detects `family: claw` when `package.json` contains `openclaw.claw`, so
`--family claw` is optional for a well-formed package.
Publication rejects:
- a missing, invalid, or escaping `openclaw.claw` path;
- package identity or version mismatches;
- malformed `CLAW.md` frontmatter or manifest fields;
- an empty `CLAW.md` body or simultaneous explicit `SOUL.md` destination;
- missing workspace source files or portable path collisions;
- floating skill/plugin versions and resolved MCP credentials.
Accepted packages continue through ClawHub's existing ownership, moderation,
static scanning, release, and artifact storage pipeline. The stored release
retains the exact artifact plus a non-sensitive summary for later search and
detail surfaces; it does not duplicate the full manifest into Convex storage.
+1 -1
View File
@@ -185,7 +185,7 @@
},
"overrides": {
"ast-v8-to-istanbul": "1.0.4",
"brace-expansion": "5.0.7",
"brace-expansion": "5.0.8",
"dompurify": "3.4.12",
"esbuild": "0.28.1",
"fast-uri": "3.1.4",
+4 -1
View File
@@ -41,6 +41,7 @@
"@openclaw/plugin-inspector": "0.3.17",
"arktype": "2.2.3",
"commander": "15.0.0",
"croner": "10.0.1",
"fflate": "0.8.3",
"ignore": "7.0.6",
"json5": "2.2.3",
@@ -48,7 +49,9 @@
"ora": "9.4.1",
"p-retry": "8.0.0",
"semver": "7.8.5",
"undici": "7.28.0"
"undici": "7.28.0",
"unicode-case-folding": "1.1.1",
"yaml": "2.9.0"
},
"devDependencies": {
"@types/node": "26.1.1",
+88 -28
View File
@@ -1,4 +1,5 @@
import { gunzipSync } from "fflate";
import { Gunzip } from "fflate";
import { findClawPackagePathHierarchyCollision } from "./schema/clawPackage.js";
type ClawPackEntry = {
path: string;
@@ -10,23 +11,29 @@ type ParsedClawPack = {
packageVersion: string;
entries: ClawPackEntry[];
packageJson: Record<string, unknown>;
pluginManifest: Record<string, unknown>;
pluginManifest?: Record<string, unknown>;
};
const TAR_BLOCK_SIZE = 512;
const MAX_UNPACKED_BYTES = 50 * 1024 * 1024;
const MAX_TAR_BYTES = 64 * 1024 * 1024;
const MAX_FILE_BYTES = MAX_UNPACKED_BYTES;
const MAX_PACKAGE_JSON_BYTES = 256 * 1024;
const MAX_FILE_COUNT = 10_000;
const GZIP_INPUT_CHUNK_BYTES = 1_024;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function textFromBytes(bytes: Uint8Array) {
return new TextDecoder().decode(bytes);
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
function readTarString(block: Uint8Array, offset: number, length: number) {
const slice = block.subarray(offset, offset + length);
const end = slice.indexOf(0);
return textFromBytes(end === -1 ? slice : slice.subarray(0, end)).trim();
return textFromBytes(end === -1 ? slice : slice.subarray(0, end));
}
function readTarSize(block: Uint8Array) {
@@ -38,15 +45,49 @@ function readTarSize(block: Uint8Array) {
}
function normalizeTarPath(path: string) {
const normalized = path.replaceAll("\\", "/").replace(/^\.\/+/, "");
if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) return null;
const segments = normalized.split("/").filter(Boolean);
if (segments.length === 0 || segments.some((segment) => segment === "." || segment === "..")) {
if (
!path ||
path !== path.trim() ||
path.includes("\\") ||
path.startsWith("/") ||
/\p{Cc}/u.test(path)
) {
return null;
}
const segments = path.split("/");
if (
segments.length === 0 ||
segments.some(
(segment, index) =>
segment === "." || segment === ".." || (segment === "" && index !== segments.length - 1),
)
) {
return null;
}
return segments.join("/");
}
function gunzipBounded(bytes: Uint8Array): Uint8Array {
const chunks: Uint8Array[] = [];
let total = 0;
const gunzip = new Gunzip((chunk) => {
total += chunk.byteLength;
if (total > MAX_TAR_BYTES) throw new Error("ClawPack unpacked archive exceeds 64MB limit");
chunks.push(Uint8Array.from(chunk));
});
for (let offset = 0; offset < bytes.byteLength; offset += GZIP_INPUT_CHUNK_BYTES) {
const end = Math.min(offset + GZIP_INPUT_CHUNK_BYTES, bytes.byteLength);
gunzip.push(bytes.subarray(offset, end), end === bytes.byteLength);
}
const result = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function isZeroBlock(block: Uint8Array) {
return block.every((byte) => byte === 0);
}
@@ -57,6 +98,7 @@ function nextTarOffset(offset: number, size: number) {
function parseTarEntries(bytes: Uint8Array): ClawPackEntry[] {
const entries: ClawPackEntry[] = [];
const paths = new Set<string>();
let offset = 0;
while (offset + TAR_BLOCK_SIZE <= bytes.byteLength) {
@@ -69,6 +111,7 @@ function parseTarEntries(bytes: Uint8Array): ClawPackEntry[] {
if (!path) throw new Error("ClawPack contains an unsafe tar path");
const size = readTarSize(header);
if (size > MAX_FILE_BYTES) throw new Error("ClawPack file exceeds 50MB limit");
const payloadOffset = offset + TAR_BLOCK_SIZE;
const payloadEnd = payloadOffset + size;
if (payloadEnd > bytes.byteLength) throw new Error("ClawPack tar entry is truncated");
@@ -79,12 +122,17 @@ function parseTarEntries(bytes: Uint8Array): ClawPackEntry[] {
throw new Error("ClawPack entries must be rooted under package/");
}
const relPath = path.slice("package/".length);
if (relPath) {
entries.push({
path: relPath,
bytes: Uint8Array.from(bytes.subarray(payloadOffset, payloadEnd)),
});
if (!relPath || relPath.endsWith("/"))
throw new Error("ClawPack contains an unsafe tar path");
if (paths.has(relPath)) {
throw new Error(`ClawPack contains duplicate path: ${relPath}`);
}
paths.add(relPath);
if (entries.length >= MAX_FILE_COUNT) throw new Error("ClawPack exceeds 10000 file limit");
entries.push({
path: relPath,
bytes: Uint8Array.from(bytes.subarray(payloadOffset, payloadEnd)),
});
} else if (typeflag !== "5") {
throw new Error("ClawPack may only contain regular files and directories");
}
@@ -93,24 +141,35 @@ function parseTarEntries(bytes: Uint8Array): ClawPackEntry[] {
}
if (entries.length === 0) throw new Error("ClawPack contains no files");
const hierarchyCollision = findClawPackagePathHierarchyCollision(
entries.map((entry) => entry.path),
);
if (hierarchyCollision) {
throw new Error(
`ClawPack contains file/ancestor path collision: ${hierarchyCollision.ancestor} and ${hierarchyCollision.descendant}`,
);
}
const unpackedSize = entries.reduce((sum, entry) => sum + entry.bytes.byteLength, 0);
if (unpackedSize > MAX_UNPACKED_BYTES) throw new Error("ClawPack package exceeds 50MB limit");
return entries;
}
export function parseClawPack(bytes: Uint8Array): ParsedClawPack {
let tarBytes: Uint8Array;
try {
tarBytes = gunzipSync(bytes);
} catch {
throw new Error("ClawPack must be a gzip-compressed npm pack tarball");
tarBytes = gunzipBounded(bytes);
} catch (error) {
if (error instanceof Error && error.message.startsWith("ClawPack ")) throw error;
throw new Error("ClawPack must be a gzip-compressed npm pack tarball", { cause: error });
}
const entries = parseTarEntries(tarBytes);
const packageJsonEntry = entries.find((entry) => entry.path === "package.json");
if (!packageJsonEntry) throw new Error("ClawPack must contain package/package.json");
const pluginManifestEntry = entries.find((entry) => entry.path === "openclaw.plugin.json");
if (!pluginManifestEntry) {
throw new Error("ClawPack must contain package/openclaw.plugin.json");
if (packageJsonEntry.bytes.byteLength > MAX_PACKAGE_JSON_BYTES) {
throw new Error("ClawPack package.json exceeds 256KB limit");
}
const pluginManifestEntry = entries.find((entry) => entry.path === "openclaw.plugin.json");
let packageJson: unknown;
try {
@@ -125,14 +184,15 @@ export function parseClawPack(bytes: Uint8Array): ParsedClawPack {
if (!packageName) throw new Error("ClawPack package.json must declare a name");
if (!packageVersion) throw new Error("ClawPack package.json must declare a version");
let pluginManifest: unknown;
try {
pluginManifest = JSON.parse(textFromBytes(pluginManifestEntry.bytes));
} catch {
throw new Error("ClawPack openclaw.plugin.json is invalid JSON");
}
if (!isRecord(pluginManifest)) {
throw new Error("ClawPack openclaw.plugin.json must be an object");
let pluginManifest: Record<string, unknown> | undefined;
if (pluginManifestEntry) {
try {
const parsed = JSON.parse(textFromBytes(pluginManifestEntry.bytes)) as unknown;
if (!isRecord(parsed)) throw new Error();
pluginManifest = parsed;
} catch {
throw new Error("ClawPack openclaw.plugin.json is invalid JSON object");
}
}
return {
@@ -140,6 +200,6 @@ export function parseClawPack(bytes: Uint8Array): ParsedClawPack {
packageVersion,
entries,
packageJson,
pluginManifest,
...(pluginManifest ? { pluginManifest } : {}),
};
}
+2 -2
View File
@@ -542,7 +542,7 @@ const packageCmd = registerCommandGroup(program, ["package"]).description(
registerCommand(packageCmd, ["package", "explore"])
.description("Browse published packages and plugins")
.argument("[query...]", "Optional search query")
.option("--family <family>", "skill|code-plugin|bundle-plugin")
.option("--family <family>", "skill|code-plugin|bundle-plugin|claw")
.option("--official", "Only official packages")
.option("--executes-code", "Only packages that execute code")
.option("--target <target>", "Filter by host target, e.g. darwin-arm64")
@@ -721,7 +721,7 @@ registerCommand(packageCmd, ["package", "pack"])
registerCommand(packageCmd, ["package", "publish"])
.description("Publish a code plugin or bundle plugin from a folder or GitHub source")
.argument("<source>", "Package folder path, GitHub repo (owner/repo[@ref]), or URL")
.option("--family <family>", "code-plugin|bundle-plugin")
.option("--family <family>", "code-plugin|bundle-plugin|claw")
.option("--name <name>", "Package name")
.option("--display-name <name>", "Display name")
.option("--owner <handle>", "Publish under this owner/publisher handle")
@@ -227,6 +227,16 @@ afterEach(() => {
});
describe("package commands", () => {
it("rejects npm pack file/ancestor collisions before extraction", () => {
const bytes = npmPackFixture({
"package/package.json": JSON.stringify({ name: "demo", version: "1.0.0" }),
"package/a": "file",
"package/a/b": "child",
});
expect(() => parseClawPack(bytes)).toThrow("file/ancestor path collision");
});
it("validates a local plugin package with bundled Plugin Inspector offline", async () => {
const workdir = await makeTmpWorkdir();
try {
@@ -1946,7 +1956,7 @@ describe("package commands", () => {
}
});
it("rejects a ClawPack tarball without openclaw.plugin.json", async () => {
it("rejects a code plugin ClawPack tarball without openclaw.plugin.json", async () => {
const workdir = await makeTmpWorkdir();
try {
const packName = "demo-plugin-1.0.0.tgz";
@@ -1964,16 +1974,54 @@ describe("package commands", () => {
await expect(
cmdPublishPackage(makeOpts(workdir), packName, {
family: "code-plugin",
sourceRepo: "openclaw/demo-plugin",
sourceCommit: "abc123",
}),
).rejects.toThrow("ClawPack must contain package/openclaw.plugin.json");
).rejects.toThrow("openclaw.plugin.json required");
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("publishes a Claw tarball without a plugin manifest", async () => {
const workdir = await makeTmpWorkdir();
try {
const packName = "demo-claw-1.0.0.tgz";
await writeFile(
join(workdir, packName),
npmPackFixture({
"package/package.json": JSON.stringify({
name: "demo-claw",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"package/CLAW.md": "---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\n# Demo Claw\n",
}),
);
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_claw",
releaseId: "rel_claw",
});
await cmdPublishPackage(makeOpts(workdir), packName);
expect(getPublishPayload()).toMatchObject({
name: "demo-claw",
family: "claw",
version: "1.0.0",
});
const uploaded = getPublishForm().get("clawpack");
expect(uploaded).toBeInstanceOf(File);
const parsed = parseClawPack(new Uint8Array(await (uploaded as File).arrayBuffer()));
expect(parsed.entries.map((entry) => entry.path)).toContain("CLAW.md");
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("mints a short-lived publish token from GitHub Actions OIDC in CI", async () => {
const workdir = await makeTmpWorkdir();
try {
@@ -2527,6 +2575,134 @@ describe("package commands", () => {
}
});
it("detects, validates, and publishes a Claw package", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "github-triage");
await mkdir(join(folder, "profiles"), { recursive: true });
await writeFile(
join(folder, "package.json"),
JSON.stringify({
name: "@acme/github-triage",
displayName: "GitHub Triage",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"utf8",
);
await writeFile(
join(folder, "CLAW.md"),
[
"---",
"schemaVersion: 1",
"agent:",
" id: github-triage",
" name: GitHub Triage",
"metadata:",
" openclaw.config: profiles/openclaw.yml",
"---",
"# GitHub Triage",
].join("\n"),
"utf8",
);
await writeFile(
join(folder, "profiles", "openclaw.yml"),
[
"schemaVersion: 1",
"agent:",
" tools:",
" profile: coding",
" alsoAllow: [cron]",
" fs:",
" workspaceOnly: true",
" memory:",
" search:",
" enabled: true",
" rememberAcrossConversations: true",
" sources: [memory, sessions]",
].join("\n"),
"utf8",
);
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_claw",
releaseId: "rel_claw",
});
await cmdPublishPackage(makeOpts(workdir), "github-triage");
expect(getPublishPayload()).toMatchObject({
name: "@acme/github-triage",
family: "claw",
version: "1.0.0",
});
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("rejects Claw manifests with malformed UTF-8 before upload", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "invalid-utf8-claw");
await mkdir(folder, { recursive: true });
await writeFile(
join(folder, "package.json"),
JSON.stringify({
name: "invalid-utf8-claw",
displayName: "Invalid UTF-8 Claw",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"utf8",
);
await writeFile(
join(folder, "CLAW.md"),
Uint8Array.from([
...new TextEncoder().encode(
"---\nschemaVersion: 1\nagent:\n id: invalid-utf8-claw\n---\n# Invalid UTF-8 Claw\n",
),
0xff,
]),
);
await expect(cmdPublishPackage(makeOpts(workdir), "invalid-utf8-claw")).rejects.toThrow(
"CLAW.md: The declared Claw manifest is missing or is not UTF-8 text.",
);
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("rejects Claws with missing workspace sources before upload", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "broken-claw");
await mkdir(folder, { recursive: true });
await writeFile(
join(folder, "package.json"),
JSON.stringify({
name: "broken-claw",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
}),
"utf8",
);
await writeFile(
join(folder, "CLAW.md"),
"---\nschemaVersion: 1\nagent:\n id: broken-claw\nworkspace:\n files:\n - source: missing.md\n path: missing.md\n---\n# Broken Claw\n",
"utf8",
);
await expect(cmdPublishPackage(makeOpts(workdir), "broken-claw")).rejects.toThrow(
"missing.md: Declared workspace source is missing",
);
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("respects package ignore rules and built-in ignored directories", async () => {
const workdir = await makeTmpWorkdir();
try {
+44 -9
View File
@@ -46,6 +46,7 @@ import {
type PackageFamily,
type PackageTrustedPublisher,
type PackageVerificationSummary,
decodeUtf8Text,
validateOpenClawExternalCodePluginPackageContents,
validateOpenClawExternalCodePluginPackageJson,
} from "../../schema/index.js";
@@ -143,8 +144,10 @@ type PackageExploreOptions = {
json?: boolean;
};
type PublishablePackageFamily = "code-plugin" | "bundle-plugin" | "claw";
type PackagePublishOptions = {
family?: "code-plugin" | "bundle-plugin";
family?: PublishablePackageFamily;
name?: string;
displayName?: string;
owner?: string;
@@ -261,7 +264,7 @@ type PackagePublishPayload = {
name: string;
displayName: string;
ownerHandle?: string;
family: "code-plugin" | "bundle-plugin";
family: PublishablePackageFamily;
version: string;
changelog: string;
manualOverrideReason?: string;
@@ -288,7 +291,7 @@ type PackagePublishPlan = {
source: string;
name: string;
displayName: string;
family: "code-plugin" | "bundle-plugin";
family: PublishablePackageFamily;
version: string;
commit?: string;
files: number;
@@ -2184,9 +2187,23 @@ function hasLooseBundleMarker(fileSet: Set<string>) {
function detectPackageFamily(
fileSet: Set<string>,
explicit?: "code-plugin" | "bundle-plugin",
): "code-plugin" | "bundle-plugin" {
packageJson: unknown,
explicit?: PublishablePackageFamily,
): PublishablePackageFamily {
if (explicit) return explicit;
const packageRecord =
packageJson && typeof packageJson === "object" && !Array.isArray(packageJson)
? (packageJson as Record<string, unknown>)
: undefined;
const openclaw =
packageRecord?.openclaw &&
typeof packageRecord.openclaw === "object" &&
!Array.isArray(packageRecord.openclaw)
? (packageRecord.openclaw as Record<string, unknown>)
: undefined;
if (typeof openclaw?.claw === "string") {
return "claw";
}
if (hasRealBundleManifest(fileSet)) return "bundle-plugin";
if (fileSet.has("openclaw.plugin.json")) return "code-plugin";
if (hasLooseBundleMarker(fileSet)) return "bundle-plugin";
@@ -2318,7 +2335,7 @@ async function preparePackagePublishPlan(
(parsedClawpack ? null : await readJsonFile(join(folder, "openclaw.plugin.json")));
const bundleManifestInfo = await readBundleManifestInfo(filesOnDisk, folder, parsedClawpack);
const bundleManifest = bundleManifestInfo.manifest;
const family = detectPackageFamily(fileSet, options.family);
const family = detectPackageFamily(fileSet, packageJson, options.family);
const name =
options.name?.trim() ||
parsedClawpack?.packageName ||
@@ -2345,9 +2362,11 @@ async function preparePackagePublishPlan(
if (!name) fail("--name required");
if (!displayName) fail("--display-name required");
if (!version) fail("--version required");
if (!fileSet.has("openclaw.plugin.json")) fail("openclaw.plugin.json required");
if (family === "code-plugin" && !semver.valid(version)) {
fail("--version must be valid semver for code plugins");
if (family !== "claw" && !fileSet.has("openclaw.plugin.json")) {
fail("openclaw.plugin.json required");
}
if ((family === "code-plugin" || family === "claw") && !semver.valid(version)) {
fail(`--version must be valid semver for ${family === "claw" ? "Claws" : "code plugins"}`);
}
if (family === "code-plugin") {
if (!fileSet.has("package.json")) fail("package.json required");
@@ -2358,6 +2377,22 @@ async function preparePackagePublishPlan(
}
}
if (family === "claw") {
const { validateClawPackageContents } = await import("../../schema/clawPackage.js");
const validation = validateClawPackageContents({
packageName: name,
version,
packageJson,
files: filesOnDisk.map((file) => ({
path: file.relPath,
text: decodeUtf8Text(file.bytes) ?? undefined,
})),
});
if (!validation.ok) {
fail(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join(" "));
}
}
if (family === "code-plugin" && !clawpackOnDisk) {
const packDestination = await mkdtemp(join(tmpdir(), "clawhub-clawpack-"));
let packed: PackedClawPack;
+622
View File
@@ -0,0 +1,622 @@
import { ArkErrors, type } from "arktype";
import { caseFold } from "unicode-case-folding";
import { isScalar, parseDocument, visit } from "yaml";
import {
summarizeClawManifest,
validateClawManifest,
type ClawManifest,
type ClawManifestSummary,
} from "./claws.js";
export type ClawPackageTextFile = { path: string; text?: string };
export type ClawPackageValidationIssue = {
code: string;
path: string;
message: string;
};
export type ValidatedClawPackage = {
manifestPath: string;
manifest: ClawManifest;
implicitWorkspaceFile?: { path: "SOUL.md"; text: string };
summary: ClawManifestSummary;
};
const EXACT_VERSION_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const WINDOWS_INVALID_PATH_CHARS = /[<>:"|?*]/;
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const UNICODE_CONTROL_CHARACTER = /\p{Cc}/u;
const MAX_CLAW_MANIFEST_BYTES = 1024 * 1024;
const MAX_OPENCLAW_PROFILE_BYTES = 256 * 1024;
const OPENCLAW_TOOL_PROFILE_IDS = new Set(["minimal", "coding", "messaging", "full"]);
const StrictStringArraySchema = type("string[]");
const OpenClawProfileSchema = type({
"+": "reject",
schemaVersion: "1",
agent: {
"+": "reject",
groupChat: type({
"+": "reject",
mentionPatterns: StrictStringArraySchema.optional(),
}).optional(),
sandbox: type({
"+": "reject",
mode: '"off"|"non-main"|"all"?',
scope: '"session"|"agent"|"shared"?',
workspaceAccess: '"none"|"ro"|"rw"?',
}).optional(),
tools: type({
"+": "reject",
profile: "string?",
allow: StrictStringArraySchema.optional(),
alsoAllow: StrictStringArraySchema.optional(),
deny: StrictStringArraySchema.optional(),
fs: type({
"+": "reject",
workspaceOnly: "true?",
}).optional(),
}).optional(),
memory: type({
"+": "reject",
search: type({
"+": "reject",
enabled: "boolean?",
rememberAcrossConversations: "boolean?",
sources: type("('memory' | 'sessions')[]").optional(),
}).optional(),
}).optional(),
heartbeat: type({
"+": "reject",
every: "string?",
activeHours: type({
"+": "reject",
start: "string?",
end: "string?",
timezone: "string?",
}).optional(),
lightContext: "boolean?",
isolatedSession: "boolean?",
timeoutSeconds: "number?",
}).optional(),
humanDelay: type({
"+": "reject",
mode: '"off"|"natural"|"custom"?',
minMs: "number?",
maxMs: "number?",
}).optional(),
},
});
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function isSafeClawPackagePath(value: string): boolean {
const normalized = value.replaceAll("\\", "/");
if (
!normalized ||
normalized !== value ||
normalized !== normalized.trim() ||
normalized.startsWith("/") ||
/^[A-Za-z]:\//.test(normalized)
) {
return false;
}
return normalized
.split("/")
.every(
(segment) =>
segment !== "" &&
segment !== "." &&
segment !== ".." &&
!WINDOWS_INVALID_PATH_CHARS.test(segment) &&
!UNICODE_CONTROL_CHARACTER.test(segment) &&
!segment.endsWith(".") &&
!segment.endsWith(" ") &&
!WINDOWS_RESERVED_PATH_SEGMENT.test(segment),
);
}
function portablePathKey(value: string): string {
return caseFold(value.replaceAll("\\", "/").normalize("NFC"));
}
export function findClawPackagePathHierarchyCollision(
paths: readonly string[],
): { ancestor: string; descendant: string } | null {
const pathByKey = new Map(paths.map((path) => [portablePathKey(path), path]));
for (const descendant of paths) {
const segments = portablePathKey(descendant).split("/");
for (let length = 1; length < segments.length; length += 1) {
const ancestor = pathByKey.get(segments.slice(0, length).join("/"));
if (ancestor !== undefined) {
return { ancestor, descendant };
}
}
}
return null;
}
function issue(code: string, path: string, message: string): ClawPackageValidationIssue {
return { code, path, message };
}
function parseJsonCompatibleYaml(raw: string, path: string) {
const document = parseDocument(raw.startsWith("\uFEFF") ? raw.slice(1) : raw, {
prettyErrors: false,
uniqueKeys: true,
});
if (document.errors.length > 0) {
return {
issues: document.errors.map((error) =>
issue("invalid_openclaw_profile", path, error.message),
),
};
}
let unsupportedFeature: string | undefined;
visit(document, {
Alias() {
unsupportedFeature ??= "aliases";
},
Node(_key, node) {
if (node.anchor) {
unsupportedFeature ??= "anchors";
} else if (node.tag) {
unsupportedFeature ??= "explicit tags";
}
},
Pair(_key, pair) {
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
unsupportedFeature ??= "non-string mapping keys";
} else if (pair.key.value === "<<") {
unsupportedFeature ??= "merge keys";
}
},
});
if (unsupportedFeature) {
return {
issues: [
issue(
"unsupported_openclaw_profile_yaml_feature",
path,
`${path} uses ${unsupportedFeature}; OpenClaw profile YAML must map directly to JSON data.`,
),
],
};
}
try {
return { value: document.toJSON() };
} catch (error) {
return {
issues: [
issue(
"invalid_openclaw_profile",
path,
error instanceof Error ? error.message : "Could not parse OpenClaw profile.",
),
],
};
}
}
function isStrictNonEmpty(value: string): boolean {
return value.length > 0 && value === value.trim();
}
function isValidDuration(value: string): boolean {
if (!isStrictNonEmpty(value)) return false;
const multipliers: Record<string, number> = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
const normalized = value.toLowerCase();
const single = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(normalized);
if (single) {
return Number.isSafeInteger(Math.round(Number(single[1]) * multipliers[single[2] ?? "m"]));
}
let totalMs = 0;
let consumed = 0;
for (const match of normalized.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h|d)/g)) {
if (match.index !== consumed) return false;
totalMs += Number(match[1]) * multipliers[match[2]];
consumed += match[0].length;
}
return (
consumed === normalized.length && consumed > 0 && Number.isSafeInteger(Math.round(totalMs))
);
}
function validateOpenClawProfile(
value: unknown,
profilePath: string,
): ClawPackageValidationIssue[] {
const parsed = OpenClawProfileSchema(value);
if (parsed instanceof ArkErrors) {
return Array.from(parsed, (error) =>
issue(
"invalid_openclaw_profile",
`${profilePath}${error.path.length > 0 ? `.${error.path.join(".")}` : ""}`,
error.description ?? "Invalid value.",
),
);
}
const issues: ClawPackageValidationIssue[] = [];
const add = (path: string, message: string) =>
issues.push(issue("invalid_openclaw_profile", `${profilePath}.${path}`, message));
const requireNonEmpty = (path: string, values: string[] | undefined) => {
if (values !== undefined && values.length === 0) add(path, "Must contain at least one value.");
for (const [index, entry] of (values ?? []).entries()) {
if (!isStrictNonEmpty(entry)) {
add(`${path}.${index}`, "Must be non-empty without leading or trailing whitespace.");
}
}
};
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent.groupChat?.mentionPatterns);
if (parsed.agent.tools?.profile !== undefined && !isStrictNonEmpty(parsed.agent.tools.profile)) {
add("agent.tools.profile", "Must be non-empty without leading or trailing whitespace.");
} else if (
parsed.agent.tools?.profile !== undefined &&
!OPENCLAW_TOOL_PROFILE_IDS.has(parsed.agent.tools.profile)
) {
add("agent.tools.profile", "Must name a registered OpenClaw built-in profile.");
}
requireNonEmpty("agent.tools.allow", parsed.agent.tools?.allow);
requireNonEmpty("agent.tools.alsoAllow", parsed.agent.tools?.alsoAllow);
requireNonEmpty("agent.tools.deny", parsed.agent.tools?.deny);
if (parsed.agent.tools?.allow && parsed.agent.tools.alsoAllow) {
add("agent.tools.alsoAllow", "Must not be combined with tools.allow.");
}
if (parsed.agent.memory?.search?.sources?.length === 0) {
add("agent.memory.search.sources", "Must contain at least one source.");
}
if (
parsed.agent.memory?.search?.sources?.includes("sessions") &&
parsed.agent.memory.search.rememberAcrossConversations !== true
) {
add(
"agent.memory.search.rememberAcrossConversations",
"Must be true when memory.search.sources includes sessions.",
);
}
const heartbeat = parsed.agent.heartbeat;
if (heartbeat?.every !== undefined && !isValidDuration(heartbeat.every)) {
add("agent.heartbeat.every", "Must be a valid duration.");
}
const activeHours = heartbeat?.activeHours;
if (activeHours?.start !== undefined && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(activeHours.start)) {
add("agent.heartbeat.activeHours.start", "Must be a valid 24-hour start time.");
}
if (
activeHours?.end !== undefined &&
!/^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/.test(activeHours.end)
) {
add("agent.heartbeat.activeHours.end", "Must be a valid 24-hour end time.");
}
if (activeHours?.timezone !== undefined) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: activeHours.timezone }).format();
} catch {
add("agent.heartbeat.activeHours.timezone", "Must be a valid IANA timezone.");
}
}
if (
heartbeat?.timeoutSeconds !== undefined &&
(!Number.isInteger(heartbeat.timeoutSeconds) || heartbeat.timeoutSeconds <= 0)
) {
add("agent.heartbeat.timeoutSeconds", "Must be a positive integer.");
}
for (const field of ["minMs", "maxMs"] as const) {
const delay = parsed.agent.humanDelay?.[field];
if (delay !== undefined && (!Number.isInteger(delay) || delay < 0)) {
add(`agent.humanDelay.${field}`, "Must be a nonnegative integer.");
}
}
return issues;
}
function parseManifestDocument(raw: string, manifestPath: string) {
const filename = manifestPath.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
if (filename === "claw.md") {
const markdown = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) {
return {
issues: [
issue(
"missing_claw_frontmatter",
manifestPath,
`${manifestPath} must start with YAML frontmatter delimited by --- lines.`,
),
],
};
}
const document = parseDocument(match[1], { prettyErrors: false, uniqueKeys: true });
if (document.errors.length > 0) {
return {
issues: document.errors.map((error) =>
issue("invalid_claw_frontmatter", manifestPath, error.message),
),
};
}
let unsupportedFeature: string | undefined;
visit(document, {
Alias() {
unsupportedFeature ??= "aliases";
},
Node(_key, node) {
if (node.anchor) {
unsupportedFeature ??= "anchors";
} else if (node.tag) {
unsupportedFeature ??= "explicit tags";
}
},
Pair(_key, pair) {
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
unsupportedFeature ??= "non-string mapping keys";
} else if (pair.key.value === "<<") {
unsupportedFeature ??= "merge keys";
}
},
});
if (unsupportedFeature) {
return {
issues: [
issue(
"unsupported_claw_yaml_feature",
manifestPath,
`${manifestPath} uses ${unsupportedFeature}; CLAW.md frontmatter must map directly to JSON data.`,
),
],
};
}
try {
return {
value: document.toJSON(),
markdownBody: markdown.slice(match[0].length),
};
} catch (error) {
return {
issues: [
issue(
"invalid_claw_frontmatter",
manifestPath,
error instanceof Error ? error.message : "Could not parse Claw frontmatter.",
),
],
};
}
}
try {
return { value: JSON.parse(raw) as unknown };
} catch (error) {
return {
issues: [
issue(
"invalid_claw_json",
manifestPath,
error instanceof Error ? error.message : "Could not parse Claw JSON.",
),
],
};
}
}
export function validateClawPackageContents(input: {
packageName: string;
version: string;
packageJson: unknown;
files: readonly ClawPackageTextFile[];
}):
| { ok: true; value: ValidatedClawPackage }
| { ok: false; issues: ClawPackageValidationIssue[] } {
const issues: ClawPackageValidationIssue[] = [];
if (!isRecord(input.packageJson)) {
return {
ok: false,
issues: [
issue("missing_package_json", "package.json", "Claw packages require package.json."),
],
};
}
const declaredName = typeof input.packageJson.name === "string" ? input.packageJson.name : "";
const declaredVersion =
typeof input.packageJson.version === "string" ? input.packageJson.version : "";
const openclaw = isRecord(input.packageJson.openclaw) ? input.packageJson.openclaw : undefined;
const manifestPath = typeof openclaw?.claw === "string" ? openclaw.claw : "";
if (declaredName !== input.packageName) {
issues.push(
issue("package_name_mismatch", "package.json.name", `Must match ${input.packageName}.`),
);
}
if (!EXACT_VERSION_PATTERN.test(declaredVersion) || declaredVersion !== input.version) {
issues.push(
issue(
"package_version_mismatch",
"package.json.version",
`Must be exact semver ${input.version}.`,
),
);
}
if (!manifestPath || !isSafeClawPackagePath(manifestPath)) {
issues.push(
issue(
"invalid_claw_manifest_path",
"package.json.openclaw.claw",
"Must name a safe package-relative Claw manifest path.",
),
);
}
if (issues.length > 0) return { ok: false, issues };
const fileByPath = new Map<string, ClawPackageTextFile>();
const portablePaths = new Set<string>();
for (const file of input.files) {
if (!isSafeClawPackagePath(file.path)) {
issues.push(
issue(
"invalid_package_path",
file.path,
"Package files must use safe canonical package-relative paths.",
),
);
continue;
}
const key = portablePathKey(file.path);
if (portablePaths.has(key)) {
issues.push(
issue(
"duplicate_portable_path",
file.path,
"Package paths must be unique across supported filesystems.",
),
);
} else {
portablePaths.add(key);
fileByPath.set(file.path, file);
}
}
const hierarchyCollision = findClawPackagePathHierarchyCollision([...fileByPath.keys()]);
if (hierarchyCollision) {
issues.push(
issue(
"portable_path_hierarchy_collision",
hierarchyCollision.descendant,
`Package file ${hierarchyCollision.ancestor} cannot also be an ancestor of ${hierarchyCollision.descendant}.`,
),
);
}
const manifestFile = fileByPath.get(manifestPath);
if (!manifestFile || manifestFile.text === undefined) {
issues.push(
issue(
"missing_claw_manifest",
manifestPath,
"The declared Claw manifest is missing or is not UTF-8 text.",
),
);
return { ok: false, issues };
}
if (new TextEncoder().encode(manifestFile.text).byteLength > MAX_CLAW_MANIFEST_BYTES) {
return {
ok: false,
issues: [
issue(
"claw_manifest_too_large",
manifestPath,
`The Claw manifest exceeds ${MAX_CLAW_MANIFEST_BYTES} bytes.`,
),
],
};
}
const parsed = parseManifestDocument(manifestFile.text, manifestPath);
if (parsed.issues) return { ok: false, issues: parsed.issues };
if (parsed.markdownBody !== undefined && parsed.markdownBody.trim().length === 0) {
return {
ok: false,
issues: [
issue("empty_claw_body", manifestPath, "CLAW.md must contain a non-empty Markdown body."),
],
};
}
const validated = validateClawManifest(parsed.value);
if (!validated.ok) {
return {
ok: false,
issues: validated.issues.map((entry) =>
issue("invalid_claw_manifest", entry.path, entry.message),
),
};
}
if (
parsed.markdownBody !== undefined &&
(validated.manifest.workspace?.bootstrapFiles?.["SOUL.md"] !== undefined ||
(validated.manifest.workspace?.files ?? []).some(
(entry) => portablePathKey(entry.path) === portablePathKey("SOUL.md"),
))
) {
return {
ok: false,
issues: [
issue(
"duplicate_claw_body_destination",
"SOUL.md",
"The CLAW.md body is the managed SOUL.md source and cannot be combined with an explicit SOUL.md declaration.",
),
],
};
}
const openClawProfilePath = validated.manifest.metadata?.["openclaw.config"];
if (openClawProfilePath !== undefined) {
const profileFile = fileByPath.get(openClawProfilePath);
if (!profileFile || profileFile.text === undefined) {
issues.push(
issue(
"missing_openclaw_profile",
openClawProfilePath,
"The declared OpenClaw profile is missing or is not UTF-8 text.",
),
);
} else if (new TextEncoder().encode(profileFile.text).byteLength > MAX_OPENCLAW_PROFILE_BYTES) {
issues.push(
issue(
"openclaw_profile_too_large",
openClawProfilePath,
`The OpenClaw profile exceeds ${MAX_OPENCLAW_PROFILE_BYTES} bytes.`,
),
);
} else {
const profile = parseJsonCompatibleYaml(profileFile.text, openClawProfilePath);
if (profile.issues) {
issues.push(...profile.issues);
} else {
issues.push(...validateOpenClawProfile(profile.value, openClawProfilePath));
}
}
}
const sources = [
...Object.values(validated.manifest.workspace?.bootstrapFiles ?? {}).map(
(entry) => entry.source,
),
...(validated.manifest.workspace?.files ?? []).map((entry) => entry.source),
];
for (const source of sources) {
if (!fileByPath.has(source)) {
issues.push(
issue(
"missing_workspace_source",
source,
"Declared workspace source is missing from the package.",
),
);
}
}
if (issues.length > 0) return { ok: false, issues };
const summary = summarizeClawManifest(validated.manifest);
const implicitWorkspaceFile =
parsed.markdownBody === undefined
? undefined
: { path: "SOUL.md" as const, text: parsed.markdownBody };
return {
ok: true,
value: {
manifestPath,
manifest: validated.manifest,
...(implicitWorkspaceFile ? { implicitWorkspaceFile } : {}),
summary: implicitWorkspaceFile
? {
...summary,
workspace: {
...summary.workspace,
bootstrapFiles: [...summary.workspace.bootstrapFiles, "SOUL.md"],
},
}
: summary,
},
};
}
+904
View File
@@ -0,0 +1,904 @@
import { ArkErrors, type BaseType, type inferred, type } from "arktype";
import { Cron } from "croner";
import { caseFold } from "unicode-case-folding";
export const CLAW_SCHEMA_VERSION = 1 as const;
export const CLAW_SUMMARY_AGENT_NAME_MAX_CHARS = 128;
export const CLAW_SUMMARY_AGENT_DESCRIPTION_MAX_CHARS = 1_024;
export const CLAW_BOOTSTRAP_FILE_NAMES = [
"AGENTS.md",
"SOUL.md",
"IDENTITY.md",
"TOOLS.md",
"HEARTBEAT.md",
] as const;
const StringArraySchema = type("string[]");
const WorkspaceSourceSchema = type({ "+": "reject", source: "string" });
const ToolFilterSchema = type({
"+": "reject",
include: StringArraySchema.optional(),
exclude: StringArraySchema.optional(),
});
const StdioMcpServerSchema = type({
"+": "reject",
command: "string",
transport: '"stdio"?',
args: StringArraySchema.optional(),
env: type({ "[string]": "string" }).optional(),
toolFilter: ToolFilterSchema.optional(),
timeout: "number?",
connectTimeout: "number?",
});
const RemoteMcpServerSchema = type({
"+": "reject",
url: "string",
transport: '"sse"|"streamable-http"',
auth: '"oauth"?',
toolFilter: ToolFilterSchema.optional(),
timeout: "number?",
connectTimeout: "number?",
});
export const ClawManifestSchema = type({
"+": "reject",
schemaVersion: "1",
agent: {
"+": "reject",
id: "string",
name: "string?",
description: "string?",
identity: type({
"+": "reject",
name: "string?",
theme: "string?",
emoji: "string?",
avatar: "string?",
}).optional(),
},
metadata: type({ "[string]": "string" }).optional(),
workspace: type({
"+": "reject",
bootstrapFiles: type({
"+": "reject",
"AGENTS.md": WorkspaceSourceSchema.optional(),
"SOUL.md": WorkspaceSourceSchema.optional(),
"IDENTITY.md": WorkspaceSourceSchema.optional(),
"TOOLS.md": WorkspaceSourceSchema.optional(),
"HEARTBEAT.md": WorkspaceSourceSchema.optional(),
}).optional(),
files: type({ "+": "reject", source: "string", path: "string" }).array().optional(),
}).optional(),
packages: type({
"+": "reject",
kind: '"skill"|"plugin"',
source: '"clawhub"',
ref: "string",
version: "string",
})
.array()
.optional(),
mcpServers: type({
"[string]": StdioMcpServerSchema.or(RemoteMcpServerSchema),
}).optional(),
cronJobs: type({
"+": "reject",
id: "string",
name: "string?",
schedule: { "+": "reject", cron: "string", timezone: "string" },
session: '"main"|"isolated"',
message: "string",
delivery: type({
"+": "reject",
mode: '"none"|"announce"',
channel: '"last"?',
}).optional(),
})
.array()
.optional(),
});
export type ClawManifest = (typeof ClawManifestSchema)[inferred];
export type ClawManifestSummary = {
schemaVersion: 1;
agent: { id: string; name?: string; description?: string };
workspace: { bootstrapFiles: string[]; fileCount: number };
packages: { skillCount: number; pluginCount: number };
mcpServerCount: number;
cronJobCount: number;
};
export type ClawManifestSummarySchemaAdapter<TValue, TOptional = TValue> = {
literalOne: TValue;
string: TValue;
number: TValue;
stringArray: TValue;
boundedString: (maxCharacters: number) => TValue;
optional: (schema: TValue) => TOptional;
object: (fields: Record<string, TValue | TOptional>) => TValue;
};
/** Builds the v1 summary structure for both the public schema and durable storage validators. */
export function createClawManifestSummarySchema<TValue, TOptional = TValue>(
adapter: ClawManifestSummarySchemaAdapter<TValue, TOptional>,
): TValue {
return adapter.object({
schemaVersion: adapter.literalOne,
agent: adapter.object({
id: adapter.string,
name: adapter.optional(adapter.boundedString(CLAW_SUMMARY_AGENT_NAME_MAX_CHARS)),
description: adapter.optional(
adapter.boundedString(CLAW_SUMMARY_AGENT_DESCRIPTION_MAX_CHARS),
),
}),
workspace: adapter.object({
bootstrapFiles: adapter.stringArray,
fileCount: adapter.number,
}),
packages: adapter.object({
skillCount: adapter.number,
pluginCount: adapter.number,
}),
mcpServerCount: adapter.number,
cronJobCount: adapter.number,
});
}
export const ClawManifestSummarySchema = createClawManifestSummarySchema<BaseType, [BaseType, "?"]>(
{
literalOne: type("1"),
string: type("string"),
number: type("number"),
stringArray: type("string[]"),
boundedString: (maxCharacters) =>
type("string").narrow((value) => Array.from(value).length <= maxCharacters),
optional: (schema) => schema.optional(),
object: (fields) => type({ "+": "reject", ...fields }),
},
) as BaseType<ClawManifestSummary>;
export const CLAW_MANIFEST_VALIDATION_PHASE = "schema" as const;
export const CLAW_MANIFEST_VALIDATION_CODES = {
invalidManifestShape: "claw_v1_invalid_manifest_shape",
invalidAgentId: "claw_v1_invalid_agent_id",
nonCanonicalString: "claw_v1_non_canonical_string",
emptyList: "claw_v1_empty_list",
invalidProfilePath: "claw_v1_invalid_profile_path",
unsafePath: "claw_v1_unsafe_path",
duplicateWorkspaceDestination: "claw_v1_duplicate_workspace_destination",
invalidAvatar: "claw_v1_invalid_avatar",
undeclaredAvatar: "claw_v1_undeclared_avatar",
invalidPackageReference: "claw_v1_invalid_package_reference",
invalidPackageVersion: "claw_v1_invalid_package_version",
duplicatePackage: "claw_v1_duplicate_package",
invalidMcpServerId: "claw_v1_invalid_mcp_server_id",
invalidMcpUrl: "claw_v1_invalid_mcp_url",
mcpUrlCredentials: "claw_v1_mcp_url_credentials",
unpinnedMcpPackage: "claw_v1_unpinned_mcp_package",
invalidToolFilter: "claw_v1_invalid_tool_filter",
duplicateToolFilter: "claw_v1_duplicate_tool_filter",
invalidEnvironmentKey: "claw_v1_invalid_environment_key",
blockedEnvironmentKey: "claw_v1_blocked_environment_key",
invalidEnvironmentReference: "claw_v1_invalid_environment_reference",
invalidTimeout: "claw_v1_invalid_timeout",
invalidCronJobId: "claw_v1_invalid_cron_job_id",
invalidCronSchedule: "claw_v1_invalid_cron_schedule",
invalidCronDelivery: "claw_v1_invalid_cron_delivery",
duplicateCronJob: "claw_v1_duplicate_cron_job",
} as const;
export type ClawManifestValidationCode =
(typeof CLAW_MANIFEST_VALIDATION_CODES)[keyof typeof CLAW_MANIFEST_VALIDATION_CODES];
export type ClawManifestValidationIssue = {
code: ClawManifestValidationCode;
phase: typeof CLAW_MANIFEST_VALIDATION_PHASE;
path: string;
message: string;
};
const AGENT_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const EXACT_VERSION_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const ENV_REFERENCE_PATTERN = /^\$\{[A-Z_][A-Z0-9_]*\}$/;
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
const WINDOWS_INVALID_PATH_CHARS = /[<>:"|?*]/;
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const UNICODE_CONTROL_CHARACTER = /\p{Cc}/u;
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
const AVATAR_MAX_DATA_URL_CHARS = 2_796_230;
const AVATAR_DATA_URL_PATTERN = /^data:image\/[^,]*,/i;
const AVATAR_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|svg)$/i;
const BASE64_PAYLOAD_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const PORTABLE_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
// Versioned compatibility artifact for the OpenClaw policy applied by the v1 consumer.
export const OPENCLAW_CLAW_HOST_ENV_POLICY_V1 = {
contractVersion: 1,
source: {
repository: "openclaw/openclaw",
commit: "f50a918af102b950721c1b380f7eb8af4e0389d1",
path: "src/infra/host-env-security-policy.json",
sha256: "4639dcfa157d4fa4c2fb84f8c5347ffe2252eb4ddb3d818afd7f87d68173b92b",
},
blockedKeys: [
"NODE_OPTIONS",
"NODE_PATH",
"NODE_REDIRECT_WARNINGS",
"NODE_REPL_EXTERNAL_MODULE",
"NODE_REPL_HISTORY",
"NODE_V8_COVERAGE",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYLIB",
"RUBYOPT",
"BASHOPTS",
"BASH_ENV",
"ENV",
"KSH_ENV",
"BROWSER",
"GIT_ALLOW_PROTOCOL",
"GIT_EDITOR",
"GIT_EXTERNAL_DIFF",
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_COMMON_DIR",
"GIT_EXEC_PATH",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_NAMESPACE",
"GIT_PROTOCOL_FROM_USER",
"GIT_SEQUENCE_EDITOR",
"GIT_TEMPLATE_DIR",
"GIT_SSL_NO_VERIFY",
"GIT_SSL_CAINFO",
"GIT_SSL_CAPATH",
"CC",
"CPP",
"CXX",
"CARGO_BUILD_RUSTC",
"CARGO_BUILD_RUSTC_WRAPPER",
"CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
"CARGO_BUILD_RUSTDOC",
"RUSTC",
"RUSTC_WRAPPER",
"RUSTC_WORKSPACE_WRAPPER",
"RUSTDOC",
"CMAKE_C_COMPILER",
"CMAKE_CXX_COMPILER",
"SHELL",
"SHELLOPTS",
"PS4",
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
"JAVA_OPTS",
"JAVA_TOOL_OPTIONS",
"_JAVA_OPTIONS",
"JDK_JAVA_OPTIONS",
"PYTHONBREAKPOINT",
"DOTNET_STARTUP_HOOKS",
"DOTNET_ADDITIONAL_DEPS",
"FPATH",
"GLIBC_TUNABLES",
"MAVEN_OPTS",
"MAKE",
"MAKEFLAGS",
"MFLAGS",
"SBT_OPTS",
"GRADLE_OPTS",
"ANT_OPTS",
"HGRCPATH",
"HGEDITOR",
"HGMERGE",
"EXINIT",
"VIMINIT",
"MYVIMRC",
"GVIMINIT",
"LUA_INIT",
"LUA_INIT_5_1",
"LUA_INIT_5_2",
"LUA_INIT_5_3",
"LUA_INIT_5_4",
"EMACSLOADPATH",
"RUBYSHELL",
"GIT_HOOK_PATH",
"SVN_EDITOR",
"SVN_SSH",
"BZR_EDITOR",
"BZR_SSH",
"BZR_PLUGIN_PATH",
"SUDO_ASKPASS",
"JULIA_EDITOR",
"CONFIG_SITE",
"CONFIG_SHELL",
"CMAKE_TOOLCHAIN_FILE",
"CATALINA_OPTS",
"CORECLR_PROFILER",
"HELM_PLUGINS",
"PACKER_PLUGIN_PATH",
"VAGRANT_VAGRANTFILE",
"ERL_AFLAGS",
"ERL_FLAGS",
"ERL_ZFLAGS",
"ELIXIR_ERL_OPTIONS",
"R_ENVIRON",
"R_PROFILE",
"R_ENVIRON_USER",
"R_PROFILE_USER",
"TCLLIBPATH",
"HOSTALIASES",
],
blockedPrefixes: ["DYLD_", "LD_", "BASH_FUNC_"],
} as const;
const BLOCKED_PROCESS_ENV_KEYS = new Set<string>(OPENCLAW_CLAW_HOST_ENV_POLICY_V1.blockedKeys);
const BLOCKED_PROCESS_ENV_PREFIXES = OPENCLAW_CLAW_HOST_ENV_POLICY_V1.blockedPrefixes;
function isStrictNonEmpty(value: string): boolean {
return value.length > 0 && value === value.trim();
}
function isBlockedProcessEnvKey(value: string): boolean {
const upper = value.toUpperCase();
return (
BLOCKED_PROCESS_ENV_KEYS.has(upper) ||
BLOCKED_PROCESS_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix))
);
}
function isSafePackagePath(value: string): boolean {
const normalized = value.replaceAll("\\", "/");
if (
!isStrictNonEmpty(normalized) ||
normalized.startsWith("/") ||
/^[A-Za-z]:\//.test(normalized)
) {
return false;
}
return normalized
.split("/")
.every(
(segment) =>
segment !== "" &&
segment !== "." &&
segment !== ".." &&
!WINDOWS_INVALID_PATH_CHARS.test(segment) &&
!UNICODE_CONTROL_CHARACTER.test(segment) &&
!segment.endsWith(".") &&
!segment.endsWith(" ") &&
!WINDOWS_RESERVED_PATH_SEGMENT.test(segment),
);
}
function portablePathKey(value: string): string {
return caseFold(value.replaceAll("\\", "/").normalize("NFC"));
}
function isPortableAvatar(value: string): boolean {
if (AVATAR_DATA_URL_PATTERN.test(value)) {
if (value.length > AVATAR_MAX_DATA_URL_CHARS) return false;
const comma = value.indexOf(",");
if (comma < 0) return false;
const metadata = value.slice(0, comma);
const payload = value.slice(comma + 1);
try {
const base64 = /;base64(?:;|$)/i.test(metadata);
if (payload.length === 0 || (base64 && !BASE64_PAYLOAD_PATTERN.test(payload))) return false;
const byteLength = base64
? (payload.length / 4) * 3 - (payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0)
: new TextEncoder().encode(decodeURIComponent(payload)).byteLength;
return byteLength > 0 && byteLength <= AVATAR_MAX_BYTES;
} catch {
return false;
}
}
return isSafePackagePath(value) && AVATAR_EXTENSION_PATTERN.test(value);
}
function conflictsWithWorkspaceTarget(targets: Set<string>, candidate: string): boolean {
for (const target of targets) {
if (
target === candidate ||
target.startsWith(`${candidate}/`) ||
candidate.startsWith(`${target}/`)
) {
return true;
}
}
return false;
}
function packageManagerArtifacts(command: string, args: string[]): string[] | undefined {
const executable = command
.split(/[\\/]/)
.at(-1)
?.replace(/\.(?:cmd|exe)$/i, "")
.toLowerCase();
let start = 0;
let scansOptionsAfterPositional = false;
if (executable === "npm") {
if (args[0] !== "exec" && args[0] !== "x") return undefined;
start = 1;
scansOptionsAfterPositional = true;
} else if (executable === "bun") {
if (args[0] !== "x") return undefined;
start = 1;
} else if (executable === "pnpm" || executable === "yarn") {
if (args[0] !== "dlx") return undefined;
start = 1;
} else if (executable !== "npx" && executable !== "pnpx" && executable !== "bunx") {
return undefined;
}
const selected: string[] = [];
let positional: string | undefined;
for (let index = start; index < args.length; index += 1) {
const value = args[index];
if (!value) continue;
if (value === "--") {
positional ??= args[index + 1] ?? "";
break;
}
if (value === "-p" || value === "--package") {
selected.push(args[index + 1] ?? "");
index += 1;
continue;
}
if (value.startsWith("--package=")) {
selected.push(value.slice("--package=".length));
continue;
}
if (value === "-y" || value === "--yes") continue;
if (value.startsWith("-")) return [""];
if (positional === undefined && !value.startsWith("-")) {
positional = value;
if (!scansOptionsAfterPositional) break;
}
}
return selected.length > 0 ? selected : [positional ?? ""];
}
function truncateSummaryText(value: string | undefined, maxChars: number): string | undefined {
if (!value) return undefined;
return Array.from(value).slice(0, maxChars).join("");
}
function isPackageManagerArtifactPinned(command: string, args: string[]): boolean | undefined {
const artifacts = packageManagerArtifacts(command, args);
if (artifacts === undefined) return undefined;
return artifacts.every((artifact) => {
const separator = artifact.lastIndexOf("@");
const scopedSlash = artifact.startsWith("@") ? artifact.indexOf("/") : -1;
return (
separator > 0 &&
separator > scopedSlash &&
EXACT_VERSION_PATTERN.test(artifact.slice(separator + 1))
);
});
}
function validationIssue(
code: ClawManifestValidationCode,
path: string,
message: string,
): ClawManifestValidationIssue {
return { code, phase: CLAW_MANIFEST_VALIDATION_PHASE, path, message };
}
function arkIssues(errors: ArkErrors): ClawManifestValidationIssue[] {
return Array.from(errors, (error) =>
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidManifestShape,
error.path.length > 0 ? `$.${error.path.join(".")}` : "$",
error.description ?? "Manifest must match the strict Claw v1 shape.",
),
);
}
function pushNonEmpty(
issues: ClawManifestValidationIssue[],
path: string,
value: string | undefined,
): void {
if (value !== undefined && !isStrictNonEmpty(value)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.nonCanonicalString,
path,
"Must be non-empty without leading or trailing whitespace.",
),
);
}
}
function pushNonEmptyArray(
issues: ClawManifestValidationIssue[],
path: string,
values: string[] | undefined,
requireEntry: boolean,
): void {
if (requireEntry && values !== undefined && values.length === 0) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.emptyList,
path,
"Must contain at least one value.",
),
);
}
for (const [index, value] of (values ?? []).entries()) {
pushNonEmpty(issues, `${path}.${index}`, value);
}
}
export function validateClawManifest(
value: unknown,
): { ok: true; manifest: ClawManifest } | { ok: false; issues: ClawManifestValidationIssue[] } {
const parsed = ClawManifestSchema(value);
if (parsed instanceof ArkErrors) return { ok: false, issues: arkIssues(parsed) };
const issues: ClawManifestValidationIssue[] = [];
if (!AGENT_ID_PATTERN.test(parsed.agent.id)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidAgentId,
"$.agent.id",
"Invalid portable agent id.",
),
);
}
pushNonEmpty(issues, "$.agent.name", parsed.agent.name);
pushNonEmpty(issues, "$.agent.description", parsed.agent.description);
for (const field of ["name", "theme", "emoji", "avatar"] as const) {
pushNonEmpty(issues, `$.agent.identity.${field}`, parsed.agent.identity?.[field]);
}
for (const key of Object.keys(parsed.metadata ?? {})) {
pushNonEmpty(issues, `$.metadata.${key}`, key);
}
const openClawProfilePath = parsed.metadata?.["openclaw.config"];
if (
openClawProfilePath !== undefined &&
(openClawProfilePath.includes("\\") ||
!isSafePackagePath(openClawProfilePath) ||
!/\.ya?ml$/i.test(openClawProfilePath))
) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidProfilePath,
"$.metadata.openclaw.config",
"Must reference a forward-slash package-relative .yml or .yaml file.",
),
);
}
const workspaceTargets = new Set<string>();
for (const name of CLAW_BOOTSTRAP_FILE_NAMES) {
const source = parsed.workspace?.bootstrapFiles?.[name]?.source;
if (source === undefined) continue;
if (!isSafePackagePath(source)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.unsafePath,
`$.workspace.bootstrapFiles.${name}.source`,
"Must be a safe package-relative path.",
),
);
}
workspaceTargets.add(portablePathKey(name));
}
for (const [index, file] of (parsed.workspace?.files ?? []).entries()) {
if (!isSafePackagePath(file.source)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.unsafePath,
`$.workspace.files.${index}.source`,
"Must be a safe package-relative path.",
),
);
}
if (!isSafePackagePath(file.path)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.unsafePath,
`$.workspace.files.${index}.path`,
"Must be a safe package-relative path.",
),
);
}
const destinationKey = portablePathKey(file.path);
if (conflictsWithWorkspaceTarget(workspaceTargets, destinationKey)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.duplicateWorkspaceDestination,
`$.workspace.files.${index}.path`,
"Workspace destination is declared more than once.",
),
);
}
workspaceTargets.add(destinationKey);
}
const avatar = parsed.agent.identity?.avatar;
if (avatar !== undefined) {
if (!isPortableAvatar(avatar)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidAvatar,
"$.agent.identity.avatar",
"Must be a bounded image data URL or managed workspace-relative image path.",
),
);
} else if (
!AVATAR_DATA_URL_PATTERN.test(avatar) &&
!workspaceTargets.has(portablePathKey(avatar))
) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.undeclaredAvatar,
"$.agent.identity.avatar",
"Workspace-relative avatar must match a workspace.files destination.",
),
);
}
}
const packageKeys = new Set<string>();
for (const [index, pkg] of (parsed.packages ?? []).entries()) {
pushNonEmpty(issues, `$.packages.${index}.ref`, pkg.ref);
if (!PACKAGE_NAME_PATTERN.test(pkg.ref)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidPackageReference,
`$.packages.${index}.ref`,
"Must be a lowercase ClawHub package name.",
),
);
}
if (!EXACT_VERSION_PATTERN.test(pkg.version)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidPackageVersion,
`$.packages.${index}.version`,
"Must be an exact semantic version.",
),
);
}
const key = `${pkg.kind}:${pkg.source}:${pkg.ref.toLowerCase()}`;
if (packageKeys.has(key)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.duplicatePackage,
`$.packages.${index}`,
"Package is declared more than once.",
),
);
}
packageKeys.add(key);
}
for (const [name, server] of Object.entries(parsed.mcpServers ?? {})) {
if (!AGENT_ID_PATTERN.test(name)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidMcpServerId,
`$.mcpServers.${name}`,
"Invalid MCP server name.",
),
);
}
if ("url" in server) {
pushNonEmpty(issues, `$.mcpServers.${name}.url`, server.url);
try {
const url = new URL(server.url);
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
throw new Error("protocol");
}
if (url.username || url.password || url.hash) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.mcpUrlCredentials,
`$.mcpServers.${name}.url`,
"Must not contain embedded credentials or fragments.",
),
);
}
} catch {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidMcpUrl,
`$.mcpServers.${name}.url`,
"Must use HTTPS, except HTTP on an exact loopback host.",
),
);
}
}
if ("command" in server) {
pushNonEmpty(issues, `$.mcpServers.${name}.command`, server.command);
pushNonEmptyArray(issues, `$.mcpServers.${name}.args`, server.args, false);
if (isPackageManagerArtifactPinned(server.command, server.args ?? []) === false) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.unpinnedMcpPackage,
`$.mcpServers.${name}.args`,
"Package-manager MCP commands must select one exact immutable package version.",
),
);
}
}
pushNonEmptyArray(
issues,
`$.mcpServers.${name}.toolFilter.include`,
server.toolFilter?.include,
true,
);
pushNonEmptyArray(
issues,
`$.mcpServers.${name}.toolFilter.exclude`,
server.toolFilter?.exclude,
true,
);
for (const field of ["include", "exclude"] as const) {
const seen = new Set<string>();
for (const [index, entry] of (server.toolFilter?.[field] ?? []).entries()) {
if (entry.includes("?") || entry.includes("[") || entry.includes("]")) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidToolFilter,
`$.mcpServers.${name}.toolFilter.${field}.${index}`,
"Tool filters support only exact names and * wildcards.",
),
);
}
if (seen.has(entry)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.duplicateToolFilter,
`$.mcpServers.${name}.toolFilter.${field}.${index}`,
"Tool filter entries must be unique.",
),
);
}
seen.add(entry);
}
}
if ("env" in server) {
for (const [key, envValue] of Object.entries(server.env ?? {})) {
pushNonEmpty(issues, `$.mcpServers.${name}.env`, key);
if (!PORTABLE_ENV_KEY_PATTERN.test(key)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidEnvironmentKey,
`$.mcpServers.${name}.env.${key}`,
"Invalid portable environment key.",
),
);
}
if (isBlockedProcessEnvKey(key)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.blockedEnvironmentKey,
`$.mcpServers.${name}.env.${key}`,
"Environment key is blocked by the spawned-process safety policy.",
),
);
}
if (!ENV_REFERENCE_PATTERN.test(envValue)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidEnvironmentReference,
`$.mcpServers.${name}.env.${key}`,
"Must be an unresolved ${ENV_VAR} reference.",
),
);
}
}
}
if (server.timeout !== undefined && (!Number.isFinite(server.timeout) || server.timeout <= 0)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidTimeout,
`$.mcpServers.${name}.timeout`,
"Must be positive.",
),
);
}
if (
server.connectTimeout !== undefined &&
(!Number.isFinite(server.connectTimeout) || server.connectTimeout <= 0)
) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidTimeout,
`$.mcpServers.${name}.connectTimeout`,
"Must be positive.",
),
);
}
}
const cronIds = new Set<string>();
for (const [index, job] of (parsed.cronJobs ?? []).entries()) {
if (!AGENT_ID_PATTERN.test(job.id)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidCronJobId,
`$.cronJobs.${index}.id`,
"Invalid cron job id.",
),
);
}
pushNonEmpty(issues, `$.cronJobs.${index}.name`, job.name);
pushNonEmpty(issues, `$.cronJobs.${index}.schedule.cron`, job.schedule.cron);
pushNonEmpty(issues, `$.cronJobs.${index}.schedule.timezone`, job.schedule.timezone);
pushNonEmpty(issues, `$.cronJobs.${index}.message`, job.message);
try {
if (job.schedule.cron.trim().split(/\s+/).length !== 5) throw new Error("fields");
const cron = new Cron(job.schedule.cron.trim(), {
timezone: job.schedule.timezone.trim(),
catch: false,
paused: true,
});
if (!cron.nextRun(new Date())) throw new Error("No future run");
} catch {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidCronSchedule,
`$.cronJobs.${index}.schedule`,
"Must contain a valid cron expression and timezone.",
),
);
}
if (
(job.delivery?.mode === "none" && job.delivery.channel !== undefined) ||
(job.delivery?.mode === "announce" && job.delivery.channel !== "last")
) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidCronDelivery,
`$.cronJobs.${index}.delivery`,
'Must be { mode: "none" } or { mode: "announce", channel: "last" }.',
),
);
}
if (cronIds.has(job.id)) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.duplicateCronJob,
`$.cronJobs.${index}.id`,
"Cron job id is declared more than once.",
),
);
}
cronIds.add(job.id);
}
return issues.length > 0 ? { ok: false, issues } : { ok: true, manifest: parsed };
}
export function summarizeClawManifest(manifest: ClawManifest): ClawManifestSummary {
const packages = manifest.packages ?? [];
const agentName = truncateSummaryText(manifest.agent.name, CLAW_SUMMARY_AGENT_NAME_MAX_CHARS);
const agentDescription = truncateSummaryText(
manifest.agent.description,
CLAW_SUMMARY_AGENT_DESCRIPTION_MAX_CHARS,
);
return {
schemaVersion: CLAW_SCHEMA_VERSION,
agent: {
id: manifest.agent.id,
...(agentName ? { name: agentName } : {}),
...(agentDescription ? { description: agentDescription } : {}),
},
workspace: {
bootstrapFiles: CLAW_BOOTSTRAP_FILE_NAMES.filter(
(name) => manifest.workspace?.bootstrapFiles?.[name] !== undefined,
),
fileCount: manifest.workspace?.files?.length ?? 0,
},
packages: {
skillCount: packages.filter((pkg) => pkg.kind === "skill").length,
pluginCount: packages.filter((pkg) => pkg.kind === "plugin").length,
},
mcpServerCount: Object.keys(manifest.mcpServers ?? {}).length,
cronJobCount: manifest.cronJobs?.length ?? 0,
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { type inferred, type } from "arktype";
import { CliPublishFileSchema, PublishSourceSchema } from "./schemas.js";
export const PackageFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"');
export const PackageFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"|"claw"');
export type PackageFamily = (typeof PackageFamilySchema)[inferred];
export const PackageChannelSchema = type('"official"|"community"|"private"');
+36
View File
@@ -0,0 +1,36 @@
import { type ClawManifest, type ClawManifestSummary } from "./claws.js";
export type ClawPackageTextFile = {
path: string;
text?: string;
};
export type ClawPackageValidationIssue = {
code: string;
path: string;
message: string;
};
export type ValidatedClawPackage = {
manifestPath: string;
manifest: ClawManifest;
implicitWorkspaceFile?: {
path: "SOUL.md";
text: string;
};
summary: ClawManifestSummary;
};
export declare function isSafeClawPackagePath(value: string): boolean;
export declare function findClawPackagePathHierarchyCollision(paths: readonly string[]): {
ancestor: string;
descendant: string;
} | null;
export declare function validateClawPackageContents(input: {
packageName: string;
version: string;
packageJson: unknown;
files: readonly ClawPackageTextFile[];
}): {
ok: true;
value: ValidatedClawPackage;
} | {
ok: false;
issues: ClawPackageValidationIssue[];
};
+468
View File
@@ -0,0 +1,468 @@
import { ArkErrors, type } from "arktype";
import { caseFold } from "unicode-case-folding";
import { isScalar, parseDocument, visit } from "yaml";
import { summarizeClawManifest, validateClawManifest, } from "./claws.js";
const EXACT_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const WINDOWS_INVALID_PATH_CHARS = /[<>:"|?*]/;
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const UNICODE_CONTROL_CHARACTER = /\p{Cc}/u;
const MAX_CLAW_MANIFEST_BYTES = 1024 * 1024;
const MAX_OPENCLAW_PROFILE_BYTES = 256 * 1024;
const OPENCLAW_TOOL_PROFILE_IDS = new Set(["minimal", "coding", "messaging", "full"]);
const StrictStringArraySchema = type("string[]");
const OpenClawProfileSchema = type({
"+": "reject",
schemaVersion: "1",
agent: {
"+": "reject",
groupChat: type({
"+": "reject",
mentionPatterns: StrictStringArraySchema.optional(),
}).optional(),
sandbox: type({
"+": "reject",
mode: '"off"|"non-main"|"all"?',
scope: '"session"|"agent"|"shared"?',
workspaceAccess: '"none"|"ro"|"rw"?',
}).optional(),
tools: type({
"+": "reject",
profile: "string?",
allow: StrictStringArraySchema.optional(),
alsoAllow: StrictStringArraySchema.optional(),
deny: StrictStringArraySchema.optional(),
fs: type({
"+": "reject",
workspaceOnly: "true?",
}).optional(),
}).optional(),
memory: type({
"+": "reject",
search: type({
"+": "reject",
enabled: "boolean?",
rememberAcrossConversations: "boolean?",
sources: type("('memory' | 'sessions')[]").optional(),
}).optional(),
}).optional(),
heartbeat: type({
"+": "reject",
every: "string?",
activeHours: type({
"+": "reject",
start: "string?",
end: "string?",
timezone: "string?",
}).optional(),
lightContext: "boolean?",
isolatedSession: "boolean?",
timeoutSeconds: "number?",
}).optional(),
humanDelay: type({
"+": "reject",
mode: '"off"|"natural"|"custom"?',
minMs: "number?",
maxMs: "number?",
}).optional(),
},
});
function isRecord(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function isSafeClawPackagePath(value) {
const normalized = value.replaceAll("\\", "/");
if (!normalized ||
normalized !== value ||
normalized !== normalized.trim() ||
normalized.startsWith("/") ||
/^[A-Za-z]:\//.test(normalized)) {
return false;
}
return normalized
.split("/")
.every((segment) => segment !== "" &&
segment !== "." &&
segment !== ".." &&
!WINDOWS_INVALID_PATH_CHARS.test(segment) &&
!UNICODE_CONTROL_CHARACTER.test(segment) &&
!segment.endsWith(".") &&
!segment.endsWith(" ") &&
!WINDOWS_RESERVED_PATH_SEGMENT.test(segment));
}
function portablePathKey(value) {
return caseFold(value.replaceAll("\\", "/").normalize("NFC"));
}
export function findClawPackagePathHierarchyCollision(paths) {
const pathByKey = new Map(paths.map((path) => [portablePathKey(path), path]));
for (const descendant of paths) {
const segments = portablePathKey(descendant).split("/");
for (let length = 1; length < segments.length; length += 1) {
const ancestor = pathByKey.get(segments.slice(0, length).join("/"));
if (ancestor !== undefined) {
return { ancestor, descendant };
}
}
}
return null;
}
function issue(code, path, message) {
return { code, path, message };
}
function parseJsonCompatibleYaml(raw, path) {
const document = parseDocument(raw.startsWith("\uFEFF") ? raw.slice(1) : raw, {
prettyErrors: false,
uniqueKeys: true,
});
if (document.errors.length > 0) {
return {
issues: document.errors.map((error) => issue("invalid_openclaw_profile", path, error.message)),
};
}
let unsupportedFeature;
visit(document, {
Alias() {
unsupportedFeature ??= "aliases";
},
Node(_key, node) {
if (node.anchor) {
unsupportedFeature ??= "anchors";
}
else if (node.tag) {
unsupportedFeature ??= "explicit tags";
}
},
Pair(_key, pair) {
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
unsupportedFeature ??= "non-string mapping keys";
}
else if (pair.key.value === "<<") {
unsupportedFeature ??= "merge keys";
}
},
});
if (unsupportedFeature) {
return {
issues: [
issue("unsupported_openclaw_profile_yaml_feature", path, `${path} uses ${unsupportedFeature}; OpenClaw profile YAML must map directly to JSON data.`),
],
};
}
try {
return { value: document.toJSON() };
}
catch (error) {
return {
issues: [
issue("invalid_openclaw_profile", path, error instanceof Error ? error.message : "Could not parse OpenClaw profile."),
],
};
}
}
function isStrictNonEmpty(value) {
return value.length > 0 && value === value.trim();
}
function isValidDuration(value) {
if (!isStrictNonEmpty(value))
return false;
const multipliers = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
const normalized = value.toLowerCase();
const single = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(normalized);
if (single) {
return Number.isSafeInteger(Math.round(Number(single[1]) * multipliers[single[2] ?? "m"]));
}
let totalMs = 0;
let consumed = 0;
for (const match of normalized.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h|d)/g)) {
if (match.index !== consumed)
return false;
totalMs += Number(match[1]) * multipliers[match[2]];
consumed += match[0].length;
}
return (consumed === normalized.length && consumed > 0 && Number.isSafeInteger(Math.round(totalMs)));
}
function validateOpenClawProfile(value, profilePath) {
const parsed = OpenClawProfileSchema(value);
if (parsed instanceof ArkErrors) {
return Array.from(parsed, (error) => issue("invalid_openclaw_profile", `${profilePath}${error.path.length > 0 ? `.${error.path.join(".")}` : ""}`, error.description ?? "Invalid value."));
}
const issues = [];
const add = (path, message) => issues.push(issue("invalid_openclaw_profile", `${profilePath}.${path}`, message));
const requireNonEmpty = (path, values) => {
if (values !== undefined && values.length === 0)
add(path, "Must contain at least one value.");
for (const [index, entry] of (values ?? []).entries()) {
if (!isStrictNonEmpty(entry)) {
add(`${path}.${index}`, "Must be non-empty without leading or trailing whitespace.");
}
}
};
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent.groupChat?.mentionPatterns);
if (parsed.agent.tools?.profile !== undefined && !isStrictNonEmpty(parsed.agent.tools.profile)) {
add("agent.tools.profile", "Must be non-empty without leading or trailing whitespace.");
}
else if (parsed.agent.tools?.profile !== undefined &&
!OPENCLAW_TOOL_PROFILE_IDS.has(parsed.agent.tools.profile)) {
add("agent.tools.profile", "Must name a registered OpenClaw built-in profile.");
}
requireNonEmpty("agent.tools.allow", parsed.agent.tools?.allow);
requireNonEmpty("agent.tools.alsoAllow", parsed.agent.tools?.alsoAllow);
requireNonEmpty("agent.tools.deny", parsed.agent.tools?.deny);
if (parsed.agent.tools?.allow && parsed.agent.tools.alsoAllow) {
add("agent.tools.alsoAllow", "Must not be combined with tools.allow.");
}
if (parsed.agent.memory?.search?.sources?.length === 0) {
add("agent.memory.search.sources", "Must contain at least one source.");
}
if (parsed.agent.memory?.search?.sources?.includes("sessions") &&
parsed.agent.memory.search.rememberAcrossConversations !== true) {
add("agent.memory.search.rememberAcrossConversations", "Must be true when memory.search.sources includes sessions.");
}
const heartbeat = parsed.agent.heartbeat;
if (heartbeat?.every !== undefined && !isValidDuration(heartbeat.every)) {
add("agent.heartbeat.every", "Must be a valid duration.");
}
const activeHours = heartbeat?.activeHours;
if (activeHours?.start !== undefined && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(activeHours.start)) {
add("agent.heartbeat.activeHours.start", "Must be a valid 24-hour start time.");
}
if (activeHours?.end !== undefined &&
!/^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/.test(activeHours.end)) {
add("agent.heartbeat.activeHours.end", "Must be a valid 24-hour end time.");
}
if (activeHours?.timezone !== undefined) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: activeHours.timezone }).format();
}
catch {
add("agent.heartbeat.activeHours.timezone", "Must be a valid IANA timezone.");
}
}
if (heartbeat?.timeoutSeconds !== undefined &&
(!Number.isInteger(heartbeat.timeoutSeconds) || heartbeat.timeoutSeconds <= 0)) {
add("agent.heartbeat.timeoutSeconds", "Must be a positive integer.");
}
for (const field of ["minMs", "maxMs"]) {
const delay = parsed.agent.humanDelay?.[field];
if (delay !== undefined && (!Number.isInteger(delay) || delay < 0)) {
add(`agent.humanDelay.${field}`, "Must be a nonnegative integer.");
}
}
return issues;
}
function parseManifestDocument(raw, manifestPath) {
const filename = manifestPath.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
if (filename === "claw.md") {
const markdown = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) {
return {
issues: [
issue("missing_claw_frontmatter", manifestPath, `${manifestPath} must start with YAML frontmatter delimited by --- lines.`),
],
};
}
const document = parseDocument(match[1], { prettyErrors: false, uniqueKeys: true });
if (document.errors.length > 0) {
return {
issues: document.errors.map((error) => issue("invalid_claw_frontmatter", manifestPath, error.message)),
};
}
let unsupportedFeature;
visit(document, {
Alias() {
unsupportedFeature ??= "aliases";
},
Node(_key, node) {
if (node.anchor) {
unsupportedFeature ??= "anchors";
}
else if (node.tag) {
unsupportedFeature ??= "explicit tags";
}
},
Pair(_key, pair) {
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
unsupportedFeature ??= "non-string mapping keys";
}
else if (pair.key.value === "<<") {
unsupportedFeature ??= "merge keys";
}
},
});
if (unsupportedFeature) {
return {
issues: [
issue("unsupported_claw_yaml_feature", manifestPath, `${manifestPath} uses ${unsupportedFeature}; CLAW.md frontmatter must map directly to JSON data.`),
],
};
}
try {
return {
value: document.toJSON(),
markdownBody: markdown.slice(match[0].length),
};
}
catch (error) {
return {
issues: [
issue("invalid_claw_frontmatter", manifestPath, error instanceof Error ? error.message : "Could not parse Claw frontmatter."),
],
};
}
}
try {
return { value: JSON.parse(raw) };
}
catch (error) {
return {
issues: [
issue("invalid_claw_json", manifestPath, error instanceof Error ? error.message : "Could not parse Claw JSON."),
],
};
}
}
export function validateClawPackageContents(input) {
const issues = [];
if (!isRecord(input.packageJson)) {
return {
ok: false,
issues: [
issue("missing_package_json", "package.json", "Claw packages require package.json."),
],
};
}
const declaredName = typeof input.packageJson.name === "string" ? input.packageJson.name : "";
const declaredVersion = typeof input.packageJson.version === "string" ? input.packageJson.version : "";
const openclaw = isRecord(input.packageJson.openclaw) ? input.packageJson.openclaw : undefined;
const manifestPath = typeof openclaw?.claw === "string" ? openclaw.claw : "";
if (declaredName !== input.packageName) {
issues.push(issue("package_name_mismatch", "package.json.name", `Must match ${input.packageName}.`));
}
if (!EXACT_VERSION_PATTERN.test(declaredVersion) || declaredVersion !== input.version) {
issues.push(issue("package_version_mismatch", "package.json.version", `Must be exact semver ${input.version}.`));
}
if (!manifestPath || !isSafeClawPackagePath(manifestPath)) {
issues.push(issue("invalid_claw_manifest_path", "package.json.openclaw.claw", "Must name a safe package-relative Claw manifest path."));
}
if (issues.length > 0)
return { ok: false, issues };
const fileByPath = new Map();
const portablePaths = new Set();
for (const file of input.files) {
if (!isSafeClawPackagePath(file.path)) {
issues.push(issue("invalid_package_path", file.path, "Package files must use safe canonical package-relative paths."));
continue;
}
const key = portablePathKey(file.path);
if (portablePaths.has(key)) {
issues.push(issue("duplicate_portable_path", file.path, "Package paths must be unique across supported filesystems."));
}
else {
portablePaths.add(key);
fileByPath.set(file.path, file);
}
}
const hierarchyCollision = findClawPackagePathHierarchyCollision([...fileByPath.keys()]);
if (hierarchyCollision) {
issues.push(issue("portable_path_hierarchy_collision", hierarchyCollision.descendant, `Package file ${hierarchyCollision.ancestor} cannot also be an ancestor of ${hierarchyCollision.descendant}.`));
}
const manifestFile = fileByPath.get(manifestPath);
if (!manifestFile || manifestFile.text === undefined) {
issues.push(issue("missing_claw_manifest", manifestPath, "The declared Claw manifest is missing or is not UTF-8 text."));
return { ok: false, issues };
}
if (new TextEncoder().encode(manifestFile.text).byteLength > MAX_CLAW_MANIFEST_BYTES) {
return {
ok: false,
issues: [
issue("claw_manifest_too_large", manifestPath, `The Claw manifest exceeds ${MAX_CLAW_MANIFEST_BYTES} bytes.`),
],
};
}
const parsed = parseManifestDocument(manifestFile.text, manifestPath);
if (parsed.issues)
return { ok: false, issues: parsed.issues };
if (parsed.markdownBody !== undefined && parsed.markdownBody.trim().length === 0) {
return {
ok: false,
issues: [
issue("empty_claw_body", manifestPath, "CLAW.md must contain a non-empty Markdown body."),
],
};
}
const validated = validateClawManifest(parsed.value);
if (!validated.ok) {
return {
ok: false,
issues: validated.issues.map((entry) => issue("invalid_claw_manifest", entry.path, entry.message)),
};
}
if (parsed.markdownBody !== undefined &&
(validated.manifest.workspace?.bootstrapFiles?.["SOUL.md"] !== undefined ||
(validated.manifest.workspace?.files ?? []).some((entry) => portablePathKey(entry.path) === portablePathKey("SOUL.md")))) {
return {
ok: false,
issues: [
issue("duplicate_claw_body_destination", "SOUL.md", "The CLAW.md body is the managed SOUL.md source and cannot be combined with an explicit SOUL.md declaration."),
],
};
}
const openClawProfilePath = validated.manifest.metadata?.["openclaw.config"];
if (openClawProfilePath !== undefined) {
const profileFile = fileByPath.get(openClawProfilePath);
if (!profileFile || profileFile.text === undefined) {
issues.push(issue("missing_openclaw_profile", openClawProfilePath, "The declared OpenClaw profile is missing or is not UTF-8 text."));
}
else if (new TextEncoder().encode(profileFile.text).byteLength > MAX_OPENCLAW_PROFILE_BYTES) {
issues.push(issue("openclaw_profile_too_large", openClawProfilePath, `The OpenClaw profile exceeds ${MAX_OPENCLAW_PROFILE_BYTES} bytes.`));
}
else {
const profile = parseJsonCompatibleYaml(profileFile.text, openClawProfilePath);
if (profile.issues) {
issues.push(...profile.issues);
}
else {
issues.push(...validateOpenClawProfile(profile.value, openClawProfilePath));
}
}
}
const sources = [
...Object.values(validated.manifest.workspace?.bootstrapFiles ?? {}).map((entry) => entry.source),
...(validated.manifest.workspace?.files ?? []).map((entry) => entry.source),
];
for (const source of sources) {
if (!fileByPath.has(source)) {
issues.push(issue("missing_workspace_source", source, "Declared workspace source is missing from the package."));
}
}
if (issues.length > 0)
return { ok: false, issues };
const summary = summarizeClawManifest(validated.manifest);
const implicitWorkspaceFile = parsed.markdownBody === undefined
? undefined
: { path: "SOUL.md", text: parsed.markdownBody };
return {
ok: true,
value: {
manifestPath,
manifest: validated.manifest,
...(implicitWorkspaceFile ? { implicitWorkspaceFile } : {}),
summary: implicitWorkspaceFile
? {
...summary,
workspace: {
...summary.workspace,
bootstrapFiles: [...summary.workspace.bootstrapFiles, "SOUL.md"],
},
}
: summary,
},
};
}
//# sourceMappingURL=clawPackage.js.map
File diff suppressed because one or more lines are too long
+1
View File
@@ -1,6 +1,7 @@
export type { ArkValidator } from "./ark.js";
export { formatArkErrors, parseArk } from "./ark.js";
export * from "./claws.js";
export * from "./clawPackage.js";
export * from "./catalogFeed.js";
export * from "./catalogMetadata.js";
export * from "./docsLinks.js";
+1
View File
@@ -1,5 +1,6 @@
export { formatArkErrors, parseArk } from "./ark.js";
export * from "./claws.js";
export * from "./clawPackage.js";
export * from "./catalogFeed.js";
export * from "./catalogMetadata.js";
export * from "./docsLinks.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,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,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"}
+3 -3
View File
@@ -10,7 +10,7 @@ export declare function getPackageScopeOwnerMismatch(name: string, ownerHandle:
} | null;
export declare const PackageFamilySchema: import("arktype/internal/variants/string.ts").StringType<"bundle-plugin" | "claw" | "code-plugin" | "skill", {}>;
export type PackageFamily = (typeof PackageFamilySchema)[inferred];
export declare const PackagePublishFamilySchema: import("arktype/internal/variants/string.ts").StringType<"bundle-plugin" | "code-plugin" | "skill", {}>;
export declare const PackagePublishFamilySchema: import("arktype/internal/variants/string.ts").StringType<"bundle-plugin" | "claw" | "code-plugin" | "skill", {}>;
export type PackagePublishFamily = (typeof PackagePublishFamilySchema)[inferred];
export declare const PackageChannelSchema: import("arktype/internal/variants/string.ts").StringType<"community" | "official" | "private", {}>;
export type PackageChannel = (typeof PackageChannelSchema)[inferred];
@@ -265,7 +265,7 @@ export declare const PackagePublishMetadataSchema: import("arktype/internal/vari
name: string;
displayName?: string | undefined;
ownerHandle?: string | undefined;
family: "bundle-plugin" | "code-plugin" | "skill";
family: "bundle-plugin" | "claw" | "code-plugin" | "skill";
version: string;
changelog: string;
manualOverrideReason?: string | undefined;
@@ -293,7 +293,7 @@ export declare const ServerPackagePublishRequestSchema: import("arktype/internal
name: string;
displayName?: string | undefined;
ownerHandle?: string | undefined;
family: "bundle-plugin" | "code-plugin" | "skill";
family: "bundle-plugin" | "claw" | "code-plugin" | "skill";
version: string;
changelog: string;
manualOverrideReason?: string | undefined;
+1 -2
View File
@@ -23,8 +23,7 @@ export function getPackageScopeOwnerMismatch(name, ownerHandle) {
};
}
export const PackageFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"|"claw"');
// Publication stays narrower until a family has a complete validation path.
export const PackagePublishFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"');
export const PackagePublishFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"|"claw"');
export const PackageChannelSchema = type('"official"|"community"|"private"');
export const PackageVerificationTierSchema = type('"structural"|"source-linked"|"provenance-verified"|"rebuild-verified"');
export const PackageVerificationScopeSchema = type('"artifact-only"|"dependency-graph-aware"');
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -32,7 +32,8 @@
"dependencies": {
"arktype": "2.2.3",
"croner": "10.0.1",
"unicode-case-folding": "1.1.1"
"unicode-case-folding": "1.1.1",
"yaml": "2.9.0"
},
"devDependencies": {
"@typescript/native": "npm:typescript@7.0.2",
+630
View File
@@ -0,0 +1,630 @@
import { describe, expect, it } from "vitest";
import {
findClawPackagePathHierarchyCollision,
validateClawPackageContents,
} from "./clawPackage.js";
const manifest = {
schemaVersion: 1,
agent: { id: "github-triage", name: "GitHub Triage", description: "Reviews issues." },
workspace: {
bootstrapFiles: {},
files: [{ source: "workspace/reference.md", path: "reference.md" }],
},
packages: [{ kind: "skill", source: "clawhub", ref: "@acme/triage", version: "1.2.0" }],
mcpServers: {},
cronJobs: [],
};
const openClawProfile = [
"schemaVersion: 1",
"agent:",
" groupChat:",
" mentionPatterns: ['@triage']",
" sandbox: { mode: non-main, scope: agent, workspaceAccess: rw }",
" tools:",
" profile: coding",
" alsoAllow: [cron]",
" deny: [gateway]",
" fs: { workspaceOnly: true }",
" memory:",
" search:",
" enabled: true",
" rememberAcrossConversations: true",
" sources: [memory, sessions]",
" heartbeat:",
" every: 30m",
" activeHours: { start: '09:00', end: '24:00', timezone: America/Los_Angeles }",
" lightContext: true",
" isolatedSession: false",
" timeoutSeconds: 30",
" humanDelay: { mode: custom, minMs: 0, maxMs: 2000 }",
].join("\n");
function packageJson(claw = "CLAW.md") {
return {
name: "@acme/github-triage",
version: "1.0.0",
openclaw: { claw },
};
}
function files(manifestText = `---\n${JSON.stringify(manifest)}\n---\n# GitHub Triage\n`) {
return [
{ path: "package.json", text: JSON.stringify(packageJson()) },
{ path: "CLAW.md", text: manifestText },
{ path: "workspace/SOUL.md", text: "Be precise.\n" },
{ path: "workspace/reference.md", text: "Reference\n" },
];
}
describe("validateClawPackageContents", () => {
it("finds hierarchy collisions even when another path sorts between them", () => {
expect(findClawPackagePathHierarchyCollision(["a", "a-0", "a/child"])).toEqual({
ancestor: "a",
descendant: "a/child",
});
});
it("finds hierarchy collisions after full Unicode case folding", () => {
expect(findClawPackagePathHierarchyCollision(["Straße", "STRASSE/child"])).toEqual({
ancestor: "Straße",
descendant: "STRASSE/child",
});
});
it("validates CLAW.md and derives its safe summary", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files(),
});
expect(result).toEqual({
ok: true,
value: expect.objectContaining({
manifestPath: "CLAW.md",
implicitWorkspaceFile: {
path: "SOUL.md",
text: "# GitHub Triage\n",
},
summary: expect.objectContaining({
agent: {
id: "github-triage",
name: "GitHub Triage",
description: "Reviews issues.",
},
packages: { skillCount: 1, pluginCount: 0 },
workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 1 },
}),
}),
});
});
it("accepts one UTF-8 BOM before CLAW.md frontmatter", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files(`\uFEFF---\n${JSON.stringify(manifest)}\n---\n# GitHub Triage\n`),
});
expect(result.ok).toBe(true);
});
it.each(["", "\n \t\n"])("rejects an empty CLAW.md body", (body) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files(`---\n${JSON.stringify(manifest)}\n---\n${body}`),
});
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code: "empty_claw_body", path: "CLAW.md" })],
});
});
it("rejects a CLAW.md envelope larger than 1 MiB", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files(`---\n${JSON.stringify(manifest)}\n---\n${"x".repeat(1024 * 1024)}`),
});
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code: "claw_manifest_too_large", path: "CLAW.md" })],
});
});
it.each([
[
"bootstrap source",
{
...manifest,
workspace: {
...manifest.workspace,
bootstrapFiles: { "SOUL.md": { source: "workspace/SOUL.md" } },
},
},
],
[
"case-folded supporting-file destination",
{
...manifest,
workspace: {
...manifest.workspace,
files: [{ source: "workspace/reference.md", path: "soul.md" }],
},
},
],
])("rejects an explicit SOUL.md %s beside the CLAW.md body", (_label, bodyManifest) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files(`---\n${JSON.stringify(bodyManifest)}\n---\n# Prompt\n`),
});
expect(result).toEqual({
ok: false,
issues: [
expect.objectContaining({ code: "duplicate_claw_body_destination", path: "SOUL.md" }),
],
});
});
it("validates a package-local OpenClaw profile without returning it", () => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
{ path: "profiles/openclaw.yml", text: openClawProfile },
],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).not.toHaveProperty("profile");
expect(result.value.manifest.agent).toEqual(profiledManifest.agent);
}
});
it("rejects an unregistered OpenClaw tool profile", () => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
{
path: "profiles/openclaw.yml",
text: "schemaVersion: 1\nagent:\n tools:\n profile: future-profile",
},
],
});
expect(result).toEqual({
ok: false,
issues: [
expect.objectContaining({
code: "invalid_openclaw_profile",
path: "profiles/openclaw.yml.agent.tools.profile",
}),
],
});
});
it("requires the exact UTF-8 profile target", () => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
{ path: "profiles/OPENCLAW.yml", text: openClawProfile },
],
});
expect(result).toEqual({
ok: false,
issues: [
expect.objectContaining({
code: "missing_openclaw_profile",
path: "profiles/openclaw.yml",
}),
],
});
});
it("enforces the shared OpenClaw host environment policy through package validation", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files(
`---\n${JSON.stringify({
...manifest,
mcpServers: {
unsafe: { command: "server", env: { CPP: "${CPP}" } },
},
})}\n---\n# Prompt\n`,
),
});
expect(result).toEqual({
ok: false,
issues: [
expect.objectContaining({
code: "invalid_claw_manifest",
path: "$.mcpServers.unsafe.env.CPP",
}),
],
});
});
it.each([
["malformed YAML", "schemaVersion: [", "invalid_openclaw_profile"],
["duplicate keys", "schemaVersion: 1\nschemaVersion: 1\nagent: {}", "invalid_openclaw_profile"],
[
"aliases",
"schemaVersion: 1\nagent: &agent {}\ncopy: *agent",
"unsupported_openclaw_profile_yaml_feature",
],
["anchors", "schemaVersion: 1\nagent: &agent {}", "unsupported_openclaw_profile_yaml_feature"],
[
"merge keys",
"schemaVersion: 1\nagent:\n <<: { tools: {} }",
"unsupported_openclaw_profile_yaml_feature",
],
[
"explicit tags",
"schemaVersion: 1\nagent: !!map {}",
"unsupported_openclaw_profile_yaml_feature",
],
[
"non-string mapping keys",
"schemaVersion: 1\nagent:\n ? [tools]\n : {}",
"unsupported_openclaw_profile_yaml_feature",
],
])("rejects OpenClaw profile %s", (_label, text, code) => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
{ path: "profiles/openclaw.yml", text },
],
});
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code })],
});
});
it("rejects an oversized OpenClaw profile", () => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
{ path: "profiles/openclaw.yml", text: `#${"x".repeat(256 * 1024)}` },
],
});
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code: "openclaw_profile_too_large" })],
});
});
it.each([
["unknown version", "schemaVersion: 2\nagent: {}"],
["unknown field", "schemaVersion: 1\nagent:\n model: gpt-5"],
[
"unsupported heartbeat field",
"schemaVersion: 1\nagent:\n heartbeat:\n skipWhenBusy: true",
],
[
"workspaceOnly false",
"schemaVersion: 1\nagent:\n tools:\n fs:\n workspaceOnly: false",
],
[
"allow conflict",
"schemaVersion: 1\nagent:\n tools:\n allow: [read]\n alsoAllow: [write]",
],
[
"sessions without consent",
"schemaVersion: 1\nagent:\n memory:\n search:\n sources: [sessions]",
],
])("rejects OpenClaw profile with %s", (_label, text) => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
{ path: "profiles/openclaw.yml", text },
],
});
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code: "invalid_openclaw_profile" })],
});
});
it.each([
["anchor", "agent: &agent { id: github-triage }"],
["alias", "agent: { id: &id github-triage, name: *id }"],
["merge key", "agent: { <<: { id: github-triage } }"],
["explicit tag", "agent: { id: !!str github-triage }"],
["non-string mapping key", "agent: { id: github-triage, true: nope }"],
])("rejects CLAW.md YAML %s", (_label, declaration) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files(
[
"---",
"schemaVersion: 1",
declaration,
"workspace: {}",
"packages: []",
"mcpServers: {}",
"cronJobs: []",
"---",
].join("\n"),
),
});
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code: "unsupported_claw_yaml_feature" })],
});
});
it("does not treat a suffix-only filename as CLAW.md", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson("NOTCLAW.md"),
files: [
...files().filter((file) => file.path !== "CLAW.md"),
{ path: "NOTCLAW.md", text: `---\n${JSON.stringify(manifest)}\n---\n` },
],
});
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code: "invalid_claw_json" })],
});
});
it("accepts the JSON compatibility manifest", () => {
const jsonManifest = {
...manifest,
workspace: {
...manifest.workspace,
bootstrapFiles: { "SOUL.md": { source: "workspace/SOUL.md" } },
},
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson("openclaw.claw.json"),
files: [
...files().filter((file) => file.path !== "CLAW.md"),
{ path: "openclaw.claw.json", text: JSON.stringify(jsonManifest) },
],
});
expect(result.ok).toBe(true);
if (result.ok) expect(result.value).not.toHaveProperty("implicitWorkspaceFile");
});
it("requires package identity to match the publication", () => {
const result = validateClawPackageContents({
packageName: "@acme/other",
version: "2.0.0",
packageJson: packageJson(),
files: files(),
});
expect(result).toEqual({
ok: false,
issues: expect.arrayContaining([
expect.objectContaining({ code: "package_name_mismatch" }),
expect.objectContaining({ code: "package_version_mismatch" }),
]),
});
});
it.each([
["name", { ...packageJson(), name: " @acme/github-triage" }],
["version", { ...packageJson(), version: "1.0.0 " }],
["manifest path", packageJson(" CLAW.md")],
])("does not trim padded package %s into validity", (_label, metadata) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: metadata,
files: files(),
});
expect(result.ok).toBe(false);
});
it("rejects missing declared workspace sources", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files().filter((file) => file.path !== "workspace/reference.md"),
});
expect(result).toEqual({
ok: false,
issues: [
expect.objectContaining({
code: "missing_workspace_source",
path: "workspace/reference.md",
}),
],
});
});
it("rejects duplicate paths that collide on portable filesystems", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [...files(), { path: "claw.md", text: "duplicate" }],
});
expect(result).toEqual({
ok: false,
issues: expect.arrayContaining([
expect.objectContaining({ code: "duplicate_portable_path" }),
]),
});
});
it.each([
["ancestor first", [{ path: "workspace", text: "file" }, ...files()]],
["descendant first", [...files(), { path: "workspace", text: "file" }]],
["case-folded ancestor", [...files(), { path: "WORKSPACE", text: "file" }]],
[
"Unicode-normalized ancestor",
[
...files(),
{ path: "cafe\u0301/reference.md", text: "child" },
{ path: "caf\u00e9", text: "file" },
],
],
])("rejects package file/ancestor collisions with %s", (_label, packageFiles) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: packageFiles,
});
expect(result).toEqual({
ok: false,
issues: expect.arrayContaining([
expect.objectContaining({ code: "portable_path_hierarchy_collision" }),
]),
});
});
it.each(["CON", "workspace/trailing.", "workspace\\backslash.md"])(
"rejects unsafe unreferenced package path %s",
(path) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [...files(), { path, text: "unused" }],
});
expect(result).toEqual({
ok: false,
issues: expect.arrayContaining([expect.objectContaining({ code: "invalid_package_path" })]),
});
},
);
it.each([
["DEL", "\u007f"],
["C1", "\u0085"],
])("rejects %s control characters in package paths", (_label, controlCharacter) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [...files(), { path: `workspace/a${controlCharacter}.md`, text: "unused" }],
});
expect(result).toEqual({
ok: false,
issues: expect.arrayContaining([expect.objectContaining({ code: "invalid_package_path" })]),
});
});
it("requires exact path spelling for manifests and workspace sources", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files().map((file) =>
file.path === "CLAW.md"
? { ...file, path: "claw.md" }
: file.path === "workspace/reference.md"
? { ...file, path: "workspace/REFERENCE.md" }
: file,
),
});
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code: "missing_claw_manifest" })],
});
});
it("rejects resolved credentials through the shared manifest validator", () => {
const unsafe = {
...manifest,
mcpServers: { github: { command: "npx", env: { GITHUB_TOKEN: "secret" } } },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files(`---\n${JSON.stringify(unsafe)}\n---\n# Prompt\n`),
});
expect(result).toEqual({
ok: false,
issues: expect.arrayContaining([
expect.objectContaining({
code: "invalid_claw_manifest",
path: "$.mcpServers.github.env.GITHUB_TOKEN",
}),
]),
});
});
});
+622
View File
@@ -0,0 +1,622 @@
import { ArkErrors, type } from "arktype";
import { caseFold } from "unicode-case-folding";
import { isScalar, parseDocument, visit } from "yaml";
import {
summarizeClawManifest,
validateClawManifest,
type ClawManifest,
type ClawManifestSummary,
} from "./claws.js";
export type ClawPackageTextFile = { path: string; text?: string };
export type ClawPackageValidationIssue = {
code: string;
path: string;
message: string;
};
export type ValidatedClawPackage = {
manifestPath: string;
manifest: ClawManifest;
implicitWorkspaceFile?: { path: "SOUL.md"; text: string };
summary: ClawManifestSummary;
};
const EXACT_VERSION_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const WINDOWS_INVALID_PATH_CHARS = /[<>:"|?*]/;
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const UNICODE_CONTROL_CHARACTER = /\p{Cc}/u;
const MAX_CLAW_MANIFEST_BYTES = 1024 * 1024;
const MAX_OPENCLAW_PROFILE_BYTES = 256 * 1024;
const OPENCLAW_TOOL_PROFILE_IDS = new Set(["minimal", "coding", "messaging", "full"]);
const StrictStringArraySchema = type("string[]");
const OpenClawProfileSchema = type({
"+": "reject",
schemaVersion: "1",
agent: {
"+": "reject",
groupChat: type({
"+": "reject",
mentionPatterns: StrictStringArraySchema.optional(),
}).optional(),
sandbox: type({
"+": "reject",
mode: '"off"|"non-main"|"all"?',
scope: '"session"|"agent"|"shared"?',
workspaceAccess: '"none"|"ro"|"rw"?',
}).optional(),
tools: type({
"+": "reject",
profile: "string?",
allow: StrictStringArraySchema.optional(),
alsoAllow: StrictStringArraySchema.optional(),
deny: StrictStringArraySchema.optional(),
fs: type({
"+": "reject",
workspaceOnly: "true?",
}).optional(),
}).optional(),
memory: type({
"+": "reject",
search: type({
"+": "reject",
enabled: "boolean?",
rememberAcrossConversations: "boolean?",
sources: type("('memory' | 'sessions')[]").optional(),
}).optional(),
}).optional(),
heartbeat: type({
"+": "reject",
every: "string?",
activeHours: type({
"+": "reject",
start: "string?",
end: "string?",
timezone: "string?",
}).optional(),
lightContext: "boolean?",
isolatedSession: "boolean?",
timeoutSeconds: "number?",
}).optional(),
humanDelay: type({
"+": "reject",
mode: '"off"|"natural"|"custom"?',
minMs: "number?",
maxMs: "number?",
}).optional(),
},
});
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function isSafeClawPackagePath(value: string): boolean {
const normalized = value.replaceAll("\\", "/");
if (
!normalized ||
normalized !== value ||
normalized !== normalized.trim() ||
normalized.startsWith("/") ||
/^[A-Za-z]:\//.test(normalized)
) {
return false;
}
return normalized
.split("/")
.every(
(segment) =>
segment !== "" &&
segment !== "." &&
segment !== ".." &&
!WINDOWS_INVALID_PATH_CHARS.test(segment) &&
!UNICODE_CONTROL_CHARACTER.test(segment) &&
!segment.endsWith(".") &&
!segment.endsWith(" ") &&
!WINDOWS_RESERVED_PATH_SEGMENT.test(segment),
);
}
function portablePathKey(value: string): string {
return caseFold(value.replaceAll("\\", "/").normalize("NFC"));
}
export function findClawPackagePathHierarchyCollision(
paths: readonly string[],
): { ancestor: string; descendant: string } | null {
const pathByKey = new Map(paths.map((path) => [portablePathKey(path), path]));
for (const descendant of paths) {
const segments = portablePathKey(descendant).split("/");
for (let length = 1; length < segments.length; length += 1) {
const ancestor = pathByKey.get(segments.slice(0, length).join("/"));
if (ancestor !== undefined) {
return { ancestor, descendant };
}
}
}
return null;
}
function issue(code: string, path: string, message: string): ClawPackageValidationIssue {
return { code, path, message };
}
function parseJsonCompatibleYaml(raw: string, path: string) {
const document = parseDocument(raw.startsWith("\uFEFF") ? raw.slice(1) : raw, {
prettyErrors: false,
uniqueKeys: true,
});
if (document.errors.length > 0) {
return {
issues: document.errors.map((error) =>
issue("invalid_openclaw_profile", path, error.message),
),
};
}
let unsupportedFeature: string | undefined;
visit(document, {
Alias() {
unsupportedFeature ??= "aliases";
},
Node(_key, node) {
if (node.anchor) {
unsupportedFeature ??= "anchors";
} else if (node.tag) {
unsupportedFeature ??= "explicit tags";
}
},
Pair(_key, pair) {
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
unsupportedFeature ??= "non-string mapping keys";
} else if (pair.key.value === "<<") {
unsupportedFeature ??= "merge keys";
}
},
});
if (unsupportedFeature) {
return {
issues: [
issue(
"unsupported_openclaw_profile_yaml_feature",
path,
`${path} uses ${unsupportedFeature}; OpenClaw profile YAML must map directly to JSON data.`,
),
],
};
}
try {
return { value: document.toJSON() };
} catch (error) {
return {
issues: [
issue(
"invalid_openclaw_profile",
path,
error instanceof Error ? error.message : "Could not parse OpenClaw profile.",
),
],
};
}
}
function isStrictNonEmpty(value: string): boolean {
return value.length > 0 && value === value.trim();
}
function isValidDuration(value: string): boolean {
if (!isStrictNonEmpty(value)) return false;
const multipliers: Record<string, number> = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
const normalized = value.toLowerCase();
const single = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(normalized);
if (single) {
return Number.isSafeInteger(Math.round(Number(single[1]) * multipliers[single[2] ?? "m"]));
}
let totalMs = 0;
let consumed = 0;
for (const match of normalized.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h|d)/g)) {
if (match.index !== consumed) return false;
totalMs += Number(match[1]) * multipliers[match[2]];
consumed += match[0].length;
}
return (
consumed === normalized.length && consumed > 0 && Number.isSafeInteger(Math.round(totalMs))
);
}
function validateOpenClawProfile(
value: unknown,
profilePath: string,
): ClawPackageValidationIssue[] {
const parsed = OpenClawProfileSchema(value);
if (parsed instanceof ArkErrors) {
return Array.from(parsed, (error) =>
issue(
"invalid_openclaw_profile",
`${profilePath}${error.path.length > 0 ? `.${error.path.join(".")}` : ""}`,
error.description ?? "Invalid value.",
),
);
}
const issues: ClawPackageValidationIssue[] = [];
const add = (path: string, message: string) =>
issues.push(issue("invalid_openclaw_profile", `${profilePath}.${path}`, message));
const requireNonEmpty = (path: string, values: string[] | undefined) => {
if (values !== undefined && values.length === 0) add(path, "Must contain at least one value.");
for (const [index, entry] of (values ?? []).entries()) {
if (!isStrictNonEmpty(entry)) {
add(`${path}.${index}`, "Must be non-empty without leading or trailing whitespace.");
}
}
};
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent.groupChat?.mentionPatterns);
if (parsed.agent.tools?.profile !== undefined && !isStrictNonEmpty(parsed.agent.tools.profile)) {
add("agent.tools.profile", "Must be non-empty without leading or trailing whitespace.");
} else if (
parsed.agent.tools?.profile !== undefined &&
!OPENCLAW_TOOL_PROFILE_IDS.has(parsed.agent.tools.profile)
) {
add("agent.tools.profile", "Must name a registered OpenClaw built-in profile.");
}
requireNonEmpty("agent.tools.allow", parsed.agent.tools?.allow);
requireNonEmpty("agent.tools.alsoAllow", parsed.agent.tools?.alsoAllow);
requireNonEmpty("agent.tools.deny", parsed.agent.tools?.deny);
if (parsed.agent.tools?.allow && parsed.agent.tools.alsoAllow) {
add("agent.tools.alsoAllow", "Must not be combined with tools.allow.");
}
if (parsed.agent.memory?.search?.sources?.length === 0) {
add("agent.memory.search.sources", "Must contain at least one source.");
}
if (
parsed.agent.memory?.search?.sources?.includes("sessions") &&
parsed.agent.memory.search.rememberAcrossConversations !== true
) {
add(
"agent.memory.search.rememberAcrossConversations",
"Must be true when memory.search.sources includes sessions.",
);
}
const heartbeat = parsed.agent.heartbeat;
if (heartbeat?.every !== undefined && !isValidDuration(heartbeat.every)) {
add("agent.heartbeat.every", "Must be a valid duration.");
}
const activeHours = heartbeat?.activeHours;
if (activeHours?.start !== undefined && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(activeHours.start)) {
add("agent.heartbeat.activeHours.start", "Must be a valid 24-hour start time.");
}
if (
activeHours?.end !== undefined &&
!/^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/.test(activeHours.end)
) {
add("agent.heartbeat.activeHours.end", "Must be a valid 24-hour end time.");
}
if (activeHours?.timezone !== undefined) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: activeHours.timezone }).format();
} catch {
add("agent.heartbeat.activeHours.timezone", "Must be a valid IANA timezone.");
}
}
if (
heartbeat?.timeoutSeconds !== undefined &&
(!Number.isInteger(heartbeat.timeoutSeconds) || heartbeat.timeoutSeconds <= 0)
) {
add("agent.heartbeat.timeoutSeconds", "Must be a positive integer.");
}
for (const field of ["minMs", "maxMs"] as const) {
const delay = parsed.agent.humanDelay?.[field];
if (delay !== undefined && (!Number.isInteger(delay) || delay < 0)) {
add(`agent.humanDelay.${field}`, "Must be a nonnegative integer.");
}
}
return issues;
}
function parseManifestDocument(raw: string, manifestPath: string) {
const filename = manifestPath.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
if (filename === "claw.md") {
const markdown = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) {
return {
issues: [
issue(
"missing_claw_frontmatter",
manifestPath,
`${manifestPath} must start with YAML frontmatter delimited by --- lines.`,
),
],
};
}
const document = parseDocument(match[1], { prettyErrors: false, uniqueKeys: true });
if (document.errors.length > 0) {
return {
issues: document.errors.map((error) =>
issue("invalid_claw_frontmatter", manifestPath, error.message),
),
};
}
let unsupportedFeature: string | undefined;
visit(document, {
Alias() {
unsupportedFeature ??= "aliases";
},
Node(_key, node) {
if (node.anchor) {
unsupportedFeature ??= "anchors";
} else if (node.tag) {
unsupportedFeature ??= "explicit tags";
}
},
Pair(_key, pair) {
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
unsupportedFeature ??= "non-string mapping keys";
} else if (pair.key.value === "<<") {
unsupportedFeature ??= "merge keys";
}
},
});
if (unsupportedFeature) {
return {
issues: [
issue(
"unsupported_claw_yaml_feature",
manifestPath,
`${manifestPath} uses ${unsupportedFeature}; CLAW.md frontmatter must map directly to JSON data.`,
),
],
};
}
try {
return {
value: document.toJSON(),
markdownBody: markdown.slice(match[0].length),
};
} catch (error) {
return {
issues: [
issue(
"invalid_claw_frontmatter",
manifestPath,
error instanceof Error ? error.message : "Could not parse Claw frontmatter.",
),
],
};
}
}
try {
return { value: JSON.parse(raw) as unknown };
} catch (error) {
return {
issues: [
issue(
"invalid_claw_json",
manifestPath,
error instanceof Error ? error.message : "Could not parse Claw JSON.",
),
],
};
}
}
export function validateClawPackageContents(input: {
packageName: string;
version: string;
packageJson: unknown;
files: readonly ClawPackageTextFile[];
}):
| { ok: true; value: ValidatedClawPackage }
| { ok: false; issues: ClawPackageValidationIssue[] } {
const issues: ClawPackageValidationIssue[] = [];
if (!isRecord(input.packageJson)) {
return {
ok: false,
issues: [
issue("missing_package_json", "package.json", "Claw packages require package.json."),
],
};
}
const declaredName = typeof input.packageJson.name === "string" ? input.packageJson.name : "";
const declaredVersion =
typeof input.packageJson.version === "string" ? input.packageJson.version : "";
const openclaw = isRecord(input.packageJson.openclaw) ? input.packageJson.openclaw : undefined;
const manifestPath = typeof openclaw?.claw === "string" ? openclaw.claw : "";
if (declaredName !== input.packageName) {
issues.push(
issue("package_name_mismatch", "package.json.name", `Must match ${input.packageName}.`),
);
}
if (!EXACT_VERSION_PATTERN.test(declaredVersion) || declaredVersion !== input.version) {
issues.push(
issue(
"package_version_mismatch",
"package.json.version",
`Must be exact semver ${input.version}.`,
),
);
}
if (!manifestPath || !isSafeClawPackagePath(manifestPath)) {
issues.push(
issue(
"invalid_claw_manifest_path",
"package.json.openclaw.claw",
"Must name a safe package-relative Claw manifest path.",
),
);
}
if (issues.length > 0) return { ok: false, issues };
const fileByPath = new Map<string, ClawPackageTextFile>();
const portablePaths = new Set<string>();
for (const file of input.files) {
if (!isSafeClawPackagePath(file.path)) {
issues.push(
issue(
"invalid_package_path",
file.path,
"Package files must use safe canonical package-relative paths.",
),
);
continue;
}
const key = portablePathKey(file.path);
if (portablePaths.has(key)) {
issues.push(
issue(
"duplicate_portable_path",
file.path,
"Package paths must be unique across supported filesystems.",
),
);
} else {
portablePaths.add(key);
fileByPath.set(file.path, file);
}
}
const hierarchyCollision = findClawPackagePathHierarchyCollision([...fileByPath.keys()]);
if (hierarchyCollision) {
issues.push(
issue(
"portable_path_hierarchy_collision",
hierarchyCollision.descendant,
`Package file ${hierarchyCollision.ancestor} cannot also be an ancestor of ${hierarchyCollision.descendant}.`,
),
);
}
const manifestFile = fileByPath.get(manifestPath);
if (!manifestFile || manifestFile.text === undefined) {
issues.push(
issue(
"missing_claw_manifest",
manifestPath,
"The declared Claw manifest is missing or is not UTF-8 text.",
),
);
return { ok: false, issues };
}
if (new TextEncoder().encode(manifestFile.text).byteLength > MAX_CLAW_MANIFEST_BYTES) {
return {
ok: false,
issues: [
issue(
"claw_manifest_too_large",
manifestPath,
`The Claw manifest exceeds ${MAX_CLAW_MANIFEST_BYTES} bytes.`,
),
],
};
}
const parsed = parseManifestDocument(manifestFile.text, manifestPath);
if (parsed.issues) return { ok: false, issues: parsed.issues };
if (parsed.markdownBody !== undefined && parsed.markdownBody.trim().length === 0) {
return {
ok: false,
issues: [
issue("empty_claw_body", manifestPath, "CLAW.md must contain a non-empty Markdown body."),
],
};
}
const validated = validateClawManifest(parsed.value);
if (!validated.ok) {
return {
ok: false,
issues: validated.issues.map((entry) =>
issue("invalid_claw_manifest", entry.path, entry.message),
),
};
}
if (
parsed.markdownBody !== undefined &&
(validated.manifest.workspace?.bootstrapFiles?.["SOUL.md"] !== undefined ||
(validated.manifest.workspace?.files ?? []).some(
(entry) => portablePathKey(entry.path) === portablePathKey("SOUL.md"),
))
) {
return {
ok: false,
issues: [
issue(
"duplicate_claw_body_destination",
"SOUL.md",
"The CLAW.md body is the managed SOUL.md source and cannot be combined with an explicit SOUL.md declaration.",
),
],
};
}
const openClawProfilePath = validated.manifest.metadata?.["openclaw.config"];
if (openClawProfilePath !== undefined) {
const profileFile = fileByPath.get(openClawProfilePath);
if (!profileFile || profileFile.text === undefined) {
issues.push(
issue(
"missing_openclaw_profile",
openClawProfilePath,
"The declared OpenClaw profile is missing or is not UTF-8 text.",
),
);
} else if (new TextEncoder().encode(profileFile.text).byteLength > MAX_OPENCLAW_PROFILE_BYTES) {
issues.push(
issue(
"openclaw_profile_too_large",
openClawProfilePath,
`The OpenClaw profile exceeds ${MAX_OPENCLAW_PROFILE_BYTES} bytes.`,
),
);
} else {
const profile = parseJsonCompatibleYaml(profileFile.text, openClawProfilePath);
if (profile.issues) {
issues.push(...profile.issues);
} else {
issues.push(...validateOpenClawProfile(profile.value, openClawProfilePath));
}
}
}
const sources = [
...Object.values(validated.manifest.workspace?.bootstrapFiles ?? {}).map(
(entry) => entry.source,
),
...(validated.manifest.workspace?.files ?? []).map((entry) => entry.source),
];
for (const source of sources) {
if (!fileByPath.has(source)) {
issues.push(
issue(
"missing_workspace_source",
source,
"Declared workspace source is missing from the package.",
),
);
}
}
if (issues.length > 0) return { ok: false, issues };
const summary = summarizeClawManifest(validated.manifest);
const implicitWorkspaceFile =
parsed.markdownBody === undefined
? undefined
: { path: "SOUL.md" as const, text: parsed.markdownBody };
return {
ok: true,
value: {
manifestPath,
manifest: validated.manifest,
...(implicitWorkspaceFile ? { implicitWorkspaceFile } : {}),
summary: implicitWorkspaceFile
? {
...summary,
workspace: {
...summary.workspace,
bootstrapFiles: [...summary.workspace.bootstrapFiles, "SOUL.md"],
},
}
: summary,
},
};
}
+2 -2
View File
@@ -50,7 +50,7 @@ const fixture = {
} as const;
describe("Claw manifest contract", () => {
it("allows Claws in storage contracts without opening generic publication", () => {
it("allows Claws in storage and publication metadata contracts", () => {
expect(PackageFamilySchema("claw")).toBe("claw");
expect(
PackagePublishMetadataSchema({
@@ -59,7 +59,7 @@ describe("Claw manifest contract", () => {
version: "1.0.0",
changelog: "Initial release",
}),
).toBeInstanceOf(ArkErrors);
).not.toBeInstanceOf(ArkErrors);
});
it("accepts the grouped OpenClaw v1 fixture and derives a safe summary", () => {
+1
View File
@@ -1,6 +1,7 @@
export type { ArkValidator } from "./ark.js";
export { formatArkErrors, parseArk } from "./ark.js";
export * from "./claws.js";
export * from "./clawPackage.js";
export * from "./catalogFeed.js";
export * from "./catalogMetadata.js";
export * from "./docsLinks.js";
+1 -2
View File
@@ -29,8 +29,7 @@ export function getPackageScopeOwnerMismatch(name: string, ownerHandle: string |
export const PackageFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"|"claw"');
export type PackageFamily = (typeof PackageFamilySchema)[inferred];
// Publication stays narrower until a family has a complete validation path.
export const PackagePublishFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"');
export const PackagePublishFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"|"claw"');
export type PackagePublishFamily = (typeof PackagePublishFamilySchema)[inferred];
export const PackageChannelSchema = type('"official"|"community"|"private"');
+28 -13
View File
@@ -8,9 +8,12 @@ schema. ClawHub owns publication, ownership, discovery, package detail APIs,
and hosted feed export. OpenClaw remains authoritative for local planning,
consent, mutation, provenance, update, and removal.
The YAML frontmatter is the portable manifest. The `CLAW.md` body remains
documentation-only in the current contract and may be empty; it does not imply
or replace any managed workspace file.
The YAML frontmatter is the portable manifest. A `CLAW.md` package envelope
must have a non-empty body; its exact body text is the implicit managed
`SOUL.md` workspace file. The manifest must not also declare an explicit
`SOUL.md` destination. The exact `CLAW.md` bytes, including the body, remain in
the immutable artifact digest and provenance input. Grouped JSON has no body,
creates no implicit file, and may declare `SOUL.md` explicitly.
The portable agent object carries only identity and purpose. Harness-specific
settings live in package-local profiles addressed through opaque string
@@ -18,10 +21,12 @@ metadata. OpenClaw recognizes `metadata.openclaw.config`; export conventionally
uses `profiles/openclaw.yml`, but the pointer is normative and authors may use
another safe package-relative YAML path.
That profile exists only inside the Claw package. It is validated during
publication and application, participates in package integrity, and is never
copied into ordinary OpenClaw configuration. Other harnesses may ignore
OpenClaw's namespaced key or define their own profile-pointer contract.
That profile exists only inside the Claw package. ClawHub requires the pointer
to resolve to an exact, bounded UTF-8 YAML package file and validates the
profile's strict v1 OpenClaw policy during publication. OpenClaw validates it
again during application, includes it in package integrity, and never copies it
into ordinary OpenClaw configuration. Other harnesses may ignore OpenClaw's
namespaced key or define their own profile-pointer contract.
## Experimental contract
@@ -36,16 +41,15 @@ OpenClaw's namespaced key or define their own profile-pointer contract.
## Staged implementation
1. Add the shared grouped manifest contract, safe summary, and storage model.
2. Add feature-gated authenticated publication, validation, and authoring docs.
1. Add the shared grouped manifest contract, safe summary, and storage model
([PR #3089](https://github.com/openclaw/clawhub/pull/3089)).
2. Add feature-gated authenticated publication, package-content validation,
CLI authoring support, and authoring docs
([PR #3090](https://github.com/openclaw/clawhub/pull/3090)).
3. Add feature-gated search, detail, and API surfaces.
4. Add hosted feed export and a published-package end-to-end proof through
OpenClaw `claws add --dry-run`.
The publish schema intentionally remains narrower than the storage family in
the first slice. This prevents existing generic package endpoints from
accepting Claws before their dedicated validation and gate are in place.
The shared validator follows the RFC's strict v1 contract: strings are not
trimmed into validity, MCP package selectors must resolve exact versions,
process environment keys follow OpenClaw's host-wide safety policy, and tool
@@ -68,3 +72,14 @@ field structure is built from `createClawManifestSummarySchema` in both the
public ArkType contract and the Convex storage validator. Convex cannot express
the summary text-length caps, so publication must validate or derive summaries
through the shared schema before storage.
Claws use the existing package publication pipeline. `package.json` declares
the package identity, version, and package-relative `openclaw.claw` manifest
path. Publication parses `CLAW.md` YAML frontmatter or the JSON compatibility
form and validates the grouped manifest, referenced workspace files, and any
declared package-local OpenClaw profile. The release retains the exact artifact
and a bounded derived summary rather than duplicating the full manifest or
OpenClaw profile into Convex storage. The server rejects
`family: claw` before mutation when the experimental gate is disabled; the gate
does not bypass ownership, moderation, scanning, or release invariants when
enabled.