feat: support skill proposal files

This commit is contained in:
Shakker
2026-05-30 04:21:23 +01:00
committed by Shakker
parent 91ba5fd4fe
commit ab0613c9d3
15 changed files with 659 additions and 25 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Skills: let Skill Workshop proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
- Skills: add Skill Workshop proposals with pending `PROPOSAL.md` drafts, CLI/Gateway review actions, rollback metadata, and the `skill_research` agent tool. Thanks @shakkernerd.
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.

View File

@@ -139,6 +139,15 @@ openclaw skills workshop propose-create \
--proposal ./PROPOSAL.md
```
Or create a proposal from a full draft skill directory:
```bash
openclaw skills workshop propose-create \
--name "qa-check" \
--description "Repeatable QA checklist" \
--proposal-dir ./qa-check-proposal
```
Update an existing workspace skill through the same pending path:
```bash
@@ -161,6 +170,12 @@ root, strips `status` and proposal `version` from the frontmatter, scans the
draft, writes rollback metadata, and refuses stale updates when the target skill
changed after the proposal was created.
When `--proposal-dir` is used, the directory must contain `PROPOSAL.md`.
Support files can be included under `assets/`, `examples/`, `references/`,
`scripts/`, or `templates/`. OpenClaw stores support files with the proposal,
scans them, verifies their hashes before apply, and writes them beside the
active `SKILL.md` only after the proposal is applied.
Agents can create pending proposals through the `skill_research` tool when they
identify reusable work, but applying, rejecting, or quarantining remains an
explicit operator action through the CLI or Gateway.

View File

@@ -106,10 +106,21 @@ openclaw skills workshop propose-create \
--proposal ./PROPOSAL.md
```
Use `--proposal-dir` when the proposal also has support files:
```bash
openclaw skills workshop propose-create \
--name "hello-world" \
--description "A simple skill that says hello." \
--proposal-dir ./hello-world-proposal
```
The draft is stored under
`<OPENCLAW_STATE_DIR>/skill-workshop/proposals/<proposal-id>/PROPOSAL.md` and
stays inactive until an operator reviews and applies it. The default state
directory is `~/.openclaw`:
directory is `~/.openclaw`. Proposal directories must contain `PROPOSAL.md`.
Support files can be included under `assets/`, `examples/`, `references/`,
`scripts/`, or `templates/`; OpenClaw stores and scans them with the proposal:
```bash
openclaw skills workshop inspect <proposal-id>
@@ -117,7 +128,8 @@ openclaw skills workshop apply <proposal-id>
```
When applied, OpenClaw writes the final `SKILL.md` into the workspace `skills/`
root and removes proposal-only frontmatter such as `status: proposal`.
root, writes approved support files beside it, and removes proposal-only
frontmatter such as `status: proposal`.
## Skill metadata reference

View File

@@ -130,6 +130,8 @@ under:
proposals/<proposal-id>/
proposal.json
PROPOSAL.md
references/
scripts/
rollback.json
```
@@ -141,6 +143,12 @@ listing manifest and can be rebuilt from proposal folders when missing or stale.
`version: v1`; those proposal-only fields are stripped when the proposal is
applied as an active `SKILL.md`.
Proposal folders can also carry support files under `assets/`, `examples/`,
`references/`, `scripts/`, or `templates/`. OpenClaw records support file
metadata in `proposal.json`, stores the file contents beside `PROPOSAL.md`,
scans them with the proposal, and verifies their hashes before apply. Approved
support files are written into the active skill directory beside `SKILL.md`.
Only pending proposals can be applied. Apply writes to the selected workspace
`skills/` root, runs the skill scanner, writes rollback metadata, refuses to
overwrite an existing create target, and marks update proposals stale when the

View File

