mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -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=="],
|
||||
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
Executable
+16
@@ -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);
|
||||
});
|
||||
});
|
||||
Executable
+10
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,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
@@ -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[];
|
||||
};
|
||||
Vendored
+468
@@ -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
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,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"}
|
||||
Vendored
+3
-3
@@ -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;
|
||||
|
||||
Vendored
+1
-2
@@ -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"');
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user