diff --git a/extensions/acpx/src/codex-auth-bridge.ts b/extensions/acpx/src/codex-auth-bridge.ts index 2b98ea46369..9b322fa3466 100644 --- a/extensions/acpx/src/codex-auth-bridge.ts +++ b/extensions/acpx/src/codex-auth-bridge.ts @@ -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 { try { const packageJsonPath = requireFromHere.resolve(`${packageName}/package.json`); - const manifest = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as PackageManifest; + const { value: manifest } = await readJsonFileWithFallback( + packageJsonPath, + {}, + ); if (manifest.name !== packageName) { return undefined; } diff --git a/extensions/browser/src/browser/chrome.profile-decoration.ts b/extensions/browser/src/browser/chrome.profile-decoration.ts index ebacb7580c3..9256edda928 100644 --- a/extensions/browser/src/browser/chrome.profile-decoration.ts +++ b/extensions/browser/src/browser/chrome.profile-decoration.ts @@ -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 | 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; - } catch { - return null; - } + const parsed = loadJsonFile(filePath); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; } function safeWriteJson(filePath: string, data: Record) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + saveJsonFile(filePath, data); } function asRecord(value: unknown): Record | null { diff --git a/extensions/codex/src/migration/helpers.ts b/extensions/codex/src/migration/helpers.ts index 0a91b2bca5f..459040f7843 100644 --- a/extensions/codex/src/migration/helpers.ts +++ b/extensions/codex/src/migration/helpers.ts @@ -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 { @@ -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(filePath, {}); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; } diff --git a/extensions/diffs/src/pierre-themes.ts b/extensions/diffs/src/pierre-themes.ts index d5d531a55db..ba6dc5c790a 100644 --- a/extensions/diffs/src/pierre-themes.ts +++ b/extensions/diffs/src/pierre-themes.ts @@ -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>(themePath, {}); cachedTheme = { - ...(JSON.parse(await fs.readFile(themePath, "utf8")) as Record), + ...theme, name: themeName, } as ThemeRegistrationResolved; return cachedTheme; diff --git a/extensions/diffs/src/store.ts b/extensions/diffs/src/store.ts index ac38e2d42d8..b7d7dfb872b 100644 --- a/extensions/diffs/src/store.ts +++ b/extensions/diffs/src/store.ts @@ -174,13 +174,13 @@ export class DiffArtifactStore { } async cleanupExpired(): Promise { - 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); } }), diff --git a/extensions/matrix/src/legacy-crypto.ts b/extensions/matrix/src/legacy-crypto.ts index a34972c16ef..931a38334a9 100644 --- a/extensions/matrix/src/legacy-crypto.ts +++ b/extensions/matrix/src/legacy-crypto.ts @@ -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(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(filePath) ?? null; } async function persistLegacyMigrationState(params: { diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index f3ed0bd6046..35a7dd3d739 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -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; + const parsed = loadJsonFile>( + 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; diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts index a147014df71..1b6d42c267b 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts @@ -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; + const parsed = loadJsonFile>(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); } diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 7a9948c00ab..cb7ecadf8e6 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -396,10 +396,6 @@ type DailyIngestionState = { files: Record; }; -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 { - 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 { - 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 { - 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 { - 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, }); } diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index 8dad1901422..fa34ce3ec01 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -757,10 +757,9 @@ async function withShortTermLock(workspaceDir: string, task: () => Promise } async function readStore(workspaceDir: string, nowIso: string): Promise { - 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 { - 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 { - 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 { - 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, }); } diff --git a/extensions/memory-wiki/src/chatgpt-import.ts b/extensions/memory-wiki/src/chatgpt-import.ts index 51be10ecd45..339ac219972 100644 --- a/extensions/memory-wiki/src/chatgpt-import.ts +++ b/extensions/memory-wiki/src/chatgpt-import.ts @@ -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 { 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( diff --git a/extensions/memory-wiki/src/source-sync-state.ts b/extensions/memory-wiki/src/source-sync-state.ts index 6eff093c2bb..db7fe6b2b4e 100644 --- a/extensions/memory-wiki/src/source-sync-state.ts +++ b/extensions/memory-wiki/src/source-sync-state.ts @@ -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 { 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; - return { - version: 1, - entries: { ...parsed.entries }, - }; - } catch { - return { - version: EMPTY_STATE.version, - entries: {}, - }; - } + const { value: parsed } = await readJsonFileWithFallback>( + statePath, + EMPTY_STATE, + ); + return { + version: 1, + entries: { ...parsed.entries }, + }; } export async function writeMemoryWikiSourceSyncState( @@ -61,8 +46,7 @@ export async function writeMemoryWikiSourceSyncState( state: MemoryWikiImportedSourceState, ): Promise { 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: { diff --git a/extensions/msteams/src/feedback-reflection-store.ts b/extensions/msteams/src/feedback-reflection-store.ts index 192b47f3c4a..f32929947b2 100644 --- a/extensions/msteams/src/feedback-reflection-store.ts +++ b/extensions/msteams/src/feedback-reflection-store.ts @@ -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); } diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index 19b61667865..1748c566c60 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -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; - }; + }>(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; diff --git a/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts b/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts index fd039fbaaea..3e8f21a0746 100644 --- a/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts +++ b/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts @@ -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)); diff --git a/extensions/qqbot/src/engine/config/credential-backup.ts b/extensions/qqbot/src/engine/config/credential-backup.ts index 0279818c8c4..619bcc8c1de 100644 --- a/extensions/qqbot/src/engine/config/credential-backup.ts +++ b/extensions/qqbot/src/engine/config/credential-backup.ts @@ -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(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(legacy); + if (data) { if (!data?.appId || !data?.clientSecret) { return null; } diff --git a/extensions/skill-workshop/src/store.ts b/extensions/skill-workshop/src/store.ts index c58c163ad2c..e5c7785c0e5 100644 --- a/extensions/skill-workshop/src/store.ts +++ b/extensions/skill-workshop/src/store.ts @@ -42,10 +42,8 @@ async function withLock(key: string, task: () => Promise): Promise { } } -async function readJson(rootDir: string, filePath: string): Promise { - const parsed = await privateFileStore(rootDir).readJsonIfExists( - path.relative(rootDir, filePath), - ); +async function readJson(rootDir: string, relativePath: string): Promise { + const parsed = await privateFileStore(rootDir).readJsonIfExists(relativePath); if (!parsed) { return { version: 1, proposals: [] }; } @@ -77,8 +75,12 @@ function normalizeReviewState( }; } -async function atomicWriteJson(rootDir: string, filePath: string, data: StoreFile): Promise { - await privateFileStore(rootDir).writeJson(path.relative(rootDir, filePath), data, { +async function atomicWriteJson( + rootDir: string, + relativePath: string, + data: StoreFile, +): Promise { + 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 { - 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 { 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 { 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 { 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 { 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; }); } diff --git a/extensions/telegram/src/update-offset-store.ts b/extensions/telegram/src/update-offset-store.ts index 5d8aacef2c5..245e2b5af76 100644 --- a/extensions/telegram/src/update-offset-store.ts +++ b/extensions/telegram/src/update-offset-store.ts @@ -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 { 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(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: { diff --git a/extensions/voice-call/src/realtime-agent-context.ts b/extensions/voice-call/src/realtime-agent-context.ts index a5f85bc5e7b..3d4630658ae 100644 --- a/extensions/voice-call/src/realtime-agent-context.ts +++ b/extensions/voice-call/src/realtime-agent-context.ts @@ -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 { 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; diff --git a/packages/memory-host-sdk/src/host/fs-utils.ts b/packages/memory-host-sdk/src/host/fs-utils.ts index cbcf140dfb7..25850cfac19 100644 --- a/packages/memory-host-sdk/src/host/fs-utils.ts +++ b/packages/memory-host-sdk/src/host/fs-utils.ts @@ -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).code === "ENOENT", + ((err as Partial).code === "ENOENT" || + (err as { code?: unknown }).code === "not-found"), ); } diff --git a/packages/memory-host-sdk/src/host/read-file.ts b/packages/memory-host-sdk/src/host/read-file.ts index a8a405b3ff8..dffa1087769 100644 --- a/packages/memory-host-sdk/src/host/read-file.ts +++ b/packages/memory-host-sdk/src/host/read-file.ts @@ -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 }; diff --git a/src/agents/cli-runner/bundle-mcp-gemini.ts b/src/agents/cli-runner/bundle-mcp-gemini.ts index e274e817ea8..5dc47f14cac 100644 --- a/src/agents/cli-runner/bundle-mcp-gemini.ts +++ b/src/agents/cli-runner/bundle-mcp-gemini.ts @@ -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> { - try { - const raw = JSON.parse(await fs.readFile(filePath, "utf-8")) as unknown; - return raw && typeof raw === "object" && !Array.isArray(raw) - ? ({ ...raw } as Record) - : {}; - } catch { - return {}; - } + const raw = await tryReadJson(filePath); + return raw && typeof raw === "object" && !Array.isArray(raw) + ? ({ ...raw } as Record) + : {}; } 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, diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index abb7529cdb0..7b41d88c0ac 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -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 { - 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(configPath)) }; } function sortJsonValue(value: unknown): unknown { diff --git a/src/agents/pi-auth-discovery-core.ts b/src/agents/pi-auth-discovery-core.ts index 4074c970883..2ac5b00dd57 100644 --- a/src/agents/pi-auth-discovery-core.ts +++ b/src/agents/pi-auth-discovery-core.ts @@ -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", + }); } diff --git a/src/agents/skills-clawhub.ts b/src/agents/skills-clawhub.ts index e53a76dc782..75dc80886b3 100644 --- a/src/agents/skills-clawhub.ts +++ b/src/agents/skills-clawhub.ts @@ -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; - if (raw.version === 1 && raw.skills && typeof raw.skills === "object") { + const raw = await tryReadJson>(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 { 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 { @@ -179,9 +176,9 @@ async function readClawHubSkillOrigin(skillDir: string): Promise; + const raw = await tryReadJson>(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 { 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: { diff --git a/src/channels/bundled-channel-catalog-read.ts b/src/channels/bundled-channel-catalog-read.ts index 0fc10332424..007c8d1ee8d 100644 --- a/src/channels/bundled-channel-catalog-read.ts +++ b/src/channels/bundled-channel-catalog-read.ts @@ -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 []; } diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index c6d5d78b412..9e4a84ff021 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -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): 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): 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; } diff --git a/src/cli/plugin-install-config-policy.ts b/src/cli/plugin-install-config-policy.ts index 7deeab95ecb..bbdbc7cd000 100644 --- a/src/cli/plugin-install-config-policy.ts +++ b/src/cli/plugin-install-config-policy.ts @@ -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( diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 2a5012853ab..557e6cba192 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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 { try { - const raw = await fs.readFile(filePath, "utf-8"); - const parsed = JSON.parse(raw) as PostCorePluginUpdateResult; + const parsed = await readJsonIfExists(filePath); if ( parsed && typeof parsed === "object" && diff --git a/src/commands/doctor-device-pairing.ts b/src/commands/doctor-device-pairing.ts index b9c9722f3f0..ea636a9bac9 100644 --- a/src/commands/doctor-device-pairing.ts +++ b/src/commands/doctor-device-pairing.ts @@ -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 { diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index dd97d7a6366..1238b634f24 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -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 { } function readJsonObject(filePath: string): Record | 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 { diff --git a/src/crestodian/rescue-message.ts b/src/crestodian/rescue-message.ts index 9ce375d3731..4402eb70b62 100644 --- a/src/crestodian/rescue-message.ts +++ b/src/crestodian/rescue-message.ts @@ -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 { try { - const parsed = JSON.parse(await fs.readFile(pendingPath, "utf8")) as RescuePendingOperation; + const parsed = await tryReadJson(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 { - 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, }); } diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index c73d38f5688..0ee8807ff8f 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -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(recordPath); + if (!record) { try { await fs.rm(recordPath, { force: true }); } catch { diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 4edeab726a0..324639ba0ff 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -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) => await mocks.rootOpen({ rootDir, relativePath, ...options }), + stat: async (relativePath: string) => await mocks.rootStat({ rootDir, relativePath }), read: async (relativePath: string, options?: Record) => 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) => Promise; read?: (params: Record) => Promise; + stat?: (params: Record) => Promise; write?: (params: Record) => Promise; }) { return async (rootDir: string) => ({ open: async (relativePath: string, options?: Record) => 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) => 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) => { + 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; diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 7a053627f7d..60a5b382a3d 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -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>; async function statWorkspaceFileSafely( - workspaceDir: string, + workspaceRoot: WorkspaceRoot, name: string, ): Promise { 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 { + 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, { diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 696b0f9bc3f..92bb158d1a4 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -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"); diff --git a/src/infra/device-auth-store.ts b/src/infra/device-auth-store.ts index 3aca1e776c6..99c93c2c2e9 100644 --- a/src/infra/device-auth-store.ts +++ b/src/infra/device-auth-store.ts @@ -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; } diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index 62aeb4c45c4..4953578f428 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -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( + 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( + path.basename(filePath), + ); if ( - parsed?.version !== 1 || + !parsed || + parsed.version !== 1 || typeof parsed.deviceId !== "string" || typeof parsed.publicKeyPem !== "string" || typeof parsed.privateKeyPem !== "string" diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index e08b68f44e4..8e66efd4e6c 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -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 { async function sanitizeManifestForNpmInstall(targetDir: string): Promise { const manifestPath = path.join(targetDir, "package.json"); - let manifestRaw = ""; - try { - manifestRaw = await fs.readFile(manifestPath, "utf-8"); - } catch { - return; - } - - let manifest: Record; - try { - const parsed = JSON.parse(manifestRaw) as unknown; - if (!isObjectRecord(parsed)) { - return; - } - manifest = parsed; - } catch { + const parsed = await tryReadJson(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 { } 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 { diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index f7a8278bdc6..4ab457ce921 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -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({ diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index 74adc2943e0..a700d7cc388 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -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 { } async function readManagedNpmRootManifest(filePath: string): Promise { - 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(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 { const lockPath = path.join(params.npmRoot, "package-lock.json"); - const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as unknown; + const parsed = await readJson(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 }); } diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index 8d23c731e05..63ce53e423c 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -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 { 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(packageJsonPath)); } function isPackagedDistPath( @@ -321,15 +314,16 @@ export async function writePackageDistInventory(packageRoot: string): Promise 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 { +async function readPackageDistInventoryOptional(packageRoot: string): Promise { 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(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 export async function readPackageDistInventoryIfPresent( packageRoot: string, ): Promise { - 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 { diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index 4f6ab8c09d0..de036643b2a 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -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>(authPath); for (const providerId of providerIds) { - const token = parsed[providerId]?.access; + const token = parsed?.[providerId]?.access; if (typeof token === "string" && token.trim()) { return token; } diff --git a/src/plugins/bundled-plugin-metadata.ts b/src/plugins/bundled-plugin-metadata.ts index a0dd55ed3ed..9cc4c625d9d 100644 --- a/src/plugins/bundled-plugin-metadata.ts +++ b/src/plugins/bundled-plugin-metadata.ts @@ -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(packagePath) ?? undefined; } function resolveBundledPluginMetadataScanDir( diff --git a/src/plugins/contracts/inventory/bundled-capability-metadata.ts b/src/plugins/contracts/inventory/bundled-capability-metadata.ts index fb9f11c89f0..a9af84e833e 100644 --- a/src/plugins/contracts/inventory/bundled-capability-metadata.ts +++ b/src/plugins/contracts/inventory/bundled-capability-metadata.ts @@ -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 | undefined { - try { - const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown; - return raw && typeof raw === "object" && !Array.isArray(raw) - ? (raw as Record) - : undefined; - } catch { - return undefined; - } + const raw = tryReadJsonSync(filePath); + return raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : undefined; } function readBundledCapabilityManifest(pluginDir: string): BundledCapabilityManifest | undefined { diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 955eea09d93..ff434732da2 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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(path.join(dir, "package.json")); } function readCandidatePackageManifest(params: { diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index f831d26b36b..cd60bb60fa7 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -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(manifestPath); + if (!manifest) { continue; } diff --git a/src/plugins/installed-plugin-index-record-reader.ts b/src/plugins/installed-plugin-index-record-reader.ts index 2046c1599cd..8e87e999c3b 100644 --- a/src/plugins/installed-plugin-index-record-reader.ts +++ b/src/plugins/installed-plugin-index-record-reader.ts @@ -34,12 +34,8 @@ function readRecordMap(value: unknown): Record | nu } function readJsonObjectFileSync(filePath: string): Record | 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 { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index cb2cd627783..3edcdf65cdc 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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< diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index 76ebc6b199a..d20b16c6254 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -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(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 { diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index fc4a25e564e..b9fa6a18773 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -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(knownPath); if (!parsed || typeof parsed !== "object") { return {}; diff --git a/src/plugins/plugin-sdk-dist-alias.ts b/src/plugins/plugin-sdk-dist-alias.ts index 58cddb1b31e..42c72bf9263 100644 --- a/src/plugins/plugin-sdk-dist-alias.ts +++ b/src/plugins/plugin-sdk-dist-alias.ts @@ -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 { diff --git a/src/plugins/runtime-sidecar-paths-baseline.ts b/src/plugins/runtime-sidecar-paths-baseline.ts index 44cabe6f802..c49a9b6d6ca 100644 --- a/src/plugins/runtime-sidecar-paths-baseline.ts +++ b/src/plugins/runtime-sidecar-paths-baseline.ts @@ -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(); diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 94e11c47140..78a5ab75e3d 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -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(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: { diff --git a/src/tts/status-config.ts b/src/tts/status-config.ts index a3c79903f7f..14141b2fd6c 100644 --- a/src/tts/status-config.ts +++ b/src/tts/status-config.ts @@ -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(prefsPath) ?? {}; } function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined { diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts index b940cad1415..65b266037df 100644 --- a/src/utils/usage-format.ts +++ b/src/utils/usage-format.ts @@ -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; - }; + }>(modelsPath); modelsJsonCostCache = { path: modelsPath, mtimeMs: stat.mtimeMs, - providers: parsed.providers, + providers: parsed?.providers, normalizedEntries: null, rawEntries: null, };