Compare commits

...
Author SHA1 Message Date
Gio Della-LiberaandCopilot 4117154eca feat(claws): align package conformance (#3463)
* feat(claws): align package conformance

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* fix(claws): enforce pinned profile contract

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* test(claws): align full CI fixtures

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* test(claws): bound publication fixture tools

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* docs(claws): bound coding profile example

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* fix(claws): preserve profile publication compatibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* fix(claws): version profile policy transition

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* fix(claws): align CLI profile preflight

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* fix(claws): expose profile policy marker

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* fix(claws): preserve legacy profile rules

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

* fix(claws): pin reserved package policy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 34377cd6-beac-4e49-8ab0-22cfaf8b54a5
2026-08-14 22:03:03 -07:00
35 changed files with 1732 additions and 77 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
timeout-minutes: 25
env:
OPENCLAW_CONTRACT_REPOSITORY: openclaw/openclaw
OPENCLAW_CONTRACT_SHA: 7422222788c4b75581c0370e0614be9e635ec3cd
OPENCLAW_CONTRACT_SHA: f8c0e1b8325b1fc36e039cf357a2c4602f76d5aa
steps:
- uses: actions/checkout@v7.0.1
+3 -1
View File
@@ -14,7 +14,8 @@
ClawHub is the **public skill registry for OpenClaw**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
It's designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
It also now exposes a native **OpenClaw package catalog** for code plugins and bundle plugins.
It also exposes a native **OpenClaw package catalog** for code plugins, bundle plugins, and
experimental whole-agent Claw packages.
<p align="center">
<a href="https://clawhub.ai">ClawHub</a> ·
@@ -35,6 +36,7 @@ It also now exposes a native **OpenClaw package catalog** for code plugins and b
- Pin local skill installs so updates and force reinstalls cannot overwrite frozen copies.
- Browse OpenClaw packages with family/trust/capability metadata.
- Publish native code plugins and bundle plugins through `/packages` APIs and CLI flows.
- Host, discover, and resolve exact artifacts for experimental whole-agent Claw packages.
## How it works (high level)
+2
View File
@@ -278,6 +278,8 @@ describe("catalog feed projection", () => {
agent: { id: "triage", name: "Triage" },
workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 1 },
packages: { skillCount: 1, pluginCount: 0 },
profiles: { count: 1, hasOpenClaw: true },
extensions: { count: 1 },
mcpServerCount: 0,
cronJobCount: 1,
};
+2
View File
@@ -127,6 +127,8 @@ const clawFeedEntryValidator = v.object({
fileCount: v.number(),
}),
packages: v.object({ skillCount: v.number(), pluginCount: v.number() }),
profiles: v.optional(v.object({ count: v.number(), hasOpenClaw: v.boolean() })),
extensions: v.optional(v.object({ count: v.number() })),
mcpServerCount: v.number(),
cronJobCount: v.number(),
}),
+185 -4
View File
@@ -9170,6 +9170,113 @@ describe("packages public queries", () => {
expect(ctx.insert).toHaveBeenCalledWith("packageReleases", expect.anything());
});
it("pins newly created Claw packages to the current profile policy", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
process.env.CLAWHUB_EXPERIMENTAL_CLAWS = "1";
const ctx = makeInsertReleaseCtx(null);
try {
await insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-claw",
displayName: "Demo Claw",
family: "claw",
version: "1.0.0",
changelog: "init",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
});
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
expect(ctx.insert).toHaveBeenCalledWith(
"packages",
expect.objectContaining({
family: "claw",
clawProfilePolicyVersion: 1,
}),
);
});
it("pins a reserved package when its first published release is a Claw", async () => {
const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
process.env.CLAWHUB_EXPERIMENTAL_CLAWS = "1";
const reservation = makePackageDoc({
family: "code-plugin",
latestReleaseId: undefined,
latestVersionSummary: undefined,
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
});
const ctx = makeInsertReleaseCtx(reservation);
try {
await insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Claw",
family: "claw",
version: "1.0.0",
changelog: "init",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
});
} finally {
if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS;
else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous;
}
expect(ctx.patch).toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({
family: "claw",
clawProfilePolicyVersion: 1,
}),
);
});
it("pins a reserved package when its pending first Claw release is published", async () => {
const reservation = makePackageDoc({
family: "code-plugin",
latestReleaseId: undefined,
latestVersionSummary: undefined,
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
});
const pendingRelease = makeReleaseDoc({
_id: "packageReleases:pending",
packageId: "packages:demo",
publicationStatus: "pending",
pendingPublication: {
family: "claw",
displayName: "Demo Claw",
tags: ["latest"],
},
});
const ctx = makeInsertReleaseCtx(reservation, [pendingRelease], {
"packages:demo": reservation,
"packageReleases:pending": pendingRelease,
});
await publishPendingReleaseInternalHandler(ctx, {
releaseId: "packageReleases:pending",
});
expect(ctx.patch).toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({
family: "claw",
clawProfilePolicyVersion: 1,
}),
);
});
it("preserves trusted GitHub Actions package publishes without org membership", async () => {
const ctx = makeInsertReleaseCtx(
makePackageDoc({
@@ -10163,7 +10270,7 @@ describe("packages public queries", () => {
"storage:claw",
`---\nschemaVersion: 1\nagent:\n id: demo-claw\n name: Demo Claw\n description: ${longClawDescription}\n---\nRun the demo workflow precisely.\n`,
],
["storage:profile", "schemaVersion: 1\nagent:\n tools:\n profile: coding\n"],
["storage:profile", "schemaVersion: 1\nagent:\n tools:\n profile: minimal\n"],
["storage:codex-profile", "version: 1\nfeatures: [future]\n"],
["storage:bootstrap", "Ask which repositories the user owns.\n"],
]);
@@ -10316,13 +10423,17 @@ describe("packages public queries", () => {
"storage:claw",
"---\nschemaVersion: 1\nagent:\n id: demo-claw\n name: Demo Claw\n---\nRun the demo workflow precisely.\n",
],
["storage:profile", "schemaVersion: 1\nagent:\n tools:\n profile: coding\n"],
["storage:profile", "schemaVersion: 1\nagent:\n tools:\n profile: future-profile\n"],
]);
const existingPackage = makePackageDoc({
family: "claw",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
});
const currentPolicyPackage = {
...existingPackage,
clawProfilePolicyVersion: 1,
};
const existingRelease = makeReleaseDoc({
_id: "packageReleases:pending",
packageId: "packages:demo",
@@ -10441,6 +10552,71 @@ describe("packages public queries", () => {
}),
);
runQuery
.mockResolvedValueOnce(currentPolicyPackage)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
_id: "users:owner",
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",
});
await expect(
publishPackageForUserInternalHandler(ctx as never, {
actorUserId: "users:owner",
payload: {
name: "demo-claw",
displayName: "Demo Claw",
family: "claw",
version: "1.0.0",
changelog: "retry",
ownerHandle: "owner",
expectedArtifactSha256: artifactSha256,
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",
},
],
artifact: {
kind: "npm-pack",
storageId: "storage:archive",
sha256: artifactSha256,
size: 3,
format: "tgz",
npmIntegrity: "sha512-demo",
npmShasum: "b".repeat(40),
npmTarballName: "demo-claw-1.0.0.tgz",
npmUnpackedSize: 3,
npmFileCount: 3,
},
},
}),
).rejects.toThrow(
"profiles/openclaw.yml.agent.tools.profile: Must name a registered OpenClaw built-in profile.",
);
expect(runMutation).toHaveBeenCalledTimes(2);
runQuery.mockReset();
runQuery
.mockResolvedValueOnce(existingPackage)
.mockResolvedValueOnce(null)
@@ -10518,7 +10694,7 @@ describe("packages public queries", () => {
}),
);
expect(runQuery.mock.calls.at(-1)?.[1]).not.toHaveProperty("artifactFingerprint");
expect(runMutation).toHaveBeenCalledTimes(2);
expect(runMutation).toHaveBeenCalledTimes(3);
expect(runMutation).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -19083,6 +19259,7 @@ describe("restorePackageInternal", () => {
const { ctx } = makePackageCtx({
pkg: makePackageDoc({
family: "claw",
clawProfilePolicyVersion: 1,
latestReleaseId: "packageReleases:demo-latest",
latestVersionSummary: { version: "2.0.0" },
}),
@@ -19125,7 +19302,11 @@ describe("restorePackageInternal", () => {
process.env.CLAWHUB_EXPERIMENTAL_CLAWS = "1";
const detail = await getByNameHandler(ctx, { name: "demo-plugin" });
expect(detail).toMatchObject({
package: { family: "claw", clawManifestSummary: latestClawManifestSummary },
package: {
family: "claw",
clawProfilePolicyVersion: 1,
clawManifestSummary: latestClawManifestSummary,
},
latestRelease: { clawManifestSummary: latestClawManifestSummary },
});
expect(detail?.latestRelease).not.toHaveProperty("extractedClawManifest");
+24 -2
View File
@@ -160,6 +160,7 @@ const MAX_OFFICIAL_MIGRATION_BLOCKERS = 20;
const MAX_OFFICIAL_MIGRATION_FIELD_LENGTH = 300;
const MAX_OFFICIAL_MIGRATION_NOTES_LENGTH = 2_000;
const MAX_STORED_PACKAGE_METADATA_DEPTH = 10;
const CURRENT_OPENCLAW_PROFILE_POLICY_VERSION = 1;
const REAL_BUNDLE_MANIFESTS = [
{ path: ".codex-plugin/plugin.json", format: "codex" },
{ path: ".claude-plugin/plugin.json", format: "claude" },
@@ -1002,6 +1003,7 @@ type PublicPackageDoc = {
name: string;
displayName: string;
family: PackageFamily;
clawProfilePolicyVersion?: 1;
channel: PackageChannel;
isOfficial: boolean;
runtimeId?: string;
@@ -1204,6 +1206,7 @@ function toPublicPackage(
name: pkg.name,
displayName: pkg.displayName,
family: pkg.family,
clawProfilePolicyVersion: pkg.clawProfilePolicyVersion,
channel: pkg.channel,
isOfficial: pkg.isOfficial,
runtimeId: pkg.runtimeId,
@@ -8549,6 +8552,12 @@ async function publishPackageImpl(
packageName: name,
version,
packageJson,
openClawProfilePolicy:
existingPackage &&
!hasNoPublishedPackageVersions(existingPackage) &&
existingPackage.clawProfilePolicyVersion !== CURRENT_OPENCLAW_PROFILE_POLICY_VERSION
? "publication-compatible"
: "current",
files: clawValidationFiles,
})
: null;
@@ -10994,9 +11003,12 @@ export const publishPendingReleaseInternal = internalMutation({
const now = Date.now();
const metadata = pendingPackagePublicationMetadata(release);
const firstPublishedRelease = hasNoPublishedPackageVersions(pkg);
const pendingFamily = stringPendingField(metadata, "family", pkg.family) as PackageFamily;
const packageFamily = firstPublishedRelease ? pendingFamily : pkg.family;
const currentLatest = await resolvePackageCurrentLatestForPublish(ctx, pkg);
const { effectiveTags, shouldPromoteLatest } = resolvePackageReleaseTagsForPublish({
family: pkg.family,
family: packageFamily,
currentLatestExists: currentLatest.exists,
currentLatestVersion: currentLatest.version,
candidateVersion: release.version,
@@ -11031,7 +11043,11 @@ export const publishPendingReleaseInternal = internalMutation({
displayName: stringPendingField(metadata, "displayName", pkg.displayName),
ownerUserId: pkg.ownerUserId,
ownerPublisherId: pkg.ownerPublisherId,
family: pkg.family,
family: packageFamily,
clawProfilePolicyVersion:
firstPublishedRelease && packageFamily === "claw"
? CURRENT_OPENCLAW_PROFILE_POLICY_VERSION
: pkg.clawProfilePolicyVersion,
summary: shouldPromoteLatest ? release.summary : pkg.summary,
icon: shouldPromoteLatest ? release.icon : pkg.icon,
categories: shouldPromoteLatest
@@ -11289,6 +11305,8 @@ export const insertReleaseInternal = internalMutation({
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
family: args.family,
clawProfilePolicyVersion:
args.family === "claw" ? CURRENT_OPENCLAW_PROFILE_POLICY_VERSION : undefined,
channel: nextChannel,
isOfficial: nextIsOfficial,
runtimeId: args.runtimeId,
@@ -11448,6 +11466,10 @@ export const insertReleaseInternal = internalMutation({
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId ?? pkg.ownerPublisherId,
family: existingIsReservation ? args.family : pkg.family,
clawProfilePolicyVersion:
existingIsReservation && args.family === "claw"
? CURRENT_OPENCLAW_PROFILE_POLICY_VERSION
: pkg.clawProfilePolicyVersion,
summary: shouldPromoteLatest ? args.summary : pkg.summary,
icon: shouldPromoteLatest ? args.icon : pkg.icon,
categories: shouldPromoteLatest ? args.categories : pkg.categories,
+2
View File
@@ -730,6 +730,7 @@ const clawManifestSummaryValidator = createClawManifestSummarySchema<GenericVali
literalOne: v.literal(1),
string: v.string(),
number: v.number(),
boolean: v.boolean(),
stringArray: v.array(v.string()),
// Convex validators cannot express string lengths; publication must validate with the shared schema.
boundedString: () => v.string(),
@@ -1687,6 +1688,7 @@ const packages = defineTable({
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
family: packageFamilyValidator,
clawProfilePolicyVersion: v.optional(v.literal(1)),
channel: packageChannelValidator,
isOfficial: v.boolean(),
runtimeId: v.optional(v.string()),
+14 -6
View File
@@ -14,6 +14,10 @@ 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.
This registry gate is independent from OpenClaw's
`OPENCLAW_EXPERIMENTAL_CLAWS=1` consumer gate. Hosting does not enable local
preview or installation, and enabling the OpenClaw CLI does not enable ClawHub
publication or hosted discovery.
## Package shape
@@ -93,11 +97,12 @@ profiles/codex.yml
```
The `profiles/` namespace is reserved for these lowercase, single-file `.yml`
paths. Each profile is a JSON-compatible YAML mapping. ClawHub validates the common
shape and fully validates `profiles/openclaw.yml`; it does not interpret foreign
profiles. A harness discovers only its own profile and ignores the others when
applying the package. The exact published artifact and its digest still cover
every profile, bootstrap instruction, and asset.
paths. Each profile is a JSON-compatible YAML mapping. ClawHub validates the common shape and validates `profiles/openclaw.yml` against
the shipped OpenClaw v1 consumer contract, including registered built-in tool
profiles and bounded tool grants; it does not interpret foreign profiles. A
harness discovers only its own profile and ignores the others when applying the
package. The exact published artifact and its digest still cover every profile,
bootstrap instruction, and asset.
The retired `metadata.openclaw.config` pointer is rejected. Move that file to
`profiles/openclaw.yml` and remove the metadata entry.
@@ -110,6 +115,7 @@ schemaVersion: 1
agent:
tools:
profile: coding
allow: [read]
extensions:
- id: issue-tools
kind: plugin
@@ -181,7 +187,9 @@ curl "https://clawhub.ai/api/v1/packages/@acme%2Fgithub-triage"
List and search results use the normal package summary fields. Package and
version detail responses may also include `clawManifestSummary`, which reports
the agent identity and resource counts without exposing the full manifest.
the agent identity, portable resource counts, harness-profile count,
OpenClaw-profile count, and native extension count without exposing the full
manifest or profile contents.
When `CLAWHUB_EXPERIMENTAL_CLAWS` is disabled, explicit `family=claw` filters
are rejected, unscoped list and search results omit Claws, and named Claw reads
+106
View File
@@ -0,0 +1,106 @@
{
"contract": "openclaw-claw-v1",
"consumer": {
"repository": "openclaw/openclaw",
"commit": "f8c0e1b8325b1fc36e039cf357a2c4602f76d5aa"
},
"profileCases": [
{
"name": "minimal",
"consumerAccepted": true,
"registryAccepted": true,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: minimal\n"
},
{
"name": "bounded coding",
"consumerAccepted": true,
"registryAccepted": true,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: coding\n allow: [read]\n"
},
{
"name": "bounded static group",
"consumerAccepted": true,
"registryAccepted": true,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: coding\n allow: ['group:fs']\n"
},
{
"name": "dynamic MCP bundle",
"consumerAccepted": false,
"registryAccepted": false,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: full\n allow: [bundle-mcp]\n"
},
{
"name": "overlong concrete MCP tool",
"consumerAccepted": false,
"registryAccepted": false,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: coding\n allow: [server_abcdefghijklmnopqrstuvwxyz0123456789__tool_abcdefghijklmnopqrstuvwxyz0123456789]\n"
},
{
"name": "unknown profile",
"consumerAccepted": false,
"registryAccepted": false,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: future-profile\n"
},
{
"name": "unbounded coding",
"consumerAccepted": false,
"registryAccepted": false,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: coding\n"
},
{
"name": "unbounded full",
"consumerAccepted": false,
"registryAccepted": false,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: full\n"
}
],
"heartbeatCases": [
{
"name": "bounded heartbeat",
"consumerAccepted": true,
"registryAccepted": true,
"yaml": "schemaVersion: 1\nagent:\n heartbeat:\n every: 30m\n activeHours: { start: '09:00', end: '24:00', timezone: UTC }\n timeoutSeconds: 30\n"
},
{
"name": "retired skipWhenBusy",
"consumerAccepted": false,
"registryAccepted": false,
"yaml": "schemaVersion: 1\nagent:\n heartbeat:\n every: 30m\n skipWhenBusy: true\n"
}
],
"extensionCases": [
{
"name": "exact OpenClaw extension",
"consumerAccepted": true,
"registryAccepted": true,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: minimal\nextensions:\n - id: issue-tools\n kind: plugin\n format: openclaw\n source: clawhub\n ref: '@acme/issue-tools'\n version: 2.3.4\n"
},
{
"name": "floating OpenClaw extension",
"consumerAccepted": false,
"registryAccepted": false,
"yaml": "schemaVersion: 1\nagent:\n tools:\n profile: minimal\nextensions:\n - id: issue-tools\n kind: plugin\n format: openclaw\n source: clawhub\n ref: '@acme/issue-tools'\n version: latest\n"
}
],
"metadataCases": [
{
"name": "no profile pointer",
"consumerAccepted": true,
"registryAccepted": true,
"metadata": {}
},
{
"name": "retired profile pointer",
"consumerAccepted": true,
"registryAccepted": false,
"metadata": {
"openclaw.config": "profiles/openclaw.yml"
}
}
],
"projectArtifact": {
"path": "fixtures/claws/hosted-e2e/package",
"consumerAccepted": true,
"registryAccepted": true
}
}
@@ -4,6 +4,9 @@ agent:
id: hosted-e2e
name: Hosted E2E
workspace:
bootstrapFiles:
HEARTBEAT.md:
source: HEARTBEAT.md
files:
- source: assets/incident.schema.json
path: assets/incident.schema.json
@@ -0,0 +1 @@
Check the hosted Claw fixture heartbeat.
@@ -1,6 +1,7 @@
{
"name": "@openclaw/hosted-e2e",
"version": "1.0.0",
"type": "module",
"openclaw": {
"claw": "CLAW.md"
}
@@ -1,7 +1,7 @@
schemaVersion: 1
agent:
tools:
profile: coding
profile: minimal
fs:
workspaceOnly: true
humanDelay: { mode: natural }
@@ -738,6 +738,31 @@ describe("package commands", () => {
expect(url.searchParams.get("limit")).toBe("7");
});
it("supports Claw family package browse requests", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
items: [
{
name: "@openclaw/hosted",
displayName: "Hosted Claw",
family: "claw",
channel: "official",
isOfficial: true,
latestVersion: "1.0.0",
},
],
nextCursor: null,
});
await cmdExplorePackages(makeOpts(), "", { family: "claw", limit: 7 });
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
const url = new URL(String(request?.url));
expect(url.pathname).toBe("/api/v1/packages");
expect(url.searchParams.get("family")).toBe("claw");
expect(url.searchParams.get("limit")).toBe("7");
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("[Claw, official]"));
});
it("uses tag param when fetching a package file", async () => {
httpMocks.apiRequest
.mockResolvedValueOnce({
@@ -2626,6 +2651,84 @@ describe("package commands", () => {
}
});
it("rejects a legacy-only profile for a first Claw publish before upload", 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",
"package/profiles/openclaw.yml":
"schemaVersion: 1\nagent:\n tools:\n profile: future-profile\n",
}),
);
httpMocks.apiRequest.mockResolvedValueOnce({ package: null, owner: null });
await expect(cmdPublishPackage(makeOpts(workdir), packName)).rejects.toThrow(
"profiles/openclaw.yml.agent.tools.profile: Must name a registered OpenClaw built-in profile.",
);
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("preserves a legacy-only profile for a grandfathered Claw package", async () => {
const workdir = await makeTmpWorkdir();
try {
const packName = "demo-claw-1.0.1.tgz";
await writeFile(
join(workdir, packName),
npmPackFixture({
"package/package.json": JSON.stringify({
name: "demo-claw",
version: "1.0.1",
openclaw: { claw: "CLAW.md" },
}),
"package/CLAW.md": "---\nschemaVersion: 1\nagent:\n id: demo-claw\n---\n# Demo Claw\n",
"package/profiles/openclaw.yml":
"schemaVersion: 1\nagent:\n tools:\n profile: future-profile\n",
}),
);
const packBytes = new Uint8Array(await readFile(join(workdir, packName)));
const artifactSha256 = artifactIdentity(packBytes).sha256;
httpMocks.apiRequest.mockResolvedValueOnce({
package: {
name: "demo-claw",
displayName: "Demo Claw",
family: "claw",
},
owner: null,
});
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_claw",
releaseId: "rel_claw",
artifactSha256,
});
await cmdPublishPackage(makeOpts(workdir), packName);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: "GET",
path: "/api/v1/packages/demo-claw",
}),
expect.anything(),
);
expect(httpMocks.apiRequestForm).toHaveBeenCalledTimes(1);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("fails closed when ClawHub does not confirm the submitted Claw digest", async () => {
const workdir = await makeTmpWorkdir();
try {
@@ -3296,7 +3399,7 @@ describe("package commands", () => {
"agent:",
" tools:",
" profile: coding",
" alsoAllow: [cron]",
" allow: [read]",
" fs:",
" workspaceOnly: true",
" memory:",
+39 -1
View File
@@ -327,6 +327,38 @@ function assertClawPublishArtifactDigest(
}
}
async function validateClawPublishProfilePolicy(
plan: PackagePublishPlan,
registry: string,
token: string,
) {
if (plan.payload.family !== "claw") return;
const { validateClawPackageContents } = await import("../../schema/clawPackage.js");
const validationInput = {
packageName: plan.payload.name,
version: plan.payload.version,
packageJson: plan.packageJson,
files: plan.filesOnDisk.map((file) => ({
path: file.relPath,
text: decodeUtf8Text(file.bytes) ?? undefined,
})),
};
const currentValidation = validateClawPackageContents({
...validationInput,
openClawProfilePolicy: "current",
});
if (currentValidation.ok) return;
const packageDetail = await apiRequestPackageDetail(registry, plan.payload.name, token);
if (
packageDetail?.package?.family === "claw" &&
packageDetail.package.clawProfilePolicyVersion === undefined
) {
return;
}
fail(currentValidation.issues.map((issue) => `${issue.path}: ${issue.message}`).join(" "));
}
type PackedClawPack = {
path: string;
file: PackageFile;
@@ -396,7 +428,9 @@ export async function cmdExplorePackages(
: ApiRoutes.packages;
const url = registryUrl(route, registry);
url.searchParams.set("limit", String(limit));
if (options.family === "skill") url.searchParams.set("family", "skill");
if (options.family === "skill" || options.family === "claw") {
url.searchParams.set("family", options.family);
}
if (options.official) url.searchParams.set("isOfficial", "true");
const result = await apiRequest(
registry,
@@ -969,6 +1003,7 @@ export async function cmdPublishPackage(
manualOverrideReason: plan.payload.manualOverrideReason,
spinner,
});
await validateClawPublishProfilePolicy(plan, registry, publishToken);
const form = new FormData();
const payloadJson = JSON.stringify(plan.payload);
form.set("payload", payloadJson);
@@ -2209,6 +2244,8 @@ function familyLabel(family: PackageFamily) {
return "Code Plugin";
case "bundle-plugin":
return "Bundle Plugin";
case "claw":
return "Claw";
default:
return "Skill";
}
@@ -2551,6 +2588,7 @@ async function preparePackagePublishPlan(
packageName: name,
version,
packageJson,
openClawProfilePolicy: "publication-compatible",
files: filesOnDisk.map((file) => ({
path: file.relPath,
text: decodeUtf8Text(file.bytes) ?? undefined,
+269 -14
View File
@@ -20,6 +20,8 @@ export type ValidatedClawPackage = {
summary: ClawManifestSummary;
hasClawMarkdownBody: boolean;
};
// Existing experimental packages retain the publication contract they were accepted under.
export type OpenClawProfilePolicy = "current" | "publication-compatible";
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-]+)*))?$/;
@@ -32,6 +34,163 @@ const MAX_HARNESS_PROFILE_BYTES = 256 * 1024;
const HARNESS_PROFILE_PATH_PATTERN = /^profiles\/[a-z][a-z0-9_-]{0,63}\.yml$/;
const AGENT_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
const CONCRETE_MCP_TOOL_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*__[A-Za-z][A-Za-z0-9_-]*$/;
export const OPENCLAW_CLAW_PROFILE_POLICY_V1 = {
contractVersion: 1,
source: {
repository: "openclaw/openclaw",
commit: "f8c0e1b8325b1fc36e039cf357a2c4602f76d5aa",
path: "src/claws/schema.ts",
},
profiles: ["minimal", "coding", "messaging", "full"],
} as const;
type OpenClawBuiltinProfile = (typeof OPENCLAW_CLAW_PROFILE_POLICY_V1.profiles)[number];
const OPENCLAW_PROFILE_TOOL_ALLOW = {
minimal: new Set(["session_status"]),
coding: new Set([
"read",
"write",
"edit",
"apply_patch",
"exec",
"process",
"code_execution",
"web_search",
"web_fetch",
"x_search",
"memory_search",
"memory_get",
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
"suggest_task",
"dismiss_task",
"screen",
"dashboard",
"terminal",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"ask_user",
"skill_workshop",
"image",
"image_generate",
"music_generate",
"video_generate",
]),
messaging: new Set([
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"sessions_yield",
"subagents",
"session_status",
"message",
"ask_user",
]),
full: null,
} as const;
const OPENCLAW_STATIC_TOOL_GROUPS: Record<string, ReadonlySet<string>> = {
"group:openclaw": new Set([
"code_execution",
"web_search",
"web_fetch",
"x_search",
"memory_search",
"memory_get",
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
"suggest_task",
"dismiss_task",
"browser",
"screen",
"dashboard",
"terminal",
"show_widget",
"message",
"heartbeat_respond",
"automations",
"gateway",
"nodes",
"computer",
"mobile_ui",
"agents_list",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"ask_user",
"skill_workshop",
"image",
"image_generate",
"music_generate",
"video_generate",
"tts",
]),
"group:fs": new Set(["read", "write", "edit", "apply_patch"]),
"group:runtime": new Set(["exec", "process", "code_execution"]),
"group:web": new Set(["web_search", "web_fetch", "x_search"]),
"group:memory": new Set(["memory_search", "memory_get"]),
"group:sessions": new Set([
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
"suggest_task",
"dismiss_task",
]),
"group:ui": new Set(["browser", "screen", "dashboard", "terminal", "canvas", "show_widget"]),
"group:messaging": new Set(["message"]),
"group:automation": new Set(["heartbeat_respond", "automations", "gateway"]),
"group:nodes": new Set(["nodes", "computer", "mobile_ui"]),
"group:agents": new Set([
"agents_list",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"ask_user",
"skill_workshop",
]),
"group:media": new Set(["image", "image_generate", "music_generate", "video_generate", "tts"]),
};
const StrictStringArraySchema = type("string[]");
const OpenClawExtensionSchema = type({
"+": "reject",
@@ -249,6 +408,50 @@ function isStrictNonEmpty(value: string): boolean {
return value.length > 0 && value === value.trim();
}
function normalizeOpenClawToolGrant(value: string): string {
const normalized = value.toLowerCase();
if (normalized === "bash") return "exec";
if (normalized === "apply-patch") return "apply_patch";
if (normalized === "cron") return "automations";
return normalized;
}
function isBoundedOpenClawToolGrant(value: string): boolean {
if (!isStrictNonEmpty(value)) return false;
const normalized = normalizeOpenClawToolGrant(value);
if (
/[*?[\]{}]/u.test(normalized) ||
normalized === "bundle-mcp" ||
normalized === "group:plugins"
) {
return false;
}
if (normalized.startsWith("group:")) {
return Object.hasOwn(OPENCLAW_STATIC_TOOL_GROUPS, normalized);
}
return !normalized.includes("__") || isConcreteOpenClawMcpToolName(value);
}
function isConcreteOpenClawMcpToolName(value: string): boolean {
return value.length <= 64 && CONCRETE_MCP_TOOL_PATTERN.test(value);
}
function isOpenClawBuiltinProfile(value: string): value is OpenClawBuiltinProfile {
return Object.hasOwn(OPENCLAW_PROFILE_TOOL_ALLOW, value);
}
function toolGrantOverlapsProfile(value: string, profile: OpenClawBuiltinProfile): boolean {
if (profile === "full") return true;
const normalized = normalizeOpenClawToolGrant(value);
const group = OPENCLAW_STATIC_TOOL_GROUPS[normalized];
return (
OPENCLAW_PROFILE_TOOL_ALLOW[profile].has(normalized) ||
(group !== undefined &&
Array.from(group).some((tool) => OPENCLAW_PROFILE_TOOL_ALLOW[profile].has(tool))) ||
((profile === "coding" || profile === "messaging") && isConcreteOpenClawMcpToolName(value))
);
}
function isValidDuration(value: string): boolean {
if (!isStrictNonEmpty(value)) return false;
const multipliers: Record<string, number> = {
@@ -278,16 +481,20 @@ function isValidDuration(value: string): boolean {
function validateOpenClawProfile(
value: unknown,
profilePath: string,
): ClawPackageValidationIssue[] {
profilePolicy: OpenClawProfilePolicy,
): { issues: ClawPackageValidationIssue[]; extensionCount: number } {
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.",
return {
issues: Array.from(parsed, (error) =>
issue(
"invalid_openclaw_profile",
`${profilePath}${error.path.length > 0 ? `.${error.path.join(".")}` : ""}`,
error.description ?? "Invalid value.",
),
),
);
extensionCount: 0,
};
}
const issues: ClawPackageValidationIssue[] = [];
const add = (path: string, message: string) =>
@@ -302,18 +509,51 @@ function validateOpenClawProfile(
};
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent?.groupChat?.mentionPatterns);
if (
parsed.agent?.tools?.profile !== undefined &&
!isStrictNonEmpty(parsed.agent?.tools.profile)
) {
const tools = parsed.agent?.tools;
const profile = tools?.profile;
if (profile !== undefined && !isStrictNonEmpty(profile)) {
add("agent.tools.profile", "Must be non-empty without leading or trailing whitespace.");
} else if (
profilePolicy === "current" &&
profile !== undefined &&
!isOpenClawBuiltinProfile(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) {
if (profilePolicy === "current") {
for (const field of ["allow", "alsoAllow"] as const) {
for (const [index, grant] of (tools?.[field] ?? []).entries()) {
if (!isBoundedOpenClawToolGrant(grant)) {
add(`agent.tools.${field}.${index}`, "Tool grants must be bounded concrete names.");
}
}
}
}
if (profilePolicy === "current" && tools?.alsoAllow && !profile) {
add("agent.tools.alsoAllow", "May be set only when a built-in profile is selected.");
}
if (tools?.allow && tools.alsoAllow) {
add("agent.tools.alsoAllow", "Must not be combined with tools.allow.");
}
if (profilePolicy === "current" && profile && isOpenClawBuiltinProfile(profile)) {
if (profile === "full" && !tools?.allow) {
add("agent.tools.profile", "The full profile requires a bounded explicit allowlist.");
}
if ((profile === "coding" || profile === "messaging") && !tools?.allow) {
add(
"agent.tools.allow",
"Profiles containing bundle MCP tools require a bounded explicit allowlist.",
);
}
for (const [index, grant] of (tools?.allow ?? []).entries()) {
if (isBoundedOpenClawToolGrant(grant) && !toolGrantOverlapsProfile(grant, profile)) {
add(`agent.tools.allow.${index}`, "Must overlap the selected built-in profile.");
}
}
}
if (parsed.agent?.memory?.search?.sources?.length === 0) {
add("agent.memory.search.sources", "Must contain at least one source.");
}
@@ -381,7 +621,7 @@ function validateOpenClawProfile(
extensionIds.add(extension.id);
extensionRefs.add(extension.ref.toLowerCase());
}
return issues;
return { issues, extensionCount: parsed.extensions?.length ?? 0 };
}
function parseManifestDocument(
@@ -479,6 +719,7 @@ export function validateClawPackageContents(input: {
version: string;
packageJson: unknown;
files: readonly ClawPackageTextFile[];
openClawProfilePolicy?: OpenClawProfilePolicy;
}):
| { ok: true; value: ValidatedClawPackage }
| { ok: false; issues: ClawPackageValidationIssue[] } {
@@ -655,6 +896,7 @@ export function validateClawPackageContents(input: {
const profileFiles = [...fileByPath.values()].filter((file) =>
portablePathKey(file.path).startsWith("profiles/"),
);
let openClawExtensionCount = 0;
for (const profileFile of profileFiles) {
if (!HARNESS_PROFILE_PATH_PATTERN.test(profileFile.path)) {
issues.push(
@@ -688,7 +930,15 @@ export function validateClawPackageContents(input: {
if (profileFile.path === "profiles/openclaw.yml") {
const profile = parseJsonCompatibleYaml(profileFile.text, profileFile.path);
if (profile.issues) issues.push(...profile.issues);
else issues.push(...validateOpenClawProfile(profile.value, profileFile.path));
else {
const validatedProfile = validateOpenClawProfile(
profile.value,
profileFile.path,
input.openClawProfilePolicy ?? "current",
);
issues.push(...validatedProfile.issues);
openClawExtensionCount = validatedProfile.extensionCount;
}
} else {
const profile = parseGenericHarnessProfile(profileFile.text, profileFile.path);
if (profile.issues) issues.push(...profile.issues);
@@ -716,6 +966,11 @@ export function validateClawPackageContents(input: {
const summary = summarizeClawManifest(validated.manifest, {
clawMarkdownBody: hasClawMarkdownBody,
});
summary.profiles = {
count: profileFiles.length,
hasOpenClaw: profileFiles.some((file) => file.path === "profiles/openclaw.yml"),
};
summary.extensions = { count: openClawExtensionCount };
if (packageBootstrap) {
summary.workspace.bootstrapFiles = [...summary.workspace.bootstrapFiles, "BOOTSTRAP.md"].sort();
}
+15
View File
@@ -104,6 +104,8 @@ export type ClawManifestSummary = {
agent: { id: string; name?: string; description?: string };
workspace: { bootstrapFiles: string[]; fileCount: number };
packages: { skillCount: number; pluginCount: number };
profiles?: { count: number; hasOpenClaw: boolean };
extensions?: { count: number };
mcpServerCount: number;
cronJobCount: number;
};
@@ -112,6 +114,7 @@ export type ClawManifestSummarySchemaAdapter<TValue, TOptional = TValue> = {
literalOne: TValue;
string: TValue;
number: TValue;
boolean: TValue;
stringArray: TValue;
boundedString: (maxCharacters: number) => TValue;
optional: (schema: TValue) => TOptional;
@@ -139,6 +142,17 @@ export function createClawManifestSummarySchema<TValue, TOptional = TValue>(
skillCount: adapter.number,
pluginCount: adapter.number,
}),
profiles: adapter.optional(
adapter.object({
count: adapter.number,
hasOpenClaw: adapter.boolean,
}),
),
extensions: adapter.optional(
adapter.object({
count: adapter.number,
}),
),
mcpServerCount: adapter.number,
cronJobCount: adapter.number,
});
@@ -149,6 +163,7 @@ export const ClawManifestSummarySchema = createClawManifestSummarySchema<BaseTyp
literalOne: type("1"),
string: type("string"),
number: type("number"),
boolean: type("boolean"),
stringArray: type("string[]"),
boundedString: (maxCharacters) =>
type("string").narrow((value) => Array.from(value).length <= maxCharacters),
+1
View File
@@ -434,6 +434,7 @@ export const ApiV1PackageResponseSchema = type({
name: "string",
displayName: "string",
family: PackageFamilySchema,
clawProfilePolicyVersion: "1?",
runtimeId: "string|null?",
channel: PackageChannelSchema,
isOfficial: "boolean",
+11
View File
@@ -14,6 +14,16 @@ export type ValidatedClawPackage = {
summary: ClawManifestSummary;
hasClawMarkdownBody: boolean;
};
export type OpenClawProfilePolicy = "current" | "publication-compatible";
export declare const OPENCLAW_CLAW_PROFILE_POLICY_V1: {
readonly contractVersion: 1;
readonly source: {
readonly repository: "openclaw/openclaw";
readonly commit: "f8c0e1b8325b1fc36e039cf357a2c4602f76d5aa";
readonly path: "src/claws/schema.ts";
};
readonly profiles: readonly ["minimal", "coding", "messaging", "full"];
};
export declare function isSafeClawPackagePath(value: string): boolean;
export declare function findClawPackagePathHierarchyCollision(paths: readonly string[]): {
ancestor: string;
@@ -24,6 +34,7 @@ export declare function validateClawPackageContents(input: {
version: string;
packageJson: unknown;
files: readonly ClawPackageTextFile[];
openClawProfilePolicy?: OpenClawProfilePolicy;
}): {
ok: true;
value: ValidatedClawPackage;
+247 -8
View File
@@ -12,6 +12,162 @@ const MAX_HARNESS_PROFILE_BYTES = 256 * 1024;
const HARNESS_PROFILE_PATH_PATTERN = /^profiles\/[a-z][a-z0-9_-]{0,63}\.yml$/;
const AGENT_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
const CONCRETE_MCP_TOOL_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*__[A-Za-z][A-Za-z0-9_-]*$/;
export const OPENCLAW_CLAW_PROFILE_POLICY_V1 = {
contractVersion: 1,
source: {
repository: "openclaw/openclaw",
commit: "f8c0e1b8325b1fc36e039cf357a2c4602f76d5aa",
path: "src/claws/schema.ts",
},
profiles: ["minimal", "coding", "messaging", "full"],
};
const OPENCLAW_PROFILE_TOOL_ALLOW = {
minimal: new Set(["session_status"]),
coding: new Set([
"read",
"write",
"edit",
"apply_patch",
"exec",
"process",
"code_execution",
"web_search",
"web_fetch",
"x_search",
"memory_search",
"memory_get",
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
"suggest_task",
"dismiss_task",
"screen",
"dashboard",
"terminal",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"ask_user",
"skill_workshop",
"image",
"image_generate",
"music_generate",
"video_generate",
]),
messaging: new Set([
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"sessions_yield",
"subagents",
"session_status",
"message",
"ask_user",
]),
full: null,
};
const OPENCLAW_STATIC_TOOL_GROUPS = {
"group:openclaw": new Set([
"code_execution",
"web_search",
"web_fetch",
"x_search",
"memory_search",
"memory_get",
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
"suggest_task",
"dismiss_task",
"browser",
"screen",
"dashboard",
"terminal",
"show_widget",
"message",
"heartbeat_respond",
"automations",
"gateway",
"nodes",
"computer",
"mobile_ui",
"agents_list",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"ask_user",
"skill_workshop",
"image",
"image_generate",
"music_generate",
"video_generate",
"tts",
]),
"group:fs": new Set(["read", "write", "edit", "apply_patch"]),
"group:runtime": new Set(["exec", "process", "code_execution"]),
"group:web": new Set(["web_search", "web_fetch", "x_search"]),
"group:memory": new Set(["memory_search", "memory_get"]),
"group:sessions": new Set([
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
"suggest_task",
"dismiss_task",
]),
"group:ui": new Set(["browser", "screen", "dashboard", "terminal", "canvas", "show_widget"]),
"group:messaging": new Set(["message"]),
"group:automation": new Set(["heartbeat_respond", "automations", "gateway"]),
"group:nodes": new Set(["nodes", "computer", "mobile_ui"]),
"group:agents": new Set([
"agents_list",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"ask_user",
"skill_workshop",
]),
"group:media": new Set(["image", "image_generate", "music_generate", "video_generate", "tts"]),
};
const StrictStringArraySchema = type("string[]");
const OpenClawExtensionSchema = type({
"+": "reject",
@@ -205,6 +361,46 @@ function parseGenericHarnessProfile(raw, path) {
function isStrictNonEmpty(value) {
return value.length > 0 && value === value.trim();
}
function normalizeOpenClawToolGrant(value) {
const normalized = value.toLowerCase();
if (normalized === "bash")
return "exec";
if (normalized === "apply-patch")
return "apply_patch";
if (normalized === "cron")
return "automations";
return normalized;
}
function isBoundedOpenClawToolGrant(value) {
if (!isStrictNonEmpty(value))
return false;
const normalized = normalizeOpenClawToolGrant(value);
if (/[*?[\]{}]/u.test(normalized) ||
normalized === "bundle-mcp" ||
normalized === "group:plugins") {
return false;
}
if (normalized.startsWith("group:")) {
return Object.hasOwn(OPENCLAW_STATIC_TOOL_GROUPS, normalized);
}
return !normalized.includes("__") || isConcreteOpenClawMcpToolName(value);
}
function isConcreteOpenClawMcpToolName(value) {
return value.length <= 64 && CONCRETE_MCP_TOOL_PATTERN.test(value);
}
function isOpenClawBuiltinProfile(value) {
return Object.hasOwn(OPENCLAW_PROFILE_TOOL_ALLOW, value);
}
function toolGrantOverlapsProfile(value, profile) {
if (profile === "full")
return true;
const normalized = normalizeOpenClawToolGrant(value);
const group = OPENCLAW_STATIC_TOOL_GROUPS[normalized];
return (OPENCLAW_PROFILE_TOOL_ALLOW[profile].has(normalized) ||
(group !== undefined &&
Array.from(group).some((tool) => OPENCLAW_PROFILE_TOOL_ALLOW[profile].has(tool))) ||
((profile === "coding" || profile === "messaging") && isConcreteOpenClawMcpToolName(value)));
}
function isValidDuration(value) {
if (!isStrictNonEmpty(value))
return false;
@@ -230,10 +426,13 @@ function isValidDuration(value) {
}
return (consumed === normalized.length && consumed > 0 && Number.isSafeInteger(Math.round(totalMs)));
}
function validateOpenClawProfile(value, profilePath) {
function validateOpenClawProfile(value, profilePath, profilePolicy) {
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."));
return {
issues: Array.from(parsed, (error) => issue("invalid_openclaw_profile", `${profilePath}${error.path.length > 0 ? `.${error.path.join(".")}` : ""}`, error.description ?? "Invalid value.")),
extensionCount: 0,
};
}
const issues = [];
const add = (path, message) => issues.push(issue("invalid_openclaw_profile", `${profilePath}.${path}`, message));
@@ -247,16 +446,47 @@ function validateOpenClawProfile(value, profilePath) {
}
};
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent?.groupChat?.mentionPatterns);
if (parsed.agent?.tools?.profile !== undefined &&
!isStrictNonEmpty(parsed.agent?.tools.profile)) {
const tools = parsed.agent?.tools;
const profile = tools?.profile;
if (profile !== undefined && !isStrictNonEmpty(profile)) {
add("agent.tools.profile", "Must be non-empty without leading or trailing whitespace.");
}
else if (profilePolicy === "current" &&
profile !== undefined &&
!isOpenClawBuiltinProfile(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) {
if (profilePolicy === "current") {
for (const field of ["allow", "alsoAllow"]) {
for (const [index, grant] of (tools?.[field] ?? []).entries()) {
if (!isBoundedOpenClawToolGrant(grant)) {
add(`agent.tools.${field}.${index}`, "Tool grants must be bounded concrete names.");
}
}
}
}
if (profilePolicy === "current" && tools?.alsoAllow && !profile) {
add("agent.tools.alsoAllow", "May be set only when a built-in profile is selected.");
}
if (tools?.allow && tools.alsoAllow) {
add("agent.tools.alsoAllow", "Must not be combined with tools.allow.");
}
if (profilePolicy === "current" && profile && isOpenClawBuiltinProfile(profile)) {
if (profile === "full" && !tools?.allow) {
add("agent.tools.profile", "The full profile requires a bounded explicit allowlist.");
}
if ((profile === "coding" || profile === "messaging") && !tools?.allow) {
add("agent.tools.allow", "Profiles containing bundle MCP tools require a bounded explicit allowlist.");
}
for (const [index, grant] of (tools?.allow ?? []).entries()) {
if (isBoundedOpenClawToolGrant(grant) && !toolGrantOverlapsProfile(grant, profile)) {
add(`agent.tools.allow.${index}`, "Must overlap the selected built-in profile.");
}
}
}
if (parsed.agent?.memory?.search?.sources?.length === 0) {
add("agent.memory.search.sources", "Must contain at least one source.");
}
@@ -316,7 +546,7 @@ function validateOpenClawProfile(value, profilePath) {
extensionIds.add(extension.id);
extensionRefs.add(extension.ref.toLowerCase());
}
return issues;
return { issues, extensionCount: parsed.extensions?.length ?? 0 };
}
function parseManifestDocument(raw, manifestPath) {
const filename = manifestPath.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
@@ -485,6 +715,7 @@ export function validateClawPackageContents(input) {
}
}
const profileFiles = [...fileByPath.values()].filter((file) => portablePathKey(file.path).startsWith("profiles/"));
let openClawExtensionCount = 0;
for (const profileFile of profileFiles) {
if (!HARNESS_PROFILE_PATH_PATTERN.test(profileFile.path)) {
issues.push(issue("invalid_harness_profile_path", profileFile.path, "Harness profiles must use profiles/<lowercase-harness-id>.yml conventional paths."));
@@ -505,8 +736,11 @@ export function validateClawPackageContents(input) {
const profile = parseJsonCompatibleYaml(profileFile.text, profileFile.path);
if (profile.issues)
issues.push(...profile.issues);
else
issues.push(...validateOpenClawProfile(profile.value, profileFile.path));
else {
const validatedProfile = validateOpenClawProfile(profile.value, profileFile.path, input.openClawProfilePolicy ?? "current");
issues.push(...validatedProfile.issues);
openClawExtensionCount = validatedProfile.extensionCount;
}
}
else {
const profile = parseGenericHarnessProfile(profileFile.text, profileFile.path);
@@ -528,6 +762,11 @@ export function validateClawPackageContents(input) {
const summary = summarizeClawManifest(validated.manifest, {
clawMarkdownBody: hasClawMarkdownBody,
});
summary.profiles = {
count: profileFiles.length,
hasOpenClaw: profileFiles.some((file) => file.path === "profiles/openclaw.yml"),
};
summary.extensions = { count: openClawExtensionCount };
if (packageBootstrap) {
summary.workspace.bootstrapFiles = [...summary.workspace.bootstrapFiles, "BOOTSTRAP.md"].sort();
}
File diff suppressed because one or more lines are too long
+8
View File
@@ -105,6 +105,13 @@ export type ClawManifestSummary = {
skillCount: number;
pluginCount: number;
};
profiles?: {
count: number;
hasOpenClaw: boolean;
};
extensions?: {
count: number;
};
mcpServerCount: number;
cronJobCount: number;
};
@@ -112,6 +119,7 @@ export type ClawManifestSummarySchemaAdapter<TValue, TOptional = TValue> = {
literalOne: TValue;
string: TValue;
number: TValue;
boolean: TValue;
stringArray: TValue;
boundedString: (maxCharacters: number) => TValue;
optional: (schema: TValue) => TOptional;
+8
View File
@@ -111,6 +111,13 @@ export function createClawManifestSummarySchema(adapter) {
skillCount: adapter.number,
pluginCount: adapter.number,
}),
profiles: adapter.optional(adapter.object({
count: adapter.number,
hasOpenClaw: adapter.boolean,
})),
extensions: adapter.optional(adapter.object({
count: adapter.number,
})),
mcpServerCount: adapter.number,
cronJobCount: adapter.number,
});
@@ -119,6 +126,7 @@ export const ClawManifestSummarySchema = createClawManifestSummarySchema({
literalOne: type("1"),
string: type("string"),
number: type("number"),
boolean: type("boolean"),
stringArray: type("string[]"),
boundedString: (maxCharacters) => type("string").narrow((value) => Array.from(value).length <= maxCharacters),
optional: (schema) => schema.optional(),
+1 -1
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -112,6 +112,17 @@ export function serializeExperimentalClawFeed(feed) {
skillCount: entry.clawManifestSummary.packages.skillCount,
pluginCount: entry.clawManifestSummary.packages.pluginCount,
},
...(entry.clawManifestSummary.profiles === undefined
? {}
: {
profiles: {
count: entry.clawManifestSummary.profiles.count,
hasOpenClaw: entry.clawManifestSummary.profiles.hasOpenClaw,
},
}),
...(entry.clawManifestSummary.extensions === undefined
? {}
: { extensions: { count: entry.clawManifestSummary.extensions.count } }),
mcpServerCount: entry.clawManifestSummary.mcpServerCount,
cronJobCount: entry.clawManifestSummary.cronJobCount,
},
File diff suppressed because one or more lines are too long
+143
View File
@@ -0,0 +1,143 @@
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
OPENCLAW_CLAW_PROFILE_POLICY_V1,
validateClawPackageContents,
type ClawPackageTextFile,
} from "./clawPackage.js";
type ProfileCase = {
name: string;
consumerAccepted: boolean;
registryAccepted: boolean;
yaml: string;
};
type MetadataCase = {
name: string;
consumerAccepted: boolean;
registryAccepted: boolean;
metadata: Record<string, string>;
};
type ConformanceCases = {
consumer: { repository: string; commit: string };
profileCases: ProfileCase[];
heartbeatCases: ProfileCase[];
extensionCases: ProfileCase[];
metadataCases: MetadataCase[];
projectArtifact: { path: string; consumerAccepted: boolean; registryAccepted: boolean };
};
const repositoryRoot = process.cwd();
const cases = JSON.parse(
readFileSync(resolve(repositoryRoot, "fixtures/claws/conformance-v1/cases.json"), "utf8"),
) as ConformanceCases;
const baseManifest = {
schemaVersion: 1,
agent: { id: "conformance-claw" },
};
const packageJson = {
name: "@openclaw/conformance-claw",
version: "1.0.0",
openclaw: { claw: "CLAW.md" },
};
function validateProfile(yaml: string, metadata: Record<string, string> = {}) {
return validateClawPackageContents({
packageName: packageJson.name,
version: packageJson.version,
packageJson,
files: [
{ path: "package.json", text: JSON.stringify(packageJson) },
{
path: "CLAW.md",
text: `---\n${JSON.stringify({ ...baseManifest, metadata })}\n---\n`,
},
...(yaml ? [{ path: "profiles/openclaw.yml", text: yaml }] : []),
],
});
}
function readProjectFiles(root: string): ClawPackageTextFile[] {
const files: ClawPackageTextFile[] = [];
const visit = (directory: string) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
visit(path);
} else {
files.push({
path: relative(root, path).replaceAll("\\", "/"),
text: readFileSync(path, "utf8"),
});
}
}
};
visit(root);
return files;
}
describe(`ClawHub parity with ${cases.consumer.repository}@${cases.consumer.commit}`, () => {
it("pins the same shipped consumer as the profile policy artifact", () => {
expect(cases.consumer).toEqual({
repository: OPENCLAW_CLAW_PROFILE_POLICY_V1.source.repository,
commit: OPENCLAW_CLAW_PROFILE_POLICY_V1.source.commit,
});
});
it("never marks a registry-accepted vector as consumer-rejected", () => {
const vectors = [
...cases.profileCases,
...cases.heartbeatCases,
...cases.extensionCases,
...cases.metadataCases,
cases.projectArtifact,
];
expect(vectors.filter((entry) => entry.registryAccepted && !entry.consumerAccepted)).toEqual(
[],
);
});
it.each(["claws.ts", "clawPackage.ts"])(
"keeps the standalone CLI %s validator identical",
(filename) => {
expect(readFileSync(resolve(repositoryRoot, "packages/schema/src", filename), "utf8")).toBe(
readFileSync(resolve(repositoryRoot, "packages/clawhub/src/schema", filename), "utf8"),
);
},
);
it.each([...cases.profileCases, ...cases.heartbeatCases, ...cases.extensionCases])(
"$name",
({ registryAccepted, yaml }) => {
expect(validateProfile(yaml).ok).toBe(registryAccepted);
},
);
it.each(cases.metadataCases)("$name", ({ registryAccepted, metadata }) => {
expect(validateProfile("", metadata).ok).toBe(registryAccepted);
});
it("accepts the shared hosted project artifact", () => {
const root = resolve(repositoryRoot, cases.projectArtifact.path);
const files = readProjectFiles(root);
const parsedPackageJson = JSON.parse(
files.find((file) => file.path === "package.json")?.text ?? "null",
) as { name: string; version: string };
const result = validateClawPackageContents({
packageName: parsedPackageJson.name,
version: parsedPackageJson.version,
packageJson: parsedPackageJson,
files,
});
expect(result.ok).toBe(cases.projectArtifact.registryAccepted);
if (result.ok) {
expect(result.value.summary).toMatchObject({
profiles: { count: 1, hasOpenClaw: true },
extensions: { count: 0 },
});
}
});
});
+147 -5
View File
@@ -22,8 +22,7 @@ const openClawProfile = [
" mentionPatterns: ['@triage']",
" sandbox: { mode: non-main, scope: agent, workspaceAccess: rw }",
" tools:",
" profile: coding",
" alsoAllow: [cron]",
" profile: minimal",
" deny: [gateway]",
" fs: { workspaceOnly: true }",
" memory:",
@@ -92,6 +91,8 @@ describe("validateClawPackageContents", () => {
description: "Reviews issues.",
},
packages: { skillCount: 1, pluginCount: 0 },
profiles: { count: 0, hasOpenClaw: false },
extensions: { count: 0 },
workspace: { bootstrapFiles: [], fileCount: 1 },
}),
}),
@@ -241,10 +242,46 @@ describe("validateClawPackageContents", () => {
if (result.ok) {
expect(result.value).not.toHaveProperty("profile");
expect(result.value.manifest.agent).toEqual(manifest.agent);
expect(result.value.summary.profiles).toEqual({ count: 1, hasOpenClaw: true });
expect(result.value.summary.extensions).toEqual({ count: 0 });
}
});
it("accepts an applying harness profile that ClawHub does not yet know", () => {
it("summarizes the public profile and extension footprint without exposing contents", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(),
{
path: "profiles/openclaw.yml",
text: [
"schemaVersion: 1",
"agent:",
" tools:",
" profile: minimal",
"extensions:",
" - id: issue-tools",
" kind: plugin",
" format: openclaw",
" source: clawhub",
" ref: '@acme/issue-tools'",
" version: 2.3.4",
].join("\n"),
},
{ path: "profiles/codex.yml", text: "version: 1" },
],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.summary.profiles).toEqual({ count: 2, hasOpenClaw: true });
expect(result.value.summary.extensions).toEqual({ count: 1 });
}
});
it("rejects an applying harness profile that shipped OpenClaw does not know", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
@@ -258,8 +295,111 @@ describe("validateClawPackageContents", () => {
],
});
expect(result).toEqual({
ok: false,
issues: [
expect.objectContaining({
code: "invalid_openclaw_profile",
path: "profiles/openclaw.yml.agent.tools.profile",
}),
],
});
});
it.each([
["a custom profile", "profile: future-profile"],
["unbounded coding", "profile: coding"],
["unbounded messaging", "profile: messaging"],
["unbounded full", "profile: full"],
["a legacy wildcard allowlist", "profile: full\n allow: ['*']"],
["alsoAllow without a profile", "alsoAllow: [read]"],
])("preserves publication compatibility for %s", (_label, tools) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
openClawProfilePolicy: "publication-compatible",
files: [
...files(),
{
path: "profiles/openclaw.yml",
text: `schemaVersion: 1\nagent:\n tools:\n ${tools}`,
},
],
});
expect(result.ok).toBe(true);
});
it("retains legacy non-empty profile validation in publication compatibility mode", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
openClawProfilePolicy: "publication-compatible",
files: [
...files(),
{
path: "profiles/openclaw.yml",
text: "schemaVersion: 1\nagent:\n tools:\n profile: ' '",
},
],
});
expect(result).toEqual({
ok: false,
issues: [
expect.objectContaining({
code: "invalid_openclaw_profile",
path: "profiles/openclaw.yml.agent.tools.profile",
}),
],
});
});
it.each([
["coding without a bounded allowlist", "profile: coding"],
["messaging without a bounded allowlist", "profile: messaging"],
["full without a bounded allowlist", "profile: full"],
["an unbounded allow grant", "profile: full\n allow: ['*']"],
["an allow grant outside the selected profile", "profile: minimal\n allow: [read]"],
])("rejects %s like shipped OpenClaw", (_label, tools) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(),
{
path: "profiles/openclaw.yml",
text: `schemaVersion: 1\nagent:\n tools:\n ${tools}`,
},
],
});
expect(result).toEqual({
ok: false,
issues: expect.arrayContaining([
expect.objectContaining({ code: "invalid_openclaw_profile" }),
]),
});
});
it("accepts the documented bounded coding profile like shipped OpenClaw", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(),
{
path: "profiles/openclaw.yml",
text: "schemaVersion: 1\nagent:\n tools:\n profile: coding\n allow: [read]",
},
],
});
expect(result.ok).toBe(true);
if (result.ok) expect(result.value).not.toHaveProperty("profile");
});
it.each([
@@ -570,7 +710,9 @@ describe("validateClawPackageContents", () => {
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code: "invalid_openclaw_profile" })],
issues: expect.arrayContaining([
expect.objectContaining({ code: "invalid_openclaw_profile" }),
]),
});
});
+269 -14
View File
@@ -20,6 +20,8 @@ export type ValidatedClawPackage = {
summary: ClawManifestSummary;
hasClawMarkdownBody: boolean;
};
// Existing experimental packages retain the publication contract they were accepted under.
export type OpenClawProfilePolicy = "current" | "publication-compatible";
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-]+)*))?$/;
@@ -32,6 +34,163 @@ const MAX_HARNESS_PROFILE_BYTES = 256 * 1024;
const HARNESS_PROFILE_PATH_PATTERN = /^profiles\/[a-z][a-z0-9_-]{0,63}\.yml$/;
const AGENT_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
const CONCRETE_MCP_TOOL_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*__[A-Za-z][A-Za-z0-9_-]*$/;
export const OPENCLAW_CLAW_PROFILE_POLICY_V1 = {
contractVersion: 1,
source: {
repository: "openclaw/openclaw",
commit: "f8c0e1b8325b1fc36e039cf357a2c4602f76d5aa",
path: "src/claws/schema.ts",
},
profiles: ["minimal", "coding", "messaging", "full"],
} as const;
type OpenClawBuiltinProfile = (typeof OPENCLAW_CLAW_PROFILE_POLICY_V1.profiles)[number];
const OPENCLAW_PROFILE_TOOL_ALLOW = {
minimal: new Set(["session_status"]),
coding: new Set([
"read",
"write",
"edit",
"apply_patch",
"exec",
"process",
"code_execution",
"web_search",
"web_fetch",
"x_search",
"memory_search",
"memory_get",
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
"suggest_task",
"dismiss_task",
"screen",
"dashboard",
"terminal",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"ask_user",
"skill_workshop",
"image",
"image_generate",
"music_generate",
"video_generate",
]),
messaging: new Set([
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"sessions_yield",
"subagents",
"session_status",
"message",
"ask_user",
]),
full: null,
} as const;
const OPENCLAW_STATIC_TOOL_GROUPS: Record<string, ReadonlySet<string>> = {
"group:openclaw": new Set([
"code_execution",
"web_search",
"web_fetch",
"x_search",
"memory_search",
"memory_get",
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
"suggest_task",
"dismiss_task",
"browser",
"screen",
"dashboard",
"terminal",
"show_widget",
"message",
"heartbeat_respond",
"automations",
"gateway",
"nodes",
"computer",
"mobile_ui",
"agents_list",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"ask_user",
"skill_workshop",
"image",
"image_generate",
"music_generate",
"video_generate",
"tts",
]),
"group:fs": new Set(["read", "write", "edit", "apply_patch"]),
"group:runtime": new Set(["exec", "process", "code_execution"]),
"group:web": new Set(["web_search", "web_fetch", "x_search"]),
"group:memory": new Set(["memory_search", "memory_get"]),
"group:sessions": new Set([
"sessions",
"sessions_list",
"sessions_history",
"sessions_search",
"conversations_list",
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"agents_wait",
"sessions_yield",
"subagents",
"session_status",
"suggest_task",
"dismiss_task",
]),
"group:ui": new Set(["browser", "screen", "dashboard", "terminal", "canvas", "show_widget"]),
"group:messaging": new Set(["message"]),
"group:automation": new Set(["heartbeat_respond", "automations", "gateway"]),
"group:nodes": new Set(["nodes", "computer", "mobile_ui"]),
"group:agents": new Set([
"agents_list",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"ask_user",
"skill_workshop",
]),
"group:media": new Set(["image", "image_generate", "music_generate", "video_generate", "tts"]),
};
const StrictStringArraySchema = type("string[]");
const OpenClawExtensionSchema = type({
"+": "reject",
@@ -249,6 +408,50 @@ function isStrictNonEmpty(value: string): boolean {
return value.length > 0 && value === value.trim();
}
function normalizeOpenClawToolGrant(value: string): string {
const normalized = value.toLowerCase();
if (normalized === "bash") return "exec";
if (normalized === "apply-patch") return "apply_patch";
if (normalized === "cron") return "automations";
return normalized;
}
function isBoundedOpenClawToolGrant(value: string): boolean {
if (!isStrictNonEmpty(value)) return false;
const normalized = normalizeOpenClawToolGrant(value);
if (
/[*?[\]{}]/u.test(normalized) ||
normalized === "bundle-mcp" ||
normalized === "group:plugins"
) {
return false;
}
if (normalized.startsWith("group:")) {
return Object.hasOwn(OPENCLAW_STATIC_TOOL_GROUPS, normalized);
}
return !normalized.includes("__") || isConcreteOpenClawMcpToolName(value);
}
function isConcreteOpenClawMcpToolName(value: string): boolean {
return value.length <= 64 && CONCRETE_MCP_TOOL_PATTERN.test(value);
}
function isOpenClawBuiltinProfile(value: string): value is OpenClawBuiltinProfile {
return Object.hasOwn(OPENCLAW_PROFILE_TOOL_ALLOW, value);
}
function toolGrantOverlapsProfile(value: string, profile: OpenClawBuiltinProfile): boolean {
if (profile === "full") return true;
const normalized = normalizeOpenClawToolGrant(value);
const group = OPENCLAW_STATIC_TOOL_GROUPS[normalized];
return (
OPENCLAW_PROFILE_TOOL_ALLOW[profile].has(normalized) ||
(group !== undefined &&
Array.from(group).some((tool) => OPENCLAW_PROFILE_TOOL_ALLOW[profile].has(tool))) ||
((profile === "coding" || profile === "messaging") && isConcreteOpenClawMcpToolName(value))
);
}
function isValidDuration(value: string): boolean {
if (!isStrictNonEmpty(value)) return false;
const multipliers: Record<string, number> = {
@@ -278,16 +481,20 @@ function isValidDuration(value: string): boolean {
function validateOpenClawProfile(
value: unknown,
profilePath: string,
): ClawPackageValidationIssue[] {
profilePolicy: OpenClawProfilePolicy,
): { issues: ClawPackageValidationIssue[]; extensionCount: number } {
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.",
return {
issues: Array.from(parsed, (error) =>
issue(
"invalid_openclaw_profile",
`${profilePath}${error.path.length > 0 ? `.${error.path.join(".")}` : ""}`,
error.description ?? "Invalid value.",
),
),
);
extensionCount: 0,
};
}
const issues: ClawPackageValidationIssue[] = [];
const add = (path: string, message: string) =>
@@ -302,18 +509,51 @@ function validateOpenClawProfile(
};
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent?.groupChat?.mentionPatterns);
if (
parsed.agent?.tools?.profile !== undefined &&
!isStrictNonEmpty(parsed.agent?.tools.profile)
) {
const tools = parsed.agent?.tools;
const profile = tools?.profile;
if (profile !== undefined && !isStrictNonEmpty(profile)) {
add("agent.tools.profile", "Must be non-empty without leading or trailing whitespace.");
} else if (
profilePolicy === "current" &&
profile !== undefined &&
!isOpenClawBuiltinProfile(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) {
if (profilePolicy === "current") {
for (const field of ["allow", "alsoAllow"] as const) {
for (const [index, grant] of (tools?.[field] ?? []).entries()) {
if (!isBoundedOpenClawToolGrant(grant)) {
add(`agent.tools.${field}.${index}`, "Tool grants must be bounded concrete names.");
}
}
}
}
if (profilePolicy === "current" && tools?.alsoAllow && !profile) {
add("agent.tools.alsoAllow", "May be set only when a built-in profile is selected.");
}
if (tools?.allow && tools.alsoAllow) {
add("agent.tools.alsoAllow", "Must not be combined with tools.allow.");
}
if (profilePolicy === "current" && profile && isOpenClawBuiltinProfile(profile)) {
if (profile === "full" && !tools?.allow) {
add("agent.tools.profile", "The full profile requires a bounded explicit allowlist.");
}
if ((profile === "coding" || profile === "messaging") && !tools?.allow) {
add(
"agent.tools.allow",
"Profiles containing bundle MCP tools require a bounded explicit allowlist.",
);
}
for (const [index, grant] of (tools?.allow ?? []).entries()) {
if (isBoundedOpenClawToolGrant(grant) && !toolGrantOverlapsProfile(grant, profile)) {
add(`agent.tools.allow.${index}`, "Must overlap the selected built-in profile.");
}
}
}
if (parsed.agent?.memory?.search?.sources?.length === 0) {
add("agent.memory.search.sources", "Must contain at least one source.");
}
@@ -381,7 +621,7 @@ function validateOpenClawProfile(
extensionIds.add(extension.id);
extensionRefs.add(extension.ref.toLowerCase());
}
return issues;
return { issues, extensionCount: parsed.extensions?.length ?? 0 };
}
function parseManifestDocument(
@@ -479,6 +719,7 @@ export function validateClawPackageContents(input: {
version: string;
packageJson: unknown;
files: readonly ClawPackageTextFile[];
openClawProfilePolicy?: OpenClawProfilePolicy;
}):
| { ok: true; value: ValidatedClawPackage }
| { ok: false; issues: ClawPackageValidationIssue[] } {
@@ -655,6 +896,7 @@ export function validateClawPackageContents(input: {
const profileFiles = [...fileByPath.values()].filter((file) =>
portablePathKey(file.path).startsWith("profiles/"),
);
let openClawExtensionCount = 0;
for (const profileFile of profileFiles) {
if (!HARNESS_PROFILE_PATH_PATTERN.test(profileFile.path)) {
issues.push(
@@ -688,7 +930,15 @@ export function validateClawPackageContents(input: {
if (profileFile.path === "profiles/openclaw.yml") {
const profile = parseJsonCompatibleYaml(profileFile.text, profileFile.path);
if (profile.issues) issues.push(...profile.issues);
else issues.push(...validateOpenClawProfile(profile.value, profileFile.path));
else {
const validatedProfile = validateOpenClawProfile(
profile.value,
profileFile.path,
input.openClawProfilePolicy ?? "current",
);
issues.push(...validatedProfile.issues);
openClawExtensionCount = validatedProfile.extensionCount;
}
} else {
const profile = parseGenericHarnessProfile(profileFile.text, profileFile.path);
if (profile.issues) issues.push(...profile.issues);
@@ -716,6 +966,11 @@ export function validateClawPackageContents(input: {
const summary = summarizeClawManifest(validated.manifest, {
clawMarkdownBody: hasClawMarkdownBody,
});
summary.profiles = {
count: profileFiles.length,
hasOpenClaw: profileFiles.some((file) => file.path === "profiles/openclaw.yml"),
};
summary.extensions = { count: openClawExtensionCount };
if (packageBootstrap) {
summary.workspace.bootstrapFiles = [...summary.workspace.bootstrapFiles, "BOOTSTRAP.md"].sort();
}
+15
View File
@@ -104,6 +104,8 @@ export type ClawManifestSummary = {
agent: { id: string; name?: string; description?: string };
workspace: { bootstrapFiles: string[]; fileCount: number };
packages: { skillCount: number; pluginCount: number };
profiles?: { count: number; hasOpenClaw: boolean };
extensions?: { count: number };
mcpServerCount: number;
cronJobCount: number;
};
@@ -112,6 +114,7 @@ export type ClawManifestSummarySchemaAdapter<TValue, TOptional = TValue> = {
literalOne: TValue;
string: TValue;
number: TValue;
boolean: TValue;
stringArray: TValue;
boundedString: (maxCharacters: number) => TValue;
optional: (schema: TValue) => TOptional;
@@ -139,6 +142,17 @@ export function createClawManifestSummarySchema<TValue, TOptional = TValue>(
skillCount: adapter.number,
pluginCount: adapter.number,
}),
profiles: adapter.optional(
adapter.object({
count: adapter.number,
hasOpenClaw: adapter.boolean,
}),
),
extensions: adapter.optional(
adapter.object({
count: adapter.number,
}),
),
mcpServerCount: adapter.number,
cronJobCount: adapter.number,
});
@@ -149,6 +163,7 @@ export const ClawManifestSummarySchema = createClawManifestSummarySchema<BaseTyp
literalOne: type("1"),
string: type("string"),
number: type("number"),
boolean: type("boolean"),
stringArray: type("string[]"),
boundedString: (maxCharacters) =>
type("string").narrow((value) => Array.from(value).length <= maxCharacters),
@@ -27,6 +27,8 @@ function makeFeed(): ExperimentalClawFeed {
agent: { id: "triage", name: "Triage" },
workspace: { bootstrapFiles: ["SOUL.md", "AGENTS.md"], fileCount: 2 },
packages: { skillCount: 1, pluginCount: 0 },
profiles: { count: 2, hasOpenClaw: true },
extensions: { count: 1 },
mcpServerCount: 0,
cronJobCount: 1,
},
@@ -54,7 +56,14 @@ describe("experimental Claw feed schema", () => {
expect(serializeExperimentalClawFeed(first)).toBe(serializeExperimentalClawFeed(second));
expect(
parseExperimentalClawFeed(JSON.parse(serializeExperimentalClawFeed(first))).entries[0],
).toMatchObject({ type: "claw", clawManifestSummary: { agent: { id: "triage" } } });
).toMatchObject({
type: "claw",
clawManifestSummary: {
agent: { id: "triage" },
profiles: { count: 2, hasOpenClaw: true },
extensions: { count: 1 },
},
});
});
it("rejects generic feed ids and non-Claw entries", () => {
@@ -125,6 +125,17 @@ export function serializeExperimentalClawFeed(feed: ExperimentalClawFeed): strin
skillCount: entry.clawManifestSummary.packages.skillCount,
pluginCount: entry.clawManifestSummary.packages.pluginCount,
},
...(entry.clawManifestSummary.profiles === undefined
? {}
: {
profiles: {
count: entry.clawManifestSummary.profiles.count,
hasOpenClaw: entry.clawManifestSummary.profiles.hasOpenClaw,
},
}),
...(entry.clawManifestSummary.extensions === undefined
? {}
: { extensions: { count: entry.clawManifestSummary.extensions.count } }),
mcpServerCount: entry.clawManifestSummary.mcpServerCount,
cronJobCount: entry.clawManifestSummary.cronJobCount,
},
+58 -6
View File
@@ -4,7 +4,7 @@ import { once } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { crc32, createDeflateRaw } from "node:zlib";
import { EXPERIMENTAL_CLAW_FEED_ID, serializeExperimentalClawFeed } from "clawhub-schema";
@@ -22,6 +22,7 @@ import {
const execFileAsync = promisify(execFile);
const openclawRepo = process.env.OPENCLAW_CLAWS_CHECKOUT;
const fixtureRoot = resolve("fixtures/claws/hosted-e2e");
const conformanceFixture = resolve("fixtures/claws/conformance-v1/cases.json");
let tempRoot = "";
let archiveBytes = new Uint8Array();
let integrity = "";
@@ -137,9 +138,14 @@ async function compactZipOfZeros(path: string, unpackedSize: number) {
}
async function npmPackFixture(destination: string) {
const npmArgs =
process.platform === "win32"
? [join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js")]
: [];
const { stdout } = await execFileAsync(
"npm",
process.platform === "win32" ? process.execPath : "npm",
[
...npmArgs,
"pack",
join(fixtureRoot, "package"),
"--json",
@@ -178,8 +184,13 @@ function feedValue() {
clawManifestSummary: {
schemaVersion: 1,
agent: { id: "hosted-e2e", name: "Hosted E2E" },
workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 0 },
workspace: {
bootstrapFiles: ["BOOTSTRAP.md", "HEARTBEAT.md", "SOUL.md"],
fileCount: 1,
},
packages: { skillCount: 0, pluginCount: 0 },
profiles: { count: 1, hasOpenClaw: true },
extensions: { count: 0 },
mcpServerCount: 0,
cronJobCount: 0,
},
@@ -199,6 +210,32 @@ function feedValue() {
);
}
async function runOpenClawProfileConformance(openclawCheckout: string) {
const runner = `
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { parseClawOpenClawProfile } from "./src/claws/schema.ts";
const cases = JSON.parse(readFileSync(process.env.CLAWHUB_CONFORMANCE_CASES, "utf8"));
const vectors = [
...cases.profileCases,
...cases.heartbeatCases,
...cases.extensionCases,
];
const mismatches = vectors
.filter((vector) => parseClawOpenClawProfile(parse(vector.yaml)).ok !== vector.consumerAccepted)
.map((vector) => vector.name);
process.stdout.write(JSON.stringify(mismatches));
`;
const { stdout } = await execFileAsync("pnpm", ["exec", "tsx", "--eval", runner], {
cwd: openclawCheckout,
env: {
...process.env,
CLAWHUB_CONFORMANCE_CASES: conformanceFixture,
},
});
return JSON.parse(stdout) as string[];
}
describe("published Claw to OpenClaw dry-run proof", () => {
beforeAll(async () => {
tempRoot = await mkdtemp(join(tmpdir(), "clawhub-hosted-e2e-fixture-"));
@@ -242,7 +279,7 @@ describe("published Claw to OpenClaw dry-run proof", () => {
afterAll(async () => {
if (server) await new Promise<void>((resolveClose) => server!.close(() => resolveClose()));
if (tempRoot) await rm(tempRoot, { recursive: true, force: true });
});
}, 30_000);
it("selects only the exact public ClawHub candidate", () => {
const selected = selectPublishedClaw(feedValue(), "@openclaw/hosted-e2e");
@@ -311,6 +348,13 @@ describe("published Claw to OpenClaw dry-run proof", () => {
await expect(readResponseBytesBounded(response)).rejects.toThrow("64MB download limit");
});
it.skipIf(!openclawRepo)(
"executes profile conformance vectors against the pinned OpenClaw parser",
async () => {
await expect(runOpenClawProfileConformance(openclawRepo!)).resolves.toEqual([]);
},
);
it.skipIf(!openclawRepo)(
"runs the downloaded package through OpenClaw dry-run",
async () => {
@@ -334,7 +378,10 @@ describe("published Claw to OpenClaw dry-run proof", () => {
kind: "agent",
id: "hosted-e2e",
details: expect.objectContaining({
tools: expect.objectContaining({ profile: "coding" }),
tools: expect.objectContaining({
profile: "full",
allow: ["session_status"],
}),
}),
}),
expect.objectContaining({
@@ -354,12 +401,17 @@ describe("published Claw to OpenClaw dry-run proof", () => {
id: "assets/incident.schema.json",
blocked: false,
}),
expect.objectContaining({
kind: "workspaceFile",
id: "HEARTBEAT.md",
blocked: false,
}),
]),
);
expect(JSON.stringify(result.plan)).not.toContain(
"Use the published Claw package without mutating local state during proof.",
);
},
30_000,
120_000,
);
});
+1 -1
View File
@@ -21,7 +21,7 @@ describe("Claw feed OpenClaw contract workflow", () => {
const job = workflow.jobs["claws-openclaw-contract"];
expect(job?.env).toMatchObject({
OPENCLAW_CONTRACT_REPOSITORY: "openclaw/openclaw",
OPENCLAW_CONTRACT_SHA: "7422222788c4b75581c0370e0614be9e635ec3cd",
OPENCLAW_CONTRACT_SHA: "f8c0e1b8325b1fc36e039cf357a2c4602f76d5aa",
});
expect(job?.steps).toContainEqual(
expect.objectContaining({
+17 -8
View File
@@ -28,15 +28,18 @@ Profiles exist only inside the Claw package. ClawHub reserves the `profiles/`
namespace for lowercase, single-file harness profiles, requires each profile to
be a bounded UTF-8 JSON-compatible YAML mapping, and rejects aliases, anchors,
tags, merge keys, non-string mapping keys, and non-finite values. It validates
the strict profile-v1 structure of `profiles/openclaw.yml` without resolving
built-in profile names, installing extensions, or claiming compatibility with
a particular applying OpenClaw release. Foreign profiles remain structurally
validated but uninterpreted. Applying harnesses discover only their own profile.
the strict profile-v1 structure of `profiles/openclaw.yml`, including the
built-in profile registry pinned by the conformance artifact, without
installing extensions or claiming compatibility beyond that pinned consumer.
Foreign profiles remain structurally validated but uninterpreted. Applying
harnesses discover only their own profile.
ClawHub validates profile shape but treats `agent.tools.profile` as an opaque,
non-empty applying-harness identifier. It does not freeze OpenClaw's evolving
built-in profile registry; OpenClaw resolves the identifier against its current
registry during preview and application.
ClawHub validates `profiles/openclaw.yml` no more permissively than the pinned
shipped OpenClaw v1 consumer contract. Registered built-in profiles, bounded
tool grants, extension references, heartbeat settings, and their cross-field
rules must pass before publication. The representative vectors in
`fixtures/claws/conformance-v1/cases.json` record the consumer commit and keep
observable accepted/rejected behavior aligned across repository boundaries.
An optional package-root `BOOTSTRAP.md` carries reviewed first-run instructions.
It must be bounded, nonempty UTF-8 text and cannot also be targeted through the
@@ -50,6 +53,9 @@ artifact digest.
- Backend Claw publication and read surfaces require
`CLAWHUB_EXPERIMENTAL_CLAWS=1`.
- This hosting gate is independent from OpenClaw's
`OPENCLAW_EXPERIMENTAL_CLAWS=1` local consumer gate; neither enables the
other side of the boundary.
- The gate is not user consent and must not bypass validation, moderation,
ownership, or scanner checks.
- Disabled deployments must not accept Claw publication or expose Claws through
@@ -103,6 +109,9 @@ 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.
New summaries also expose only the profile/extension footprint: total harness
profiles, conventional OpenClaw profiles, and OpenClaw-native extension count.
They never expose profile contents.
Claws use the existing package publication pipeline. `package.json` declares
the package identity, version, and package-relative `openclaw.claw` manifest