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;

View File

@@ -1,4 +1,5 @@
import { configureFsSafePython } from "@openclaw/fs-safe/config";
export { root } from "@openclaw/fs-safe/root";
export { isPathInside } from "@openclaw/fs-safe/path";
export {
readRegularFile,
@@ -21,6 +22,7 @@ export function isFileMissingError(
err &&
typeof err === "object" &&
"code" in err &&
(err as Partial<NodeJS.ErrnoException>).code === "ENOENT",
((err as Partial<NodeJS.ErrnoException>).code === "ENOENT" ||
(err as { code?: unknown }).code === "not-found"),
);
}

View File

@@ -6,7 +6,13 @@ import {
resolveMemorySearchConfig,
type OpenClawConfig,
} from "./config-utils.js";
import { isFileMissingError, isPathInside, readRegularFile, statRegularFile } from "./fs-utils.js";
import {
isFileMissingError,
isPathInside,
readRegularFile,
root,
statRegularFile,
} from "./fs-utils.js";
import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js";
import {
buildMemoryReadResult,
@@ -66,6 +72,17 @@ export async function readMemoryFile(params: {
if (!absPath.endsWith(".md")) {
throw new Error("path required");
}
if (allowedWorkspace) {
try {
const workspaceRoot = await root(params.workspaceDir);
await workspaceRoot.resolve(relPath);
} catch (err) {
if (isFileMissingError(err)) {
return { text: "", path: relPath };
}
throw err;
}
}
const statResult = await statRegularFile(absPath);
if (statResult.missing) {
return { text: "", path: relPath };

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { applyMergePatch } from "../../config/merge-patch.js";
import { tryReadJson, writeJson } from "../../infra/json-files.js";
import type { BundleMcpConfig, BundleMcpServerConfig } from "../../plugins/bundle-mcp.js";
import {
applyCommonServerConfig,
@@ -11,14 +12,10 @@ import {
} from "./bundle-mcp-adapter-shared.js";
async function readJsonObject(filePath: string): Promise<Record<string, unknown>> {
try {
const raw = JSON.parse(await fs.readFile(filePath, "utf-8")) as unknown;
return raw && typeof raw === "object" && !Array.isArray(raw)
? ({ ...raw } as Record<string, unknown>)
: {};
} catch {
return {};
}
const raw = await tryReadJson<unknown>(filePath);
return raw && typeof raw === "object" && !Array.isArray(raw)
? ({ ...raw } as Record<string, unknown>)
: {};
}
function resolveEnvPlaceholder(
@@ -86,7 +83,7 @@ export async function writeGeminiSystemSettings(
if (!isRecord(settings.mcp) || !isRecord(settings.mcpServers)) {
throw new Error("Gemini MCP settings merge produced an invalid object");
}
await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
await writeJson(settingsPath, settings, { trailingNewline: true });
return {
env: {
...inheritedEnv,

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { applyMergePatch } from "../../config/merge-patch.js";
import type { CliBackendConfig } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { tryReadJson } from "../../infra/json-files.js";
import { extractMcpServerMap, type BundleMcpConfig } from "../../plugins/bundle-mcp.js";
import type { CliBundleMcpMode } from "../../plugins/types.js";
import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle-mcp-config.js";
@@ -26,12 +27,7 @@ function resolveBundleMcpMode(mode: CliBundleMcpMode | undefined): CliBundleMcpM
}
async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfig> {
try {
const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown;
return { mcpServers: extractMcpServerMap(raw) };
} catch {
return { mcpServers: {} };
}
return { mcpServers: extractMcpServerMap(await tryReadJson<unknown>(configPath)) };
}
function sortJsonValue(value: unknown): unknown {

View File

@@ -1,5 +1,7 @@
import fs from "node:fs";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { tryReadJsonSync } from "../infra/json-files.js";
import { replaceFileAtomicSync } from "../infra/replace-file.js";
import { isRecord } from "../utils.js";
import {
listProviderEnvAuthLookupKeys,
@@ -63,12 +65,7 @@ export function scrubLegacyStaticAuthJsonEntriesForDiscovery(pathname: string):
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(pathname, "utf8")) as unknown;
} catch {
return;
}
const parsed = tryReadJsonSync(pathname);
if (!isRecord(parsed)) {
return;
}
@@ -94,6 +91,11 @@ export function scrubLegacyStaticAuthJsonEntriesForDiscovery(pathname: string):
return;
}
fs.writeFileSync(pathname, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
fs.chmodSync(pathname, 0o600);
replaceFileAtomicSync({
filePath: pathname,
content: `${JSON.stringify(parsed, null, 2)}\n`,
dirMode: 0o700,
mode: 0o600,
tempPrefix: ".pi-auth",
});
}

View File

@@ -1,4 +1,3 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
downloadClawHubSkillArchive,
@@ -13,6 +12,7 @@ import { pathExists } from "../infra/fs-safe.js";
import { withExtractedArchiveRoot } from "../infra/install-flow.js";
import { installPackageDir } from "../infra/install-package-dir.js";
import { resolveSafeInstallDir } from "../infra/install-safe-path.js";
import { tryReadJson, writeJson } from "../infra/json-files.js";
const DOT_DIR = ".clawhub";
const LEGACY_DOT_DIR = ".clawdhub";
@@ -147,10 +147,8 @@ async function readClawHubSkillsLockfile(workspaceDir: string): Promise<ClawHubS
];
for (const candidate of candidates) {
try {
const raw = JSON.parse(
await fs.readFile(candidate, "utf8"),
) as Partial<ClawHubSkillsLockfile>;
if (raw.version === 1 && raw.skills && typeof raw.skills === "object") {
const raw = await tryReadJson<Partial<ClawHubSkillsLockfile>>(candidate);
if (raw?.version === 1 && raw.skills && typeof raw.skills === "object") {
return {
version: 1,
skills: raw.skills,
@@ -168,8 +166,7 @@ async function writeClawHubSkillsLockfile(
lockfile: ClawHubSkillsLockfile,
): Promise<void> {
const targetPath = path.join(workspaceDir, DOT_DIR, "lock.json");
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, `${JSON.stringify(lockfile, null, 2)}\n`, "utf8");
await writeJson(targetPath, lockfile, { trailingNewline: true });
}
async function readClawHubSkillOrigin(skillDir: string): Promise<ClawHubSkillOrigin | null> {
@@ -179,9 +176,9 @@ async function readClawHubSkillOrigin(skillDir: string): Promise<ClawHubSkillOri
];
for (const candidate of candidates) {
try {
const raw = JSON.parse(await fs.readFile(candidate, "utf8")) as Partial<ClawHubSkillOrigin>;
const raw = await tryReadJson<Partial<ClawHubSkillOrigin>>(candidate);
if (
raw.version === 1 &&
raw?.version === 1 &&
typeof raw.registry === "string" &&
typeof raw.slug === "string" &&
typeof raw.installedVersion === "string" &&
@@ -201,8 +198,7 @@ async function writeClawHubSkillOrigin(
origin: ClawHubSkillOrigin,
): Promise<void> {
const targetPath = path.join(skillDir, SKILL_ORIGIN_RELATIVE_PATH);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, `${JSON.stringify(origin, null, 2)}\n`, "utf8");
await writeJson(targetPath, origin, { trailingNewline: true });
}
export async function searchSkillsFromClawHub(params: {

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { tryReadJsonSync } from "../infra/json-files.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { listChannelCatalogEntries } from "../plugins/channel-catalog-registry.js";
import type { PluginPackageChannel } from "../plugins/manifest.js";
@@ -50,19 +51,15 @@ function readOfficialCatalogFileSync(): ChannelCatalogEntryLike[] {
officialCatalogFileCache.set(candidate, null);
continue;
}
try {
const payload = JSON.parse(fs.readFileSync(candidate, "utf8")) as {
entries?: unknown;
};
const payload = tryReadJsonSync<{ entries?: unknown }>(candidate);
if (payload) {
const entries = Array.isArray(payload.entries)
? (payload.entries as ChannelCatalogEntryLike[])
: [];
officialCatalogFileCache.set(candidate, entries);
return entries;
} catch {
officialCatalogFileCache.set(candidate, null);
continue;
}
officialCatalogFileCache.set(candidate, null);
}
return [];
}

View File

@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
import { tryReadJsonSync } from "../../infra/json-files.js";
import { isPrereleaseSemverVersion, parseRegistryNpmSpec } from "../../infra/npm-registry-spec.js";
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js";
@@ -135,15 +135,11 @@ function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEnt
function loadCatalogEntriesFromPaths(paths: Iterable<string>): ExternalCatalogEntry[] {
const entries: ExternalCatalogEntry[] = [];
for (const resolvedPath of paths) {
if (!fs.existsSync(resolvedPath)) {
const payload = tryReadJsonSync(resolvedPath);
if (payload === null) {
continue;
}
try {
const payload = JSON.parse(fs.readFileSync(resolvedPath, "utf-8")) as unknown;
entries.push(...parseCatalogEntries(payload));
} catch {
// Ignore invalid catalog files.
}
entries.push(...parseCatalogEntries(payload));
}
return entries;
}
@@ -158,18 +154,14 @@ function loadOfficialCatalogEntriesFromPaths(paths: Iterable<string>): ExternalC
}
continue;
}
if (!fs.existsSync(resolvedPath)) {
const payload = tryReadJsonSync(resolvedPath);
if (payload === null) {
officialCatalogEntriesByPath.set(resolvedPath, null);
continue;
}
try {
const payload = JSON.parse(fs.readFileSync(resolvedPath, "utf-8")) as unknown;
const parsed = parseCatalogEntries(payload);
officialCatalogEntriesByPath.set(resolvedPath, parsed);
entries.push(...parsed);
} catch {
officialCatalogEntriesByPath.set(resolvedPath, null);
}
const parsed = parseCatalogEntries(payload);
officialCatalogEntriesByPath.set(resolvedPath, parsed);
entries.push(...parsed);
}
return entries;
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import type { Command } from "commander";
import { tryReadJsonSync } from "../infra/json-files.js";
import { findBundledPluginSource } from "../plugins/bundled-sources.js";
import { loadPluginManifest } from "../plugins/manifest.js";
import {
@@ -40,24 +41,17 @@ function readBundledInstallRecoveryMetadata(rootDir: string): {
}
const manifest = loadPluginManifest(rootDir, false);
const pluginId = manifest.ok ? manifest.manifest.id : undefined;
try {
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
openclaw?: {
install?: {
allowInvalidConfigRecovery?: boolean;
};
const parsed = tryReadJsonSync<{
openclaw?: {
install?: {
allowInvalidConfigRecovery?: boolean;
};
};
return {
...(pluginId ? { pluginId } : {}),
allowInvalidConfigRecovery: parsed.openclaw?.install?.allowInvalidConfigRecovery === true,
};
} catch {
return {
...(pluginId ? { pluginId } : {}),
allowInvalidConfigRecovery: false,
};
}
}>(packageJsonPath);
return {
...(pluginId ? { pluginId } : {}),
allowInvalidConfigRecovery: parsed?.openclaw?.install?.allowInvalidConfigRecovery === true,
};
}
function resolveBundledInstallRecoveryMetadata(

View File

@@ -28,6 +28,7 @@ import {
} from "../../daemon/service.js";
import { createLowDiskSpaceWarning } from "../../infra/disk-space.js";
import { pathExists } from "../../infra/fs-safe.js";
import { readJsonIfExists, writeJson } from "../../infra/json-files.js";
import { runGlobalPackageUpdateSteps } from "../../infra/package-update-steps.js";
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
@@ -1702,15 +1703,14 @@ async function writePostCorePluginUpdateResultFile(
if (!filePath) {
return;
}
await fs.writeFile(filePath, `${JSON.stringify(result)}\n`, "utf-8");
await writeJson(filePath, result, { trailingNewline: true });
}
async function readPostCorePluginUpdateResultFile(
filePath: string,
): Promise<PostCorePluginUpdateResult | undefined> {
try {
const raw = await fs.readFile(filePath, "utf-8");
const parsed = JSON.parse(raw) as PostCorePluginUpdateResult;
const parsed = await readJsonIfExists<PostCorePluginUpdateResult>(filePath);
if (
parsed &&
typeof parsed === "object" &&

View File

@@ -1,4 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveStateDir } from "../config/paths.js";
@@ -12,7 +11,7 @@ import {
type DevicePairingPendingRequest,
type PairedDevice,
} from "../infra/device-pairing.js";
import { JsonFileReadError } from "../infra/json-files.js";
import { JsonFileReadError, tryReadJsonSync } from "../infra/json-files.js";
import type { DeviceAuthStore } from "../shared/device-auth.js";
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
@@ -391,14 +390,7 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
}
function readJsonFile(filePath: string): unknown {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
return tryReadJsonSync(filePath);
}
function readLocalIdentity(env: NodeJS.ProcessEnv = process.env): StoredDeviceIdentity | null {

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { saveJsonFile } from "../infra/json-file.js";
import { tryReadJsonSync } from "../infra/json-files.js";
import { resolveDefaultPluginNpmDir } from "../plugins/install-paths.js";
import type { InstalledPluginIndexRecordStoreOptions } from "../plugins/installed-plugin-index-records.js";
import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js";
@@ -36,12 +37,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
function readJsonObject(filePath: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
const parsed = tryReadJsonSync(filePath);
return isRecord(parsed) ? parsed : null;
}
function readStringMap(value: unknown): Record<string, string> {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import type { CommandContext } from "../auto-reply/reply/commands-types.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { tryReadJson, writeJson } from "../infra/json-files.js";
import type { RuntimeEnv } from "../runtime.js";
import {
executeCrestodianOperation,
@@ -81,7 +82,10 @@ async function readPending(
now = new Date(),
): Promise<RescuePendingOperation | null> {
try {
const parsed = JSON.parse(await fs.readFile(pendingPath, "utf8")) as RescuePendingOperation;
const parsed = await tryReadJson<RescuePendingOperation>(pendingPath);
if (!parsed) {
return null;
}
if (Date.parse(parsed.expiresAt) <= now.getTime()) {
await fs.rm(pendingPath, { force: true });
return null;
@@ -93,13 +97,10 @@ async function readPending(
}
async function writePending(pendingPath: string, pending: RescuePendingOperation): Promise<void> {
await fs.mkdir(path.dirname(pendingPath), { recursive: true });
await fs.writeFile(pendingPath, `${JSON.stringify(pending, null, 2)}\n`, {
encoding: "utf8",
await writeJson(pendingPath, pending, {
dirMode: 0o700,
mode: 0o600,
});
await fs.chmod(pendingPath, 0o600).catch(() => {
// Best-effort on platforms/filesystems without POSIX modes.
trailingNewline: true,
});
}

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry.js";
import { resolveStateDir } from "../config/paths.js";
import { readLocalFileSafely } from "../infra/fs-safe.js";
import { tryReadJson, writeJson } from "../infra/json-files.js";
import { safeFileURLToPath } from "../infra/local-file-access.js";
import {
getImageMetadata,
@@ -381,8 +382,7 @@ async function getVariantStats(filePath: string) {
async function writeManagedImageRecord(record: ManagedImageRecord, stateDir = resolveStateDir()) {
const recordPath = resolveOutgoingRecordPath(record.attachmentId, stateDir);
await fs.mkdir(path.dirname(recordPath), { recursive: true });
await fs.writeFile(recordPath, JSON.stringify(record, null, 2), "utf-8");
await writeJson(recordPath, record, { trailingNewline: true });
}
async function deleteManagedImageRecordArtifacts(
@@ -479,10 +479,8 @@ export async function cleanupManagedOutgoingImageRecords(params?: {
continue;
}
const recordPath = path.join(recordsDir, name);
let record: ManagedImageRecord;
try {
record = JSON.parse(await fs.readFile(recordPath, "utf-8")) as ManagedImageRecord;
} catch {
const record = await tryReadJson<ManagedImageRecord>(recordPath);
if (!record) {
try {
await fs.rm(recordPath, { force: true });
} catch {

View File

@@ -49,6 +49,13 @@ const mocks = vi.hoisted(() => ({
realPath: "/workspace/test-agent/AGENTS.md",
stat: { size: 0, mtimeMs: 0 },
})),
rootStat: vi.fn(async (_params?: unknown) => ({
isFile: true,
isSymbolicLink: false,
mtimeMs: 0,
nlink: 1,
size: 0,
})),
rootWrite: vi.fn(async (_params?: unknown) => {}),
}));
@@ -119,6 +126,7 @@ vi.mock("../../infra/fs-safe.js", async () => {
root: vi.fn(async (rootDir: string) => ({
open: async (relativePath: string, options?: Record<string, unknown>) =>
await mocks.rootOpen({ rootDir, relativePath, ...options }),
stat: async (relativePath: string) => await mocks.rootStat({ rootDir, relativePath }),
read: async (relativePath: string, options?: Record<string, unknown>) =>
await mocks.rootRead({ rootDir, relativePath, ...options }),
write: async (
@@ -190,18 +198,28 @@ beforeEach(() => {
realPath: "/workspace/test-agent/AGENTS.md",
stat: { size: 0, mtimeMs: 0 },
});
mocks.rootStat.mockResolvedValue({
isFile: true,
isSymbolicLink: false,
mtimeMs: 0,
nlink: 1,
size: 0,
});
mocks.rootWrite.mockResolvedValue(undefined);
});
function makeRootForTest(overrides?: {
open?: (params: Record<string, unknown>) => Promise<unknown>;
read?: (params: Record<string, unknown>) => Promise<unknown>;
stat?: (params: Record<string, unknown>) => Promise<unknown>;
write?: (params: Record<string, unknown>) => Promise<unknown>;
}) {
return async (rootDir: string) =>
({
open: async (relativePath: string, options?: Record<string, unknown>) =>
await (overrides?.open ?? mocks.rootOpen)({ rootDir, relativePath, ...options }),
stat: async (relativePath: string) =>
await (overrides?.stat ?? mocks.rootStat)({ rootDir, relativePath }),
read: async (relativePath: string, options?: Record<string, unknown>) =>
await (overrides?.read ?? mocks.rootRead)({ rootDir, relativePath, ...options }),
write: async (
@@ -1156,19 +1174,19 @@ describe("agents.files.list", () => {
const rootOpen = vi.fn(async () => {
throw createErrnoError("EACCES");
});
agentsTesting.setDepsForTests({ root: makeRootForTest({ open: rootOpen }) });
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
if (args[0] === "/workspace/main/AGENTS.md") {
return makeFileStat({ size: 17, mtimeMs: 4567 });
}
throw createEnoentError();
});
mocks.fsStat.mockImplementation(async (...args: unknown[]) => {
if (args[0] === "/workspace/main/AGENTS.md") {
return makeFileStat({ size: 17, mtimeMs: 4567 });
const rootStat = vi.fn(async ({ relativePath }: Record<string, unknown>) => {
if (relativePath === "AGENTS.md") {
return {
isFile: true,
isSymbolicLink: false,
mtimeMs: 4567,
nlink: 1,
size: 17,
};
}
throw createEnoentError();
});
agentsTesting.setDepsForTests({ root: makeRootForTest({ open: rootOpen, stat: rootStat }) });
const { respond, promise } = makeCall("agents.files.list", { agentId: "main" });
await promise;

View File

@@ -33,7 +33,6 @@ import {
} from "../../config/sessions.js";
import type { IdentityConfig } from "../../config/types.base.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { sameFileIdentity } from "../../infra/fs-safe-advanced.js";
import { root, FsSafeError, type ReadResult } from "../../infra/fs-safe.js";
import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
@@ -131,46 +130,34 @@ type FileMeta = {
updatedAtMs: number;
};
function isPathInsideDirectory(rootDir: string, candidatePath: string): boolean {
const relative = path.relative(rootDir, candidatePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
type WorkspaceRoot = Awaited<ReturnType<typeof root>>;
async function statWorkspaceFileSafely(
workspaceDir: string,
workspaceRoot: WorkspaceRoot,
name: string,
): Promise<FileMeta | null> {
try {
const workspaceReal = await fs.realpath(workspaceDir);
const candidatePath = path.resolve(workspaceReal, name);
if (!isPathInsideDirectory(workspaceReal, candidatePath)) {
const stat = await workspaceRoot.stat(name);
if (!stat.isFile || stat.isSymbolicLink || stat.nlink > 1) {
return null;
}
const pathStat = await fs.lstat(candidatePath);
if (!pathStat.isFile() || pathStat.nlink > 1) {
return null;
}
const realPath = await fs.realpath(candidatePath);
if (!isPathInsideDirectory(workspaceReal, realPath)) {
return null;
}
const realStat = await fs.stat(realPath);
if (!realStat.isFile() || realStat.nlink > 1 || !sameFileIdentity(pathStat, realStat)) {
return null;
}
return {
size: realStat.size,
updatedAtMs: Math.floor(realStat.mtimeMs),
size: stat.size,
updatedAtMs: Math.floor(stat.mtimeMs),
};
} catch {
return null;
}
}
async function openWorkspaceRootSafely(workspaceDir: string): Promise<WorkspaceRoot | null> {
try {
return await agentsHandlerDeps.root(workspaceDir);
} catch {
return null;
}
}
async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) {
const files: Array<{
name: string;
@@ -180,12 +167,25 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
updatedAtMs?: number;
}> = [];
const workspaceRoot = await openWorkspaceRootSafely(workspaceDir);
if (!workspaceRoot) {
const missingNames = [
...(options?.hideBootstrap ? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING : BOOTSTRAP_FILE_NAMES),
DEFAULT_MEMORY_FILENAME,
];
return missingNames.map((name) => ({
name,
path: path.join(workspaceDir, name),
missing: true,
}));
}
const bootstrapFileNames = options?.hideBootstrap
? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING
: BOOTSTRAP_FILE_NAMES;
for (const name of bootstrapFileNames) {
const filePath = path.join(workspaceDir, name);
const meta = await statWorkspaceFileSafely(workspaceDir, name);
const meta = await statWorkspaceFileSafely(workspaceRoot, name);
if (meta) {
files.push({
name,
@@ -199,7 +199,7 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
}
}
const primaryMeta = await statWorkspaceFileSafely(workspaceDir, DEFAULT_MEMORY_FILENAME);
const primaryMeta = await statWorkspaceFileSafely(workspaceRoot, DEFAULT_MEMORY_FILENAME);
if (primaryMeta) {
files.push({
name: DEFAULT_MEMORY_FILENAME,
@@ -757,8 +757,9 @@ export const agentsHandlers: GatewayRequestHandlers = {
await fs.mkdir(workspaceDir, { recursive: true });
const filePath = path.join(workspaceDir, name);
const content = params.content;
let workspaceRoot: WorkspaceRoot;
try {
const workspaceRoot = await agentsHandlerDeps.root(workspaceDir);
workspaceRoot = await agentsHandlerDeps.root(workspaceDir);
await workspaceRoot.write(name, content, { encoding: "utf8" });
} catch (err) {
if (!(err instanceof FsSafeError)) {
@@ -767,7 +768,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
respondWorkspaceFileUnsafe(respond, name);
return;
}
const meta = await statWorkspaceFileSafely(workspaceDir, name);
const meta = await statWorkspaceFileSafely(workspaceRoot, name);
respond(
true,
{

View File

@@ -13,6 +13,7 @@ import {
import { isPathWithin } from "../commands/cleanup-utils.js";
import { resolveHomeDir, resolveUserPath } from "../utils.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { writeJson } from "./json-files.js";
type TarRuntime = typeof import("tar");
@@ -366,7 +367,7 @@ export async function createBackupArchive(
oauthDir: plan.oauthDir,
workspaceDirs: plan.workspaceDirs,
});
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
await writeJson(manifestPath, manifest, { trailingNewline: true });
const tar = await loadTarRuntime();
const stateAsset = result.assets.find((asset) => asset.kind === "state");

View File

@@ -1,4 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import { resolveStateDir } from "../config/paths.js";
@@ -9,7 +8,6 @@ import {
storeDeviceAuthTokenInStore,
} from "../shared/device-auth-store.js";
import type { DeviceAuthStore } from "../shared/device-auth.js";
import { safeParseJsonWithSchema } from "../utils/zod-parse.js";
import { privateFileStoreSync } from "./private-file-store.js";
const DEVICE_AUTH_FILE = "device-auth.json";
@@ -25,11 +23,11 @@ function resolveDeviceAuthPath(env: NodeJS.ProcessEnv = process.env): string {
function readStore(filePath: string): DeviceAuthStore | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const raw = fs.readFileSync(filePath, "utf8");
return safeParseJsonWithSchema(DeviceAuthStoreSchema, raw);
const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists(
path.basename(filePath),
);
const store = DeviceAuthStoreSchema.safeParse(parsed);
return store.success ? store.data : null;
} catch {
return null;
}

View File

@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { privateFileStoreSync } from "./private-file-store.js";
@@ -63,36 +62,35 @@ export function loadOrCreateDeviceIdentity(
filePath: string = resolveDefaultIdentityPath(),
): DeviceIdentity {
try {
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as StoredIdentity;
if (
parsed?.version === 1 &&
typeof parsed.deviceId === "string" &&
typeof parsed.publicKeyPem === "string" &&
typeof parsed.privateKeyPem === "string"
) {
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
if (derivedId && derivedId !== parsed.deviceId) {
const updated: StoredIdentity = {
...parsed,
deviceId: derivedId,
};
privateFileStoreSync(path.dirname(filePath)).writeJson(path.basename(filePath), updated, {
trailingNewline: true,
});
return {
deviceId: derivedId,
publicKeyPem: parsed.publicKeyPem,
privateKeyPem: parsed.privateKeyPem,
};
}
const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists<StoredIdentity>(
path.basename(filePath),
);
if (
parsed?.version === 1 &&
typeof parsed.deviceId === "string" &&
typeof parsed.publicKeyPem === "string" &&
typeof parsed.privateKeyPem === "string"
) {
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
if (derivedId && derivedId !== parsed.deviceId) {
const updated: StoredIdentity = {
...parsed,
deviceId: derivedId,
};
privateFileStoreSync(path.dirname(filePath)).writeJson(path.basename(filePath), updated, {
trailingNewline: true,
});
return {
deviceId: parsed.deviceId,
deviceId: derivedId,
publicKeyPem: parsed.publicKeyPem,
privateKeyPem: parsed.privateKeyPem,
};
}
return {
deviceId: parsed.deviceId,
publicKeyPem: parsed.publicKeyPem,
privateKeyPem: parsed.privateKeyPem,
};
}
} catch {
// fall through to regenerate
@@ -116,13 +114,12 @@ export function loadDeviceIdentityIfPresent(
filePath: string = resolveDefaultIdentityPath(),
): DeviceIdentity | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as StoredIdentity;
const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists<StoredIdentity>(
path.basename(filePath),
);
if (
parsed?.version !== 1 ||
!parsed ||
parsed.version !== 1 ||
typeof parsed.deviceId !== "string" ||
typeof parsed.publicKeyPem !== "string" ||
typeof parsed.privateKeyPem !== "string"

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { runCommandWithTimeout } from "../process/exec.js";
import { pathExists } from "./fs-safe.js";
import { assertCanonicalPathWithinBase } from "./install-safe-path.js";
import { tryReadJson, writeJson } from "./json-files.js";
import { createSafeNpmInstallArgs, createSafeNpmInstallEnv } from "./safe-package-install.js";
const INSTALL_BASE_CHANGED_ERROR_MESSAGE = "install base directory changed during install";
@@ -25,23 +26,11 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
async function sanitizeManifestForNpmInstall(targetDir: string): Promise<void> {
const manifestPath = path.join(targetDir, "package.json");
let manifestRaw = "";
try {
manifestRaw = await fs.readFile(manifestPath, "utf-8");
} catch {
return;
}
let manifest: Record<string, unknown>;
try {
const parsed = JSON.parse(manifestRaw) as unknown;
if (!isObjectRecord(parsed)) {
return;
}
manifest = parsed;
} catch {
const parsed = await tryReadJson<unknown>(manifestPath);
if (!isObjectRecord(parsed)) {
return;
}
const manifest = parsed;
const devDependencies = manifest.devDependencies;
if (!isObjectRecord(devDependencies)) {
@@ -61,7 +50,7 @@ async function sanitizeManifestForNpmInstall(targetDir: string): Promise<void> {
} else {
manifest.devDependencies = Object.fromEntries(filteredEntries);
}
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
await writeJson(manifestPath, manifest, { trailingNewline: true });
}
async function hideProjectNpmConfigForInstall(targetDir: string): Promise<HiddenProjectConfigFile> {

View File

@@ -61,6 +61,22 @@ describe("managed npm root", () => {
});
});
it("does not overwrite a present malformed package manifest", async () => {
const npmRoot = await makeTempRoot();
const manifestPath = path.join(npmRoot, "package.json");
await fs.writeFile(manifestPath, "{not-json", "utf8");
await expect(
upsertManagedNpmRootDependency({
npmRoot,
packageName: "@openclaw/feishu",
dependencySpec: "2026.5.2",
}),
).rejects.toThrow();
await expect(fs.readFile(manifestPath, "utf8")).resolves.toBe("{not-json");
});
it("pins managed dependencies to the resolved version", () => {
expect(
resolveManagedNpmRootDependencySpec({

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { NpmSpecResolution } from "./install-source-utils.js";
import { readJson, readJsonIfExists, writeJson } from "./json-files.js";
import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js";
type ManagedNpmRootManifest = {
@@ -37,15 +38,8 @@ function readDependencyRecord(value: unknown): Record<string, string> {
}
async function readManagedNpmRootManifest(filePath: string): Promise<ManagedNpmRootManifest> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
return isRecord(parsed) ? { ...parsed } : {};
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {};
}
throw err;
}
const parsed = await readJsonIfExists<unknown>(filePath);
return isRecord(parsed) ? { ...parsed } : {};
}
export function resolveManagedNpmRootDependencySpec(params: {
@@ -72,7 +66,7 @@ export async function upsertManagedNpmRootDependency(params: {
[params.packageName]: params.dependencySpec,
},
};
await fs.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
await writeJson(manifestPath, next, { trailingNewline: true });
}
export async function readManagedNpmRootInstalledDependency(params: {
@@ -80,7 +74,7 @@ export async function readManagedNpmRootInstalledDependency(params: {
packageName: string;
}): Promise<ManagedNpmRootInstalledDependency | null> {
const lockPath = path.join(params.npmRoot, "package-lock.json");
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as unknown;
const parsed = await readJson<unknown>(lockPath);
if (!isRecord(parsed) || !isRecord(parsed.packages)) {
return null;
}
@@ -111,5 +105,5 @@ export async function removeManagedNpmRootDependency(params: {
private: true,
dependencies: nextDependencies,
};
await fs.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
await writeJson(manifestPath, next, { trailingNewline: true });
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { isLocalBuildMetadataDistPath } from "../../scripts/lib/local-build-metadata-paths.mjs";
import { readJsonIfExists, writeJson } from "./json-files.js";
export { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mjs";
@@ -117,15 +118,7 @@ async function collectExternalizedBundledExtensionIds(
packageRoot: string,
): Promise<ExternalizedBundledExtensionIds> {
const packageJsonPath = path.join(packageRoot, "package.json");
try {
const parsed = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as unknown;
return collectExcludedPackagedExtensionDirs(parsed);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return new Set();
}
throw error;
}
return collectExcludedPackagedExtensionDirs(await readJsonIfExists<unknown>(packageJsonPath));
}
function isPackagedDistPath(
@@ -321,15 +314,16 @@ export async function writePackageDistInventory(packageRoot: string): Promise<st
(left, right) => left.localeCompare(right),
);
const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH);
await fs.mkdir(path.dirname(inventoryPath), { recursive: true });
await fs.writeFile(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, "utf8");
await writeJson(inventoryPath, inventory, { trailingNewline: true });
return inventory;
}
async function readPackageDistInventory(packageRoot: string): Promise<string[]> {
async function readPackageDistInventoryOptional(packageRoot: string): Promise<string[] | null> {
const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH);
const raw = await fs.readFile(inventoryPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const parsed = await readJsonIfExists<unknown>(inventoryPath);
if (parsed === null) {
return null;
}
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
throw new Error(`Invalid package dist inventory at ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`);
}
@@ -341,14 +335,7 @@ async function readPackageDistInventory(packageRoot: string): Promise<string[]>
export async function readPackageDistInventoryIfPresent(
packageRoot: string,
): Promise<string[] | null> {
try {
return await readPackageDistInventory(packageRoot);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
return await readPackageDistInventoryOptional(packageRoot);
}
export async function collectPackageDistInventoryErrors(packageRoot: string): Promise<string[]> {

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { normalizeProviderId } from "../agents/provider-id.js";
import { resolveRequiredHomeDir } from "./home-dir.js";
import { tryReadJsonSync } from "./json-files.js";
import type { UsageProviderId } from "./provider-usage.types.js";
export const DEFAULT_TIMEOUT_MS = 5000;
@@ -84,12 +85,9 @@ export function resolveLegacyPiAgentAccessToken(
if (!fs.existsSync(authPath)) {
return undefined;
}
const parsed = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record<
string,
{ access?: string }
>;
const parsed = tryReadJsonSync<Record<string, { access?: string }>>(authPath);
for (const providerId of providerIds) {
const token = parsed[providerId]?.access;
const token = parsed?.[providerId]?.access;
if (typeof token === "string" && token.trim()) {
return token;
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { tryReadJsonSync } from "../infra/json-files.js";
import { collectBundledChannelConfigs } from "./bundled-channel-config-metadata.js";
import {
collectBundledPluginPublicSurfaceArtifacts,
@@ -51,14 +52,7 @@ export type BundledPluginMetadata = {
function readPackageManifest(pluginDir: string): PackageManifest | undefined {
const packagePath = path.join(pluginDir, "package.json");
if (!fs.existsSync(packagePath)) {
return undefined;
}
try {
return JSON.parse(fs.readFileSync(packagePath, "utf-8")) as PackageManifest;
} catch {
return undefined;
}
return tryReadJsonSync<PackageManifest>(packagePath) ?? undefined;
}
function resolveBundledPluginMetadataScanDir(

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { tryReadJsonSync } from "../../../infra/json-files.js";
import {
normalizeBundledPluginStringList,
resolveBundledPluginScanDir,
@@ -59,14 +60,10 @@ export type BundledCapabilityManifest = Pick<
>;
function readJsonRecord(filePath: string): Record<string, unknown> | undefined {
try {
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
return raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
const raw = tryReadJsonSync(filePath);
return raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: undefined;
}
function readBundledCapabilityManifest(pluginDir: string): BundledCapabilityManifest | undefined {

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { openRootFileSync } from "../infra/boundary-file-read.js";
import { tryReadJsonSync } from "../infra/json-files.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -436,11 +437,7 @@ function readPackageManifest(
}
function readTrustedPackageManifest(dir: string): PackageManifest | null {
try {
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")) as PackageManifest;
} catch {
return null;
}
return tryReadJsonSync<PackageManifest>(path.join(dir, "package.json"));
}
function readCandidatePackageManifest(params: {

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { tryReadJson } from "../infra/json-files.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js";
import { scanDirectoryWithSummary } from "../security/skill-scanner.js";
@@ -496,10 +497,8 @@ async function scanManifestDependencyDenylist(params: {
});
const packageManifestPaths = traversalResult.packageManifestPaths;
for (const manifestPath of packageManifestPaths) {
let manifest: PackageManifest;
try {
manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as PackageManifest;
} catch {
const manifest = await tryReadJson<PackageManifest>(manifestPath);
if (!manifest) {
continue;
}

View File

@@ -34,12 +34,8 @@ function readRecordMap(value: unknown): Record<string, PluginInstallRecord> | nu
}
function readJsonObjectFileSync(filePath: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
const parsed = tryReadJsonSync(filePath);
return isRecord(parsed) ? parsed : null;
}
function readStringRecord(value: unknown): Record<string, string> {

View File

@@ -10,6 +10,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { openRootFileSync } from "../infra/boundary-file-read.js";
import { tryReadJsonSync } from "../infra/json-files.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
DEFAULT_MEMORY_DREAMING_PLUGIN_ID,
@@ -662,16 +663,12 @@ function resolveBundledPackageRootForCache(stockRoot?: string): string | undefin
}
function readPackageVersionForCache(packageJsonPath: string): string {
try {
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "unknown";
}
const version = (parsed as { version?: unknown }).version;
return typeof version === "string" && version.trim() ? version.trim() : "unknown";
} catch {
const parsed = tryReadJsonSync(packageJsonPath);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "unknown";
}
const version = (parsed as { version?: unknown }).version;
return typeof version === "string" && version.trim() ? version.trim() : "unknown";
}
const bundledPackageCacheIdentityByStockRoot = new Map<

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { tryReadJsonSync } from "../infra/json-files.js";
import type { PluginCandidate } from "./discovery.js";
import { hashJson } from "./installed-plugin-index-hash.js";
import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js";
@@ -129,8 +130,8 @@ function resolveInstalledPackageMetadata(record: InstalledPluginIndexRecord): {
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {};
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PackageManifest;
const packageJson = tryReadJsonSync<PackageManifest>(packageJsonPath);
if (packageJson) {
const packageManifest = getPackageManifestMetadata(packageJson);
const dependencies = normalizePluginDependencySpecs({
dependencies: packageJson.dependencies,
@@ -158,9 +159,8 @@ function resolveInstalledPackageMetadata(record: InstalledPluginIndexRecord): {
packageDependencies: dependencies.dependencies,
packageOptionalDependencies: dependencies.optionalDependencies,
};
} catch {
return fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {};
}
return fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {};
}
function toPluginCandidate(record: InstalledPluginIndexRecord): PluginCandidate {

View File

@@ -5,6 +5,7 @@ import { resolveArchiveKind } from "../infra/archive.js";
import { formatErrorMessage } from "../infra/errors.js";
import { pathExists } from "../infra/fs-safe.js";
import { resolveOsHomeRelativePath } from "../infra/home-dir.js";
import { tryReadJson } from "../infra/json-files.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { isPathInside } from "../infra/path-guards.js";
import { runCommandWithTimeout } from "../process/exec.js";
@@ -315,12 +316,7 @@ async function readClaudeKnownMarketplaces(): Promise<Record<string, KnownMarket
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(await fs.readFile(knownPath, "utf-8"));
} catch {
return {};
}
const parsed = await tryReadJson<unknown>(knownPath);
if (!parsed || typeof parsed !== "object") {
return {};

View File

@@ -1,9 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import { writeJsonSync } from "../infra/json-files.js";
function writeRuntimeJsonFile(targetPath: string, value: unknown): void {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
writeJsonSync(targetPath, value);
}
function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void {

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { tryReadJsonSync } from "../infra/json-files.js";
import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js";
const NON_PACKAGED_RUNTIME_SIDECAR_PLUGIN_DIRS = new Set(["qa-channel", "qa-lab", "qa-matrix"]);
@@ -13,10 +14,8 @@ function collectRootPackageExcludedRuntimeSidecarPluginDirs(rootDir: string): Se
if (!fs.existsSync(packageJsonPath)) {
return new Set();
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
files?: unknown;
};
if (!Array.isArray(packageJson.files)) {
const packageJson = tryReadJsonSync<{ files?: unknown }>(packageJsonPath);
if (!Array.isArray(packageJson?.files)) {
return new Set();
}
const excluded = new Set<string>();

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { tryReadJsonSync } from "../infra/json-files.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { PluginLruCache } from "./plugin-cache-primitives.js";
@@ -37,15 +38,13 @@ function readPluginSdkPackageJson(packageRoot: string): PluginSdkPackageJson | n
if (pluginSdkPackageJsonByRoot.has(cacheKey)) {
return pluginSdkPackageJsonByRoot.get(cacheKey) ?? null;
}
try {
const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
const parsed = JSON.parse(pkgRaw) as PluginSdkPackageJson;
pluginSdkPackageJsonByRoot.set(cacheKey, parsed);
return parsed;
} catch {
const parsed = tryReadJsonSync<PluginSdkPackageJson>(path.join(packageRoot, "package.json"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
pluginSdkPackageJsonByRoot.set(cacheKey, null);
return null;
}
pluginSdkPackageJsonByRoot.set(cacheKey, parsed);
return parsed;
}
function isSafePluginSdkSubpathSegment(subpath: string): boolean {
@@ -306,29 +305,19 @@ function isUsableDistPluginSdkArtifact(candidate: string): boolean {
}
function readPrivateLocalOnlyPluginSdkSubpaths(packageRoot: string): string[] {
try {
const raw = fs.readFileSync(
path.join(packageRoot, "scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"),
"utf-8",
);
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((subpath): subpath is string => isSafePluginSdkSubpathSegment(subpath));
} catch {
const parsed = tryReadJsonSync(
path.join(packageRoot, "scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"),
);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((subpath): subpath is string => isSafePluginSdkSubpathSegment(subpath));
}
function readBundledPluginPackageName(packageJsonPath: string): string | null {
try {
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { name?: unknown };
const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
return name.startsWith("@openclaw/") ? name : null;
} catch {
return null;
}
const parsed = tryReadJsonSync<{ name?: unknown }>(packageJsonPath);
const name = typeof parsed?.name === "string" ? parsed.name.trim() : "";
return name.startsWith("@openclaw/") ? name : null;
}
function isBundledPluginPublicSurfaceSourceBasename(params: {

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/types.js";
import type { TtsAutoMode, TtsConfig, TtsProvider } from "../config/types.tts.js";
import { tryReadJsonSync } from "../infra/json-files.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -87,14 +87,7 @@ function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
}
function readPrefs(prefsPath: string): TtsUserPrefs {
try {
if (!fs.existsSync(prefsPath)) {
return {};
}
return JSON.parse(fs.readFileSync(prefsPath, "utf8")) as TtsUserPrefs;
} catch {
return {};
}
return tryReadJsonSync<TtsUserPrefs>(prefsPath) ?? {};
}
function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined {

View File

@@ -7,6 +7,7 @@ import type { ModelProviderConfig } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getGatewayModelPricingCacheFingerprint } from "../gateway/model-pricing-cache-state.js";
import { getCachedGatewayModelPricing } from "../gateway/model-pricing-cache.js";
import { tryReadJsonSync } from "../infra/json-files.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
/**
@@ -212,13 +213,13 @@ function loadModelsJsonCostIndex(options?: {
modelsJsonCostCache.path !== modelsPath ||
modelsJsonCostCache.mtimeMs !== stat.mtimeMs
) {
const parsed = JSON.parse(fs.readFileSync(modelsPath, "utf8")) as {
const parsed = tryReadJsonSync<{
providers?: Record<string, ModelProviderConfig>;
};
}>(modelsPath);
modelsJsonCostCache = {
path: modelsPath,
mtimeMs: stat.mtimeMs,
providers: parsed.providers,
providers: parsed?.providers,
normalizedEntries: null,
rawEntries: null,
};