* refactor: extract filesystem safety primitives * refactor: use fs-safe for file access helpers * refactor: reuse fs-safe for media reads * refactor: use fs-safe for image reads * refactor: reuse fs-safe in qqbot media opener * refactor: reuse fs-safe for local media checks * refactor: consume cleaner fs-safe api * refactor: align fs-safe json option names * fix: preserve fs-safe migration contracts * refactor: use fs-safe primitive subpaths * refactor: use grouped fs-safe subpaths * refactor: align fs-safe api usage * refactor: adapt private state store api * chore: refresh proof gate * refactor: follow fs-safe json api split * refactor: follow reduced fs-safe surface * build: default fs-safe python helper off * fix: preserve fs-safe plugin sdk aliases * refactor: consolidate fs-safe usage * refactor: unify fs-safe store usage * refactor: trim fs-safe temp workspace usage * refactor: hide low-level fs-safe primitives * build: use published fs-safe package * fix: preserve outbound recovery durability after rebase * chore: refresh pr checks
14 KiB
title, summary, read_when
| title | summary | read_when | |||
|---|---|---|---|---|---|
| fs-safe Cleanup Plan | Plan for consolidating OpenClaw filesystem helpers around @openclaw/fs-safe |
|
Status
Implemented on codex/extract-fs-safe-primitives. Keep this file as the
cleanup checklist for follow-up reviews and future fs-safe surface changes.
Goal
Make OpenClaw's filesystem access boring and predictable:
- Core code uses one small set of OpenClaw wrappers that apply OpenClaw policy.
- Plugin SDK compatibility aliases stay deliberate and documented.
- fs-safe keeps a small public story centered on
root(), with lower-level primitives behind explicit subpaths. - Duplicate JSON, temp, private-store, and path helper names disappear from OpenClaw internals.
- Security-sensitive behavior keeps regression tests before names move.
Non-goals
- Do not remove public plugin SDK exports in this cleanup. Keep deprecated aliases until a versioned SDK migration removes them.
- Do not make fs-safe a sandbox. It remains a library guardrail for local file access, not OS isolation.
- Do not convert all absolute-path reads to root-bounded reads. Some OpenClaw paths are trusted absolute paths and should stay explicit.
- Do not chase cosmetic import churn without reducing helper count or clarifying trust boundaries.
fs-safe Package Pin
@openclaw/fs-safe is published on npm and consumed through a semver range.
Fresh checkouts and CI runners should install the package from the public
registry, not from a local link:../fs-safe checkout or a GitHub tarball.
Current range:
^0.1.0
The published package ships built dist files, so OpenClaw should not list it
in pnpm.onlyBuiltDependencies.
Current Shape
fs-safe's main entry is intentionally narrow:
rootFsSafeErrorcategorizeFsSafeError- root option/result types
- Python helper configuration
The wider surface lives behind subpaths:
/json/store/temp/atomic/root/advanced/archive/walk
OpenClaw now keeps fs-safe behind a small wrapper boundary:
- local
src/infra/*wrappers for core policy defaults - public plugin SDK aliases, including older names from before fs-safe
- package-local utility exports where importing
src/infrawould cross a package boundary
An import-boundary test rejects new direct fs-safe imports outside those allowed areas.
Usage Map
Root-bounded access
Representative use:
src/gateway/server-methods/agents.tssrc/agents/pi-tools.read.tssrc/agents/apply-patch.tssrc/plugins/install.tssrc/auto-reply/reply/stage-sandbox-media.tssrc/gateway/canvas-documents.ts
Keep this family. root() is the fs-safe product surface OpenClaw should push
callers toward.
JSON helpers
OpenClaw still uses many names for the same operations:
readJsonFilereadJsonFileStrictreadDurableJsonFilewriteJsonAtomicloadJsonFilesaveJsonFilereadJsonFileWithFallbackwriteJsonFileAtomically
fs-safe's canonical names are clearer:
tryReadJsonreadJsonreadJsonIfExistswriteJsonreadJsonSynctryReadJsonSyncwriteJsonSync
This was the highest-value cleanup because it removed naming drift without
changing semantics. Compatibility aliases stay in src/infra/json-files.ts and
plugin SDK barrels.
Private state and stores
Representative use:
src/commitments/store.tssrc/agents/models-config.tssrc/agents/pi-auth-json.tssrc/cron/run-log.tssrc/secrets/shared.tssrc/infra/device-auth-store.tssrc/infra/device-identity.ts
Current overlap:
fileStorefileStore({ private: true })- plugin SDK private-state aliases
The concepts are now one family. fs-safe exposes private mode through
fileStore({ private: true }); OpenClaw internals and bundled plugins use
store-shaped wrappers instead of standalone private JSON/text helpers.
Temp workspaces
Representative use:
src/media/qr-image.tsextensions/discord/src/send.voice.tsextensions/discord/src/voice/audio.tsextensions/qa-lab/src/temp-dir.test-helper.ts
tempWorkspace is the stable useful primitive. One-shot temp targets and
sibling-temp helpers are lower-level implementation tools.
Atomic writes
Representative use:
- config and session stores
- cron stores
- plugin install paths
- extension state files
Keep atomic replacement as a public fs-safe subpath. OpenClaw should use the same canonical JSON/text helpers where possible instead of hand-picking lower level atomic calls for ordinary JSON state.
Regular, secure, and root file reads
These are not true duplicates:
root()protects root-relative untrusted paths.- regular-file helpers read trusted absolute paths with regular-file checks.
- secure-file helpers add ownership and mode checks for secret references.
Keep them separate. Document the trust boundary instead of hiding it behind one generic "read file" helper.
Archive helpers
Representative use:
- plugin install
- skill install
- marketplace and ClawHub archive flows
Keep as a separate fs-safe subpath. Do not leak archive entry plumbing into OpenClaw core call sites unless the caller is actually validating archive metadata.
Target Design
OpenClaw imports
Core OpenClaw code should use local policy wrappers:
src/infra/fs-safe.tsfor common root/error helperssrc/infra/json-files.tsfor the temporary JSON compatibility layersrc/infra/private-file-store.tsuntil private stores are unifiedsrc/infra/replace-file.tsfor low-level atomic replacementsrc/infra/boundary-file-read.tsfor loader/package boundary readssrc/infra/archive.tsfor archive extraction policysrc/infra/file-lock-manager.tsfor the rare core service that needs manager-style lock lifecycle/diagnostics
New direct imports from @openclaw/fs-safe/* should be reserved for:
- package-level utilities outside core that cannot import
src/infra - compatibility shims
- code that intentionally consumes a narrow fs-safe subpath, such as
openclaw/plugin-sdk/file-lockusing@openclaw/fs-safe/file-lock
Plugin SDK exports
Plugin SDK exports are contractual. Keep aliases even when OpenClaw internals move to canonical names.
Mark older names as deprecated in types/docs when the replacement is stable:
readJsonFileWithFallback->readJsonIfExistsor a store methodwriteJsonFileAtomically->writeJsonloadJsonFile->tryReadJsonsaveJsonFile->writeJsonreadFileWithinRoot->root(...).read*writeFileWithinRoot->root(...).write
fs-safe stores
Move toward one store family:
const store = fileStore({
rootDir,
private: true,
mode: 0o600,
dirMode: 0o700,
});
or a thin alias:
const store = stateStore({ rootDir, private: true });
The store family should cover:
readreadTextreadJsonreadTextIfExistsreadJsonIfExistswritewriteJsonremoveexistsopencopyInwriteStreampruneExpired
This cleanup added that store shape in fs-safe, removed the unshipped
privateStateStore surface, and moved OpenClaw internals and bundled plugins
onto explicit store reads/writes.
Temp
Keep stable public temp surface small:
await using workspace = await tempWorkspace({ prefix: "openclaw-" });
const target = workspace.path("payload.bin");
Move one-shot temp target helpers and sibling-temp helpers to advanced/internal unless a concrete OpenClaw caller needs the public contract.
Refactor Phases
Phase 1: Inventory and Guards
- Add a small import-boundary test that lists allowed direct
@openclaw/fs-safe/*imports in OpenClaw core. - Add regression tests for the JSON symlink behavior kept by
src/infra/json-file.ts. - Add regression tests for public plugin SDK aliases that must keep resolving.
- Add a doc note to the plugin SDK runtime docs once aliases are marked deprecated.
Exit criteria:
- The current compatibility surface is executable-tested.
- New direct fs-safe imports are visible in review.
Phase 2: JSON Name Cleanup
- Convert OpenClaw internal callers from old JSON names to canonical fs-safe names where the semantics are identical.
- Keep plugin SDK aliases unchanged.
- Collapse
src/infra/json-file.tsandsrc/infra/json-files.tsinto one compatibility module if that reduces indirection without losing symlink semantics. - Keep
saveJsonFilesymlink-target behavior until every caller/test is intentionally migrated.
Exit criteria:
- Core internal code no longer imports
readJsonFileStrict,readDurableJsonFile, orwriteJsonAtomicunless it is a compatibility shim. - Plugin SDK aliases still pass import/type tests.
Phase 3: Store Unification
- Add the unified private mode to fs-safe's store API.
- Remove the unshipped
privateStateStoresurface instead of keeping a second store family. - Migrate OpenClaw private-state internals to the unified store shape in small
groups:
- auth/profile state
- device identity and device auth
- cron/run logs
- commitments
- extension state
- Regenerate the plugin SDK API baseline for the intentional pre-release private-helper removal.
Exit criteria:
- OpenClaw internals and bundled plugins do not call standalone private JSON/text helpers.
fileStore({ private: true })is the only private multi-file store API.
Phase 4: Temp Simplification
- Replace OpenClaw one-shot temp target call sites with
tempWorkspace. - Keep
resolvePreferredOpenClawTmpDiras OpenClaw policy. - Move one-shot temp and sibling-temp helpers out of the curated OpenClaw wrapper surface.
Exit criteria:
- OpenClaw uses
tempWorkspacefor temporary file lifetimes unless a low-level atomic helper owns the temp path.
Phase 5: Shim Reduction
- Group one-line fs-safe shims into a smaller number of named OpenClaw policy modules.
- Delete shims that are no longer imported.
- Keep shims that preserve public SDK names or OpenClaw-specific defaults.
Candidate stable shims:
src/infra/fs-safe.tssrc/infra/json-files.tssrc/infra/private-file-store.tssrc/infra/replace-file.tssrc/infra/boundary-file-read.tssrc/infra/archive.ts
Candidate advanced-only grouping:
- path guards
- symlink parent guards
- hardlink guards
- move-path helpers
- file identity helpers
- sibling temp helpers
Exit criteria:
- The local wrapper list has policy meaning, not one file per fs-safe module.
Phase 6: fs-safe Public Surface Finalization
- Keep
@openclaw/fs-safemain entry curated. - Keep
root()as the primary README/API story. - Keep
openPinnedFileSyncinternal. UsereadSecureFile,root().open, oropenRootFile*wrappers instead of exposing the fd-level pinned primitive. - Keep
createSidecarLockManagerinternal. Public callers should useacquireFileLock/withFileLock;createFileLockManageris subpath-only for long-lived services that need held-lock inspection or drain/reset. - Move rare root escape hatches such as
openWritableto advanced only if API checks show no supported caller needs the main root interface. - Keep
regular-file,secure-file, archive, and root helpers separate because their trust models differ. - Remove or mark unstable any standalone helper that is fully covered by root or store methods.
Exit criteria:
- fs-safe has a stable pre-1.0 public surface.
- OpenClaw imports only stable fs-safe APIs outside compatibility shims.
Verification
Use targeted proof per phase:
- JSON cleanup:
- JSON symlink tests
- plugin SDK JSON-store import tests
- representative extension tests that use JSON store aliases
- Store unification:
- private mode tests in fs-safe
- auth profile persistence tests
- device identity tests
- cron/run-log tests
- Temp cleanup:
- media temp tests
- Discord voice temp tests
- QA-lab temp helper tests
- Shim reduction:
- plugin SDK API generation/check
- import-boundary tests
pnpm build
Before merging a broad cleanup batch, run the changed gate and build:
pnpm check:changed
pnpm build
Implementation proof from this cleanup:
pnpm test src/infra/fs-safe-import-boundary.test.ts src/plugin-sdk/temp-path.test.ts src/agents/models-config.write-serialization.test.ts src/infra/json-file.test.ts src/infra/json-files.test.tspnpm test src/infra/fs-safe-import-boundary.test.ts src/infra/device-auth-store.test.ts src/infra/device-identity.test.ts src/infra/exec-approvals.test.ts src/agents/models-config.write-serialization.test.ts src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts src/agents/harness/native-hook-relay.test.tspnpm test src/infra/fs-safe-import-boundary.test.ts src/infra/hardlink-guards.test.ts src/infra/file-identity.test.ts src/plugin-sdk/fs-safe-compat.test.ts src/plugin-sdk/temp-path.test.tspnpm plugin-sdk:api:checkpnpm build- Blacksmith Testbox
pnpm install --frozen-lockfile --config.minimum-release-age=0 && pnpm check:changed - In
../fs-safe:pnpm docs:site && pnpm build && pnpm test test/api-coverage.test.ts test/new-primitives.test.ts
Review Checklist
- Does this change reduce a public name, local wrapper, or duplicated semantic family?
- Is the old name public plugin SDK surface? If yes, keep a deprecated alias.
- Does the replacement preserve symlink, hardlink, mode, and missing-file behavior?
- Is the caller using an untrusted relative path, trusted absolute path, secret path, archive entry, or temp lifetime? Pick the helper that says that out loud.
- Are docs and plugin SDK API snapshots updated when exported names change?