mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4117154eca |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -434,6 +434,7 @@ export const ApiV1PackageResponseSchema = type({
|
||||
name: "string",
|
||||
displayName: "string",
|
||||
family: PackageFamilySchema,
|
||||
clawProfilePolicyVersion: "1?",
|
||||
runtimeId: "string|null?",
|
||||
channel: PackageChannelSchema,
|
||||
isOfficial: "boolean",
|
||||
|
||||
+11
@@ -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;
|
||||
|
||||
Vendored
+247
-8
@@ -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();
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+8
@@ -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;
|
||||
|
||||
Vendored
+8
@@ -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(),
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+11
@@ -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,
|
||||
},
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user