mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: support skill proposal files
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user