Refactor file access to use fs-safe primitives (#78255)

* refactor: use fs-safe primitives across file access

* fix: preserve invalid managed npm manifests

* fix: keep fs seams for startup metadata
This commit is contained in:
Peter Steinberger
2026-05-06 05:03:11 +01:00
committed by GitHub
parent 0d73f174a9
commit b85b1c68d1
56 changed files with 409 additions and 568 deletions

View File

@@ -2,6 +2,7 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
import { resolveAcpxPluginRoot } from "./config.js";
import type { ResolvedAcpxPluginConfig } from "./config.js";
@@ -113,7 +114,10 @@ async function resolveInstalledAcpPackageBinPath(
): Promise<string | undefined> {
try {
const packageJsonPath = requireFromHere.resolve(`${packageName}/package.json`);
const manifest = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as PackageManifest;
const { value: manifest } = await readJsonFileWithFallback<PackageManifest>(
packageJsonPath,
{},
);
if (manifest.name !== packageName) {
return undefined;
}

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
import {
DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
@@ -10,24 +11,14 @@ function decoratedMarkerPath(userDataDir: string) {
}
function safeReadJson(filePath: string): Record<string, unknown> | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
const parsed = loadJsonFile(filePath);
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
}
function safeWriteJson(filePath: string, data: Record<string, unknown>) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
saveJsonFile(filePath, data);
}
function asRecord(value: unknown): Record<string, unknown> | null {

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
export async function exists(filePath: string): Promise<boolean> {
@@ -47,10 +48,8 @@ export async function readJsonObject(
if (!filePath) {
return {};
}
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
const { value: parsed } = await readJsonFileWithFallback<unknown>(filePath, {});
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
}

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import type { ThemeRegistrationResolved } from "@pierre/diffs";
import { RegisteredCustomThemes, ResolvedThemes, ResolvingThemes } from "@pierre/diffs";
import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
type PierreThemeName = "pierre-dark" | "pierre-light";
const themeRequire = createRequire(import.meta.url);
@@ -20,8 +20,9 @@ function createThemeLoader(
return cachedTheme;
}
const themePath = themeRequire.resolve(themeSpecifier);
const { value: theme } = await readJsonFileWithFallback<Record<string, unknown>>(themePath, {});
cachedTheme = {
...(JSON.parse(await fs.readFile(themePath, "utf8")) as Record<string, unknown>),
...theme,
name: themeName,
} as ThemeRegistrationResolved;
return cachedTheme;

View File

@@ -174,13 +174,13 @@ export class DiffArtifactStore {
}
async cleanupExpired(): Promise<void> {
await this.ensureRoot();
const entries = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []);
const root = await this.artifactRoot();
const entries = await root.list("", { withFileTypes: true }).catch(() => []);
const now = Date.now();
await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.filter((entry) => entry.isDirectory)
.map(async (entry) => {
const id = entry.name;
const meta = await this.readMeta(id);
@@ -199,12 +199,7 @@ export class DiffArtifactStore {
return;
}
const artifactPath = this.artifactDir(id);
const stat = await fs.stat(artifactPath).catch(() => null);
if (!stat) {
return;
}
if (now - stat.mtimeMs > SWEEP_FALLBACK_AGE_MS) {
if (now - entry.mtimeMs > SWEEP_FALLBACK_AGE_MS) {
await this.deleteArtifact(id);
}
}),

View File

@@ -2,7 +2,10 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "openclaw/plugin-sdk/json-store";
import {
loadJsonFile,
writeJsonFileAtomically as writeJsonFileAtomicallyImpl,
} from "openclaw/plugin-sdk/json-store";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
@@ -208,20 +211,13 @@ function resolveLegacyMatrixFlatStorePlan(params: {
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
try {
if (!fs.existsSync(metadataPath)) {
return fallback;
}
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as {
deviceId?: unknown;
};
return {
deviceId:
typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null,
};
} catch {
return fallback;
}
const parsed = loadJsonFile<{ deviceId?: unknown }>(metadataPath);
return {
deviceId:
typeof parsed?.deviceId === "string" && parsed.deviceId.trim()
? parsed.deviceId
: fallback.deviceId,
};
}
function resolveMatrixLegacyCryptoPlans(params: {
@@ -288,25 +284,11 @@ function resolveMatrixLegacyCryptoPlans(params: {
}
function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey;
} catch {
return null;
}
return loadJsonFile<MatrixStoredRecoveryKey>(filePath) ?? null;
}
function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState;
} catch {
return null;
}
return loadJsonFile<MatrixLegacyCryptoMigrationState>(filePath) ?? null;
}
async function persistLegacyMigrationState(params: {

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
import {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
@@ -105,10 +106,10 @@ function resolveStorageRootMtimeMs(rootDir: string): number {
function readStoredRootMetadata(rootDir: string): StoredRootMetadata {
const metadata: StoredRootMetadata = {};
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(rootDir, STORAGE_META_FILENAME), "utf8"),
) as Partial<StoredRootMetadata>;
const parsed = loadJsonFile<Partial<StoredRootMetadata>>(
path.join(rootDir, STORAGE_META_FILENAME),
);
if (parsed) {
if (typeof parsed.homeserver === "string" && parsed.homeserver.trim()) {
metadata.homeserver = parsed.homeserver.trim();
}
@@ -130,19 +131,17 @@ function readStoredRootMetadata(rootDir: string): StoredRootMetadata {
if (typeof parsed.createdAt === "string" && parsed.createdAt.trim()) {
metadata.createdAt = parsed.createdAt.trim();
}
} catch {
// ignore missing or malformed storage metadata
}
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(rootDir, STARTUP_VERIFICATION_FILENAME), "utf8"),
) as { deviceId?: unknown };
if (!metadata.deviceId && typeof parsed.deviceId === "string" && parsed.deviceId.trim()) {
metadata.deviceId = parsed.deviceId.trim();
}
} catch {
// ignore missing or malformed verification state
const verification = loadJsonFile<{ deviceId?: unknown }>(
path.join(rootDir, STARTUP_VERIFICATION_FILENAME),
);
if (
!metadata.deviceId &&
typeof verification?.deviceId === "string" &&
verification.deviceId.trim()
) {
metadata.deviceId = verification.deviceId.trim();
}
return metadata;
@@ -473,8 +472,7 @@ function writeStoredRootMetadata(
},
): boolean {
try {
fs.mkdirSync(path.dirname(metaPath), { recursive: true });
fs.writeFileSync(metaPath, JSON.stringify(payload, null, 2), "utf-8");
saveJsonFile(metaPath, payload);
return true;
} catch {
return false;

View File

@@ -1,6 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
import { formatMatrixErrorMessage, formatMatrixErrorReason } from "../errors.js";
import { LogService } from "./logger.js";
import type {
@@ -399,13 +398,9 @@ export class MatrixRecoveryKeyStore {
return null;
}
try {
if (!fs.existsSync(this.recoveryKeyPath)) {
return null;
}
const raw = fs.readFileSync(this.recoveryKeyPath, "utf8");
const parsed = JSON.parse(raw) as Partial<MatrixStoredRecoveryKey>;
const parsed = loadJsonFile<Partial<MatrixStoredRecoveryKey>>(this.recoveryKeyPath);
if (
parsed.version !== 1 ||
parsed?.version !== 1 ||
typeof parsed.createdAt !== "string" ||
typeof parsed.privateKeyBase64 !== "string" || // pragma: allowlist secret
!parsed.privateKeyBase64.trim()
@@ -450,9 +445,7 @@ export class MatrixRecoveryKeyStore {
}
: undefined,
};
fs.mkdirSync(path.dirname(this.recoveryKeyPath), { recursive: true });
fs.writeFileSync(this.recoveryKeyPath, JSON.stringify(payload, null, 2), "utf8");
fs.chmodSync(this.recoveryKeyPath, 0o600);
saveJsonFile(this.recoveryKeyPath, payload);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to persist recovery key:", err);
}

View File

@@ -396,10 +396,6 @@ type DailyIngestionState = {
files: Record<string, DailyIngestionFileState>;
};
function resolveDailyIngestionStatePath(workspaceDir: string): string {
return path.join(workspaceDir, DAILY_INGESTION_STATE_RELATIVE_PATH);
}
function normalizeDailyIngestionState(raw: unknown): DailyIngestionState {
const record = asRecord(raw);
const filesRaw = asRecord(record?.files);
@@ -442,10 +438,9 @@ function normalizeMemoryDay(value: unknown): string | undefined {
}
async function readDailyIngestionState(workspaceDir: string): Promise<DailyIngestionState> {
const statePath = resolveDailyIngestionStatePath(workspaceDir);
try {
return normalizeDailyIngestionState(
await privateFileStore(workspaceDir).readJsonIfExists(path.relative(workspaceDir, statePath)),
await privateFileStore(workspaceDir).readJsonIfExists(DAILY_INGESTION_STATE_RELATIVE_PATH),
);
} catch (err) {
if (err instanceof SyntaxError) {
@@ -459,8 +454,7 @@ async function writeDailyIngestionState(
workspaceDir: string,
state: DailyIngestionState,
): Promise<void> {
const statePath = resolveDailyIngestionStatePath(workspaceDir);
await privateFileStore(workspaceDir).writeJson(path.relative(workspaceDir, statePath), state, {
await privateFileStore(workspaceDir).writeJson(DAILY_INGESTION_STATE_RELATIVE_PATH, state, {
trailingNewline: true,
});
}
@@ -496,10 +490,6 @@ function normalizeWorkspaceKey(workspaceDir: string): string {
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
function resolveSessionIngestionStatePath(workspaceDir: string): string {
return path.join(workspaceDir, SESSION_INGESTION_STATE_RELATIVE_PATH);
}
function normalizeSessionIngestionState(raw: unknown): SessionIngestionState {
const record = asRecord(raw);
const filesRaw = asRecord(record?.files);
@@ -554,10 +544,9 @@ function normalizeSessionIngestionState(raw: unknown): SessionIngestionState {
}
async function readSessionIngestionState(workspaceDir: string): Promise<SessionIngestionState> {
const statePath = resolveSessionIngestionStatePath(workspaceDir);
try {
return normalizeSessionIngestionState(
await privateFileStore(workspaceDir).readJsonIfExists(path.relative(workspaceDir, statePath)),
await privateFileStore(workspaceDir).readJsonIfExists(SESSION_INGESTION_STATE_RELATIVE_PATH),
);
} catch (err) {
if (err instanceof SyntaxError) {
@@ -571,8 +560,7 @@ async function writeSessionIngestionState(
workspaceDir: string,
state: SessionIngestionState,
): Promise<void> {
const statePath = resolveSessionIngestionStatePath(workspaceDir);
await privateFileStore(workspaceDir).writeJson(path.relative(workspaceDir, statePath), state, {
await privateFileStore(workspaceDir).writeJson(SESSION_INGESTION_STATE_RELATIVE_PATH, state, {
trailingNewline: true,
});
}

View File

@@ -757,10 +757,9 @@ async function withShortTermLock<T>(workspaceDir: string, task: () => Promise<T>
}
async function readStore(workspaceDir: string, nowIso: string): Promise<ShortTermRecallStore> {
const storePath = resolveStorePath(workspaceDir);
try {
return normalizeStore(
await privateFileStore(workspaceDir).readJsonIfExists(path.relative(workspaceDir, storePath)),
await privateFileStore(workspaceDir).readJsonIfExists(SHORT_TERM_STORE_RELATIVE_PATH),
nowIso,
);
} catch (err) {
@@ -830,12 +829,9 @@ async function readPhaseSignalStore(
workspaceDir: string,
nowIso: string,
): Promise<ShortTermPhaseSignalStore> {
const phaseSignalPath = resolvePhaseSignalPath(workspaceDir);
try {
return normalizePhaseSignalStore(
await privateFileStore(workspaceDir).readJsonIfExists(
path.relative(workspaceDir, phaseSignalPath),
),
await privateFileStore(workspaceDir).readJsonIfExists(SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH),
nowIso,
);
} catch {
@@ -847,21 +843,15 @@ async function writePhaseSignalStore(
workspaceDir: string,
store: ShortTermPhaseSignalStore,
): Promise<void> {
const phaseSignalPath = resolvePhaseSignalPath(workspaceDir);
await ensureShortTermArtifactsDir(workspaceDir);
await privateFileStore(workspaceDir).writeJson(
path.relative(workspaceDir, phaseSignalPath),
store,
{
trailingNewline: true,
},
);
await privateFileStore(workspaceDir).writeJson(SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH, store, {
trailingNewline: true,
});
}
async function writeStore(workspaceDir: string, store: ShortTermRecallStore): Promise<void> {
const storePath = resolveStorePath(workspaceDir);
await ensureShortTermArtifactsDir(workspaceDir);
await privateFileStore(workspaceDir).writeJson(path.relative(workspaceDir, storePath), store, {
await privateFileStore(workspaceDir).writeJson(SHORT_TERM_STORE_RELATIVE_PATH, store, {
trailingNewline: true,
});
}

View File

@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import {
replaceManagedMarkdownBlock,
withTrailingNewline,
@@ -679,8 +680,7 @@ async function writeImportRunRecord(
record: ChatGptImportRunRecord,
): Promise<void> {
const recordPath = resolveImportRunPath(vaultRoot, record.runId);
await fs.mkdir(path.dirname(recordPath), { recursive: true });
await fs.writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
await writeJsonFileAtomically(recordPath, record);
}
async function readImportRunRecord(

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
export type MemoryWikiImportedSourceGroup = "bridge" | "unsafe-local";
@@ -30,30 +31,14 @@ export async function readMemoryWikiSourceSyncState(
vaultRoot: string,
): Promise<MemoryWikiImportedSourceState> {
const statePath = resolveMemoryWikiSourceSyncStatePath(vaultRoot);
const raw = await fs.readFile(statePath, "utf8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
if (!raw.trim()) {
return {
version: EMPTY_STATE.version,
entries: {},
};
}
try {
const parsed = JSON.parse(raw) as Partial<MemoryWikiImportedSourceState>;
return {
version: 1,
entries: { ...parsed.entries },
};
} catch {
return {
version: EMPTY_STATE.version,
entries: {},
};
}
const { value: parsed } = await readJsonFileWithFallback<Partial<MemoryWikiImportedSourceState>>(
statePath,
EMPTY_STATE,
);
return {
version: 1,
entries: { ...parsed.entries },
};
}
export async function writeMemoryWikiSourceSyncState(
@@ -61,8 +46,7 @@ export async function writeMemoryWikiSourceSyncState(
state: MemoryWikiImportedSourceState,
): Promise<void> {
const statePath = resolveMemoryWikiSourceSyncStatePath(vaultRoot);
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
await writeJsonFileAtomically(statePath, state);
}
export async function shouldSkipImportedSourceWrite(params: {

View File

@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
/** Default cooldown between reflections per session (5 minutes). */
export const DEFAULT_COOLDOWN_MS = 300_000;
@@ -93,11 +93,7 @@ export async function storeSessionLearning(params: {
learnings = learnings.slice(-10);
}
await replaceFileAtomic({
filePath: learningsFile,
content: JSON.stringify(learnings, null, 2),
tempPrefix: ".msteams-learnings",
});
await writeJsonFileAtomically(learningsFile, learnings);
if (!exists && legacyLearningsFile !== learningsFile) {
await fs.rm(legacyLearningsFile, { force: true }).catch(() => undefined);
}

View File

@@ -1,6 +1,6 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
import {
buildExecRemoteCommand,
createSshSandboxSessionFromConfigText,
@@ -37,11 +37,11 @@ function resolveBundledOpenShellCommand(): string | null {
}
try {
const packageJsonPath = require.resolve("openshell/package.json");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
const packageJson = loadJsonFile<{
bin?: string | Record<string, string>;
};
}>(packageJsonPath);
const relativeBin =
typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.openshell;
typeof packageJson?.bin === "string" ? packageJson.bin : packageJson?.bin?.openshell;
cachedBundledOpenShellCommand = relativeBin
? path.resolve(path.dirname(packageJsonPath), relativeBin)
: null;

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
import { getHomeDir, getQQBotDataDir, isWindows } from "../../utils/platform.js";
import type { SlashCommandResult } from "../slash-commands.js";
@@ -10,10 +11,7 @@ function getConfiguredLogFiles(): string[] {
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
try {
const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
if (!fs.existsSync(cfgPath)) {
continue;
}
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
const cfg = loadJsonFile<{ logging?: { file?: unknown } }>(cfgPath);
const logFile = cfg?.logging?.file;
if (logFile && typeof logFile === "string") {
files.push(path.resolve(logFile));

View File

@@ -26,6 +26,7 @@
*/
import fs from "node:fs";
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
import { replaceFileAtomicSync } from "openclaw/plugin-sdk/security-runtime";
import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "../utils/data-paths.js";
@@ -70,17 +71,15 @@ export function loadCredentialBackup(accountId?: string): CredentialBackup | nul
try {
if (accountId) {
const newPath = getCredentialBackupFile(accountId);
if (fs.existsSync(newPath)) {
const data = JSON.parse(fs.readFileSync(newPath, "utf8")) as CredentialBackup;
if (data?.appId && data.clientSecret) {
return data;
}
const data = loadJsonFile<CredentialBackup>(newPath);
if (data?.appId && data.clientSecret) {
return data;
}
}
const legacy = getLegacyCredentialBackupFile();
if (fs.existsSync(legacy)) {
const data = JSON.parse(fs.readFileSync(legacy, "utf8")) as CredentialBackup;
const data = loadJsonFile<CredentialBackup>(legacy);
if (data) {
if (!data?.appId || !data?.clientSecret) {
return null;
}

View File

@@ -42,10 +42,8 @@ async function withLock<T>(key: string, task: () => Promise<T>): Promise<T> {
}
}
async function readJson(rootDir: string, filePath: string): Promise<StoreFile> {
const parsed = await privateFileStore(rootDir).readJsonIfExists<StoreFile>(
path.relative(rootDir, filePath),
);
async function readJson(rootDir: string, relativePath: string): Promise<StoreFile> {
const parsed = await privateFileStore(rootDir).readJsonIfExists<StoreFile>(relativePath);
if (!parsed) {
return { version: 1, proposals: [] };
}
@@ -77,8 +75,12 @@ function normalizeReviewState(
};
}
async function atomicWriteJson(rootDir: string, filePath: string, data: StoreFile): Promise<void> {
await privateFileStore(rootDir).writeJson(path.relative(rootDir, filePath), data, {
async function atomicWriteJson(
rootDir: string,
relativePath: string,
data: StoreFile,
): Promise<void> {
await privateFileStore(rootDir).writeJson(relativePath, data, {
trailingNewline: true,
});
}
@@ -86,18 +88,16 @@ async function atomicWriteJson(rootDir: string, filePath: string, data: StoreFil
export class SkillWorkshopStore {
readonly stateDir: string;
readonly filePath: string;
private readonly relativePath: string;
constructor(params: { stateDir: string; workspaceDir: string }) {
this.stateDir = path.resolve(params.stateDir);
this.filePath = path.join(
this.stateDir,
"skill-workshop",
`${workspaceKey(params.workspaceDir)}.json`,
);
this.relativePath = path.join("skill-workshop", `${workspaceKey(params.workspaceDir)}.json`);
this.filePath = path.join(this.stateDir, this.relativePath);
}
async list(status?: SkillWorkshopStatus): Promise<SkillProposal[]> {
const file = await readJson(this.stateDir, this.filePath);
const file = await readJson(this.stateDir, this.relativePath);
const proposals = status
? file.proposals.filter((proposal) => proposal.status === status)
: file.proposals;
@@ -110,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.stateDir, this.filePath);
const file = await readJson(this.stateDir, this.relativePath);
const duplicate = file.proposals.find(
(item) =>
(item.status === "pending" || item.status === "quarantined") &&
@@ -132,7 +132,7 @@ export class SkillWorkshopStore {
).length <= maxPending
);
});
await atomicWriteJson(this.stateDir, this.filePath, {
await atomicWriteJson(this.stateDir, this.relativePath, {
...file,
version: 1,
proposals: nextProposals,
@@ -143,41 +143,41 @@ export class SkillWorkshopStore {
async updateStatus(id: string, status: SkillWorkshopStatus): Promise<SkillProposal> {
return await withLock(this.filePath, async () => {
const file = await readJson(this.stateDir, this.filePath);
const file = await readJson(this.stateDir, this.relativePath);
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.stateDir, this.filePath, file);
await atomicWriteJson(this.stateDir, this.relativePath, file);
return updated;
});
}
async recordReviewTurn(toolCalls: number): Promise<SkillWorkshopReviewState> {
return await withLock(this.filePath, async () => {
const file = await readJson(this.stateDir, this.filePath);
const file = await readJson(this.stateDir, this.relativePath);
const current = normalizeReviewState(file.review);
const next = {
...current,
turnsSinceReview: current.turnsSinceReview + 1,
toolCallsSinceReview: current.toolCallsSinceReview + Math.max(0, Math.trunc(toolCalls)),
};
await atomicWriteJson(this.stateDir, this.filePath, { ...file, review: next });
await atomicWriteJson(this.stateDir, this.relativePath, { ...file, review: next });
return next;
});
}
async markReviewed(): Promise<SkillWorkshopReviewState> {
return await withLock(this.filePath, async () => {
const file = await readJson(this.stateDir, this.filePath);
const file = await readJson(this.stateDir, this.relativePath);
const next = {
turnsSinceReview: 0,
toolCallsSinceReview: 0,
lastReviewAt: Date.now(),
};
await atomicWriteJson(this.stateDir, this.filePath, { ...file, review: next });
await atomicWriteJson(this.stateDir, this.relativePath, { ...file, review: next });
return next;
});
}

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
const STORE_VERSION = 2;
@@ -45,30 +45,30 @@ function extractBotIdFromToken(token?: string): string | null {
return rawBotId;
}
function safeParseState(raw: string): TelegramUpdateOffsetState | null {
function safeParseState(parsed: unknown): TelegramUpdateOffsetState | null {
try {
const parsed = JSON.parse(raw) as {
const state = parsed as {
version?: number;
lastUpdateId?: number | null;
botId?: string | null;
};
if (parsed?.version !== STORE_VERSION && parsed?.version !== 1) {
if (state?.version !== STORE_VERSION && state?.version !== 1) {
return null;
}
if (parsed.lastUpdateId !== null && !isValidUpdateId(parsed.lastUpdateId)) {
if (state.lastUpdateId !== null && !isValidUpdateId(state.lastUpdateId)) {
return null;
}
if (
parsed.version === STORE_VERSION &&
parsed.botId !== null &&
typeof parsed.botId !== "string"
state.version === STORE_VERSION &&
state.botId !== null &&
typeof state.botId !== "string"
) {
return null;
}
return {
version: STORE_VERSION,
lastUpdateId: parsed.lastUpdateId ?? null,
botId: parsed.version === STORE_VERSION ? (parsed.botId ?? null) : null,
lastUpdateId: state.lastUpdateId ?? null,
botId: state.version === STORE_VERSION ? (state.botId ?? null) : null,
};
} catch {
return null;
@@ -81,24 +81,16 @@ export async function readTelegramUpdateOffset(params: {
env?: NodeJS.ProcessEnv;
}): Promise<number | null> {
const filePath = resolveTelegramUpdateOffsetPath(params.accountId, params.env);
try {
const raw = await fs.readFile(filePath, "utf-8");
const parsed = safeParseState(raw);
const expectedBotId = extractBotIdFromToken(params.botToken);
if (expectedBotId && parsed?.botId && parsed.botId !== expectedBotId) {
return null;
}
if (expectedBotId && parsed?.botId === null) {
return null;
}
return parsed?.lastUpdateId ?? null;
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return null;
}
const { value } = await readJsonFileWithFallback<unknown>(filePath, null);
const parsed = safeParseState(value);
const expectedBotId = extractBotIdFromToken(params.botToken);
if (expectedBotId && parsed?.botId && parsed.botId !== expectedBotId) {
return null;
}
if (expectedBotId && parsed?.botId === null) {
return null;
}
return parsed?.lastUpdateId ?? null;
}
export async function writeTelegramUpdateOffset(params: {

View File

@@ -1,6 +1,5 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { root } from "openclaw/plugin-sdk/security-runtime";
import type { VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
@@ -42,15 +41,6 @@ function resolveAgentSystemPromptOverride(cfg: CoreConfig, agentId: string): str
);
}
function isSafeWorkspaceRelativeFile(file: string): boolean {
if (!file.trim() || path.isAbsolute(file)) {
return false;
}
const normalized = path.normalize(file);
const parts = normalized.split(/[\\/]+/);
return normalized !== "." && !parts.includes("..") && !normalized.includes("\0");
}
function limitText(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
@@ -65,12 +55,15 @@ async function readWorkspaceVoiceContextFiles(params: {
}): Promise<string[]> {
const sections: string[] = [];
let remaining = params.maxChars;
const workspaceRoot = await root(params.workspaceDir).catch(() => null);
if (!workspaceRoot) {
return sections;
}
for (const file of params.files) {
if (remaining <= 0 || !isSafeWorkspaceRelativeFile(file)) {
if (remaining <= 0) {
continue;
}
const fullPath = path.join(params.workspaceDir, path.normalize(file));
const content = await readFile(fullPath, "utf8").catch(() => undefined);
const content = await workspaceRoot.readText(file).catch(() => undefined);
const trimmed = content?.trim();
if (!trimmed) {
continue;