Skip to content

Commit 34a1102

Browse files
committed
fix: preflight skill writes before rollback metadata
1 parent 1156ab6 commit 34a1102

3 files changed

Lines changed: 30 additions & 25 deletions

File tree

src/skills/lifecycle/workspace-skill-write.ts

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ type WorkspaceSkillSymlinkWritePolicy = {
1313
allowWrites: boolean;
1414
allowedTargetRealPaths: readonly string[];
1515
};
16+
type WorkspaceSkillSupportFileWrite = { path: string; content: string };
1617

17-
type WorkspaceSkillSupportFileWrite = {
18-
path: string;
19-
content: string;
18+
type WorkspaceSkillWriteTargetParams = {
19+
workspaceDir: string;
20+
filePath: string;
21+
symlinkPolicy: WorkspaceSkillSymlinkWritePolicy;
2022
};
2123

2224
type PreviousSupportFile = { path: string; existed: boolean; previousContent?: string };
@@ -96,6 +98,12 @@ export async function readWorkspaceSupportFile(params: {
9698
return read.buffer.toString("utf8");
9799
}
98100

101+
export async function assertWorkspaceSkillWriteTarget(
102+
params: WorkspaceSkillWriteTargetParams,
103+
): Promise<void> {
104+
await resolveWorkspaceSkillWriteTarget(params);
105+
}
106+
99107
export async function writeWorkspaceSkill(params: {
100108
workspaceDir: string;
101109
skillDir: string;
@@ -106,7 +114,6 @@ export async function writeWorkspaceSkill(params: {
106114
symlinkPolicy: WorkspaceSkillSymlinkWritePolicy;
107115
}): Promise<void> {
108116
assertInsideWorkspace(params.workspaceDir, params.skillDir, "skill directory");
109-
assertInsideWorkspace(params.workspaceDir, params.skillFile, "skill file");
110117
const supportFiles = normalizeSupportFiles(params.supportFiles ?? []);
111118
const previousSupportFiles = await prepareWorkspaceSkillWrite({
112119
mode: params.mode,
@@ -188,22 +195,19 @@ async function prepareWorkspaceSkillWrite(params: {
188195
filePath,
189196
symlinkPolicy: params.symlinkPolicy,
190197
});
191-
const previousContent = await readWorkspaceSupportFile({
192-
skillDir: params.skillDir,
193-
relativePath: file.path,
194-
});
195-
if (params.mode === "create" && previousContent !== null) {
196-
throw new Error(
197-
`Target support file already exists: ${path.join(params.skillDir, file.path)}`,
198+
if (params.mode === "update") {
199+
const previousContent = await readWorkspaceSupportFile({
200+
skillDir: params.skillDir,
201+
relativePath: file.path,
202+
});
203+
previousSupportFiles.push(
204+
previousContent === null
205+
? { path: file.path, existed: false }
206+
: { path: file.path, existed: true, previousContent },
198207
);
199208
}
200-
previousSupportFiles.push(
201-
previousContent === null
202-
? { path: file.path, existed: false }
203-
: { path: file.path, existed: true, previousContent },
204-
);
205209
}
206-
return params.mode === "update" ? previousSupportFiles : [];
210+
return previousSupportFiles;
207211
}
208212

209213
async function writeWorkspaceFile(params: {
@@ -268,11 +272,9 @@ async function restoreSupportFilesAfterFailedWrite(params: {
268272
);
269273
}
270274

271-
async function resolveWorkspaceSkillWriteTarget(params: {
272-
workspaceDir: string;
273-
filePath: string;
274-
symlinkPolicy: WorkspaceSkillSymlinkWritePolicy;
275-
}): Promise<{ rootDir: string; relativePath: string }> {
275+
async function resolveWorkspaceSkillWriteTarget(
276+
params: WorkspaceSkillWriteTargetParams,
277+
): Promise<{ rootDir: string; relativePath: string }> {
276278
assertInsideWorkspace(params.workspaceDir, params.filePath, "skill file");
277279
const workspaceDir = path.resolve(params.workspaceDir);
278280
const filePath = path.resolve(params.filePath);

src/skills/workshop/service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// Workshop service orchestrates skill draft creation, validation, and persistence.
21
import fs from "node:fs/promises";
32
import path from "node:path";
43
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
@@ -12,6 +11,7 @@ import {
1211
} from "../discovery/status.js";
1312
import {
1413
assertInsideWorkspace,
14+
assertWorkspaceSkillWriteTarget,
1515
MAX_WORKSPACE_SKILL_SUPPORT_FILE_BYTES,
1616
normalizeWorkspaceSkillSupportPath,
1717
readWorkspaceSkillFile,
@@ -538,6 +538,11 @@ export async function applySkillProposal(
538538
? resolveAllowedSkillSymlinkTargetRealPaths(input.config)
539539
: [],
540540
};
541+
await assertWorkspaceSkillWriteTarget({
542+
workspaceDir: input.workspaceDir,
543+
filePath: record.target.skillFile,
544+
symlinkPolicy,
545+
});
541546
const targetState = await readApplyTargetState(record, supportFiles);
542547
const rollback = createSkillProposalRollback({
543548
proposalId: record.id,

src/skills/workshop/store.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// Workshop store persists generated skill drafts and metadata under the workspace.
21
import crypto from "node:crypto";
32
import fs from "node:fs/promises";
43
import path from "node:path";
@@ -35,7 +34,6 @@ const MANIFEST_LOCK_REL_PATH = path.join(TARGET_LOCKS_REL_DIR, "proposals-manife
3534
const PROPOSAL_RECORD_FILE = "proposal.json";
3635
const PROPOSAL_DRAFT_FILE = "PROPOSAL.md";
3736
const PROPOSAL_ROLLBACK_FILE = "rollback.json";
38-
/** Maximum bytes accepted for a proposal draft. */
3937
export const MAX_PROPOSAL_BYTES = 1024 * 1024;
4038
export const MAX_PROPOSAL_SUPPORT_FILES = 64;
4139
export const MAX_PROPOSAL_SUPPORT_FILES_TOTAL_BYTES = 2 * 1024 * 1024;

0 commit comments

Comments
 (0)