mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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,32 +611,87 @@ 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(
|
||||
"invalid_package_path",
|
||||
packageBootstrap.path,
|
||||
"Package-root bootstrap files must use the exact path BOOTSTRAP.md.",
|
||||
),
|
||||
);
|
||||
} else if (packageBootstrap) {
|
||||
if (packageBootstrap.text === undefined) {
|
||||
issues.push(
|
||||
issue(
|
||||
"missing_openclaw_profile",
|
||||
openClawProfilePath,
|
||||
"The declared OpenClaw profile is missing or is not UTF-8 text.",
|
||||
"package_bootstrap_invalid",
|
||||
"BOOTSTRAP.md",
|
||||
"Package-root BOOTSTRAP.md must be UTF-8 text.",
|
||||
),
|
||||
);
|
||||
} else if (new TextEncoder().encode(profileFile.text).byteLength > MAX_OPENCLAW_PROFILE_BYTES) {
|
||||
} else if (
|
||||
new TextEncoder().encode(packageBootstrap.text).byteLength > MAX_PACKAGE_BOOTSTRAP_BYTES
|
||||
) {
|
||||
issues.push(
|
||||
issue(
|
||||
"openclaw_profile_too_large",
|
||||
openClawProfilePath,
|
||||
`The OpenClaw profile exceeds ${MAX_OPENCLAW_PROFILE_BYTES} bytes.`,
|
||||
"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 {
|
||||
const profile = parseJsonCompatibleYaml(profileFile.text, openClawProfilePath);
|
||||
if (profile.issues) {
|
||||
issues.push(...profile.issues);
|
||||
} else {
|
||||
issues.push(...validateOpenClawProfile(profile.value, openClawProfilePath));
|
||||
}
|
||||
const profile = parseGenericHarnessProfile(profileFile.text, profileFile.path);
|
||||
if (profile.issues) issues.push(...profile.issues);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
Vendored
+128
-28
@@ -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 (packageBootstrap) {
|
||||
if (packageBootstrap.text === undefined) {
|
||||
issues.push(issue("package_bootstrap_invalid", "BOOTSTRAP.md", "Package-root BOOTSTRAP.md must be UTF-8 text."));
|
||||
}
|
||||
else if (new TextEncoder().encode(profileFile.text).byteLength > MAX_OPENCLAW_PROFILE_BYTES) {
|
||||
issues.push(issue("openclaw_profile_too_large", openClawProfilePath, `The OpenClaw profile exceeds ${MAX_OPENCLAW_PROFILE_BYTES} bytes.`));
|
||||
else 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 {
|
||||
const profile = parseJsonCompatibleYaml(profileFile.text, openClawProfilePath);
|
||||
if (profile.issues) {
|
||||
const profile = parseGenericHarnessProfile(profileFile.text, profileFile.path);
|
||||
if (profile.issues)
|
||||
issues.push(...profile.issues);
|
||||
}
|
||||
else {
|
||||
issues.push(...validateOpenClawProfile(profile.value, openClawProfilePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-1
@@ -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";
|
||||
|
||||
Vendored
+7
-7
@@ -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."));
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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,32 +611,87 @@ 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(
|
||||
"invalid_package_path",
|
||||
packageBootstrap.path,
|
||||
"Package-root bootstrap files must use the exact path BOOTSTRAP.md.",
|
||||
),
|
||||
);
|
||||
} else if (packageBootstrap) {
|
||||
if (packageBootstrap.text === undefined) {
|
||||
issues.push(
|
||||
issue(
|
||||
"missing_openclaw_profile",
|
||||
openClawProfilePath,
|
||||
"The declared OpenClaw profile is missing or is not UTF-8 text.",
|
||||
"package_bootstrap_invalid",
|
||||
"BOOTSTRAP.md",
|
||||
"Package-root BOOTSTRAP.md must be UTF-8 text.",
|
||||
),
|
||||
);
|
||||
} else if (new TextEncoder().encode(profileFile.text).byteLength > MAX_OPENCLAW_PROFILE_BYTES) {
|
||||
} else if (
|
||||
new TextEncoder().encode(packageBootstrap.text).byteLength > MAX_PACKAGE_BOOTSTRAP_BYTES
|
||||
) {
|
||||
issues.push(
|
||||
issue(
|
||||
"openclaw_profile_too_large",
|
||||
openClawProfilePath,
|
||||
`The OpenClaw profile exceeds ${MAX_OPENCLAW_PROFILE_BYTES} bytes.`,
|
||||
"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 {
|
||||
const profile = parseJsonCompatibleYaml(profileFile.text, openClawProfilePath);
|
||||
if (profile.issues) {
|
||||
issues.push(...profile.issues);
|
||||
} else {
|
||||
issues.push(...validateOpenClawProfile(profile.value, openClawProfilePath));
|
||||
}
|
||||
const profile = parseGenericHarnessProfile(profileFile.text, profileFile.path);
|
||||
if (profile.issues) issues.push(...profile.issues);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user