diff --git a/CHANGELOG.md b/CHANGELOG.md index 5537a1f7f1f4..16e62339ff2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/skills.md b/docs/cli/skills.md index a037222a0583..bc3c66f2236c 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -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. diff --git a/docs/tools/creating-skills.md b/docs/tools/creating-skills.md index ec33b931fb9f..dddae8e82191 100644 --- a/docs/tools/creating-skills.md +++ b/docs/tools/creating-skills.md @@ -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 `/skill-workshop/proposals//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 @@ -117,7 +128,8 @@ openclaw skills workshop apply ``` 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 diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 33b0377e0cf4..3a2ada078763 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -130,6 +130,8 @@ under: proposals// 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 diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 312449843f85..95bce48e9bea 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -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()), }, diff --git a/src/agents/tools/skill-research-tool.test.ts b/src/agents/tools/skill-research-tool.test.ts index 818b651d85c4..68b30556746d 100644 --- a/src/agents/tools/skill-research-tool.test.ts +++ b/src/agents/tools/skill-research-tool.test.ts @@ -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(); diff --git a/src/agents/tools/skill-research-tool.ts b/src/agents/tools/skill-research-tool.ts index 77bd8be2a4a5..8ea9639ea165 100644 --- a/src/agents/tools/skill-research-tool.ts +++ b/src/agents/tools/skill-research-tool.ts @@ -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, +): 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; + 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, + }; + }); +} diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 81251feb2590..6135edd03605 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -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 ", "Skill name") .requiredOption("--description ", "Skill description") - .requiredOption("--proposal ", "Path to PROPOSAL.md draft content") + .option("--proposal ", "Path to PROPOSAL.md draft content") + .option( + "--proposal-dir ", + "Path to proposal directory with PROPOSAL.md and support files", + ) .option("--goal ", "Research or improvement goal") .option("--evidence ", "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 name or key") - .requiredOption("--proposal ", "Path to PROPOSAL.md draft content") + .option("--proposal ", "Path to PROPOSAL.md draft content") + .option( + "--proposal-dir ", + "Path to proposal directory with PROPOSAL.md and support files", + ) .option("--goal ", "Research or improvement goal") .option("--evidence ", "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, diff --git a/src/cli/skills-cli.workshop.test.ts b/src/cli/skills-cli.workshop.test.ts index 996c78dd7409..57efdc23b43a 100644 --- a/src/cli/skills-cli.workshop.test.ts +++ b/src/cli/skills-cli.workshop.test.ts @@ -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 () => { diff --git a/src/gateway/server-methods/skills.proposals.test.ts b/src/gateway/server-methods/skills.proposals.test.ts index c88f22a414fd..b42a65043fe0 100644 --- a/src/gateway/server-methods/skills.proposals.test.ts +++ b/src/gateway/server-methods/skills.proposals.test.ts @@ -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 () => { diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 983e4ebde187..d020f90c337f 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -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, diff --git a/src/skills/workshop/service.test.ts b/src/skills/workshop/service.test.ts index c7117e8421e2..ba342280d824 100644 --- a/src/skills/workshop/service.test.ts +++ b/src/skills/workshop/service.test.ts @@ -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(); + }); }); diff --git a/src/skills/workshop/service.ts b/src/skills/workshop/service.ts index 2c150a10dc29..7f04b12700dc 100644 --- a/src/skills/workshop/service.ts +++ b/src/skills/workshop/service.ts @@ -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 { return await readSkillProposalManifest(); @@ -60,6 +70,53 @@ export async function readSkillProposalDraftFile(filePath: string): Promise { + 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 { @@ -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("/"); +} diff --git a/src/skills/workshop/store.ts b/src/skills/workshop/store.ts index d375fc19bc53..f00d48e3eef0 100644 --- a/src/skills/workshop/store.ts +++ b/src/skills/workshop/store.ts @@ -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(); + 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 { 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 { + 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 { + 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 { + 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(); + 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; diff --git a/src/skills/workshop/types.ts b/src/skills/workshop/types.ts index cd151b47b3b7..fff73208de32 100644 --- a/src/skills/workshop/types.ts +++ b/src/skills/workshop/types.ts @@ -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;