mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix: harden skill proposal boundaries
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ToolsEffectiveResultSchema } from "./agents-models-skills.js";
|
||||
import {
|
||||
SkillsProposalInspectResultSchema,
|
||||
ToolsEffectiveResultSchema,
|
||||
} from "./agents-models-skills.js";
|
||||
|
||||
function toolsEffectiveResult() {
|
||||
return {
|
||||
@@ -58,3 +61,51 @@ describe("ToolsEffectiveResultSchema", () => {
|
||||
expect(Value.Check(ToolsEffectiveResultSchema, result)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkillsProposalInspectResultSchema", () => {
|
||||
it("accepts update proposal support file target metadata", () => {
|
||||
const result = {
|
||||
record: {
|
||||
id: "proposal-1",
|
||||
kind: "update",
|
||||
status: "pending",
|
||||
title: "weather-helper",
|
||||
description: "Improve weather checks",
|
||||
schema: "openclaw.skill-workshop.proposal.v1",
|
||||
createdAt: "2026-05-30T00:00:00.000Z",
|
||||
updatedAt: "2026-05-30T00:00:00.000Z",
|
||||
createdBy: "skill-research",
|
||||
proposedVersion: "v1",
|
||||
draftFile: "PROPOSAL.md",
|
||||
target: {
|
||||
skillName: "weather-helper",
|
||||
skillDir: "/tmp/workspace/skills/weather-helper",
|
||||
skillFile: "/tmp/workspace/skills/weather-helper/SKILL.md",
|
||||
skillKey: "weather-helper",
|
||||
currentContentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
draftHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
scan: {
|
||||
state: "clean",
|
||||
scannedAt: "2026-05-30T00:00:00.000Z",
|
||||
critical: 0,
|
||||
warn: 0,
|
||||
info: 0,
|
||||
findings: [],
|
||||
},
|
||||
supportFiles: [
|
||||
{
|
||||
path: "references/weather.md",
|
||||
sizeBytes: 42,
|
||||
hash: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
||||
targetExisted: true,
|
||||
targetContentHash: "123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0",
|
||||
},
|
||||
],
|
||||
},
|
||||
content: "# Weather Helper\n",
|
||||
};
|
||||
|
||||
expect(Value.Check(SkillsProposalInspectResultSchema, result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,6 +514,8 @@ const SkillProposalSupportFileSchema = Type.Object(
|
||||
path: NonEmptyString,
|
||||
sizeBytes: Type.Integer({ minimum: 0, maximum: 262_144 }),
|
||||
hash: Sha256String,
|
||||
targetExisted: Type.Optional(Type.Boolean()),
|
||||
targetContentHash: Type.Optional(Sha256String),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -442,11 +442,15 @@ export function createOpenClawTools(
|
||||
sessionAgentId,
|
||||
config: resolvedConfig,
|
||||
}),
|
||||
createSkillResearchTool({
|
||||
workspaceDir,
|
||||
config: resolvedConfig,
|
||||
agentId: sessionAgentId,
|
||||
}),
|
||||
...(options?.sandboxed
|
||||
? []
|
||||
: [
|
||||
createSkillResearchTool({
|
||||
workspaceDir,
|
||||
config: resolvedConfig,
|
||||
agentId: sessionAgentId,
|
||||
}),
|
||||
]),
|
||||
...(includeUpdatePlanTool ? [createUpdatePlanTool()] : []),
|
||||
createSessionsListTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
|
||||
@@ -32,6 +32,18 @@ describe("skill_research tool", () => {
|
||||
expect(tools.some((tool) => tool.name === "skill_research")).toBe(true);
|
||||
});
|
||||
|
||||
it("is not exposed from sandboxed OpenClaw tool sets", async () => {
|
||||
const workspaceDir = await tempDirs.make("openclaw-skill-research-tool-");
|
||||
const tools = createOpenClawTools({
|
||||
workspaceDir,
|
||||
config: {},
|
||||
disablePluginTools: true,
|
||||
sandboxed: true,
|
||||
});
|
||||
|
||||
expect(tools.some((tool) => tool.name === "skill_research")).toBe(false);
|
||||
});
|
||||
|
||||
it("creates pending skill proposals without applying them", async () => {
|
||||
const workspaceDir = await tempDirs.make("openclaw-skill-research-tool-");
|
||||
const tool = createSkillResearchTool({ workspaceDir, config: {}, agentId: "main" });
|
||||
|
||||
@@ -415,6 +415,44 @@ describe("skill workshop proposals", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps update proposal support baselines when revising", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const skillDir = path.join(workspaceDir, "skills", "support-revise-stale");
|
||||
await writeSkill({
|
||||
dir: skillDir,
|
||||
name: "support-revise-stale",
|
||||
description: "Detect stale support files during revision",
|
||||
body: "# Support Revise Stale\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: "support-revise-stale",
|
||||
content: "# Support Revise Stale\n\nNew checklist.\n",
|
||||
supportFiles: [
|
||||
{
|
||||
path: "references/qa.md",
|
||||
content: "New support file.\n",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await fs.writeFile(path.join(skillDir, "references", "qa.md"), "Changed elsewhere.\n", "utf8");
|
||||
|
||||
await expect(
|
||||
reviseSkillProposal({
|
||||
workspaceDir,
|
||||
proposalId: proposal.record.id,
|
||||
content: "# Support Revise Stale\n\nRevised checklist.\n",
|
||||
}),
|
||||
).rejects.toThrow("Target support file changed after proposal creation");
|
||||
expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("stale");
|
||||
await expect(fs.readFile(path.join(skillDir, "references", "qa.md"), "utf8")).resolves.toBe(
|
||||
"Changed elsewhere.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects and quarantines proposals without touching active skills", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const rejected = await proposeCreateSkill({
|
||||
|
||||
@@ -339,6 +339,7 @@ export async function reviseSkillProposal(
|
||||
await markProposalStale(record, "Target skill changed after proposal creation.");
|
||||
throw new Error("Target skill changed after proposal creation; proposal marked stale.");
|
||||
}
|
||||
await assertSupportTargetsUnchanged(record);
|
||||
}
|
||||
|
||||
const supportFiles =
|
||||
@@ -670,6 +671,22 @@ async function assertSupportTargetUnchanged(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSupportTargetsUnchanged(record: SkillProposalRecord): Promise<void> {
|
||||
if (record.kind !== "update" || !record.supportFiles) {
|
||||
return;
|
||||
}
|
||||
for (const file of record.supportFiles) {
|
||||
if (file.targetExisted === undefined) {
|
||||
continue;
|
||||
}
|
||||
const currentContent = await readWorkspaceSupportFile({
|
||||
skillDir: record.target.skillDir,
|
||||
relativePath: file.path,
|
||||
});
|
||||
await assertSupportTargetUnchanged({ record, file, currentContent });
|
||||
}
|
||||
}
|
||||
|
||||
async function readRequiredProposal(
|
||||
proposalId: string,
|
||||
workspaceDir?: string,
|
||||
|
||||
Reference in New Issue
Block a user