Skip to content

Commit 2d65ead

Browse files
authored
fix(skill-workshop): honor pending approval for tool suggestions [AI] (#78516)
* fix: honor pending skill workshop approvals * addressing review-skill * addressing codex review * addressing codex review * fix: require approval before skill workshop apply * docs: add changelog entry for PR merge
1 parent 1ef85c7 commit 2d65ead

7 files changed

Lines changed: 113 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ Docs: https://docs.openclaw.ai
140140

141141
### Fixes
142142

143+
- fix(skill-workshop): honor pending approval for tool suggestions [AI]. (#78516) Thanks @pgondhi987.
143144
- Native chat: decode gateway-provided thinking metadata for the iOS/macOS picker so provider-specific levels such as `adaptive`, `xhigh`, and `max` appear without leaking unsupported default-model options. Thanks @BunsDev.
144145
- Agents/tools: fail `exec host=node` before `system.run` when the selected node is known to be disconnected, with an actionable reconnect message instead of a raw node invoke failure. Thanks @BunsDev.
145146
- Agents/models: accept legacy `anthropic-cli/*` model refs as Claude CLI runtime refs instead of failing model resolution with `Unknown model`. Thanks @BunsDev.

docs/plugins/skill-workshop.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ Create a proposal. With `approvalPolicy: "pending"` (default), this queues inste
357357
```
358358

359359
<AccordionGroup>
360-
<Accordion title="Force a safe write (apply: true)">
360+
<Accordion title="Request immediate write in auto mode (apply: true)">
361361

362362
```json
363363
{
@@ -369,6 +369,9 @@ Create a proposal. With `approvalPolicy: "pending"` (default), this queues inste
369369
}
370370
```
371371

372+
With `approvalPolicy: "pending"`, `apply: true` still queues the proposal. Review it, then use
373+
the `apply` action after approval.
374+
372375
</Accordion>
373376

374377
<Accordion title="Force pending under auto policy (apply: false)">
@@ -417,6 +420,9 @@ Create a proposal. With `approvalPolicy: "pending"` (default), this queues inste
417420

418421
Apply a pending proposal.
419422

423+
With `approvalPolicy: "pending"`, this action asks for operator approval before writing the
424+
workspace skill.
425+
420426
```json
421427
{
422428
"action": "apply",
@@ -551,8 +557,8 @@ The guidance emphasizes:
551557

552558
The write mode text changes with `approvalPolicy`:
553559

554-
- pending mode: queue suggestions; apply only after explicit approval
555-
- auto mode: apply safe workspace-skill updates when clearly reusable
560+
- pending mode: queue suggestions; use `apply` after explicit approval
561+
- auto mode: apply safe workspace-skill updates unless `apply: false` queues instead
556562

557563
## Costs and runtime behavior
558564

extensions/skill-workshop/index.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-runtime";
5+
import type { PluginTrustedToolPolicyRegistration } from "openclaw/plugin-sdk/core";
56
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
67
import { afterEach, describe, expect, it, vi } from "vitest";
78
import plugin, {
@@ -617,6 +618,72 @@ describe("skill-workshop", () => {
617618
expect(await store.list("pending")).toHaveLength(1);
618619
});
619620

621+
it("queues apply true suggestions in pending mode before explicit apply", async () => {
622+
const workspaceDir = await makeTempDir();
623+
const stateDir = await makeTempDir();
624+
let tool: AnyAgentTool | undefined;
625+
const api = createTestPluginApi({
626+
pluginConfig: { approvalPolicy: "pending" },
627+
runtime: {
628+
agent: {
629+
resolveAgentWorkspaceDir: () => workspaceDir,
630+
},
631+
state: {
632+
resolveStateDir: () => stateDir,
633+
},
634+
} as never,
635+
registerTool(registered) {
636+
const resolved =
637+
typeof registered === "function" ? registered({ workspaceDir }) : registered;
638+
tool = Array.isArray(resolved) ? resolved[0] : (resolved ?? undefined);
639+
},
640+
});
641+
642+
plugin.register(api);
643+
const result = await tool?.execute?.("call-1", {
644+
action: "suggest",
645+
apply: true,
646+
skillName: "screenshot-asset-workflow",
647+
description: "Screenshot asset workflow",
648+
body: "Verify dimensions, optimize the PNG, and run the relevant gate.",
649+
});
650+
651+
expect(result?.details).toMatchObject({ status: "pending" });
652+
const proposalId =
653+
(result?.details as { proposal?: { id?: string } } | undefined)?.proposal?.id ?? "";
654+
expect(proposalId).toBeTruthy();
655+
await expect(
656+
fs.access(path.join(workspaceDir, "skills", "screenshot-asset-workflow", "SKILL.md")),
657+
).rejects.toMatchObject({ code: "ENOENT" });
658+
const store = new SkillWorkshopStore({ stateDir, workspaceDir });
659+
expect(await store.list("pending")).toHaveLength(1);
660+
expect(await store.list("applied")).toHaveLength(0);
661+
});
662+
663+
it("requires operator approval before applying queued proposals in pending mode", async () => {
664+
let trustedPolicy: PluginTrustedToolPolicyRegistration | undefined;
665+
const api = createTestPluginApi({
666+
pluginConfig: { approvalPolicy: "pending" },
667+
registerTrustedToolPolicy(policy) {
668+
trustedPolicy = policy;
669+
},
670+
});
671+
672+
plugin.register(api);
673+
674+
const result = await trustedPolicy?.evaluate(
675+
{ toolName: "skill_workshop", params: { action: "apply", id: "proposal-1" } },
676+
{ toolName: "skill_workshop" },
677+
);
678+
679+
expect(result).toMatchObject({
680+
requireApproval: {
681+
title: "Apply workspace skill proposal",
682+
allowedDecisions: ["allow-once", "deny"],
683+
},
684+
});
685+
});
686+
620687
it("uses the reviewer to propose existing skill repairs", async () => {
621688
const workspaceDir = await makeTempDir();
622689
const stateDir = await makeTempDir();

extensions/skill-workshop/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,30 @@ export default definePluginEntry({
3838
},
3939
);
4040

41+
api.registerTrustedToolPolicy({
42+
id: "skill-workshop-apply-approval",
43+
description: "Require operator approval before applying queued workspace skill proposals.",
44+
evaluate(event) {
45+
const config = resolveCurrentConfig();
46+
if (
47+
!config.enabled ||
48+
config.approvalPolicy === "auto" ||
49+
event.toolName !== "skill_workshop" ||
50+
event.params.action !== "apply"
51+
) {
52+
return undefined;
53+
}
54+
return {
55+
requireApproval: {
56+
title: "Apply workspace skill proposal",
57+
description: "Apply a queued workspace skill proposal.",
58+
severity: "warning",
59+
allowedDecisions: ["allow-once", "deny"],
60+
},
61+
};
62+
},
63+
});
64+
4165
api.on("before_prompt_build", async () => {
4266
const config = resolveCurrentConfig();
4367
if (!config.enabled) {

extensions/skill-workshop/src/prompt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import type { SkillWorkshopConfig } from "./config.js";
33
export function buildWorkshopGuidance(config: SkillWorkshopConfig): string {
44
const writeMode =
55
config.approvalPolicy === "auto"
6-
? "Auto mode: apply safe workspace-skill updates when clearly reusable."
7-
: "Pending mode: queue suggestions; apply only after explicit approval.";
6+
? "Auto mode: apply safe workspace-skill updates; apply=false queues instead."
7+
: "Pending mode: queue suggestions; use apply action after explicit approval.";
88
return [
99
"<skill_workshop>",
1010
"Use for durable procedural memory, not facts/preferences.",

extensions/skill-workshop/src/tool.ts

Lines changed: 8 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,9 @@ import { randomUUID } from "node:crypto";
22
import { Type } from "typebox";
33
import { jsonResult, type OpenClawPluginApi } from "../api.js";
44
import type { SkillWorkshopConfig } from "./config.js";
5-
import {
6-
applyProposalToWorkspace,
7-
normalizeSkillName,
8-
prepareProposalWrite,
9-
writeSupportFile,
10-
} from "./skills.js";
5+
import { applyProposalToWorkspace, normalizeSkillName, writeSupportFile } from "./skills.js";
116
import type { SkillChange, SkillProposal, SkillWorkshopStatus } from "./types.js";
12-
import { createStoreForContext, resolveWorkspaceDir } from "./workshop.js";
7+
import { applyOrStoreProposal, createStoreForContext, resolveWorkspaceDir } from "./workshop.js";
138

149
type ToolParams = {
1510
action?: string;
@@ -150,65 +145,14 @@ export function createSkillWorkshopTool(params: {
150145
}
151146
if (action === "suggest") {
152147
const proposal = buildProposal({ workspaceDir, raw, source: "tool" });
153-
const shouldApply =
154-
raw.apply === true || (raw.apply !== false && params.config.approvalPolicy === "auto");
155-
if (shouldApply) {
156-
const prepared = await prepareProposalWrite({
157-
proposal,
158-
maxSkillBytes: params.config.maxSkillBytes,
159-
});
160-
const critical = prepared.findings.find((finding) => finding.severity === "critical");
161-
if (critical) {
162-
const stored = await store.add(
163-
{
164-
...proposal,
165-
status: "quarantined",
166-
updatedAt: Date.now(),
167-
scanFindings: prepared.findings,
168-
quarantineReason: critical.message,
169-
},
170-
params.config.maxPending,
171-
);
172-
return jsonResult({ status: "quarantined", proposal: stored });
173-
}
174-
const applied = await applyProposalToWorkspace({
175-
proposal,
176-
maxSkillBytes: params.config.maxSkillBytes,
177-
});
178-
const stored = await store.add(
179-
{
180-
...proposal,
181-
status: "applied",
182-
updatedAt: Date.now(),
183-
scanFindings: applied.findings,
184-
},
185-
params.config.maxPending,
186-
);
187-
return jsonResult({ status: "applied", skillPath: applied.skillPath, proposal: stored });
188-
}
189-
const prepared = await prepareProposalWrite({
148+
const result = await applyOrStoreProposal({
190149
proposal,
191-
maxSkillBytes: params.config.maxSkillBytes,
150+
store,
151+
config: params.config,
152+
workspaceDir,
153+
skipAutoApply: raw.apply === false,
192154
});
193-
const critical = prepared.findings.find((finding) => finding.severity === "critical");
194-
if (critical) {
195-
const stored = await store.add(
196-
{
197-
...proposal,
198-
status: "quarantined",
199-
updatedAt: Date.now(),
200-
scanFindings: prepared.findings,
201-
quarantineReason: critical.message,
202-
},
203-
params.config.maxPending,
204-
);
205-
return jsonResult({ status: "quarantined", proposal: stored });
206-
}
207-
const stored = await store.add(
208-
{ ...proposal, scanFindings: prepared.findings },
209-
params.config.maxPending,
210-
);
211-
return jsonResult({ status: "pending", proposal: stored });
155+
return jsonResult(result);
212156
}
213157
if (action === "apply") {
214158
if (!raw.id) {

extensions/skill-workshop/src/workshop.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export async function applyOrStoreProposal(params: {
3737
store: SkillWorkshopStore;
3838
config: SkillWorkshopConfig;
3939
workspaceDir: string;
40+
skipAutoApply?: boolean;
4041
}): Promise<{
4142
status: "pending" | "applied" | "quarantined";
4243
skillPath?: string;
@@ -60,7 +61,7 @@ export async function applyOrStoreProposal(params: {
6061
);
6162
return { status: "quarantined", proposal: stored };
6263
}
63-
if (params.config.approvalPolicy === "auto") {
64+
if (params.config.approvalPolicy === "auto" && !params.skipAutoApply) {
6465
const applied = await applyProposalToWorkspace({
6566
proposal: params.proposal,
6667
maxSkillBytes: params.config.maxSkillBytes,

0 commit comments

Comments
 (0)