@@ -502,6 +502,21 @@ const SkillProposalSourceSchema = Type.Union([
Type.Literal("gateway"),
]);
const SkillProposalContentString = Type.String({ minLength: 1, maxLength: 1_048_576 });
const SkillProposalSupportFileInputSchema = Type.Object(
{
path: NonEmptyString,
content: Type.String({ maxLength: 262_144 }),
},
{ additionalProperties: false },
);
const SkillProposalSupportFileSchema = Type.Object(
{
path: NonEmptyString,
sizeBytes: Type.Integer({ minimum: 0, maximum: 262_144 }),
hash: Sha256String,
},
{ additionalProperties: false },
);
const SkillProposalFindingSchema = Type.Object(
{
@@ -553,6 +568,7 @@ const SkillProposalRecordSchema = Type.Object(
proposedVersion: NonEmptyString,
draftFile: Type.Literal("PROPOSAL.md"),
draftHash: NonEmptyString,
supportFiles: Type.Optional(Type.Array(SkillProposalSupportFileSchema, { maxItems: 64 })),
target: SkillProposalTargetSchema,
scan: SkillProposalScanSchema,
goal: Type.Optional(Type.String()),
@@ -620,6 +636,7 @@ export const SkillsProposalCreateParamsSchema = Type.Object(
name: NonEmptyString,
description: NonEmptyString,
content: SkillProposalContentString,
supportFiles: Type.Optional(Type.Array(SkillProposalSupportFileInputSchema, { maxItems: 64 })),
goal: Type.Optional(Type.String()),
evidence: Type.Optional(Type.String()),
},
@@ -631,6 +648,7 @@ export const SkillsProposalUpdateParamsSchema = Type.Object(
agentId: Type.Optional(NonEmptyString),
skillName: NonEmptyString,
content: SkillProposalContentString,
supportFiles: Type.Optional(Type.Array(SkillProposalSupportFileInputSchema, { maxItems: 64 })),
goal: Type.Optional(Type.String()),
evidence: Type.Optional(Type.String()),
},

View File

@@ -41,6 +41,12 @@ describe("skill_research tool", () => {
name: "Weather Planner",
description: "Plan around current weather",
proposal_content: "# Weather Planner\n\nCheck weather before outdoor recommendations.\n",
support_files: [
{
path: "references/weather.md",
content: "Use weather API details.\n",
},
],
goal: "Reuse weather planning steps",
});
@@ -49,6 +55,7 @@ describe("skill_research tool", () => {
kind: "create",
skillKey: "weather-planner",
scanState: "clean",
supportFileCount: 1,
});
await expect(
fs.readFile(
@@ -62,6 +69,19 @@ describe("skill_research tool", () => {
"utf8",
),
).resolves.toContain("status: proposal");
await expect(
fs.readFile(
path.join(
stateDir,
"skill-workshop",
"proposals",
(result.details as { id: string }).id,
"references",
"weather.md",
),
"utf8",
),
).resolves.toContain("Use weather API details.");
await expect(
fs.access(path.join(workspaceDir, "skills", "weather-planner", "SKILL.md")),
).rejects.toThrow();

View File

@@ -1,7 +1,10 @@
import { Type } from "typebox";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { proposeCreateSkill, proposeUpdateSkill } from "../../skills/workshop/service.js";
import type { SkillProposalReadResult } from "../../skills/workshop/types.js";
import type {
SkillProposalReadResult,
SkillProposalSupportFileInput,
} from "../../skills/workshop/types.js";
import { stringEnum } from "../schema/typebox.js";
import {
asToolParamsRecord,
@@ -27,6 +30,21 @@ const SkillResearchToolSchema = Type.Object(
proposal_content: Type.String({
description: "Full proposed procedure markdown. It will be stored as PROPOSAL.md.",
}),
support_files: Type.Optional(
Type.Array(
Type.Object(
{
path: Type.String({
description:
"Relative support file path under assets/, examples/, references/, scripts/, or templates/.",
}),
content: Type.String({ description: "Support file text content." }),
},
{ additionalProperties: false },
),
{ description: "Optional support files to store with the proposal." },
),
),
goal: Type.Optional(Type.String({ description: "Research or improvement goal." })),
evidence: Type.Optional(Type.String({ description: "Short evidence or notes." })),
},
@@ -54,6 +72,7 @@ export function createSkillResearchTool(options: SkillResearchToolOptions): AnyA
required: true,
label: "proposal_content",
});
const supportFiles = readSupportFilesParam(params);
const goal = readStringParam(params, "goal");
const evidence = readStringParam(params, "evidence");
@@ -64,6 +83,7 @@ export function createSkillResearchTool(options: SkillResearchToolOptions): AnyA
name: readStringParam(params, "name", { required: true }),
description: readStringParam(params, "description", { required: true }),
content: proposalContent,
supportFiles,
createdBy: "skill-research",
goal,
evidence,
@@ -78,6 +98,7 @@ export function createSkillResearchTool(options: SkillResearchToolOptions): AnyA
label: "skill_name",
}),
content: proposalContent,
supportFiles,
createdBy: "skill-research",
goal,
evidence,
@@ -95,6 +116,7 @@ export function createSkillResearchTool(options: SkillResearchToolOptions): AnyA
skillName: proposal.record.target.skillName,
skillKey: proposal.record.target.skillKey,
proposalFile: proposal.record.draftFile,
supportFileCount: proposal.record.supportFiles?.length ?? 0,
targetSkillFile: proposal.record.target.skillFile,
scanState: proposal.record.scan.state,
},
@@ -102,3 +124,31 @@ export function createSkillResearchTool(options: SkillResearchToolOptions): AnyA
},
};
}
function readSupportFilesParam(
params: Record<string, unknown>,
): SkillProposalSupportFileInput[] | undefined {
const raw = params.support_files;
if (raw === undefined) {
return undefined;
}
if (!Array.isArray(raw)) {
throw new ToolInputError("support_files must be an array");
}
return raw.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new ToolInputError(`support_files[${index}] must be an object`);
}
const file = item as Record<string, unknown>;
if (typeof file.path !== "string" || !file.path.trim()) {
throw new ToolInputError(`support_files[${index}].path required`);
}
if (typeof file.content !== "string") {
throw new ToolInputError(`support_files[${index}].content required`);
}
return {
path: file.path,
content: file.content,
};
});
}

View File

