[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:
Peter Steinberger
2026-05-06 02:15:17 +01:00
committed by GitHub
parent 61481eb34f
commit 538605ff44
356 changed files with 4918 additions and 11913 deletions

View File

@@ -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;
}

View File

@@ -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;
});
}