feat(claws): align package layers with schema v1 (#3328)

Adds conventional harness profiles, package-root BOOTSTRAP.md, strict OpenClaw validation, portable path hardening, and an official upstream contract pin.
This commit is contained in:
Gio Della-Libera
2026-08-09 07:46:06 -07:00
committed by GitHub
parent 64db9c3fae
commit 348851eeb9
22 changed files with 886 additions and 228 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
timeout-minutes: 25
env:
OPENCLAW_CONTRACT_REPOSITORY: openclaw/openclaw
OPENCLAW_CONTRACT_SHA: e79faff8aa755b201302edd286976a03f9ed79ea
OPENCLAW_CONTRACT_SHA: 7422222788c4b75581c0370e0614be9e635ec3cd
steps:
- uses: actions/checkout@v7.0.1
+18 -2
View File
@@ -9944,9 +9944,11 @@ describe("packages public queries", () => {
],
[
"storage:claw",
`---\nschemaVersion: 1\nagent:\n id: demo-claw\n name: Demo Claw\n description: ${longClawDescription}\nmetadata:\n openclaw.config: profiles/openclaw.yml\n---\nRun the demo workflow precisely.\n`,
`---\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:codex-profile", "version: 1\nfeatures: [future]\n"],
["storage:bootstrap", "Ask which repositories the user owns.\n"],
]);
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if (args.minimumRole === "publisher") {
@@ -10012,6 +10014,18 @@ describe("packages public queries", () => {
storageId: "storage:profile",
sha256: "profile",
},
{
path: "profiles/codex.yml",
size: 1,
storageId: "storage:codex-profile",
sha256: "codex-profile",
},
{
path: "BOOTSTRAP.md",
size: 1,
storageId: "storage:bootstrap",
sha256: "bootstrap",
},
],
},
}),
@@ -10036,7 +10050,9 @@ describe("packages public queries", () => {
id: "demo-claw",
description: "x".repeat(1_024),
}),
workspace: expect.objectContaining({ bootstrapFiles: ["SOUL.md"] }),
workspace: expect.objectContaining({
bootstrapFiles: ["BOOTSTRAP.md", "SOUL.md"],
}),
}),
pluginManifestSummary: undefined,
}),
+31 -25
View File
@@ -8472,11 +8472,40 @@ async function publishPackageImpl(
if (family !== "claw" && !pluginManifest) {
throw new ConvexError("openclaw.plugin.json is required for plugin packages");
}
const clawTextByPath = new Map<string, string>();
if (clawManifestEntry) {
clawTextByPath.set(clawManifestEntry.file.path, clawManifestEntry.text);
}
if (family === "claw") {
const bootstrapEntry = await readOptionalTextFile(
ctx,
files,
(path) => path === "BOOTSTRAP.md",
{
exactPath: true,
maxBytes: 2 * 1024 * 1024,
label: "Claw BOOTSTRAP.md",
strictUtf8: true,
},
);
if (bootstrapEntry) {
clawTextByPath.set(bootstrapEntry.file.path, bootstrapEntry.text);
}
for (const profileFile of files.filter((file) => /^profiles\/.*\.ya?ml$/i.test(file.path))) {
const profileText = await readStorageText(ctx, profileFile.storageId, {
maxBytes: 256 * 1024,
label:
profileFile.path === "profiles/openclaw.yml" ? "OpenClaw profile" : "Harness profile",
strictUtf8: true,
});
clawTextByPath.set(profileFile.path, profileText);
}
}
const clawValidationFiles = files.map((file) => ({
path: file.path,
...(clawManifestEntry?.file.path === file.path ? { text: clawManifestEntry.text } : {}),
...(clawTextByPath.has(file.path) ? { text: clawTextByPath.get(file.path) } : {}),
}));
let clawPackage =
const clawPackage =
family === "claw"
? validateClawPackageContents({
packageName: name,
@@ -8485,29 +8514,6 @@ async function publishPackageImpl(
files: clawValidationFiles,
})
: null;
if (clawPackage && !clawPackage.ok) {
const profilePath = clawPackage.issues.find(
(entry) => entry.code === "missing_openclaw_profile",
)?.path;
if (profilePath) {
const profileEntry = await readOptionalTextFile(ctx, files, (path) => path === profilePath, {
exactPath: true,
maxBytes: 256 * 1024,
label: "OpenClaw profile",
strictUtf8: true,
});
if (profileEntry) {
clawPackage = validateClawPackageContents({
packageName: name,
version,
packageJson,
files: clawValidationFiles.map((file) =>
file.path === profileEntry.file.path ? { ...file, text: profileEntry.text } : file,
),
});
}
}
}
if (clawPackage && !clawPackage.ok) {
throw new ConvexError(
`Invalid Claw package: ${clawPackage.issues
+57 -3
View File
@@ -68,9 +68,61 @@ Do not combine a non-empty `CLAW.md` body with an explicit workspace file whose
portable destination is `SOUL.md`. ClawHub rejects that ambiguous dual source.
Headings and task lists in the body are prompt text, not package-time commands.
Every `workspace.*.source` must name a file in the same package. Package names
and versions must match `package.json`, dependency versions must be exact, and
MCP environment values must remain unresolved `${ENV_VAR}` references.
Every `workspace.*.source` must name a file in the same package. Assets such as
schemas, templates, examples, and images are portable ordinary workspace files;
place them under paths such as `assets/`, `schemas/`, or `templates/` and declare
them in `workspace.files`. Package names and versions must match `package.json`,
dependency versions must be exact, and MCP environment values must remain
unresolved `${ENV_VAR}` references.
## Bootstrap and harness profiles
An optional package-root `BOOTSTRAP.md` contains first-run instructions. It is
seeded once by a supporting harness and remains separate from the reusable
portable prompt in the `CLAW.md` body. Do not declare root `BOOTSTRAP.md` as a
workspace destination; that path is reserved for the package-root file. ClawHub
includes bootstrap presence, but never its contents, in the bounded catalog
summary.
Harness-specific tuning uses conventional package paths:
```text
profiles/openclaw.yml
profiles/hermes.yml
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.
The retired `metadata.openclaw.config` pointer is rejected. Move that file to
`profiles/openclaw.yml` and remove the metadata entry.
OpenClaw-native extension packages belong in `profiles/openclaw.yml`, while
portable MCP servers remain in `CLAW.md`:
```yaml
schemaVersion: 1
agent:
tools:
profile: coding
extensions:
- id: issue-tools
kind: plugin
format: openclaw
source: clawhub
ref: "@acme/issue-tools"
version: 2.3.4
```
Extension ids and package references must be unique, and versions must be
exact. Other harnesses may bind the same application needs using their own
native profile model; the portable manifest does not impose a capability-name
registry.
## Validate and publish
@@ -97,6 +149,8 @@ Publication rejects:
- malformed `CLAW.md` frontmatter or manifest fields;
- a non-empty `CLAW.md` body combined with an explicit `SOUL.md` destination;
- missing workspace source files or portable path collisions;
- invalid package-root bootstrap instructions or conventional harness profiles;
- the retired `metadata.openclaw.config` pointer;
- floating skill/plugin versions and resolved MCP credentials.
Accepted packages continue through ClawHub's existing ownership, moderation,
@@ -0,0 +1 @@
Ask which services the user owns before beginning.
+4 -2
View File
@@ -3,8 +3,10 @@ schemaVersion: 1
agent:
id: hosted-e2e
name: Hosted E2E
metadata:
openclaw.config: profiles/openclaw.yml
workspace:
files:
- source: assets/incident.schema.json
path: assets/incident.schema.json
packages: []
mcpServers: {}
cronJobs: []
@@ -0,0 +1 @@
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object" }
@@ -3198,13 +3198,18 @@ describe("package commands", () => {
"agent:",
" id: github-triage",
" name: GitHub Triage",
"metadata:",
" openclaw.config: profiles/openclaw.yml",
"workspace:",
" files:",
" - source: assets/triage.schema.json",
" path: assets/triage.schema.json",
"---",
"Be precise.",
].join("\n"),
"utf8",
);
await mkdir(join(folder, "assets"), { recursive: true });
await writeFile(join(folder, "BOOTSTRAP.md"), "Ask which repositories to triage.\n", "utf8");
await writeFile(join(folder, "assets", "triage.schema.json"), "{}\n", "utf8");
await writeFile(
join(folder, "profiles", "openclaw.yml"),
[
+165 -32
View File
@@ -27,12 +27,25 @@ const WINDOWS_INVALID_PATH_CHARS = /[<>:"|?*]/;
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const UNICODE_CONTROL_CHARACTER = /\p{Cc}/u;
const MAX_CLAW_MANIFEST_BYTES = 1024 * 1024;
const MAX_OPENCLAW_PROFILE_BYTES = 256 * 1024;
const MAX_PACKAGE_BOOTSTRAP_BYTES = 2 * 1024 * 1024;
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 StrictStringArraySchema = type("string[]");
const OpenClawExtensionSchema = type({
"+": "reject",
id: "string",
kind: '"plugin"',
format: '"openclaw"|"claude"|"codex"|"cursor"',
source: '"clawhub"',
ref: "string",
version: "string",
});
const OpenClawProfileSchema = type({
"+": "reject",
schemaVersion: "1",
agent: {
agent: type({
"+": "reject",
groupChat: type({
"+": "reject",
@@ -83,13 +96,22 @@ const OpenClawProfileSchema = type({
minMs: "number?",
maxMs: "number?",
}).optional(),
},
}),
extensions: OpenClawExtensionSchema.array().optional(),
});
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isJsonCompatibleValue(value: unknown): boolean {
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonCompatibleValue);
if (isRecord(value)) return Object.values(value).every(isJsonCompatibleValue);
return false;
}
export function isSafeClawPackagePath(value: string): boolean {
const normalized = value.replaceAll("\\", "/");
if (
@@ -198,6 +220,31 @@ function parseJsonCompatibleYaml(raw: string, path: string) {
}
}
function parseGenericHarnessProfile(raw: string, path: string) {
const parsed = parseJsonCompatibleYaml(raw, path);
if (parsed.issues) {
return {
issues: parsed.issues.map((entry) => ({
...entry,
code: entry.code.replace("openclaw", "harness"),
message: entry.message.replaceAll("OpenClaw profile", "Harness profile"),
})),
};
}
if (!isRecord(parsed.value) || !isJsonCompatibleValue(parsed.value)) {
return {
issues: [
issue(
"invalid_harness_profile",
path,
"Harness profiles must be JSON-compatible YAML mappings.",
),
],
};
}
return parsed;
}
function isStrictNonEmpty(value: string): boolean {
return value.length > 0 && value === value.trim();
}
@@ -254,29 +301,32 @@ function validateOpenClawProfile(
}
};
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent.groupChat?.mentionPatterns);
if (parsed.agent.tools?.profile !== undefined && !isStrictNonEmpty(parsed.agent.tools.profile)) {
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent?.groupChat?.mentionPatterns);
if (
parsed.agent?.tools?.profile !== undefined &&
!isStrictNonEmpty(parsed.agent?.tools.profile)
) {
add("agent.tools.profile", "Must be non-empty without leading or trailing whitespace.");
}
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) {
requireNonEmpty("agent.tools.allow", parsed.agent?.tools?.allow);
requireNonEmpty("agent.tools.alsoAllow", parsed.agent?.tools?.alsoAllow);
requireNonEmpty("agent.tools.deny", parsed.agent?.tools?.deny);
if (parsed.agent?.tools?.allow && parsed.agent?.tools.alsoAllow) {
add("agent.tools.alsoAllow", "Must not be combined with tools.allow.");
}
if (parsed.agent.memory?.search?.sources?.length === 0) {
if (parsed.agent?.memory?.search?.sources?.length === 0) {
add("agent.memory.search.sources", "Must contain at least one source.");
}
if (
parsed.agent.memory?.search?.sources?.includes("sessions") &&
parsed.agent.memory.search.rememberAcrossConversations !== true
parsed.agent?.memory?.search?.sources?.includes("sessions") &&
parsed.agent?.memory.search.rememberAcrossConversations !== true
) {
add(
"agent.memory.search.rememberAcrossConversations",
"Must be true when memory.search.sources includes sessions.",
);
}
const heartbeat = parsed.agent.heartbeat;
const heartbeat = parsed.agent?.heartbeat;
if (heartbeat?.every !== undefined && !isValidDuration(heartbeat.every)) {
add("agent.heartbeat.every", "Must be a valid duration.");
}
@@ -304,11 +354,33 @@ function validateOpenClawProfile(
add("agent.heartbeat.timeoutSeconds", "Must be a positive integer.");
}
for (const field of ["minMs", "maxMs"] as const) {
const delay = parsed.agent.humanDelay?.[field];
const delay = parsed.agent?.humanDelay?.[field];
if (delay !== undefined && (!Number.isInteger(delay) || delay < 0)) {
add(`agent.humanDelay.${field}`, "Must be a nonnegative integer.");
}
}
const extensionIds = new Set<string>();
const extensionRefs = new Set<string>();
for (const [index, extension] of (parsed.extensions ?? []).entries()) {
const path = `extensions.${index}`;
if (!AGENT_ID_PATTERN.test(extension.id)) {
add(`${path}.id`, "Must use the portable agent-id syntax.");
}
if (!PACKAGE_NAME_PATTERN.test(extension.ref)) {
add(`${path}.ref`, "Must use a canonical lowercase ClawHub package name.");
}
if (!EXACT_VERSION_PATTERN.test(extension.version)) {
add(`${path}.version`, "Must use an exact semantic version.");
}
if (extensionIds.has(extension.id)) {
add(`${path}.id`, "Extension ids must be unique.");
}
if (extensionRefs.has(extension.ref.toLowerCase())) {
add(`${path}.ref`, "Extension package references must be unique.");
}
extensionIds.add(extension.id);
extensionRefs.add(extension.ref.toLowerCase());
}
return issues;
}
@@ -539,33 +611,88 @@ export function validateClawPackageContents(input: {
);
}
const openClawProfilePath = validated.manifest.metadata?.["openclaw.config"];
if (openClawProfilePath !== undefined) {
const profileFile = fileByPath.get(openClawProfilePath);
if (!profileFile || profileFile.text === undefined) {
const packageBootstrap = [...fileByPath.values()].find(
(file) => portablePathKey(file.path) === portablePathKey("BOOTSTRAP.md"),
);
if (packageBootstrap && packageBootstrap.path !== "BOOTSTRAP.md") {
issues.push(
issue(
"missing_openclaw_profile",
openClawProfilePath,
"The declared OpenClaw profile is missing or is not UTF-8 text.",
"invalid_package_path",
packageBootstrap.path,
"Package-root bootstrap files must use the exact path BOOTSTRAP.md.",
),
);
} else if (new TextEncoder().encode(profileFile.text).byteLength > MAX_OPENCLAW_PROFILE_BYTES) {
} else if (packageBootstrap) {
if (packageBootstrap.text === undefined) {
issues.push(
issue(
"openclaw_profile_too_large",
openClawProfilePath,
`The OpenClaw profile exceeds ${MAX_OPENCLAW_PROFILE_BYTES} bytes.`,
"package_bootstrap_invalid",
"BOOTSTRAP.md",
"Package-root BOOTSTRAP.md must be UTF-8 text.",
),
);
} else if (
new TextEncoder().encode(packageBootstrap.text).byteLength > MAX_PACKAGE_BOOTSTRAP_BYTES
) {
issues.push(
issue(
"package_bootstrap_too_large",
"BOOTSTRAP.md",
`Package-root BOOTSTRAP.md exceeds ${MAX_PACKAGE_BOOTSTRAP_BYTES} bytes.`,
),
);
} else if (packageBootstrap.text.trim().length === 0) {
issues.push(
issue(
"package_bootstrap_empty",
"BOOTSTRAP.md",
"Package-root BOOTSTRAP.md must contain first-run instructions.",
),
);
} else {
const profile = parseJsonCompatibleYaml(profileFile.text, openClawProfilePath);
if (profile.issues) {
issues.push(...profile.issues);
} else {
issues.push(...validateOpenClawProfile(profile.value, openClawProfilePath));
}
}
const profileFiles = [...fileByPath.values()].filter((file) =>
portablePathKey(file.path).startsWith("profiles/"),
);
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.",
),
);
continue;
}
if (profileFile.text === undefined) {
issues.push(
issue("invalid_harness_profile", profileFile.path, "Harness profiles must be UTF-8 text."),
);
continue;
}
if (new TextEncoder().encode(profileFile.text).byteLength > MAX_HARNESS_PROFILE_BYTES) {
const isOpenClawProfile = profileFile.path === "profiles/openclaw.yml";
issues.push(
issue(
isOpenClawProfile ? "openclaw_profile_too_large" : "harness_profile_too_large",
profileFile.path,
isOpenClawProfile
? `OpenClaw profiles may not exceed ${MAX_HARNESS_PROFILE_BYTES} bytes.`
: `Harness profiles may not exceed ${MAX_HARNESS_PROFILE_BYTES} bytes.`,
),
);
continue;
}
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 profile = parseGenericHarnessProfile(profileFile.text, profileFile.path);
if (profile.issues) issues.push(...profile.issues);
}
}
const sources = [
@@ -586,12 +713,18 @@ export function validateClawPackageContents(input: {
}
}
if (issues.length > 0) return { ok: false, issues };
const summary = summarizeClawManifest(validated.manifest, {
clawMarkdownBody: hasClawMarkdownBody,
});
if (packageBootstrap) {
summary.workspace.bootstrapFiles = [...summary.workspace.bootstrapFiles, "BOOTSTRAP.md"].sort();
}
return {
ok: true,
value: {
manifestPath,
manifest: validated.manifest,
summary: summarizeClawManifest(validated.manifest, { clawMarkdownBody: hasClawMarkdownBody }),
summary,
hasClawMarkdownBody,
},
};
+14 -10
View File
@@ -163,7 +163,8 @@ export const CLAW_MANIFEST_VALIDATION_CODES = {
invalidAgentId: "claw_v1_invalid_agent_id",
nonCanonicalString: "claw_v1_non_canonical_string",
emptyList: "claw_v1_empty_list",
invalidProfilePath: "claw_v1_invalid_profile_path",
legacyProfilePointer: "claw_v1_legacy_profile_pointer",
reservedWorkspaceTarget: "claw_v1_reserved_workspace_target",
unsafePath: "claw_v1_unsafe_path",
duplicateWorkspaceDestination: "claw_v1_duplicate_workspace_destination",
invalidAvatar: "claw_v1_invalid_avatar",
@@ -555,18 +556,12 @@ export function validateClawManifest(
for (const key of Object.keys(parsed.metadata ?? {})) {
pushNonEmpty(issues, `$.metadata.${key}`, key);
}
const openClawProfilePath = parsed.metadata?.["openclaw.config"];
if (
openClawProfilePath !== undefined &&
(openClawProfilePath.includes("\\") ||
!isSafePackagePath(openClawProfilePath) ||
!/\.ya?ml$/i.test(openClawProfilePath))
) {
if (Object.hasOwn(parsed.metadata ?? {}, "openclaw.config")) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidProfilePath,
CLAW_MANIFEST_VALIDATION_CODES.legacyProfilePointer,
"$.metadata.openclaw.config",
"Must reference a forward-slash package-relative .yml or .yaml file.",
"metadata.openclaw.config is no longer supported; move the profile to profiles/openclaw.yml and remove the metadata entry.",
),
);
}
@@ -605,6 +600,15 @@ export function validateClawManifest(
);
}
const destinationKey = portablePathKey(file.path);
if (destinationKey === portablePathKey("BOOTSTRAP.md")) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.reservedWorkspaceTarget,
`$.workspace.files.${index}.path`,
"Root BOOTSTRAP.md is reserved for the package-root seed-once bootstrap file.",
),
);
}
if (conflictsWithWorkspaceTarget(workspaceTargets, destinationKey)) {
issues.push(
validationIssue(
+127 -27
View File
@@ -7,12 +7,25 @@ const WINDOWS_INVALID_PATH_CHARS = /[<>:"|?*]/;
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const UNICODE_CONTROL_CHARACTER = /\p{Cc}/u;
const MAX_CLAW_MANIFEST_BYTES = 1024 * 1024;
const MAX_OPENCLAW_PROFILE_BYTES = 256 * 1024;
const MAX_PACKAGE_BOOTSTRAP_BYTES = 2 * 1024 * 1024;
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 StrictStringArraySchema = type("string[]");
const OpenClawExtensionSchema = type({
"+": "reject",
id: "string",
kind: '"plugin"',
format: '"openclaw"|"claude"|"codex"|"cursor"',
source: '"clawhub"',
ref: "string",
version: "string",
});
const OpenClawProfileSchema = type({
"+": "reject",
schemaVersion: "1",
agent: {
agent: type({
"+": "reject",
groupChat: type({
"+": "reject",
@@ -63,11 +76,23 @@ const OpenClawProfileSchema = type({
minMs: "number?",
maxMs: "number?",
}).optional(),
},
}),
extensions: OpenClawExtensionSchema.array().optional(),
});
function isRecord(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isJsonCompatibleValue(value) {
if (value === null || typeof value === "string" || typeof value === "boolean")
return true;
if (typeof value === "number")
return Number.isFinite(value);
if (Array.isArray(value))
return value.every(isJsonCompatibleValue);
if (isRecord(value))
return Object.values(value).every(isJsonCompatibleValue);
return false;
}
export function isSafeClawPackagePath(value) {
const normalized = value.replaceAll("\\", "/");
if (!normalized ||
@@ -157,6 +182,26 @@ function parseJsonCompatibleYaml(raw, path) {
};
}
}
function parseGenericHarnessProfile(raw, path) {
const parsed = parseJsonCompatibleYaml(raw, path);
if (parsed.issues) {
return {
issues: parsed.issues.map((entry) => ({
...entry,
code: entry.code.replace("openclaw", "harness"),
message: entry.message.replaceAll("OpenClaw profile", "Harness profile"),
})),
};
}
if (!isRecord(parsed.value) || !isJsonCompatibleValue(parsed.value)) {
return {
issues: [
issue("invalid_harness_profile", path, "Harness profiles must be JSON-compatible YAML mappings."),
],
};
}
return parsed;
}
function isStrictNonEmpty(value) {
return value.length > 0 && value === value.trim();
}
@@ -201,24 +246,25 @@ function validateOpenClawProfile(value, profilePath) {
}
}
};
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent.groupChat?.mentionPatterns);
if (parsed.agent.tools?.profile !== undefined && !isStrictNonEmpty(parsed.agent.tools.profile)) {
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent?.groupChat?.mentionPatterns);
if (parsed.agent?.tools?.profile !== undefined &&
!isStrictNonEmpty(parsed.agent?.tools.profile)) {
add("agent.tools.profile", "Must be non-empty without leading or trailing whitespace.");
}
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) {
requireNonEmpty("agent.tools.allow", parsed.agent?.tools?.allow);
requireNonEmpty("agent.tools.alsoAllow", parsed.agent?.tools?.alsoAllow);
requireNonEmpty("agent.tools.deny", parsed.agent?.tools?.deny);
if (parsed.agent?.tools?.allow && parsed.agent?.tools.alsoAllow) {
add("agent.tools.alsoAllow", "Must not be combined with tools.allow.");
}
if (parsed.agent.memory?.search?.sources?.length === 0) {
if (parsed.agent?.memory?.search?.sources?.length === 0) {
add("agent.memory.search.sources", "Must contain at least one source.");
}
if (parsed.agent.memory?.search?.sources?.includes("sessions") &&
parsed.agent.memory.search.rememberAcrossConversations !== true) {
if (parsed.agent?.memory?.search?.sources?.includes("sessions") &&
parsed.agent?.memory.search.rememberAcrossConversations !== true) {
add("agent.memory.search.rememberAcrossConversations", "Must be true when memory.search.sources includes sessions.");
}
const heartbeat = parsed.agent.heartbeat;
const heartbeat = parsed.agent?.heartbeat;
if (heartbeat?.every !== undefined && !isValidDuration(heartbeat.every)) {
add("agent.heartbeat.every", "Must be a valid duration.");
}
@@ -243,11 +289,33 @@ function validateOpenClawProfile(value, profilePath) {
add("agent.heartbeat.timeoutSeconds", "Must be a positive integer.");
}
for (const field of ["minMs", "maxMs"]) {
const delay = parsed.agent.humanDelay?.[field];
const delay = parsed.agent?.humanDelay?.[field];
if (delay !== undefined && (!Number.isInteger(delay) || delay < 0)) {
add(`agent.humanDelay.${field}`, "Must be a nonnegative integer.");
}
}
const extensionIds = new Set();
const extensionRefs = new Set();
for (const [index, extension] of (parsed.extensions ?? []).entries()) {
const path = `extensions.${index}`;
if (!AGENT_ID_PATTERN.test(extension.id)) {
add(`${path}.id`, "Must use the portable agent-id syntax.");
}
if (!PACKAGE_NAME_PATTERN.test(extension.ref)) {
add(`${path}.ref`, "Must use a canonical lowercase ClawHub package name.");
}
if (!EXACT_VERSION_PATTERN.test(extension.version)) {
add(`${path}.version`, "Must use an exact semantic version.");
}
if (extensionIds.has(extension.id)) {
add(`${path}.id`, "Extension ids must be unique.");
}
if (extensionRefs.has(extension.ref.toLowerCase())) {
add(`${path}.ref`, "Extension package references must be unique.");
}
extensionIds.add(extension.id);
extensionRefs.add(extension.ref.toLowerCase());
}
return issues;
}
function parseManifestDocument(raw, manifestPath) {
@@ -401,23 +469,49 @@ export function validateClawPackageContents(input) {
if (hasClawMarkdownBody && hasImplicitSoulConflict) {
issues.push(issue("claw_body_soul_conflict", "$.workspace", "CLAW.md body content and an explicit SOUL.md workspace declaration cannot both be present."));
}
const openClawProfilePath = validated.manifest.metadata?.["openclaw.config"];
if (openClawProfilePath !== undefined) {
const profileFile = fileByPath.get(openClawProfilePath);
if (!profileFile || profileFile.text === undefined) {
issues.push(issue("missing_openclaw_profile", openClawProfilePath, "The declared OpenClaw profile is missing or is not UTF-8 text."));
const packageBootstrap = [...fileByPath.values()].find((file) => portablePathKey(file.path) === portablePathKey("BOOTSTRAP.md"));
if (packageBootstrap && packageBootstrap.path !== "BOOTSTRAP.md") {
issues.push(issue("invalid_package_path", packageBootstrap.path, "Package-root bootstrap files must use the exact path BOOTSTRAP.md."));
}
else if (new TextEncoder().encode(profileFile.text).byteLength > MAX_OPENCLAW_PROFILE_BYTES) {
issues.push(issue("openclaw_profile_too_large", openClawProfilePath, `The OpenClaw profile exceeds ${MAX_OPENCLAW_PROFILE_BYTES} bytes.`));
else if (packageBootstrap) {
if (packageBootstrap.text === undefined) {
issues.push(issue("package_bootstrap_invalid", "BOOTSTRAP.md", "Package-root BOOTSTRAP.md must be UTF-8 text."));
}
else {
const profile = parseJsonCompatibleYaml(profileFile.text, openClawProfilePath);
if (profile.issues) {
else if (new TextEncoder().encode(packageBootstrap.text).byteLength > MAX_PACKAGE_BOOTSTRAP_BYTES) {
issues.push(issue("package_bootstrap_too_large", "BOOTSTRAP.md", `Package-root BOOTSTRAP.md exceeds ${MAX_PACKAGE_BOOTSTRAP_BYTES} bytes.`));
}
else if (packageBootstrap.text.trim().length === 0) {
issues.push(issue("package_bootstrap_empty", "BOOTSTRAP.md", "Package-root BOOTSTRAP.md must contain first-run instructions."));
}
}
const profileFiles = [...fileByPath.values()].filter((file) => portablePathKey(file.path).startsWith("profiles/"));
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."));
continue;
}
if (profileFile.text === undefined) {
issues.push(issue("invalid_harness_profile", profileFile.path, "Harness profiles must be UTF-8 text."));
continue;
}
if (new TextEncoder().encode(profileFile.text).byteLength > MAX_HARNESS_PROFILE_BYTES) {
const isOpenClawProfile = profileFile.path === "profiles/openclaw.yml";
issues.push(issue(isOpenClawProfile ? "openclaw_profile_too_large" : "harness_profile_too_large", profileFile.path, isOpenClawProfile
? `OpenClaw profiles may not exceed ${MAX_HARNESS_PROFILE_BYTES} bytes.`
: `Harness profiles may not exceed ${MAX_HARNESS_PROFILE_BYTES} bytes.`));
continue;
}
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 {
issues.push(...validateOpenClawProfile(profile.value, openClawProfilePath));
}
const profile = parseGenericHarnessProfile(profileFile.text, profileFile.path);
if (profile.issues)
issues.push(...profile.issues);
}
}
const sources = [
@@ -431,12 +525,18 @@ export function validateClawPackageContents(input) {
}
if (issues.length > 0)
return { ok: false, issues };
const summary = summarizeClawManifest(validated.manifest, {
clawMarkdownBody: hasClawMarkdownBody,
});
if (packageBootstrap) {
summary.workspace.bootstrapFiles = [...summary.workspace.bootstrapFiles, "BOOTSTRAP.md"].sort();
}
return {
ok: true,
value: {
manifestPath,
manifest: validated.manifest,
summary: summarizeClawManifest(validated.manifest, { clawMarkdownBody: hasClawMarkdownBody }),
summary,
hasClawMarkdownBody,
},
};
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -126,7 +126,8 @@ export declare const CLAW_MANIFEST_VALIDATION_CODES: {
readonly invalidAgentId: "claw_v1_invalid_agent_id";
readonly nonCanonicalString: "claw_v1_non_canonical_string";
readonly emptyList: "claw_v1_empty_list";
readonly invalidProfilePath: "claw_v1_invalid_profile_path";
readonly legacyProfilePointer: "claw_v1_legacy_profile_pointer";
readonly reservedWorkspaceTarget: "claw_v1_reserved_workspace_target";
readonly unsafePath: "claw_v1_unsafe_path";
readonly duplicateWorkspaceDestination: "claw_v1_duplicate_workspace_destination";
readonly invalidAvatar: "claw_v1_invalid_avatar";
+7 -7
View File
@@ -130,7 +130,8 @@ export const CLAW_MANIFEST_VALIDATION_CODES = {
invalidAgentId: "claw_v1_invalid_agent_id",
nonCanonicalString: "claw_v1_non_canonical_string",
emptyList: "claw_v1_empty_list",
invalidProfilePath: "claw_v1_invalid_profile_path",
legacyProfilePointer: "claw_v1_legacy_profile_pointer",
reservedWorkspaceTarget: "claw_v1_reserved_workspace_target",
unsafePath: "claw_v1_unsafe_path",
duplicateWorkspaceDestination: "claw_v1_duplicate_workspace_destination",
invalidAvatar: "claw_v1_invalid_avatar",
@@ -463,12 +464,8 @@ export function validateClawManifest(value) {
for (const key of Object.keys(parsed.metadata ?? {})) {
pushNonEmpty(issues, `$.metadata.${key}`, key);
}
const openClawProfilePath = parsed.metadata?.["openclaw.config"];
if (openClawProfilePath !== undefined &&
(openClawProfilePath.includes("\\") ||
!isSafePackagePath(openClawProfilePath) ||
!/\.ya?ml$/i.test(openClawProfilePath))) {
issues.push(validationIssue(CLAW_MANIFEST_VALIDATION_CODES.invalidProfilePath, "$.metadata.openclaw.config", "Must reference a forward-slash package-relative .yml or .yaml file."));
if (Object.hasOwn(parsed.metadata ?? {}, "openclaw.config")) {
issues.push(validationIssue(CLAW_MANIFEST_VALIDATION_CODES.legacyProfilePointer, "$.metadata.openclaw.config", "metadata.openclaw.config is no longer supported; move the profile to profiles/openclaw.yml and remove the metadata entry."));
}
const workspaceTargets = new Set();
for (const name of CLAW_BOOTSTRAP_FILE_NAMES) {
@@ -488,6 +485,9 @@ export function validateClawManifest(value) {
issues.push(validationIssue(CLAW_MANIFEST_VALIDATION_CODES.unsafePath, `$.workspace.files.${index}.path`, "Must be a safe package-relative path."));
}
const destinationKey = portablePathKey(file.path);
if (destinationKey === portablePathKey("BOOTSTRAP.md")) {
issues.push(validationIssue(CLAW_MANIFEST_VALIDATION_CODES.reservedWorkspaceTarget, `$.workspace.files.${index}.path`, "Root BOOTSTRAP.md is reserved for the package-root seed-once bootstrap file."));
}
if (conflictsWithWorkspaceTarget(workspaceTargets, destinationKey)) {
issues.push(validationIssue(CLAW_MANIFEST_VALIDATION_CODES.duplicateWorkspaceDestination, `$.workspace.files.${index}.path`, "Workspace destination is declared more than once."));
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+180 -34
View File
@@ -227,16 +227,12 @@ describe("validateClawPackageContents", () => {
});
it("validates a package-local OpenClaw profile without returning it", () => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
...files(`---\n${JSON.stringify(manifest)}\n---\n# Prompt\n`),
{ path: "profiles/openclaw.yml", text: openClawProfile },
],
});
@@ -244,21 +240,17 @@ describe("validateClawPackageContents", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).not.toHaveProperty("profile");
expect(result.value.manifest.agent).toEqual(profiledManifest.agent);
expect(result.value.manifest.agent).toEqual(manifest.agent);
}
});
it("accepts an applying harness profile that ClawHub does not yet know", () => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
...files(`---\n${JSON.stringify(manifest)}\n---\n# Prompt\n`),
{
path: "profiles/openclaw.yml",
text: "schemaVersion: 1\nagent:\n tools:\n profile: future-profile",
@@ -270,18 +262,48 @@ describe("validateClawPackageContents", () => {
if (result.ok) expect(result.value).not.toHaveProperty("profile");
});
it("requires the exact UTF-8 profile target", () => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
it.each([
"profiles/OPENCLAW.yml",
"profiles/openclaw.yaml",
"Profiles/openclaw.yml",
"profiles/openclaw.json",
"profiles/codex/settings.yml",
])("requires conventional harness profile path %s", (profilePath) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [...files(), { path: profilePath, text: openClawProfile }],
});
expect(result).toEqual({
ok: false,
issues: [
expect.objectContaining({ code: "invalid_harness_profile_path", path: profilePath }),
],
});
});
it("requires agent settings in the conventional OpenClaw profile", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
{ path: "profiles/OPENCLAW.yml", text: openClawProfile },
...files(),
{
path: "profiles/openclaw.yml",
text: [
"schemaVersion: 1",
"extensions:",
" - id: issue-tools",
" kind: plugin",
" format: openclaw",
" source: clawhub",
" ref: '@acme/issue-tools'",
" version: 2.3.4",
].join("\n"),
},
],
});
@@ -289,8 +311,144 @@ describe("validateClawPackageContents", () => {
ok: false,
issues: [
expect.objectContaining({
code: "missing_openclaw_profile",
code: "invalid_openclaw_profile",
path: "profiles/openclaw.yml.agent",
}),
],
});
});
it.each([
["duplicate id", "id: issue-tools", "id: issue-tools"],
["duplicate ref", "id: issue-tools", "id: other-tools"],
])("rejects OpenClaw extension %s", (_label, firstId, secondId) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(),
{
path: "profiles/openclaw.yml",
text: [
"schemaVersion: 1",
"extensions:",
` - ${firstId}`,
" kind: plugin",
" format: openclaw",
" source: clawhub",
" ref: '@acme/issue-tools'",
" version: 2.3.4",
` - ${secondId}`,
" kind: plugin",
" format: codex",
" source: clawhub",
" ref: '@acme/issue-tools'",
" version: 2.3.5",
].join("\n"),
},
],
});
expect(result).toEqual({
ok: false,
issues: expect.arrayContaining([
expect.objectContaining({ code: "invalid_openclaw_profile" }),
]),
});
});
it("accepts but does not interpret a foreign conventional profile", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [...files(), { path: "profiles/codex.yml", text: "version: 27\nfeatures: [future]" }],
});
expect(result.ok).toBe(true);
});
it.each([
["malformed", "version: ["],
["non-mapping", "- one\n- two"],
["non-finite scalar", "limits: [.inf, .nan]"],
["alias", "base: &base {}\ncopy: *base"],
])("rejects %s foreign harness profile YAML", (_label, profileText) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [...files(), { path: "profiles/codex.yml", text: profileText }],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.issues[0]?.code).toMatch(/harness_profile/);
});
it("accepts a nonempty UTF-8 package-root BOOTSTRAP.md", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [...files(), { path: "BOOTSTRAP.md", text: "Interview the user once.\n" }],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.summary.workspace.bootstrapFiles).toEqual(["BOOTSTRAP.md"]);
}
});
it.each(["bootstrap.md", "Bootstrap.md"])(
"rejects noncanonical package-root bootstrap path %s",
(bootstrapPath) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [...files(), { path: bootstrapPath, text: "Unvalidated instructions.\n" }],
});
expect(result).toEqual({
ok: false,
issues: [expect.objectContaining({ code: "invalid_package_path", path: bootstrapPath })],
});
},
);
it.each([
["non-UTF-8", undefined, "package_bootstrap_invalid"],
["empty", " \n", "package_bootstrap_empty"],
["oversized", "x".repeat(2 * 1024 * 1024 + 1), "package_bootstrap_too_large"],
])("rejects %s package-root bootstrap content", (_label, bootstrapText, code) => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [...files(), { path: "BOOTSTRAP.md", text: bootstrapText }],
});
expect(result).toEqual({ ok: false, issues: [expect.objectContaining({ code })] });
});
it("rejects the retired profile pointer with migration guidance", () => {
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: files(
`---\n${JSON.stringify({ ...manifest, metadata: { "openclaw.config": "profiles/openclaw.yml" } })}\n---\n`,
),
});
expect(result).toEqual({
ok: false,
issues: [
expect.objectContaining({
code: "invalid_claw_manifest",
path: "$.metadata.openclaw.config",
message: expect.stringContaining("move the profile to profiles/openclaw.yml"),
}),
],
});
@@ -347,16 +505,12 @@ describe("validateClawPackageContents", () => {
"unsupported_openclaw_profile_yaml_feature",
],
])("rejects OpenClaw profile %s", (_label, text, code) => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
...files(`---\n${JSON.stringify(manifest)}\n---\n# Prompt\n`),
{ path: "profiles/openclaw.yml", text },
],
});
@@ -368,16 +522,12 @@ describe("validateClawPackageContents", () => {
});
it("rejects an oversized OpenClaw profile", () => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
...files(`---\n${JSON.stringify(manifest)}\n---\n# Prompt\n`),
{ path: "profiles/openclaw.yml", text: `#${"x".repeat(256 * 1024)}` },
],
});
@@ -408,16 +558,12 @@ describe("validateClawPackageContents", () => {
"schemaVersion: 1\nagent:\n memory:\n search:\n sources: [sessions]",
],
])("rejects OpenClaw profile with %s", (_label, text) => {
const profiledManifest = {
...manifest,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
};
const result = validateClawPackageContents({
packageName: "@acme/github-triage",
version: "1.0.0",
packageJson: packageJson(),
files: [
...files(`---\n${JSON.stringify(profiledManifest)}\n---\n# Prompt\n`),
...files(`---\n${JSON.stringify(manifest)}\n---\n# Prompt\n`),
{ path: "profiles/openclaw.yml", text },
],
});
+165 -32
View File
@@ -27,12 +27,25 @@ const WINDOWS_INVALID_PATH_CHARS = /[<>:"|?*]/;
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const UNICODE_CONTROL_CHARACTER = /\p{Cc}/u;
const MAX_CLAW_MANIFEST_BYTES = 1024 * 1024;
const MAX_OPENCLAW_PROFILE_BYTES = 256 * 1024;
const MAX_PACKAGE_BOOTSTRAP_BYTES = 2 * 1024 * 1024;
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 StrictStringArraySchema = type("string[]");
const OpenClawExtensionSchema = type({
"+": "reject",
id: "string",
kind: '"plugin"',
format: '"openclaw"|"claude"|"codex"|"cursor"',
source: '"clawhub"',
ref: "string",
version: "string",
});
const OpenClawProfileSchema = type({
"+": "reject",
schemaVersion: "1",
agent: {
agent: type({
"+": "reject",
groupChat: type({
"+": "reject",
@@ -83,13 +96,22 @@ const OpenClawProfileSchema = type({
minMs: "number?",
maxMs: "number?",
}).optional(),
},
}),
extensions: OpenClawExtensionSchema.array().optional(),
});
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isJsonCompatibleValue(value: unknown): boolean {
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonCompatibleValue);
if (isRecord(value)) return Object.values(value).every(isJsonCompatibleValue);
return false;
}
export function isSafeClawPackagePath(value: string): boolean {
const normalized = value.replaceAll("\\", "/");
if (
@@ -198,6 +220,31 @@ function parseJsonCompatibleYaml(raw: string, path: string) {
}
}
function parseGenericHarnessProfile(raw: string, path: string) {
const parsed = parseJsonCompatibleYaml(raw, path);
if (parsed.issues) {
return {
issues: parsed.issues.map((entry) => ({
...entry,
code: entry.code.replace("openclaw", "harness"),
message: entry.message.replaceAll("OpenClaw profile", "Harness profile"),
})),
};
}
if (!isRecord(parsed.value) || !isJsonCompatibleValue(parsed.value)) {
return {
issues: [
issue(
"invalid_harness_profile",
path,
"Harness profiles must be JSON-compatible YAML mappings.",
),
],
};
}
return parsed;
}
function isStrictNonEmpty(value: string): boolean {
return value.length > 0 && value === value.trim();
}
@@ -254,29 +301,32 @@ function validateOpenClawProfile(
}
};
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent.groupChat?.mentionPatterns);
if (parsed.agent.tools?.profile !== undefined && !isStrictNonEmpty(parsed.agent.tools.profile)) {
requireNonEmpty("agent.groupChat.mentionPatterns", parsed.agent?.groupChat?.mentionPatterns);
if (
parsed.agent?.tools?.profile !== undefined &&
!isStrictNonEmpty(parsed.agent?.tools.profile)
) {
add("agent.tools.profile", "Must be non-empty without leading or trailing whitespace.");
}
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) {
requireNonEmpty("agent.tools.allow", parsed.agent?.tools?.allow);
requireNonEmpty("agent.tools.alsoAllow", parsed.agent?.tools?.alsoAllow);
requireNonEmpty("agent.tools.deny", parsed.agent?.tools?.deny);
if (parsed.agent?.tools?.allow && parsed.agent?.tools.alsoAllow) {
add("agent.tools.alsoAllow", "Must not be combined with tools.allow.");
}
if (parsed.agent.memory?.search?.sources?.length === 0) {
if (parsed.agent?.memory?.search?.sources?.length === 0) {
add("agent.memory.search.sources", "Must contain at least one source.");
}
if (
parsed.agent.memory?.search?.sources?.includes("sessions") &&
parsed.agent.memory.search.rememberAcrossConversations !== true
parsed.agent?.memory?.search?.sources?.includes("sessions") &&
parsed.agent?.memory.search.rememberAcrossConversations !== true
) {
add(
"agent.memory.search.rememberAcrossConversations",
"Must be true when memory.search.sources includes sessions.",
);
}
const heartbeat = parsed.agent.heartbeat;
const heartbeat = parsed.agent?.heartbeat;
if (heartbeat?.every !== undefined && !isValidDuration(heartbeat.every)) {
add("agent.heartbeat.every", "Must be a valid duration.");
}
@@ -304,11 +354,33 @@ function validateOpenClawProfile(
add("agent.heartbeat.timeoutSeconds", "Must be a positive integer.");
}
for (const field of ["minMs", "maxMs"] as const) {
const delay = parsed.agent.humanDelay?.[field];
const delay = parsed.agent?.humanDelay?.[field];
if (delay !== undefined && (!Number.isInteger(delay) || delay < 0)) {
add(`agent.humanDelay.${field}`, "Must be a nonnegative integer.");
}
}
const extensionIds = new Set<string>();
const extensionRefs = new Set<string>();
for (const [index, extension] of (parsed.extensions ?? []).entries()) {
const path = `extensions.${index}`;
if (!AGENT_ID_PATTERN.test(extension.id)) {
add(`${path}.id`, "Must use the portable agent-id syntax.");
}
if (!PACKAGE_NAME_PATTERN.test(extension.ref)) {
add(`${path}.ref`, "Must use a canonical lowercase ClawHub package name.");
}
if (!EXACT_VERSION_PATTERN.test(extension.version)) {
add(`${path}.version`, "Must use an exact semantic version.");
}
if (extensionIds.has(extension.id)) {
add(`${path}.id`, "Extension ids must be unique.");
}
if (extensionRefs.has(extension.ref.toLowerCase())) {
add(`${path}.ref`, "Extension package references must be unique.");
}
extensionIds.add(extension.id);
extensionRefs.add(extension.ref.toLowerCase());
}
return issues;
}
@@ -539,33 +611,88 @@ export function validateClawPackageContents(input: {
);
}
const openClawProfilePath = validated.manifest.metadata?.["openclaw.config"];
if (openClawProfilePath !== undefined) {
const profileFile = fileByPath.get(openClawProfilePath);
if (!profileFile || profileFile.text === undefined) {
const packageBootstrap = [...fileByPath.values()].find(
(file) => portablePathKey(file.path) === portablePathKey("BOOTSTRAP.md"),
);
if (packageBootstrap && packageBootstrap.path !== "BOOTSTRAP.md") {
issues.push(
issue(
"missing_openclaw_profile",
openClawProfilePath,
"The declared OpenClaw profile is missing or is not UTF-8 text.",
"invalid_package_path",
packageBootstrap.path,
"Package-root bootstrap files must use the exact path BOOTSTRAP.md.",
),
);
} else if (new TextEncoder().encode(profileFile.text).byteLength > MAX_OPENCLAW_PROFILE_BYTES) {
} else if (packageBootstrap) {
if (packageBootstrap.text === undefined) {
issues.push(
issue(
"openclaw_profile_too_large",
openClawProfilePath,
`The OpenClaw profile exceeds ${MAX_OPENCLAW_PROFILE_BYTES} bytes.`,
"package_bootstrap_invalid",
"BOOTSTRAP.md",
"Package-root BOOTSTRAP.md must be UTF-8 text.",
),
);
} else if (
new TextEncoder().encode(packageBootstrap.text).byteLength > MAX_PACKAGE_BOOTSTRAP_BYTES
) {
issues.push(
issue(
"package_bootstrap_too_large",
"BOOTSTRAP.md",
`Package-root BOOTSTRAP.md exceeds ${MAX_PACKAGE_BOOTSTRAP_BYTES} bytes.`,
),
);
} else if (packageBootstrap.text.trim().length === 0) {
issues.push(
issue(
"package_bootstrap_empty",
"BOOTSTRAP.md",
"Package-root BOOTSTRAP.md must contain first-run instructions.",
),
);
} else {
const profile = parseJsonCompatibleYaml(profileFile.text, openClawProfilePath);
if (profile.issues) {
issues.push(...profile.issues);
} else {
issues.push(...validateOpenClawProfile(profile.value, openClawProfilePath));
}
}
const profileFiles = [...fileByPath.values()].filter((file) =>
portablePathKey(file.path).startsWith("profiles/"),
);
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.",
),
);
continue;
}
if (profileFile.text === undefined) {
issues.push(
issue("invalid_harness_profile", profileFile.path, "Harness profiles must be UTF-8 text."),
);
continue;
}
if (new TextEncoder().encode(profileFile.text).byteLength > MAX_HARNESS_PROFILE_BYTES) {
const isOpenClawProfile = profileFile.path === "profiles/openclaw.yml";
issues.push(
issue(
isOpenClawProfile ? "openclaw_profile_too_large" : "harness_profile_too_large",
profileFile.path,
isOpenClawProfile
? `OpenClaw profiles may not exceed ${MAX_HARNESS_PROFILE_BYTES} bytes.`
: `Harness profiles may not exceed ${MAX_HARNESS_PROFILE_BYTES} bytes.`,
),
);
continue;
}
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 profile = parseGenericHarnessProfile(profileFile.text, profileFile.path);
if (profile.issues) issues.push(...profile.issues);
}
}
const sources = [
@@ -586,12 +713,18 @@ export function validateClawPackageContents(input: {
}
}
if (issues.length > 0) return { ok: false, issues };
const summary = summarizeClawManifest(validated.manifest, {
clawMarkdownBody: hasClawMarkdownBody,
});
if (packageBootstrap) {
summary.workspace.bootstrapFiles = [...summary.workspace.bootstrapFiles, "BOOTSTRAP.md"].sort();
}
return {
ok: true,
value: {
manifestPath,
manifest: validated.manifest,
summary: summarizeClawManifest(validated.manifest, { clawMarkdownBody: hasClawMarkdownBody }),
summary,
hasClawMarkdownBody,
},
};
+35 -16
View File
@@ -86,20 +86,31 @@ describe("Claw manifest contract", () => {
}
});
it("accepts opaque string metadata for namespaced harness profile pointers", () => {
it("accepts unrelated opaque namespaced metadata", () => {
const result = validateClawManifest({
...fixture,
metadata: {
"openclaw.config": "profiles/openclaw.yml",
"example.hint": "opaque-value",
},
metadata: { "example.hint": "opaque-value" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.manifest.metadata).toEqual({
"openclaw.config": "profiles/openclaw.yml",
"example.hint": "opaque-value",
expect(result.manifest.metadata).toEqual({ "example.hint": "opaque-value" });
});
it("fails closed on the retired OpenClaw profile pointer", () => {
const result = validateClawManifest({
...fixture,
metadata: { "openclaw.config": "profiles/openclaw.yml" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.issues).toContainEqual({
code: CLAW_MANIFEST_VALIDATION_CODES.legacyProfilePointer,
phase: "schema",
path: "$.metadata.openclaw.config",
message:
"metadata.openclaw.config is no longer supported; move the profile to profiles/openclaw.yml and remove the metadata entry.",
});
});
@@ -118,7 +129,7 @@ describe("Claw manifest contract", () => {
expect(
validateClawManifest({
...fixture,
metadata: { "openclaw.config": { path: "profiles/openclaw.yml" } },
metadata: { "example.hint": { path: "profiles/example.yml" } },
}).ok,
).toBe(false);
@@ -143,18 +154,26 @@ describe("Claw manifest contract", () => {
).toBe(false);
});
it.each([
"../openclaw.yml",
"/profiles/openclaw.yml",
"profiles/openclaw.json",
"profiles\\openclaw.yml",
])("rejects unsafe or non-YAML OpenClaw profile pointer %s", (profilePath) => {
it("reserves root BOOTSTRAP.md for package-root seed-once setup", () => {
const result = validateClawManifest({
...fixture,
metadata: { "openclaw.config": profilePath },
workspace: {
...fixture.workspace,
files: [
...fixture.workspace.files,
{ source: "workspace/first-run.md", path: "BOOTSTRAP.md" },
],
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.issues).toContainEqual({
code: CLAW_MANIFEST_VALIDATION_CODES.reservedWorkspaceTarget,
phase: "schema",
path: "$.workspace.files.1.path",
message: "Root BOOTSTRAP.md is reserved for the package-root seed-once bootstrap file.",
});
});
it("fails closed on unknown fields", () => {
+14 -10
View File
@@ -163,7 +163,8 @@ export const CLAW_MANIFEST_VALIDATION_CODES = {
invalidAgentId: "claw_v1_invalid_agent_id",
nonCanonicalString: "claw_v1_non_canonical_string",
emptyList: "claw_v1_empty_list",
invalidProfilePath: "claw_v1_invalid_profile_path",
legacyProfilePointer: "claw_v1_legacy_profile_pointer",
reservedWorkspaceTarget: "claw_v1_reserved_workspace_target",
unsafePath: "claw_v1_unsafe_path",
duplicateWorkspaceDestination: "claw_v1_duplicate_workspace_destination",
invalidAvatar: "claw_v1_invalid_avatar",
@@ -555,18 +556,12 @@ export function validateClawManifest(
for (const key of Object.keys(parsed.metadata ?? {})) {
pushNonEmpty(issues, `$.metadata.${key}`, key);
}
const openClawProfilePath = parsed.metadata?.["openclaw.config"];
if (
openClawProfilePath !== undefined &&
(openClawProfilePath.includes("\\") ||
!isSafePackagePath(openClawProfilePath) ||
!/\.ya?ml$/i.test(openClawProfilePath))
) {
if (Object.hasOwn(parsed.metadata ?? {}, "openclaw.config")) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.invalidProfilePath,
CLAW_MANIFEST_VALIDATION_CODES.legacyProfilePointer,
"$.metadata.openclaw.config",
"Must reference a forward-slash package-relative .yml or .yaml file.",
"metadata.openclaw.config is no longer supported; move the profile to profiles/openclaw.yml and remove the metadata entry.",
),
);
}
@@ -605,6 +600,15 @@ export function validateClawManifest(
);
}
const destinationKey = portablePathKey(file.path);
if (destinationKey === portablePathKey("BOOTSTRAP.md")) {
issues.push(
validationIssue(
CLAW_MANIFEST_VALIDATION_CODES.reservedWorkspaceTarget,
`$.workspace.files.${index}.path`,
"Root BOOTSTRAP.md is reserved for the package-root seed-once bootstrap file.",
),
);
}
if (conflictsWithWorkspaceTarget(workspaceTargets, destinationKey)) {
issues.push(
validationIssue(
+18
View File
@@ -330,12 +330,30 @@ describe("published Claw to OpenClaw dry-run proof", () => {
});
expect(result.plan.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "agent",
id: "hosted-e2e",
details: expect.objectContaining({
tools: expect.objectContaining({ profile: "coding" }),
}),
}),
expect.objectContaining({
kind: "workspaceFile",
id: "SOUL.md",
sourceKind: "clawMarkdownBody",
blocked: false,
}),
expect.objectContaining({
kind: "bootstrap",
id: "BOOTSTRAP.md",
blocked: false,
details: expect.objectContaining({ lifecycle: "native-seed-once" }),
}),
expect.objectContaining({
kind: "workspaceFile",
id: "assets/incident.schema.json",
blocked: false,
}),
]),
);
expect(JSON.stringify(result.plan)).not.toContain(
+1 -1
View File
@@ -21,7 +21,7 @@ describe("Claw feed OpenClaw contract workflow", () => {
const job = workflow.jobs["claws-openclaw-contract"];
expect(job?.env).toMatchObject({
OPENCLAW_CONTRACT_REPOSITORY: "openclaw/openclaw",
OPENCLAW_CONTRACT_SHA: "e79faff8aa755b201302edd286976a03f9ed79ea",
OPENCLAW_CONTRACT_SHA: "7422222788c4b75581c0370e0614be9e635ec3cd",
});
expect(job?.steps).toContainEqual(
expect.objectContaining({
+35 -20
View File
@@ -1,8 +1,10 @@
# Experimental Claw packages
ClawHub's Claw support implements the registry side of
[OpenClaw RFC #27](https://github.com/openclaw/rfcs/pull/27) plus the experimental
portable-core addendum in [RFC #48](https://github.com/openclaw/rfcs/pull/48). A
ClawHub's Claw support implements the registry side of the merged
[OpenClaw RFC 0016](https://github.com/openclaw/rfcs/blob/main/rfcs/0016-claws.md),
the experimental portable-core addendum in
[RFC #48](https://github.com/openclaw/rfcs/pull/48), and the application-layer
follow-up in [RFC #52](https://github.com/openclaw/rfcs/pull/52). A
Claw package describes one complete new agent using the grouped `CLAW.md`
schema. ClawHub owns publication, ownership, discovery, package detail APIs,
and hosted feed export. OpenClaw remains authoritative for local planning,
@@ -18,23 +20,32 @@ artifact digest and provenance input. Grouped JSON has no body, creates no
implicit file, and may declare `SOUL.md` explicitly.
The portable agent object carries only identity and purpose. Harness-specific
settings live in package-local profiles addressed through opaque string
metadata. OpenClaw recognizes `metadata.openclaw.config`; export conventionally
uses `profiles/openclaw.yml`, but the pointer is normative and authors may use
another safe package-relative YAML path.
settings live in package-local profiles discovered at conventional
`profiles/<harness>.yml` paths; the manifest contains no profile pointer.
`metadata.openclaw.config` is retired and rejected with migration guidance.
That profile exists only inside the Claw package. ClawHub requires the pointer
to resolve to an exact, bounded UTF-8 YAML package file and validates the
profile's strict v1 OpenClaw policy during publication. OpenClaw validates it
again during application, includes it in package integrity, and never copies it
into ordinary OpenClaw configuration. Other harnesses may ignore OpenClaw's
namespaced key or define their own profile-pointer contract.
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.
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.
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
portable workspace file map. Its presence is included in the bounded manifest
summary, while its contents remain only in the exact immutable artifact.
Schemas, templates, examples, fixtures, and static assets require no special
registry role: they remain ordinary declared `workspace.files` covered by the
artifact digest.
## Experimental contract
- Backend Claw publication and read surfaces require
@@ -62,6 +73,9 @@ registry during preview and application.
`SOUL.md` capability metadata, and prove the feed-to-OpenClaw mapping
([PR #3262](https://github.com/openclaw/clawhub/pull/3262), stacked after
PR #3092).
6. Adopt conventional harness profiles, package-root bootstrap, native
OpenClaw extensions, and ordinary application assets
([PR #3328](https://github.com/openclaw/clawhub/pull/3328)).
The hosted projection uses the separate
[experimental Claw feed contract](experimental-claw-feed.md), not an extension
@@ -93,13 +107,14 @@ through the shared schema before storage.
Claws use the existing package publication pipeline. `package.json` declares
the package identity, version, and package-relative `openclaw.claw` manifest
path. Publication parses `CLAW.md` YAML frontmatter or the JSON compatibility
form and validates the grouped manifest, referenced workspace files, and any
declared package-local OpenClaw profile. A non-empty Markdown body is the
portable agent prompt and maps to managed `SOUL.md`; publication rejects a body
combined with any explicit `SOUL.md` workspace declaration. The release retains
the exact artifact and a bounded derived summary, including the implicit
`SOUL.md` capability, rather than duplicating the full manifest, prompt body, or
OpenClaw profile into Convex storage. The server rejects
form and validates the grouped manifest, referenced workspace files,
conventional harness profiles, and optional package-root bootstrap. A non-empty
Markdown body is the portable agent prompt and maps to managed `SOUL.md`;
publication rejects a body combined with any explicit `SOUL.md` workspace
declaration. The release retains the exact artifact and a bounded derived
summary, including implicit prompt and bootstrap presence, rather than
duplicating the full manifest, prompt body, profile, or bootstrap content into
Convex storage. The server rejects
`family: claw` before mutation when the experimental gate is disabled; the gate
does not bypass ownership, moderation, scanning, or release invariants when
enabled.