@@ -32,10 +32,15 @@ import {
proposeCreateSkill,
proposeUpdateSkill,
quarantineSkillProposal,
readSkillProposalDraftDirectory,
readSkillProposalDraftFile,
rejectSkillProposal,
} from "../skills/workshop/service.js";
import type { SkillProposalManifest, SkillProposalReadResult } from "../skills/workshop/types.js";
import type {
SkillProposalManifest,
SkillProposalReadResult,
SkillProposalSupportFileInput,
} from "../skills/workshop/types.js";
import { CONFIG_DIR } from "../utils.js";
import { resolveOptionFromCommand } from "./cli-utils.js";
import { parseStrictPositiveIntOption } from "./program/helpers.js";
@@ -201,6 +206,24 @@ function formatSkillProposalInspect(read: SkillProposalReadResult): string {
.join("\n");
}
async function readSkillProposalInput(options: {
proposal?: string;
proposalDir?: string;
}): Promise<{ content: string; supportFiles?: SkillProposalSupportFileInput[] }> {
const proposal = normalizeOptionalString(options.proposal);
const proposalDir = normalizeOptionalString(options.proposalDir);
if (proposal && proposalDir) {
throw new Error("Use either --proposal or --proposal-dir, not both.");
}
if (!proposal && !proposalDir) {
throw new Error("Provide --proposal or --proposal-dir.");
}
if (proposalDir) {
return await readSkillProposalDraftDirectory(proposalDir);
}
return { content: await readSkillProposalDraftFile(proposal!) };
}
/**
* Register the skills CLI commands
*/
@@ -512,7 +535,11 @@ export function registerSkillsCli(program: Command) {
.description("Create a pending proposal for a new workspace skill")
.requiredOption("--name <name>", "Skill name")
.requiredOption("--description <description>", "Skill description")
.requiredOption("--proposal <path>", "Path to PROPOSAL.md draft content")
.option("--proposal <path>", "Path to PROPOSAL.md draft content")
.option(
"--proposal-dir <path>",
"Path to proposal directory with PROPOSAL.md and support files",
)
.option("--goal <text>", "Research or improvement goal")
.option("--evidence <text>", "Evidence or notes for the proposal")
.option("--json", "Output as JSON", false)
@@ -521,7 +548,8 @@ export function registerSkillsCli(program: Command) {
opts: {
name: string;
description: string;
proposal: string;
proposal?: string;
proposalDir?: string;
goal?: string;
evidence?: string;
json?: boolean;
@@ -531,12 +559,13 @@ export function registerSkillsCli(program: Command) {
) => {
try {
const { workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts);
const content = await readSkillProposalDraftFile(opts.proposal);
const draft = await readSkillProposalInput(opts);
const proposal = await proposeCreateSkill({
workspaceDir,
name: opts.name,
description: opts.description,
content,
content: draft.content,
supportFiles: draft.supportFiles,
createdBy: "cli",
goal: opts.goal,
evidence: opts.evidence,
@@ -557,7 +586,11 @@ export function registerSkillsCli(program: Command) {
.command("propose-update")
.description("Create a pending proposal for an existing workspace skill")
.argument("<skill>", "Skill name or key")
.requiredOption("--proposal <path>", "Path to PROPOSAL.md draft content")
.option("--proposal <path>", "Path to PROPOSAL.md draft content")
.option(
"--proposal-dir <path>",
"Path to proposal directory with PROPOSAL.md and support files",
)
.option("--goal <text>", "Research or improvement goal")
.option("--evidence <text>", "Evidence or notes for the proposal")
.option("--json", "Output as JSON", false)
@@ -565,7 +598,8 @@ export function registerSkillsCli(program: Command) {
async (
skill: string,
opts: {
proposal: string;
proposal?: string;
proposalDir?: string;
goal?: string;
evidence?: string;
json?: boolean;
@@ -578,13 +612,14 @@ export function registerSkillsCli(program: Command) {
command.parent,
opts,
);
const content = await readSkillProposalDraftFile(opts.proposal);
const draft = await readSkillProposalInput(opts);
const proposal = await proposeUpdateSkill({
workspaceDir,
config,
agentId,
skillName: skill,
content,
content: draft.content,
supportFiles: draft.supportFiles,
createdBy: "cli",
goal: opts.goal,
evidence: opts.evidence,

View File

@@ -91,12 +91,18 @@ describe("skills workshop cli", () => {
});
it("creates, lists, inspects, and applies a skill proposal", async () => {
const draftPath = path.join(mocks.workspaceDir, "draft.md");
const draftPath = path.join(mocks.workspaceDir, "proposal-draft");
await fs.mkdir(path.join(draftPath, "references"), { recursive: true });
await fs.writeFile(
draftPath,
path.join(draftPath, "PROPOSAL.md"),
"# Paris Weather\n\nCheck current weather before advice.\n",
"utf8",
);
await fs.writeFile(
path.join(draftPath, "references", "weather.md"),
"Use current conditions before recommendations.\n",
"utf8",
);
await runCommand([
"skills",
@@ -106,7 +112,7 @@ describe("skills workshop cli", () => {
"Paris Weather",
"--description",
"Weather lookup workflow",
"--proposal",
"--proposal-dir",
draftPath,
]);
@@ -124,6 +130,12 @@ describe("skills workshop cli", () => {
await expect(
fs.readFile(path.join(mocks.workspaceDir, "skills", "paris-weather", "SKILL.md"), "utf8"),
).resolves.toContain("# Paris Weather");
await expect(
fs.readFile(
path.join(mocks.workspaceDir, "skills", "paris-weather", "references", "weather.md"),
"utf8",
),
).resolves.toContain("Use current conditions");
});
it("rejects missing proposal drafts before creating workshop state", async () => {

View File

@@ -93,10 +93,19 @@ describe("skills proposal gateway handlers", () => {
name: "Weather Planner",
description: "Plan around current weather",
content: "# Weather Planner\n\nCheck weather before outdoor recommendations.\n",
supportFiles: [
{
path: "references/weather.md",
content: "Use current weather before recommendations.\n",
},
],
});
expect(create.ok).toBe(true);
const created = create.response as { record: { id: string } };
const created = create.response as {
record: { id: string; supportFiles?: Array<{ path: string }> };
};
expect(created.record.id).toMatch(/^weather-planner-/);
expect(created.record.supportFiles?.[0]?.path).toBe("references/weather.md");
const list = await callHandler("skills.proposals.list", {});
expect(list.ok).toBe(true);
@@ -117,6 +126,12 @@ describe("skills proposal gateway handlers", () => {
await expect(
fs.readFile(path.join(mocks.workspaceDir, "skills", "weather-planner", "SKILL.md"), "utf8"),
).resolves.toContain("# Weather Planner");
await expect(
fs.readFile(
path.join(mocks.workspaceDir, "skills", "weather-planner", "references", "weather.md"),
"utf8",
),
).resolves.toContain("Use current weather");
});
it("rejects invalid params before touching workshop state", async () => {

View File

@@ -306,6 +306,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
name: params.name,
description: params.description,
content: params.content,
supportFiles: params.supportFiles,
createdBy: "gateway",
goal: params.goal,
evidence: params.evidence,
@@ -341,6 +342,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
agentId: resolved.agentId,
skillName: params.skillName,
content: params.content,
supportFiles: params.supportFiles,
createdBy: "gateway",
goal: params.goal,
evidence: params.evidence,

View File

@@ -43,12 +43,26 @@ describe("skill workshop proposals", () => {
name: "Weather Helper",
description: "Check weather before planning outdoor tasks",
content: "# Weather Helper\n\nUse the weather provider before answering.\n",
supportFiles: [
{
path: "references/weather-api.md",
content: "# Weather API\n\nUse the current weather endpoint.\n",
},
{
path: "scripts/check-weather.js",
content: "export function parseWeather(value) { return value; }\n",
},
],
createdBy: "skill-research",
goal: "Reuse weather lookup steps",
});
expect(proposal.record.status).toBe("pending");
expect(proposal.record.scan.state).toBe("clean");
expect(proposal.record.supportFiles?.map((file) => file.path)).toEqual([
"references/weather-api.md",
"scripts/check-weather.js",
]);
expect(proposal.record.target.skillFile).toBe(
path.join(workspaceDir, "skills", "weather-helper", "SKILL.md"),
);
@@ -73,6 +87,18 @@ describe("skill workshop proposals", () => {
await expect(fs.readFile(applied.targetSkillFile, "utf8")).resolves.toBe(
'---\nname: "Weather Helper"\ndescription: "Check weather before planning outdoor tasks"\n---\n\n# Weather Helper\n\nUse the weather provider before answering.\n',
);
await expect(
fs.readFile(
path.join(workspaceDir, "skills", "weather-helper", "references", "weather-api.md"),
"utf8",
),
).resolves.toContain("Use the current weather endpoint.");
await expect(
fs.readFile(
path.join(workspaceDir, "skills", "weather-helper", "scripts", "check-weather.js"),
"utf8",
),
).resolves.toContain("parseWeather");
const status = buildWorkspaceSkillStatus(workspaceDir);
expect(status.skills.find((skill) => skill.name === "Weather Helper")).toMatchObject({
@@ -120,10 +146,18 @@ describe("skill workshop proposals", () => {
description: "Run QA checks",
body: "# QA\n\nOld checklist.\n",
});
await fs.mkdir(path.join(skillDir, "references"), { recursive: true });
await fs.writeFile(path.join(skillDir, "references", "qa.md"), "Old support file.\n", "utf8");
const proposal = await proposeUpdateSkill({
workspaceDir,
skillName: "qa-check",
content: "# QA\n\nNew checklist.\n",
supportFiles: [
{
path: "references/qa.md",
content: "New support file.\n",
},
],
});
await applySkillProposal({ workspaceDir, proposalId: proposal.record.id });
@@ -136,8 +170,12 @@ describe("skill workshop proposals", () => {
path.join(stateDir, "skill-workshop", "proposals", proposal.record.id, "rollback.json"),
"utf8",
),
) as { previousContent?: string };
) as { previousContent?: string; supportFiles?: Array<{ previousContent?: string }> };
expect(rollback.previousContent).toContain("Old checklist.");
expect(rollback.supportFiles?.[0]?.previousContent).toContain("Old support file.");
await expect(fs.readFile(path.join(skillDir, "references", "qa.md"), "utf8")).resolves.toBe(
"New support file.\n",
);
});
it("rejects and quarantines proposals without touching active skills", async () => {
@@ -214,4 +252,85 @@ describe("skill workshop proposals", () => {
).rejects.toThrow("Proposal scan failed");
expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("quarantined");
});
it("rejects unsafe support paths before creating proposal state", async () => {
const workspaceDir = await makeWorkspace();
await expect(
proposeCreateSkill({
workspaceDir,
name: "Unsafe Support Path",
description: "Reject traversal",
content: "# Unsafe Support Path\n",
supportFiles: [
{
path: "scripts/../references/escape.md",
content: "bad\n",
},
],
}),
).rejects.toThrow("plain relative path segments");
await expect(fs.access(path.join(stateDir, "skill-workshop"))).rejects.toThrow();
});
it("quarantines proposals with unsafe support file contents during apply", async () => {
const workspaceDir = await makeWorkspace();
const proposal = await proposeCreateSkill({
workspaceDir,
name: "Unsafe Support",
description: "Unsafe support script",
content: "# Unsafe Support\n",
supportFiles: [
{
path: "scripts/run.js",
content: "eval('2 + 2');\n",
},
],
});
expect(proposal.record.scan.state).toBe("failed");
await expect(
applySkillProposal({ workspaceDir, proposalId: proposal.record.id }),
).rejects.toThrow("Proposal scan failed");
expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("quarantined");
await expect(
fs.access(path.join(workspaceDir, "skills", "unsafe-support", "scripts", "run.js")),
).rejects.toThrow();
});
it("rejects tampered support files during apply", async () => {
const workspaceDir = await makeWorkspace();
const proposal = await proposeCreateSkill({
workspaceDir,
name: "Tamper Guard",
description: "Detect changed proposal support files",
content: "# Tamper Guard\n",
supportFiles: [
{
path: "references/check.md",
content: "Original\n",
},
],
});
await fs.writeFile(
path.join(
stateDir,
"skill-workshop",
"proposals",
proposal.record.id,
"references",
"check.md",
),
"Changed\n",
"utf8",
);
await expect(
applySkillProposal({ workspaceDir, proposalId: proposal.record.id }),
).rejects.toThrow("changed without updating metadata");
await expect(
fs.access(path.join(workspaceDir, "skills", "tamper-guard", "SKILL.md")),
).rejects.toThrow();
});
});

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { readLocalFileSafely } from "../../infra/fs-safe.js";
import { readLocalFileSafely, root, walkDirectory } from "../../infra/fs-safe.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import {
buildWorkspaceSkillStatus,
@@ -18,15 +18,23 @@ import {
createSkillProposalId,
createSkillProposalRollback,
hashSkillProposalContent,
MAX_PROPOSAL_SUPPORT_FILE_BYTES,
MAX_PROPOSAL_SUPPORT_FILES,
normalizeSkillProposalSupportPath,
prepareSkillProposalSupportFiles,
readProposalSupportFiles,
readSkillProposal,
readSkillProposalManifest,
readWorkspaceSupportFile,
readWorkspaceSkillFile,
refreshSkillProposalManifest,
resolveSkillProposalTarget,
updateSkillProposalRecord,
writeSkillProposal,
writeSkillProposalRollback,
writeWorkspaceSupportFile,
writeWorkspaceSkillFile,
type PreparedSkillProposalSupportFile,
} from "./store.js";
import {
SKILL_WORKSHOP_SCHEMA,
@@ -37,6 +45,7 @@ import {
type SkillProposalReadResult,
type SkillProposalRecord,
type SkillProposalScan,
type SkillProposalSupportFileInput,
type SkillProposalUpdateInput,
} from "./types.js";
@@ -47,6 +56,7 @@ type SkillWorkshopWorkspaceOptions = {
const WRITABLE_WORKSPACE_SOURCES = new Set(["openclaw-workspace", "agents-skills-project"]);
const MAX_PROPOSAL_DRAFT_BYTES = 1024 * 1024;
const MAX_PROPOSAL_DIRECTORY_ENTRIES = MAX_PROPOSAL_SUPPORT_FILES * 4;
export async function listSkillProposals(): Promise<SkillProposalManifest> {
return await readSkillProposalManifest();
@@ -60,6 +70,53 @@ export async function readSkillProposalDraftFile(filePath: string): Promise<stri
return read.buffer.toString("utf8");
}
export async function readSkillProposalDraftDirectory(dirPath: string): Promise<{
content: string;
supportFiles: SkillProposalSupportFileInput[];
}> {
const absoluteDir = path.resolve(dirPath);
const draftRoot = await root(absoluteDir);
const proposal = await draftRoot.read("PROPOSAL.md", {
hardlinks: "reject",
maxBytes: MAX_PROPOSAL_DRAFT_BYTES,
symlinks: "reject",
});
const scanned = await walkDirectory(absoluteDir, {
maxDepth: 8,
maxEntries: MAX_PROPOSAL_DIRECTORY_ENTRIES,
symlinks: "include",
});
if (scanned.truncated) {
throw new Error("Proposal directory has too many entries.");
}
const supportFiles: SkillProposalSupportFileInput[] = [];
for (const entry of scanned.entries.toSorted((a, b) =>
a.relativePath.localeCompare(b.relativePath),
)) {
const relativePath = toPortableRelativePath(entry.relativePath);
if (!relativePath || relativePath === "PROPOSAL.md") {
continue;
}
if (entry.kind === "directory") {
continue;
}
if (entry.kind !== "file") {
throw new Error(`Proposal support file must be a regular file: ${relativePath}`);
}
const supportPath = normalizeSkillProposalSupportPath(relativePath);
const read = await draftRoot.read(relativePath, {
hardlinks: "reject",
maxBytes: MAX_PROPOSAL_SUPPORT_FILE_BYTES,
symlinks: "reject",
});
supportFiles.push({ path: supportPath, content: read.buffer.toString("utf8") });
}
return {
content: proposal.buffer.toString("utf8"),
supportFiles,
};
}
export async function inspectSkillProposal(
proposalId: string,
): Promise<SkillProposalReadResult | null> {
@@ -76,6 +133,7 @@ export async function proposeCreateSkill(
throw new Error(`Skill already exists at ${target.skillFile}.`);
}
const supportFiles = prepareSkillProposalSupportFiles(input.supportFiles);
const proposalContent = renderProposalMarkdown({
name,
description,
@@ -105,11 +163,12 @@ export async function proposeCreateSkill(
skillFile: target.skillFile,
source: "openclaw-workspace",
},
scan: scanProposalContent(proposalContent),
scan: scanProposalBundle(proposalContent, supportFiles),
...(supportFiles.length > 0 ? { supportFiles: supportFileMetadata(supportFiles) } : {}),
...(goal ? { goal } : {}),
...(evidence ? { evidence } : {}),
};
await writeSkillProposal({ record, content: proposalContent });
await writeSkillProposal({ record, content: proposalContent, supportFiles });
return { record, content: proposalContent };
}
@@ -131,6 +190,7 @@ export async function proposeUpdateSkill(
throw new Error(`Skill file is missing: ${targetSkill.filePath}`);
}
const supportFiles = prepareSkillProposalSupportFiles(input.supportFiles);
const proposalContent = renderProposalMarkdown({
name: targetSkill.name,
description: targetSkill.description,
@@ -161,11 +221,12 @@ export async function proposeUpdateSkill(
source: targetSkill.source,
currentContentHash: hashSkillProposalContent(currentContent),
},
scan: scanProposalContent(proposalContent),
scan: scanProposalBundle(proposalContent, supportFiles),
...(supportFiles.length > 0 ? { supportFiles: supportFileMetadata(supportFiles) } : {}),
...(goal ? { goal } : {}),
...(evidence ? { evidence } : {}),
};
await writeSkillProposal({ record, content: proposalContent });
await writeSkillProposal({ record, content: proposalContent, supportFiles });
return { record, content: proposalContent };
}
@@ -207,11 +268,12 @@ export async function applySkillProposal(
if (draftHash !== record.draftHash) {
throw new Error("Proposal draft changed without updating proposal metadata.");
}
const supportFiles = await readProposalSupportFiles(record);
const draftFrontmatter = readProposalFrontmatter(content);
if (!draftFrontmatter) {
throw new Error("Proposal draft must include proposal frontmatter.");
}
const scan = scanProposalContent(content);
const scan = scanProposalBundle(content, supportFiles);
if (scan.state !== "clean") {
const updated = {
...record,
@@ -231,6 +293,31 @@ export async function applySkillProposal(
if (record.kind === "create" && previousContent !== null) {
throw new Error(`Target skill already exists: ${record.target.skillFile}`);
}
const previousSupportFiles = [];
for (const file of supportFiles) {
const previousSupportContent = await readWorkspaceSupportFile({
skillDir: record.target.skillDir,
relativePath: file.path,
});
if (record.kind === "create" && previousSupportContent !== null) {
throw new Error(
`Target support file already exists: ${path.join(record.target.skillDir, file.path)}`,
);
}
previousSupportFiles.push(
previousSupportContent === null
? {
path: file.path,
existed: false,
}
: {
path: file.path,
existed: true,
previousContent: previousSupportContent,
previousContentHash: hashSkillProposalContent(previousSupportContent),
},
);
}
if (record.kind === "update") {
if (previousContent === null) {
throw new Error(`Target skill is missing: ${record.target.skillFile}`);
@@ -256,6 +343,7 @@ export async function applySkillProposal(
targetSkillFile: record.target.skillFile,
action: record.kind,
...(previousContent !== null ? { previousContent } : {}),
...(previousSupportFiles.length > 0 ? { supportFiles: previousSupportFiles } : {}),
});
await writeSkillProposalRollback({
proposalId: record.id,
@@ -268,6 +356,13 @@ export async function applySkillProposal(
filePath: record.target.skillFile,
content: skillContent,
});
for (const file of supportFiles) {
await writeWorkspaceSupportFile({
skillDir: record.target.skillDir,
relativePath: file.path,
content: file.content,
});
}
const now = new Date().toISOString();
const applied: SkillProposalRecord = {
...record,
@@ -281,9 +376,15 @@ export async function applySkillProposal(
return { record: applied, targetSkillFile: record.target.skillFile };
}
function scanProposalContent(content: string): SkillProposalScan {
function scanProposalBundle(
content: string,
supportFiles: readonly PreparedSkillProposalSupportFile[] = [],
): SkillProposalScan {
const scannedAt = new Date().toISOString();
const findings = scanSource(content, "PROPOSAL.md");
const findings = [
...scanSource(content, "PROPOSAL.md"),
...supportFiles.flatMap((file) => scanSource(file.content, file.path)),
];
const critical = findings.filter((finding) => finding.severity === "critical").length;
const warn = findings.filter((finding) => finding.severity === "warn").length;
const info = findings.filter((finding) => finding.severity === "info").length;
@@ -297,6 +398,14 @@ function scanProposalContent(content: string): SkillProposalScan {
};
}
function supportFileMetadata(files: readonly PreparedSkillProposalSupportFile[]) {
return files.map((file) => ({
path: file.path,
sizeBytes: file.sizeBytes,
hash: file.hash,
}));
}
async function markProposal(
input: SkillProposalActionInput,
status: "rejected",
@@ -340,3 +449,7 @@ function normalizeRequired(value: string, label: string): string {
}
return normalized;
}
function toPortableRelativePath(relativePath: string): string {
return relativePath.split(path.sep).join("/");
}

View File

@@ -15,6 +15,8 @@ import {
type SkillProposalReadResult,
type SkillProposalRecord,
type SkillProposalRollback,
type SkillProposalSupportFile,
type SkillProposalSupportFileInput,
} from "./types.js";
const WORKSHOP_REL_DIR = "skill-workshop";
@@ -24,6 +26,16 @@ const PROPOSAL_RECORD_FILE = "proposal.json";
const PROPOSAL_DRAFT_FILE = "PROPOSAL.md";
const PROPOSAL_ROLLBACK_FILE = "rollback.json";
const MAX_PROPOSAL_BYTES = 1024 * 1024;
export const MAX_PROPOSAL_SUPPORT_FILE_BYTES = 256 * 1024;
export const MAX_PROPOSAL_SUPPORT_FILES = 64;
export const MAX_PROPOSAL_SUPPORT_FILES_TOTAL_BYTES = 2 * 1024 * 1024;
const ALLOWED_SUPPORT_FILE_ROOTS = new Set([
"assets",
"examples",
"references",
"scripts",
"templates",
]);
const PROPOSAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{5,120}$/;
type SkillWorkshopStoreOptions = {
@@ -31,6 +43,10 @@ type SkillWorkshopStoreOptions = {
stateDir?: string;
};
export type PreparedSkillProposalSupportFile = SkillProposalSupportFile & {
content: string;
};
export function createSkillProposalId(name: string, now = new Date()): string {
const normalized = normalizeSkillIndexName(name) || "skill";
const date = now.toISOString().slice(0, 10).replaceAll("-", "");
@@ -42,6 +58,10 @@ export function hashSkillProposalContent(content: string): string {
return crypto.createHash("sha256").update(content).digest("hex");
}
function contentSizeBytes(content: string): number {
return Buffer.byteLength(content, "utf8");
}
function resolveSkillWorkshopStateDir(options: SkillWorkshopStoreOptions = {}): string {
return path.resolve(options.stateDir ?? resolveStateDir(options.env));
}
@@ -72,6 +92,78 @@ export function resolveProposalDraftPath(
return path.join(resolveProposalDir(proposalId, options), PROPOSAL_DRAFT_FILE);
}
export function normalizeSkillProposalSupportPath(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Support file path is required.");
}
if (trimmed.includes("\\")) {
throw new Error("Support file paths must use forward slashes.");
}
if (path.posix.isAbsolute(trimmed)) {
throw new Error("Support file paths must be relative.");
}
const rawParts = trimmed.split("/");
if (rawParts.some((part) => !part || part === "." || part === ".." || part.startsWith("."))) {
throw new Error("Support file paths must use plain relative path segments.");
}
const normalized = path.posix.normalize(trimmed);
if (
!normalized ||
normalized === "." ||
normalized.startsWith("../") ||
normalized.includes("/../")
) {
throw new Error("Support file paths must stay inside the skill directory.");
}
const parts = normalized.split("/");
if (!ALLOWED_SUPPORT_FILE_ROOTS.has(parts[0] ?? "")) {
throw new Error(
`Support file paths must be under one of: ${[...ALLOWED_SUPPORT_FILE_ROOTS].join(", ")}.`,
);
}
if (normalized === PROPOSAL_DRAFT_FILE || normalized === "SKILL.md") {
throw new Error("Support files cannot replace the proposal or skill markdown file.");
}
return normalized;
}
export function prepareSkillProposalSupportFiles(
input: readonly SkillProposalSupportFileInput[] | undefined,
): PreparedSkillProposalSupportFile[] {
if (!input || input.length === 0) {
return [];
}
if (input.length > MAX_PROPOSAL_SUPPORT_FILES) {
throw new Error(`A skill proposal can include at most ${MAX_PROPOSAL_SUPPORT_FILES} files.`);
}
const seen = new Set<string>();
let totalBytes = 0;
const files: PreparedSkillProposalSupportFile[] = [];
for (const file of input) {
const filePath = normalizeSkillProposalSupportPath(file.path);
if (seen.has(filePath)) {
throw new Error(`Duplicate support file path: ${filePath}`);
}
seen.add(filePath);
const sizeBytes = contentSizeBytes(file.content);
if (sizeBytes > MAX_PROPOSAL_SUPPORT_FILE_BYTES) {
throw new Error(`Support file is too large: ${filePath}`);
}
totalBytes += sizeBytes;
if (totalBytes > MAX_PROPOSAL_SUPPORT_FILES_TOTAL_BYTES) {
throw new Error("Skill proposal support files exceed the total size limit.");
}
files.push({
path: filePath,
sizeBytes,
hash: hashSkillProposalContent(file.content),
content: file.content,
});
}
return files;
}
export function resolveSkillProposalTarget(params: { workspaceDir: string; skillName: string }): {
skillKey: string;
skillDir: string;
@@ -119,6 +211,7 @@ export async function readSkillProposalRecord(
export async function writeSkillProposal(params: {
record: SkillProposalRecord;
content: string;
supportFiles?: readonly PreparedSkillProposalSupportFile[];
store?: SkillWorkshopStoreOptions;
}): Promise<void> {
assertProposalId(params.record.id);
@@ -128,6 +221,12 @@ export async function writeSkillProposal(params: {
await stateRoot.write(path.join(relativeDir, PROPOSAL_DRAFT_FILE), params.content, {
encoding: "utf8",
});
for (const file of params.supportFiles ?? []) {
await stateRoot.write(path.join(relativeDir, file.path), file.content, {
encoding: "utf8",
mkdir: true,
});
}
await stateRoot.writeJson(path.join(relativeDir, PROPOSAL_RECORD_FILE), params.record, {
trailingNewline: true,
});
@@ -216,6 +315,48 @@ export async function readWorkspaceSkillFile(filePath: string): Promise<string |
return read.buffer.toString("utf8");
}
export async function readProposalSupportFiles(
record: SkillProposalRecord,
options: SkillWorkshopStoreOptions = {},
): Promise<PreparedSkillProposalSupportFile[]> {
const stateRoot = await root(resolveSkillWorkshopStateDir(options));
const out: PreparedSkillProposalSupportFile[] = [];
for (const file of record.supportFiles ?? []) {
const filePath = normalizeSkillProposalSupportPath(file.path);
const read = await stateRoot.read(path.join(proposalRelativeDir(record.id), filePath), {
hardlinks: "reject",
maxBytes: MAX_PROPOSAL_SUPPORT_FILE_BYTES,
symlinks: "reject",
});
const content = read.buffer.toString("utf8");
const sizeBytes = contentSizeBytes(content);
const hash = hashSkillProposalContent(content);
if (file.sizeBytes !== sizeBytes || file.hash !== hash) {
throw new Error(`Proposal support file changed without updating metadata: ${filePath}`);
}
out.push({ path: filePath, sizeBytes, hash, content });
}
return out;
}
export async function readWorkspaceSupportFile(params: {
skillDir: string;
relativePath: string;
}): Promise<string | null> {
const relativePath = normalizeSkillProposalSupportPath(params.relativePath);
const absolutePath = path.join(params.skillDir, ...relativePath.split("/"));
if (!(await pathExists(absolutePath))) {
return null;
}
const skillRoot = await root(params.skillDir);
const read = await skillRoot.read(relativePath, {
hardlinks: "reject",
maxBytes: MAX_PROPOSAL_SUPPORT_FILE_BYTES,
symlinks: "reject",
});
return read.buffer.toString("utf8");
}
export async function writeWorkspaceSkillFile(params: {
workspaceDir: string;
filePath: string;
@@ -230,11 +371,22 @@ export async function writeWorkspaceSkillFile(params: {
await workspaceRoot.write(relativePath, params.content, { encoding: "utf8", mkdir: true });
}
export async function writeWorkspaceSupportFile(params: {
skillDir: string;
relativePath: string;
content: string;
}): Promise<void> {
const relativePath = normalizeSkillProposalSupportPath(params.relativePath);
const skillRoot = await root(params.skillDir);
await skillRoot.write(relativePath, params.content, { encoding: "utf8", mkdir: true });
}
export function createSkillProposalRollback(params: {
proposalId: string;
targetSkillFile: string;
action: "create" | "update";
previousContent?: string;
supportFiles?: SkillProposalRollback["supportFiles"];
}): SkillProposalRollback {
return {
schema: SKILL_WORKSHOP_ROLLBACK_SCHEMA,
@@ -248,6 +400,9 @@ export function createSkillProposalRollback(params: {
previousContentHash: hashSkillProposalContent(params.previousContent),
}
: {}),
...(params.supportFiles && params.supportFiles.length > 0
? { supportFiles: params.supportFiles }
: {}),
};
}
@@ -299,6 +454,7 @@ function parseSkillProposalRecord(raw: unknown): SkillProposalRecord | null {
typeof record.updatedAt !== "string" ||
typeof record.draftHash !== "string" ||
record.draftFile !== PROPOSAL_DRAFT_FILE ||
!isValidSupportFileList(record.supportFiles) ||
!record.target ||
typeof record.target !== "object" ||
typeof record.target.skillName !== "string" ||
@@ -313,6 +469,44 @@ function parseSkillProposalRecord(raw: unknown): SkillProposalRecord | null {
return record;
}
function isValidSupportFileList(value: unknown): boolean {
if (value === undefined) {
return true;
}
if (!Array.isArray(value) || value.length > MAX_PROPOSAL_SUPPORT_FILES) {
return false;
}
const seen = new Set<string>();
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return false;
}
const file = item as SkillProposalSupportFile;
if (
typeof file.path !== "string" ||
typeof file.hash !== "string" ||
!/^[a-f0-9]{64}$/i.test(file.hash) ||
typeof file.sizeBytes !== "number" ||
!Number.isSafeInteger(file.sizeBytes) ||
file.sizeBytes < 0 ||
file.sizeBytes > MAX_PROPOSAL_SUPPORT_FILE_BYTES
) {
return false;
}
let normalized: string;
try {
normalized = normalizeSkillProposalSupportPath(file.path);
} catch {
return false;
}
if (seen.has(normalized)) {
return false;
}
seen.add(normalized);
}
return true;
}
function parseSkillProposalManifest(raw: unknown): SkillProposalManifest | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;

View File

@@ -28,6 +28,12 @@ export type SkillProposalTarget = {
currentContentHash?: string;
};
export type SkillProposalSupportFile = {
path: string;
sizeBytes: number;
hash: string;
};
export type SkillProposalRecord = {
schema: typeof SKILL_WORKSHOP_SCHEMA;
id: string;
@@ -41,6 +47,7 @@ export type SkillProposalRecord = {
proposedVersion: string;
draftFile: "PROPOSAL.md";
draftHash: string;
supportFiles?: SkillProposalSupportFile[];
target: SkillProposalTarget;
scan: SkillProposalScan;
goal?: string;
@@ -79,6 +86,17 @@ export type SkillProposalRollback = {
action: "create" | "update";
previousContentHash?: string;
previousContent?: string;
supportFiles?: Array<{
path: string;
existed: boolean;
previousContentHash?: string;
previousContent?: string;
}>;
};
export type SkillProposalSupportFileInput = {
path: string;
content: string;
};
export type SkillProposalCreateInput = {
@@ -86,6 +104,7 @@ export type SkillProposalCreateInput = {
name: string;
description: string;
content: string;
supportFiles?: SkillProposalSupportFileInput[];
createdBy?: SkillProposalSource;
goal?: string;
evidence?: string;
@@ -95,6 +114,7 @@ export type SkillProposalUpdateInput = {
workspaceDir: string;
skillName: string;
content: string;
supportFiles?: SkillProposalSupportFileInput[];
createdBy?: SkillProposalSource;
goal?: string;
evidence?: string;