mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:20:42 +00:00
[codex] Extract filesystem safety primitives (#77918)
* refactor: extract filesystem safety primitives * refactor: use fs-safe for file access helpers * refactor: reuse fs-safe for media reads * refactor: use fs-safe for image reads * refactor: reuse fs-safe in qqbot media opener * refactor: reuse fs-safe for local media checks * refactor: consume cleaner fs-safe api * refactor: align fs-safe json option names * fix: preserve fs-safe migration contracts * refactor: use fs-safe primitive subpaths * refactor: use grouped fs-safe subpaths * refactor: align fs-safe api usage * refactor: adapt private state store api * chore: refresh proof gate * refactor: follow fs-safe json api split * refactor: follow reduced fs-safe surface * build: default fs-safe python helper off * fix: preserve fs-safe plugin sdk aliases * refactor: consolidate fs-safe usage * refactor: unify fs-safe store usage * refactor: trim fs-safe temp workspace usage * refactor: hide low-level fs-safe primitives * build: use published fs-safe package * fix: preserve outbound recovery durability after rebase * chore: refresh pr checks
This commit is contained in:
committed by
GitHub
parent
61481eb34f
commit
538605ff44
@@ -1,6 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
pathExists,
|
||||
replaceFileAtomic,
|
||||
resolvePathWithinRoot,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { bumpSkillsSnapshotVersion } from "../api.js";
|
||||
import { assertSkillContentSafe, scanSkillContent } from "./scanner.js";
|
||||
import type { SkillProposal, SkillScanFinding } from "./types.js";
|
||||
@@ -38,31 +42,27 @@ function assertValidSection(section: string): string {
|
||||
function skillDir(workspaceDir: string, skillName: string): string {
|
||||
const safeName = assertValidSkillName(skillName);
|
||||
const root = path.resolve(workspaceDir, "skills");
|
||||
const dir = path.resolve(root, safeName);
|
||||
if (!dir.startsWith(`${root}${path.sep}`)) {
|
||||
const dir = resolvePathWithinRoot({
|
||||
rootDir: root,
|
||||
requestedPath: safeName,
|
||||
scopeLabel: "workspace skills directory",
|
||||
});
|
||||
if (!dir.ok) {
|
||||
throw new Error("skill path escapes workspace skills directory");
|
||||
}
|
||||
return dir;
|
||||
return dir.path;
|
||||
}
|
||||
|
||||
function skillPath(workspaceDir: string, skillName: string): string {
|
||||
return path.join(skillDir(workspaceDir, skillName), "SKILL.md");
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicWrite(filePath: string, content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now().toString(36)}-${randomUUID()}`;
|
||||
await fs.writeFile(tempPath, content, "utf8");
|
||||
await fs.rename(tempPath, filePath);
|
||||
await replaceFileAtomic({
|
||||
filePath,
|
||||
content,
|
||||
tempPrefix: ".skill-workshop",
|
||||
});
|
||||
}
|
||||
|
||||
function formatSkillMarkdown(params: { name: string; description: string; body: string }): string {
|
||||
@@ -173,10 +173,14 @@ export async function writeSupportFile(params: {
|
||||
}
|
||||
assertSkillContentSafe(params.content);
|
||||
const root = skillDir(params.workspaceDir, name);
|
||||
const target = path.resolve(root, ...parts);
|
||||
if (!target.startsWith(`${root}${path.sep}`)) {
|
||||
const target = resolvePathWithinRoot({
|
||||
rootDir: root,
|
||||
requestedPath: path.join(...parts),
|
||||
scopeLabel: "skill directory",
|
||||
});
|
||||
if (!target.ok) {
|
||||
throw new Error("support file path escapes skill directory");
|
||||
}
|
||||
await atomicWrite(target, `${params.content.trimEnd()}\n`);
|
||||
return target;
|
||||
await atomicWrite(target.path, `${params.content.trimEnd()}\n`);
|
||||
return target.path;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { privateFileStore } from "openclaw/plugin-sdk/security-runtime";
|
||||
import type { SkillProposal, SkillWorkshopStatus } from "./types.js";
|
||||
|
||||
type StoreFile = {
|
||||
@@ -42,24 +42,21 @@ async function withLock<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(filePath: string): Promise<StoreFile> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as StoreFile;
|
||||
return {
|
||||
version: 1,
|
||||
proposals: Array.isArray(parsed.proposals) ? parsed.proposals : [],
|
||||
review:
|
||||
parsed.review && typeof parsed.review === "object"
|
||||
? normalizeReviewState(parsed.review as Partial<SkillWorkshopReviewState>)
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return { version: 1, proposals: [] };
|
||||
}
|
||||
throw error;
|
||||
async function readJson(rootDir: string, filePath: string): Promise<StoreFile> {
|
||||
const parsed = await privateFileStore(rootDir).readJsonIfExists<StoreFile>(
|
||||
path.relative(rootDir, filePath),
|
||||
);
|
||||
if (!parsed) {
|
||||
return { version: 1, proposals: [] };
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
proposals: Array.isArray(parsed.proposals) ? parsed.proposals : [],
|
||||
review:
|
||||
parsed.review && typeof parsed.review === "object"
|
||||
? normalizeReviewState(parsed.review as Partial<SkillWorkshopReviewState>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReviewState(
|
||||
@@ -80,26 +77,27 @@ function normalizeReviewState(
|
||||
};
|
||||
}
|
||||
|
||||
async function atomicWriteJson(filePath: string, data: StoreFile): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now().toString(36)}-${randomUUID()}`;
|
||||
await fs.writeFile(tempPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
await fs.rename(tempPath, filePath);
|
||||
async function atomicWriteJson(rootDir: string, filePath: string, data: StoreFile): Promise<void> {
|
||||
await privateFileStore(rootDir).writeJson(path.relative(rootDir, filePath), data, {
|
||||
trailingNewline: true,
|
||||
});
|
||||
}
|
||||
|
||||
export class SkillWorkshopStore {
|
||||
readonly stateDir: string;
|
||||
readonly filePath: string;
|
||||
|
||||
constructor(params: { stateDir: string; workspaceDir: string }) {
|
||||
this.stateDir = path.resolve(params.stateDir);
|
||||
this.filePath = path.join(
|
||||
params.stateDir,
|
||||
this.stateDir,
|
||||
"skill-workshop",
|
||||
`${workspaceKey(params.workspaceDir)}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
async list(status?: SkillWorkshopStatus): Promise<SkillProposal[]> {
|
||||
const file = await readJson(this.filePath);
|
||||
const file = await readJson(this.stateDir, this.filePath);
|
||||
const proposals = status
|
||||
? file.proposals.filter((proposal) => proposal.status === status)
|
||||
: file.proposals;
|
||||
@@ -112,7 +110,7 @@ export class SkillWorkshopStore {
|
||||
|
||||
async add(proposal: SkillProposal, maxPending: number): Promise<SkillProposal> {
|
||||
return await withLock(this.filePath, async () => {
|
||||
const file = await readJson(this.filePath);
|
||||
const file = await readJson(this.stateDir, this.filePath);
|
||||
const duplicate = file.proposals.find(
|
||||
(item) =>
|
||||
(item.status === "pending" || item.status === "quarantined") &&
|
||||
@@ -134,48 +132,52 @@ export class SkillWorkshopStore {
|
||||
).length <= maxPending
|
||||
);
|
||||
});
|
||||
await atomicWriteJson(this.filePath, { ...file, version: 1, proposals: nextProposals });
|
||||
await atomicWriteJson(this.stateDir, this.filePath, {
|
||||
...file,
|
||||
version: 1,
|
||||
proposals: nextProposals,
|
||||
});
|
||||
return proposal;
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: SkillWorkshopStatus): Promise<SkillProposal> {
|
||||
return await withLock(this.filePath, async () => {
|
||||
const file = await readJson(this.filePath);
|
||||
const file = await readJson(this.stateDir, this.filePath);
|
||||
const index = file.proposals.findIndex((proposal) => proposal.id === id);
|
||||
if (index < 0) {
|
||||
throw new Error(`proposal not found: ${id}`);
|
||||
}
|
||||
const updated = { ...file.proposals[index], status, updatedAt: Date.now() };
|
||||
file.proposals[index] = updated;
|
||||
await atomicWriteJson(this.filePath, file);
|
||||
await atomicWriteJson(this.stateDir, this.filePath, file);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async recordReviewTurn(toolCalls: number): Promise<SkillWorkshopReviewState> {
|
||||
return await withLock(this.filePath, async () => {
|
||||
const file = await readJson(this.filePath);
|
||||
const file = await readJson(this.stateDir, this.filePath);
|
||||
const current = normalizeReviewState(file.review);
|
||||
const next = {
|
||||
...current,
|
||||
turnsSinceReview: current.turnsSinceReview + 1,
|
||||
toolCallsSinceReview: current.toolCallsSinceReview + Math.max(0, Math.trunc(toolCalls)),
|
||||
};
|
||||
await atomicWriteJson(this.filePath, { ...file, review: next });
|
||||
await atomicWriteJson(this.stateDir, this.filePath, { ...file, review: next });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async markReviewed(): Promise<SkillWorkshopReviewState> {
|
||||
return await withLock(this.filePath, async () => {
|
||||
const file = await readJson(this.filePath);
|
||||
const file = await readJson(this.stateDir, this.filePath);
|
||||
const next = {
|
||||
turnsSinceReview: 0,
|
||||
toolCallsSinceReview: 0,
|
||||
lastReviewAt: Date.now(),
|
||||
};
|
||||
await atomicWriteJson(this.filePath, { ...file, review: next });
|
||||
await atomicWriteJson(this.stateDir, this.filePath, { ...file, review: next });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user