diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index 006afbea6fa..a547da4cb3b 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -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); + }); +}); diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 27251ac96f8..a848c97c833 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -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 }, ); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 22813a44f1a..01d4cb5aa12 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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, diff --git a/src/agents/tools/skill-research-tool.test.ts b/src/agents/tools/skill-research-tool.test.ts index 56cdc9d029e..53242c8ac42 100644 --- a/src/agents/tools/skill-research-tool.test.ts +++ b/src/agents/tools/skill-research-tool.test.ts @@ -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" }); diff --git a/src/skills/workshop/service.test.ts b/src/skills/workshop/service.test.ts index 22f6fd1b69d..73e7d34472f 100644 --- a/src/skills/workshop/service.test.ts +++ b/src/skills/workshop/service.test.ts @@ -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({ diff --git a/src/skills/workshop/service.ts b/src/skills/workshop/service.ts index 191af7fcf8e..68628d6a6fc 100644 --- a/src/skills/workshop/service.ts +++ b/src/skills/workshop/service.ts @@ -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 { + 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,