diff --git a/README.md b/README.md index 3f3333f..e7a0a46 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,28 @@ pnpm add @openclaw/fs-safe Node 20.11 or newer. Core root/path/json/temp helpers avoid framework dependencies. Archive helpers use optional `jszip` and `tar` dependencies for ZIP/TAR support; installs that omit optional dependencies can still use every non-archive subpath. +On POSIX, `root()` uses one process-global persistent Python helper for the +fd-relative operations Node does not expose ergonomically (`renameat`, +`unlinkat`, recursive `mkdirat`-style walks, and parent-fd writes). Configure it +before first use when you need a strict environment policy: + +```ts +import { configureFsSafePython } from "@openclaw/fs-safe"; + +configureFsSafePython({ mode: "auto" }); // default: use helper, fall back if unavailable +configureFsSafePython({ mode: "off" }); // never spawn Python; use Node fallbacks +configureFsSafePython({ mode: "require" }); // fail closed if helper cannot start +``` + +Equivalent env vars: `FS_SAFE_PYTHON_MODE=auto|off|require` and +`FS_SAFE_PYTHON=/path/to/python3`. Without Python, `fs-safe` keeps lexical and +canonical root checks, no-follow opens, atomic temp+rename writes, and +post-write identity verification. What you lose is the strongest POSIX +fd-relative protection against a same-process-user racer swapping parent +directories between validation and mutation. Windows already uses the Node +fallback path. See the [Python helper policy](docs/python-helper.md) for +deployment guidance. + ## Quick start ```ts @@ -128,6 +150,7 @@ that OpenClaw needs to compose higher-level APIs are grouped under | Subpath | Contents | |---|---| | `@openclaw/fs-safe/root` | `root()`, `Root`, `RootDefaults`, related types | +| `@openclaw/fs-safe/config` | process-global Python helper configuration | | `@openclaw/fs-safe/path` | canonical path checks: `isPathInside`, `safeRealpathSync`, `isNotFoundPathError`, `isSymlinkOpenError` | | `@openclaw/fs-safe/json` | `tryReadJson`, `readJson`, `readJsonIfExists`, `writeJson`, sync variants | | `@openclaw/fs-safe/store` | `fileStore`, `jsonStore`, and `privateStateStore` | @@ -352,7 +375,7 @@ Current `FsSafeErrorCode` values are `already-exists`, `hardlink`, `helper-faile - root-bounded APIs resolve paths against a configured root and reject canonical escapes - reads open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle - writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity -- `remove` and `mkdir` use fd-relative syscalls on POSIX through a small Python helper, with a Node fallback when the helper cannot spawn +- `remove`, `mkdir`, `move`, `stat`, `list`, and parent-fd writes use one persistent fd-relative Python helper on POSIX, with Node fallbacks when the helper is disabled or unavailable - archive extraction stages into a private directory and merges through the same boundary checks used by direct writes ## Limitations diff --git a/docs/errors.md b/docs/errors.md index b9d831e..8967bdd 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -56,8 +56,8 @@ type FsSafeErrorCode = |---|---|---| | `already-exists` | `create()`, `createJson()`, `move({ overwrite: false })`. | Target file or directory already at the destination. | | `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. | -| `helper-failed` | Internal POSIX helper (Python-based fd-relative ops, sidecar lock acquire) failed. | Inspect `cause` for the underlying error. | -| `helper-unavailable` | Helper could not be spawned at all. | Python missing in PATH; restricted sandbox. Library falls back to Node-only path where possible. | +| `helper-failed` | Internal POSIX helper failed after startup. | Inspect `cause`; retrying may be unsafe if the operation may have partially completed. | +| `helper-unavailable` | Persistent Python helper was disabled or could not be spawned. | `FS_SAFE_PYTHON_MODE=off`, Python missing in PATH, restricted sandbox. `auto` falls back where possible; `require` fails closed. | | `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. | | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows. | | `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. | diff --git a/docs/index.md b/docs/index.md index 21c4d12..31c1f54 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,7 +38,7 @@ await fs.remove("notes/archive/today.txt"); ## Pick your path - **First time?** [Install](install.md), then walk through the [Quickstart](quickstart.md). Five minutes from `pnpm add` to a working root. -- **Designing a workspace feature.** Read the [Security model](security-model.md) before you trust the boundary, and the [Errors](errors.md) reference so you know what to catch. +- **Designing a workspace feature.** Read the [Security model](security-model.md) before you trust the boundary, the [Python helper policy](python-helper.md) before you pick deployment defaults, and the [Errors](errors.md) reference so you know what to catch. - **Replacing ad-hoc atomic writes.** Jump to [Atomic writes](atomic.md) or, for keyed JSON state, [JSON files](json.md). - **Extracting an upload.** Start at [Archive extraction](archive.md) — handles ZIP and TAR with traversal, link, count, and byte limits. - **Running an agent in a sandbox.** [Private temp workspaces](temp.md) plus [secret files](secret-file.md) cover the common scratch-and-credentials shape. @@ -49,6 +49,7 @@ await fs.remove("notes/archive/today.txt"); | Surface | Use it for | |---|---| | [`root()`](root.md) | One boundary for read/write/move/remove inside a trusted directory. | +| [Python helper policy](python-helper.md) | Choose `auto`, `off`, or `require` for POSIX fd-relative hardening. | | [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. | | [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. | | [`jsonStore`](json-store.md) | Single JSON state file with explicit fallback, atomic writes, and optional locking. | diff --git a/docs/install.md b/docs/install.md index 4f83bc9..0924efb 100644 --- a/docs/install.md +++ b/docs/install.md @@ -64,6 +64,7 @@ Use the main entry for the common surface, or the focused subpaths when you want |---|---| | `@openclaw/fs-safe` | Small common surface: `root`, root types, and errors. | | `@openclaw/fs-safe/root` | `root()`, `Root`, `RootDefaults`, related types. | +| `@openclaw/fs-safe/config` | Process-global Python helper configuration. | | `@openclaw/fs-safe/path` | `isPathInside`, `safeRealpathSync`, `isWithinDir`, error helpers. | | `@openclaw/fs-safe/json` | `tryReadJson`, `readJson`, `readJsonIfExists`, `writeJson`, sync variants. | | `@openclaw/fs-safe/store` | `fileStore()`, `jsonStore()`, and `privateStateStore()`. | @@ -85,6 +86,39 @@ Use the main entry for the common surface, or the focused subpaths when you want There are no peer dependencies and no native build step. +## Python helper policy + +On POSIX, `root()` uses one persistent Python helper process for the +fd-relative operations Node does not expose cleanly. The default is `auto`: use +the helper when it starts, fall back to Node-only behavior when it is disabled +or unavailable. + +```ts +import { configureFsSafePython } from "@openclaw/fs-safe/config"; + +configureFsSafePython({ mode: "auto" }); // default +configureFsSafePython({ mode: "off" }); // never spawn Python +configureFsSafePython({ mode: "require" }); // fail closed if unavailable +``` + +Environment variables are read at runtime: + +```bash +FS_SAFE_PYTHON_MODE=off # auto | off | require +FS_SAFE_PYTHON=/usr/bin/python3 +``` + +OpenClaw compatibility aliases are also accepted: +`OPENCLAW_FS_SAFE_PYTHON_MODE`, `OPENCLAW_FS_SAFE_PYTHON`, +`OPENCLAW_PINNED_PYTHON`, and `OPENCLAW_PINNED_WRITE_PYTHON`. + +Disabling Python keeps the public API working, but downgrades POSIX mutation +hardening from fd-relative syscalls to Node path operations guarded by lexical +and canonical checks plus identity verification. Use `require` for +security-sensitive deployments where that downgrade should be a startup/runtime +failure instead of a fallback. The full tradeoff is documented in +[Python helper policy](python-helper.md). + ## Verify the install ```ts diff --git a/docs/python-helper.md b/docs/python-helper.md new file mode 100644 index 0000000..258b0eb --- /dev/null +++ b/docs/python-helper.md @@ -0,0 +1,103 @@ +--- +title: Python helper policy +description: "How fs-safe uses its optional persistent Python helper, how to configure it, and what Node-only mode changes." +--- + +# Python helper policy + +`fs-safe` is a Node library. On POSIX systems, it can optionally keep one persistent Python helper process for filesystem operations that Node does not expose ergonomically as fd-relative APIs. + +The helper is not a sandbox and does not add new authority. It uses the same process user and the same filesystem permissions as your Node process. Its job is narrower: reduce race windows around parent-directory mutations after a root boundary has already been chosen. + +## Default + +The package default is: + +```ts +configureFsSafePython({ mode: "auto" }); +``` + +`auto` means: + +- use the helper for supported POSIX operations when it starts successfully; +- fall back to Node-only behavior when Python is missing, disabled by the host, or unavailable; +- keep the public API working in ordinary desktop, CI, Docker, and bundled-app environments. + +Applications can choose a stricter or simpler policy before the first filesystem operation: + +```ts +import { configureFsSafePython } from "@openclaw/fs-safe/config"; + +configureFsSafePython({ mode: "off" }); // never spawn Python +configureFsSafePython({ mode: "require" }); // fail closed if helper cannot start +``` + +Environment variables provide the same policy: + +```bash +FS_SAFE_PYTHON_MODE=auto # auto | off | require +FS_SAFE_PYTHON=/usr/bin/python3 +``` + +OpenClaw compatibility aliases are accepted too: `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OPENCLAW_FS_SAFE_PYTHON`, `OPENCLAW_PINNED_PYTHON`, and `OPENCLAW_PINNED_WRITE_PYTHON`. + +## What the helper does + +Node's `fs` API is path-string oriented. It exposes `O_NOFOLLOW`, file handles, and some identity checks, but not a complete ergonomic `openat` / `renameat` / `unlinkat` / `mkdirat` surface for every operation `root()` needs. + +The helper fills that gap for supported POSIX operations: + +- stat/list paths relative to an already-open root directory; +- create directories while walking from a pinned parent; +- remove entries relative to a pinned parent; +- move entries with fd-relative rename semantics; +- run parent-fd write paths used by atomic replacement helpers. + +`fs-safe` sends requests to the helper over a JSON-lines protocol. It is one persistent process per Node process, not one Python spawn per filesystem call. + +## What you lose with `mode: "off"` + +Node-only mode still keeps the important application-level guardrails: + +- root-relative path validation; +- canonical root checks; +- no-follow opens where Node/platform support exists; +- file identity checks around reads and writes; +- atomic sibling-temp replacement; +- hardlink/symlink policy checks where the API requests them; +- byte limits and structured `FsSafeError` failures. + +What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation. Without fd-relative mutation, `root().move()`, `root().remove()`, `root().mkdir()`, and some write paths rely on Node path operations plus pre/post checks instead of parent-fd syscalls. + +That is usually acceptable when the root directory is only writable by the trusted application user. It is not the right posture if untrusted local processes can race writes in the same tree and you are relying on `fs-safe` as part of the security boundary. + +## Choosing a mode + +| Mode | Use when | +|---|---| +| `auto` | You want the strongest available POSIX path when Python exists, but installs should still work without it. This is the package default. | +| `off` | You want deterministic Node-only behavior, no Python process, or a runtime that forbids spawning Python. | +| `require` | The fd-relative helper is part of your security posture and startup/runtime should fail closed if it is unavailable. | + +If you deploy with `require`, set `FS_SAFE_PYTHON` to an absolute interpreter path and test it in the same container, bundle, service manager, or sandbox that runs your app. + +## Application defaults + +Libraries should normally leave the package default alone. Applications can set a process-global policy once at startup: + +```ts +import { configureFsSafePython } from "@openclaw/fs-safe/config"; + +if (!process.env.FS_SAFE_PYTHON_MODE) { + configureFsSafePython({ mode: "off" }); +} +``` + +This is the right shape for apps that want to make Python an explicit operator choice while still letting deployment env vars opt back into `auto` or `require`. + +## Related pages + +- [Security model](security-model.md) — what `root()` does and does not promise. +- [Root API](root.md) — root-bounded read/write/move/remove methods. +- [Errors](errors.md) — `helper-unavailable` and `helper-failed` handling. +- [Testing](testing.md) — forcing helper modes in tests. diff --git a/docs/root.md b/docs/root.md index 8692c21..5048e25 100644 --- a/docs/root.md +++ b/docs/root.md @@ -92,6 +92,26 @@ fs.resolve(rel) // absolute path inside the root, after canonic These do not pin a later operation. They are safe to expose to UIs and decision points; for the actual read or write, use the verb methods so the operation pins identity at the point of use. +## Python helper mode + +On POSIX, mutation and inspection methods that need fd-relative directory +operations go through one persistent Python helper process. This avoids a +spawn-per-call cost while still using `openat`/`renameat`/`unlinkat`-style +operations that Node's `fs` API does not expose ergonomically. + +```ts +import { configureFsSafePython } from "@openclaw/fs-safe/config"; + +configureFsSafePython({ mode: "off" }); // Node-only fallback path +configureFsSafePython({ mode: "require" }); // fail if fd-relative helper unavailable +``` + +`auto` is the default. Configure the mode before creating roots. Without the +helper, root methods still run, but same-UID races that swap parent directories +between validation and mutation are harder to close completely. Use `require` +when that downgrade should be treated as a deployment failure. See +[Python helper policy](python-helper.md) for deployment guidance. + ### Properties ```ts diff --git a/docs/security-model.md b/docs/security-model.md index aa79abe..559f7b2 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -66,7 +66,7 @@ The library does not modify or constrain the global Node.js `fs` namespace, and ## Platform notes -- **POSIX (Linux, macOS):** Best-defended path. Uses `O_NOFOLLOW`, `openat`-style helpers via a small Python helper for fd-relative `unlinkat` / `mkdirat` / `renameat`, with a Node fallback when the helper cannot spawn. fd identity checks are reliable. +- **POSIX (Linux, macOS):** Best-defended path. Uses `O_NOFOLLOW`, fd identity checks, and one persistent Python helper process for fd-relative `unlinkat` / `mkdirat` / `renameat` / parent-fd write operations. Configure `FS_SAFE_PYTHON_MODE=require` when helper startup must fail closed, or `off` when you need a no-Python runtime. See [Python helper policy](python-helper.md). - **Windows:** Falls back to the safest Node-level behavior available. `O_NOFOLLOW` is not honored. Some fd-relative POSIX hardening is unavailable. The library does the path canonicalization, identity, and atomic-rename checks it can. The library does not advertise different security guarantees per platform — it advertises the same surface and relies on the strongest mechanism the platform offers. @@ -82,7 +82,7 @@ The library does not advertise different security guarantees per platform — it | Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. | | Mode bits are not a full policy engine | `replaceFileAtomic` and secret-file helpers set requested modes, but you should still set umask and inspect modes when policy requires it. | | Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. | -| Helper failures degrade fd-relative hardening | `helper-failed` / `helper-unavailable` mean the Node fallback is being used. Atomicity remains, but some POSIX fd-relative race resistance is unavailable. | +| Helper failures degrade fd-relative hardening | `helper-unavailable` falls back in `auto` mode and fails closed in `require` mode. Atomicity and identity checks remain, but parent-directory swaps between validation and mutation are less tightly pinned without the helper. | ## Recommended deployment shape diff --git a/docs/testing.md b/docs/testing.md index 5e79146..bb2d6b3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,7 +14,7 @@ The double-underscore prefix is a deliberate "hands off" signal: production code ## When to reach for hooks - Reproduce a TOCTOU race deterministically: simulate a symlink swap between resolve and open, or between write and rename. -- Make a "helper unavailable" branch reachable in CI without uninstalling Python from your runners. +- Force Node-only behavior without uninstalling Python from your runners. - Inject latency to test cancellation/timeout paths. If you don't need to inject a race, you don't need hooks — most tests should drive the library through normal calls and assert on observable behavior. @@ -23,10 +23,9 @@ If you don't need to inject a race, you don't need hooks — most tests should d ```ts type FsSafeTestHooks = { - beforeOpen?: (info: { absPath: string }) => void | Promise; - beforeRename?: (info: { tempPath: string; destPath: string }) => void | Promise; - afterFstat?: (info: { absPath: string; stat: import("node:fs").Stats }) => void | Promise; - helperUnavailableReason?: () => string | undefined; + afterPreOpenLstat?: (filePath: string) => Promise | void; + beforeOpen?: (filePath: string, flags: number) => Promise | void; + afterOpen?: (filePath: string, handle: import("node:fs/promises").FileHandle) => Promise | void; }; function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void; @@ -35,10 +34,9 @@ function getFsSafeTestHooks(): FsSafeTestHooks | undefined; Hooks are called at well-defined points in the library's hot paths: -- **`beforeOpen`** — runs before `fs.open` or the pinned-open helper opens the file. A common use is to swap the path's target via `fs.symlink`/`fs.unlink` to drive a TOCTOU race. -- **`beforeRename`** — runs after the temp file is fully written and before the rename. Use to mutate the destination dir, simulate a parallel writer, or unlink the temp file. -- **`afterFstat`** — runs after the post-open `fstat`. Useful to validate the library called `fstat` (and not bypassed it). -- **`helperUnavailableReason`** — when defined, the library treats the POSIX helper as unavailable and surfaces the returned string as the failure reason. Use this to drive the Node-only fallback path on systems where the helper *would* spawn. +- **`afterPreOpenLstat`** — runs after the pre-open `lstat`. A common use is to swap the path's target via `fs.symlink`/`fs.unlink` to drive a TOCTOU race. +- **`beforeOpen`** — runs before `fs.open` with the exact flags the root read path will use. +- **`afterOpen`** — runs after the file handle is opened. Useful to wrap handle methods or inject a size race before a stream is consumed. `__setFsSafeTestHooksForTest(undefined)` clears all hooks. Always clean up between tests. @@ -67,7 +65,7 @@ it("rejects a swap between resolve and open", async () => { const fs = await root(dir, { symlinks: "reject" }); __setFsSafeTestHooksForTest({ - beforeOpen: async ({ absPath }) => { + afterPreOpenLstat: async (absPath) => { // swap real.txt for a symlink to decoy.txt right before the open await unlink(absPath); await symlink(path.join(dir, "decoy.txt"), absPath); @@ -83,20 +81,23 @@ it("rejects a swap between resolve and open", async () => { The `code` may be `symlink` (caught at open by `O_NOFOLLOW`) or `path-mismatch` (caught by the post-open identity check) depending on platform — both are correct refusals. -## Example: force the helper-unavailable branch +## Example: force Node-only fallback behavior ```ts -import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks"; +import { configureFsSafePython } from "@openclaw/fs-safe/config"; beforeEach(() => { - __setFsSafeTestHooksForTest({ - helperUnavailableReason: () => "test: pretend Python is missing", - }); + configureFsSafePython({ mode: "off" }); }); -it("falls back to the Node-only path", async () => { - // exercise an operation that would normally use the helper - // and assert the Node fallback succeeded with the same observable result +afterEach(() => { + configureFsSafePython({ mode: "auto", pythonPath: undefined }); +}); + +it("runs without the Python helper", async () => { + const fs = await root(dir); + await fs.write("file.txt", "ok"); + await expect(fs.readText("file.txt")).resolves.toBe("ok"); }); ``` diff --git a/package.json b/package.json index afe5417..9957901 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,10 @@ "types": "./dist/root.d.ts", "default": "./dist/root.js" }, + "./config": { + "types": "./dist/config.d.ts", + "default": "./dist/config.js" + }, "./path": { "types": "./dist/path.d.ts", "default": "./dist/path.js" diff --git a/scripts/build-docs-site.mjs b/scripts/build-docs-site.mjs index 259c696..99d3283 100644 --- a/scripts/build-docs-site.mjs +++ b/scripts/build-docs-site.mjs @@ -26,7 +26,7 @@ const productDescription = const installCmd = "pnpm add @openclaw/fs-safe"; const sections = [ - ["Start", ["index.md", "install.md", "quickstart.md", "security-model.md"]], + ["Start", ["index.md", "install.md", "quickstart.md", "security-model.md", "python-helper.md"]], ["Root API", ["root.md", "reading.md", "writing.md", "path-scope.md"]], ["Atomic & temp", ["atomic.md", "json.md", "temp.md", "archive.md"]], ["Stores", ["json-store.md", "file-store.md", "private-file-store.md"]], diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3e42f5a --- /dev/null +++ b/src/config.ts @@ -0,0 +1,6 @@ +export { + configureFsSafePython, + getFsSafePythonConfig, + type FsSafePythonConfig, + type FsSafePythonMode, +} from "./pinned-python-config.js"; diff --git a/src/index.ts b/src/index.ts index 7afd9cf..eedadaf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,3 +26,9 @@ export { type WritableOpenMode, type WritableOpenResult, } from "./root.js"; +export { + configureFsSafePython, + getFsSafePythonConfig, + type FsSafePythonConfig, + type FsSafePythonMode, +} from "./pinned-python-config.js"; diff --git a/src/pinned-helper.ts b/src/pinned-helper.ts index a093b27..5871d96 100644 --- a/src/pinned-helper.ts +++ b/src/pinned-helper.ts @@ -1,425 +1,27 @@ -import { spawn } from "node:child_process"; -import fsSync from "node:fs"; - -import { FsSafeError } from "./errors.js"; -import { splitSafeRelativePath } from "./path.js"; import type { DirEntry, PathStat } from "./types.js"; +import { + isPinnedHelperUnavailable, + runPinnedPythonOperation, + validatePinnedOperationPayload, +} from "./pinned-python.js"; -const PINNED_HELPER_SOURCE = String.raw` -import base64 -import errno -import json -import os -import stat -import sys -import tempfile +type HelperOperation = "stat" | "readdir" | "mkdirp" | "remove" | "rename"; -operation = sys.argv[1] -root_path = sys.argv[2] -payload = json.loads(sys.stdin.read() or "{}") - -DIR_FLAGS = os.O_RDONLY -if hasattr(os, "O_DIRECTORY"): - DIR_FLAGS |= os.O_DIRECTORY -if hasattr(os, "O_NOFOLLOW"): - DIR_FLAGS |= os.O_NOFOLLOW -READ_FLAGS = os.O_RDONLY -if hasattr(os, "O_NOFOLLOW"): - READ_FLAGS |= os.O_NOFOLLOW -WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL -if hasattr(os, "O_NOFOLLOW"): - WRITE_FLAGS |= os.O_NOFOLLOW - -def fail(code, message): - print(json.dumps({"ok": False, "code": code, "message": message}), file=sys.stderr) - sys.exit(1) - -def split_relative(value): - if value in ("", "."): - return [] - if "\x00" in value or "\\" in value or value.startswith("/") or value.startswith("//"): - raise OSError(errno.EPERM, "invalid relative path") - parts = [part for part in value.split("/") if part and part != "."] - for part in parts: - if part == "..": - raise OSError(errno.EPERM, "path traversal is not allowed") - return parts - -def open_dir(path_value, dir_fd=None): - return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd) - -def walk_dir(root_fd, segments, mkdir_enabled=False): - current_fd = os.dup(root_fd) - try: - for segment in segments: - try: - next_fd = open_dir(segment, dir_fd=current_fd) - except FileNotFoundError: - if not mkdir_enabled: - raise - os.mkdir(segment, 0o777, dir_fd=current_fd) - next_fd = open_dir(segment, dir_fd=current_fd) - os.close(current_fd) - current_fd = next_fd - return current_fd - except Exception: - os.close(current_fd) - raise - -def parent_and_basename(root_fd, relative): - segments = split_relative(relative) - if not segments: - raise OSError(errno.EPERM, "operation requires a non-root path") - parent_fd = walk_dir(root_fd, segments[:-1]) - return parent_fd, segments[-1] - -def encode_stat(st): - mode = st.st_mode - return { - "dev": st.st_dev, - "gid": st.st_gid, - "ino": st.st_ino, - "isDirectory": stat.S_ISDIR(mode), - "isFile": stat.S_ISREG(mode), - "isSymbolicLink": stat.S_ISLNK(mode), - "mode": mode, - "mtimeMs": st.st_mtime * 1000, - "nlink": st.st_nlink, - "size": st.st_size, - "uid": st.st_uid, - } - -def reject_unsafe_endpoint(st): - mode = st.st_mode - if stat.S_ISLNK(mode): - raise OSError(errno.ELOOP, "symlink endpoint is not allowed") - if stat.S_ISREG(mode) and st.st_nlink > 1: - raise OSError(errno.EPERM, "hardlinked file endpoint is not allowed") - -def stat_path(root_fd, relative): - segments = split_relative(relative) - if not segments: - return encode_stat(os.fstat(root_fd)) - parent_fd, basename = parent_and_basename(root_fd, relative) - try: - st = os.lstat(basename, dir_fd=parent_fd) - if payload.get("rejectSymlink", True) and stat.S_ISLNK(st.st_mode): - raise OSError(errno.ELOOP, "symlink endpoint is not allowed") - return encode_stat(st) - finally: - os.close(parent_fd) - -def readdir_path(root_fd, relative): - dir_fd = walk_dir(root_fd, split_relative(relative)) - try: - names = sorted(os.listdir(dir_fd)) - if not payload.get("withFileTypes", False): - return names - entries = [] - for name in names: - st = os.lstat(name, dir_fd=dir_fd) - entry = encode_stat(st) - entry["name"] = name - entries.append(entry) - return entries - finally: - os.close(dir_fd) - -def mkdirp_path(root_fd, relative): - dir_fd = walk_dir(root_fd, split_relative(relative), mkdir_enabled=True) - os.close(dir_fd) - return None - -def remove_tree(parent_fd, basename): - st = os.lstat(basename, dir_fd=parent_fd) - if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode): - dir_fd = open_dir(basename, dir_fd=parent_fd) - try: - for child in os.listdir(dir_fd): - remove_tree(dir_fd, child) - finally: - os.close(dir_fd) - os.rmdir(basename, dir_fd=parent_fd) - else: - os.unlink(basename, dir_fd=parent_fd) - -def remove_path(root_fd, relative): - parent_fd, basename = parent_and_basename(root_fd, relative) - try: - try: - st = os.lstat(basename, dir_fd=parent_fd) - except FileNotFoundError: - if payload.get("force", True): - return None - raise - if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode): - if payload.get("recursive", False): - remove_tree(parent_fd, basename) - else: - os.rmdir(basename, dir_fd=parent_fd) - else: - os.unlink(basename, dir_fd=parent_fd) - return None - finally: - os.close(parent_fd) - -def read_path(root_fd, relative): - parent_fd, basename = parent_and_basename(root_fd, relative) - try: - fd = os.open(basename, READ_FLAGS, dir_fd=parent_fd) - try: - st = os.fstat(fd) - reject_unsafe_endpoint(st) - if not stat.S_ISREG(st.st_mode): - raise OSError(errno.EPERM, "only regular files can be read") - chunks = [] - while True: - chunk = os.read(fd, 1024 * 1024) - if not chunk: - break - chunks.append(chunk) - return {"base64": base64.b64encode(b"".join(chunks)).decode("ascii"), "stat": encode_stat(st)} - finally: - os.close(fd) - finally: - os.close(parent_fd) - -def write_path(root_fd, relative): - parent_fd, basename = parent_and_basename(root_fd, relative) - data = base64.b64decode(payload.get("base64", "")) - overwrite = payload.get("overwrite", True) - try: - if not overwrite: - try: - os.lstat(basename, dir_fd=parent_fd) - raise FileExistsError(errno.EEXIST, "destination exists", basename) - except FileNotFoundError: - pass - prefix = ".fs-safe-" + basename.replace("/", "_") + "-" - temp_name = None - fd = None - try: - for _ in range(32): - candidate = prefix + next(tempfile._get_candidate_names()) - try: - fd = os.open(candidate, WRITE_FLAGS, 0o600, dir_fd=parent_fd) - temp_name = candidate - break - except FileExistsError: - continue - if fd is None or temp_name is None: - raise FileExistsError(errno.EEXIST, "could not allocate temp file") - os.write(fd, data) - os.fsync(fd) - os.close(fd) - fd = None - os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) - os.fsync(parent_fd) - return None - finally: - if fd is not None: - os.close(fd) - if temp_name is not None: - try: - os.unlink(temp_name, dir_fd=parent_fd) - except FileNotFoundError: - pass - finally: - os.close(parent_fd) - -def rename_path(root_fd): - from_parent_fd, from_base = parent_and_basename(root_fd, payload["from"]) - to_parent_fd, to_base = parent_and_basename(root_fd, payload["to"]) - try: - from_stat = os.lstat(from_base, dir_fd=from_parent_fd) - reject_unsafe_endpoint(from_stat) - if not payload.get("overwrite", True): - try: - os.lstat(to_base, dir_fd=to_parent_fd) - raise FileExistsError(errno.EEXIST, "destination exists", to_base) - except FileNotFoundError: - pass - os.rename(from_base, to_base, src_dir_fd=from_parent_fd, dst_dir_fd=to_parent_fd) - os.fsync(from_parent_fd) - if from_parent_fd != to_parent_fd: - os.fsync(to_parent_fd) - return None - finally: - os.close(from_parent_fd) - os.close(to_parent_fd) - -try: - root_fd = open_dir(root_path) - try: - relative = payload.get("relativePath", "") - if operation == "stat": - result = stat_path(root_fd, relative) - elif operation == "readdir": - result = readdir_path(root_fd, relative) - elif operation == "mkdirp": - result = mkdirp_path(root_fd, relative) - elif operation == "remove": - result = remove_path(root_fd, relative) - elif operation == "read": - result = read_path(root_fd, relative) - elif operation == "write": - result = write_path(root_fd, relative) - elif operation == "rename": - result = rename_path(root_fd) - else: - raise RuntimeError("unknown operation: " + operation) - print(json.dumps({"ok": True, "result": result}, separators=(",", ":"))) - finally: - os.close(root_fd) -except Exception as exc: - fail(type(exc).__name__, str(exc)) -`; - -type HelperOperation = "stat" | "readdir" | "mkdirp" | "remove" | "read" | "write" | "rename"; - -const PYTHON_CANDIDATES = [ - process.env.OPENCLAW_FS_SAFE_PYTHON, - process.env.OPENCLAW_PINNED_PYTHON, - "/usr/bin/python3", - "/opt/homebrew/bin/python3", - "/usr/local/bin/python3", -].filter((value): value is string => Boolean(value)); - -let cachedPython = ""; - -function canExecute(binPath: string): boolean { - try { - fsSync.accessSync(binPath, fsSync.constants.X_OK); - return true; - } catch { - return false; - } -} - -function resolvePython(): string { - if (cachedPython) { - return cachedPython; - } - for (const candidate of PYTHON_CANDIDATES) { - if (canExecute(candidate)) { - cachedPython = candidate; - return cachedPython; - } - } - cachedPython = "python3"; - return cachedPython; -} - -function assertPinnedHelperSupported(): void { - if (process.platform === "win32") { - throw new FsSafeError( - "unsupported-platform", - "fd-relative pinned filesystem operations are not available on Windows", - ); - } -} - -function isSpawnUnavailable(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - const maybeErrno = error as NodeJS.ErrnoException; - return ( - typeof maybeErrno.syscall === "string" && - maybeErrno.syscall.startsWith("spawn") && - ["EACCES", "ENOENT", "ENOEXEC"].includes(maybeErrno.code ?? "") - ); -} +export { isPinnedHelperUnavailable }; export async function runPinnedHelper( operation: HelperOperation, rootDir: string, payload: Record, ): Promise { - assertPinnedHelperSupported(); - if (typeof payload.relativePath === "string") { - splitSafeRelativePath(payload.relativePath); - } - if (typeof payload.from === "string") { - splitSafeRelativePath(payload.from); - } - if (typeof payload.to === "string") { - splitSafeRelativePath(payload.to); - } - - const child = spawn(resolvePython(), ["-c", PINNED_HELPER_SOURCE, operation, rootDir], { - stdio: ["pipe", "pipe", "pipe"], + validatePinnedOperationPayload(payload); + return await runPinnedPythonOperation({ + operation, + rootPath: rootDir, + payload, }); - - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - - child.stdin.end(JSON.stringify(payload)); - - const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>( - (resolve, reject) => { - child.once("error", reject); - child.once("close", (exitCode, exitSignal) => resolve([exitCode, exitSignal])); - }, - ).catch((error: unknown) => { - if (isSpawnUnavailable(error)) { - throw new FsSafeError("helper-unavailable", "Python helper is unavailable", { cause: error }); - } - throw error; - }); - - const raw = code === 0 ? stdout : stderr; - let decoded: unknown; - try { - decoded = JSON.parse(raw.trim()); - } catch { - throw new FsSafeError( - "helper-failed", - `pinned helper failed with code ${code ?? "null"} (${signal ?? "?"}): ${raw.trim()}`, - ); - } - - if ( - typeof decoded !== "object" || - decoded === null || - !("ok" in decoded) || - typeof decoded.ok !== "boolean" - ) { - throw new FsSafeError("helper-failed", "pinned helper returned an invalid response"); - } - if (!decoded.ok) { - const helperCode = "code" in decoded && typeof decoded.code === "string" ? decoded.code : ""; - const message = - "message" in decoded && typeof decoded.message === "string" - ? decoded.message - : "pinned helper failed"; - if (helperCode === "FileNotFoundError") { - throw new FsSafeError("not-found", "file not found"); - } - if (helperCode === "NotADirectoryError" || helperCode === "OSError") { - throw new FsSafeError("path-alias", message); - } - if (helperCode === "FileExistsError") { - throw new FsSafeError("already-exists", message); - } - throw new FsSafeError("helper-failed", message); - } - return (decoded as unknown as { result: T }).result; } -export type HelperReadResult = { - base64: string; - stat: PathStat; -}; - export async function helperStat(rootDir: string, relativePath: string): Promise { return await runPinnedHelper("stat", rootDir, { relativePath }); } diff --git a/src/pinned-path.ts b/src/pinned-path.ts index e155475..10d848d 100644 --- a/src/pinned-path.ts +++ b/src/pinned-path.ts @@ -1,177 +1,9 @@ -import { spawn } from "node:child_process"; -import fsSync from "node:fs"; import { FsSafeError } from "./errors.js"; - -const LOCAL_PINNED_PATH_PYTHON = [ - "import errno", - "import json", - "import os", - "import stat", - "import sys", - "", - "operation = sys.argv[1]", - "root_path = sys.argv[2]", - "relative_path = sys.argv[3]", - "", - "DIR_FLAGS = os.O_RDONLY", - "if hasattr(os, 'O_DIRECTORY'):", - " DIR_FLAGS |= os.O_DIRECTORY", - "if hasattr(os, 'O_NOFOLLOW'):", - " DIR_FLAGS |= os.O_NOFOLLOW", - "", - "def open_dir(path_value, dir_fd=None):", - " return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)", - "", - "def split_segments(relative_path):", - " return [part for part in relative_path.split('/') if part and part != '.']", - "", - "def validate_segment(segment):", - " if segment == '..':", - " raise OSError(errno.EPERM, 'path traversal is not allowed', segment)", - "", - "def walk_existing_path(root_fd, segments):", - " current_fd = os.dup(root_fd)", - " try:", - " for segment in segments:", - " validate_segment(segment)", - " next_fd = open_dir(segment, dir_fd=current_fd)", - " os.close(current_fd)", - " current_fd = next_fd", - " return current_fd", - " except Exception:", - " os.close(current_fd)", - " raise", - "", - "def mkdirp_within_root(root_fd, segments):", - " current_fd = os.dup(root_fd)", - " try:", - " for segment in segments:", - " validate_segment(segment)", - " try:", - " next_fd = open_dir(segment, dir_fd=current_fd)", - " except FileNotFoundError:", - " os.mkdir(segment, 0o777, dir_fd=current_fd)", - " next_fd = open_dir(segment, dir_fd=current_fd)", - " os.close(current_fd)", - " current_fd = next_fd", - " finally:", - " os.close(current_fd)", - "", - "def remove_within_root(root_fd, segments):", - " if not segments:", - " raise OSError(errno.EPERM, 'refusing to remove root path')", - " parent_segments = segments[:-1]", - " basename = segments[-1]", - " validate_segment(basename)", - " parent_fd = walk_existing_path(root_fd, parent_segments)", - " try:", - " target_stat = os.lstat(basename, dir_fd=parent_fd)", - " if stat.S_ISDIR(target_stat.st_mode) and not stat.S_ISLNK(target_stat.st_mode):", - " os.rmdir(basename, dir_fd=parent_fd)", - " else:", - " os.unlink(basename, dir_fd=parent_fd)", - " finally:", - " os.close(parent_fd)", - "", - "def emit_error(exc):", - " payload = {", - " 'name': exc.__class__.__name__,", - " 'errno': getattr(exc, 'errno', None),", - " 'message': str(exc),", - " }", - " print(json.dumps(payload), file=sys.stderr)", - "", - "root_fd = None", - "try:", - " root_fd = open_dir(root_path)", - " segments = split_segments(relative_path)", - " if operation == 'mkdirp':", - " mkdirp_within_root(root_fd, segments)", - " elif operation == 'remove':", - " remove_within_root(root_fd, segments)", - " else:", - " raise RuntimeError(f'unknown pinned path operation: {operation}')", - "except Exception as exc:", - " emit_error(exc)", - " sys.exit(1)", - "finally:", - " if root_fd is not None:", - " os.close(root_fd)", -].join("\n"); - -const PINNED_PATH_PYTHON_CANDIDATES = [ - process.env.OPENCLAW_PINNED_PYTHON, - // Keep the write-specific alias for backwards compatibility. - process.env.OPENCLAW_PINNED_WRITE_PYTHON, - "/usr/bin/python3", - "/opt/homebrew/bin/python3", - "/usr/local/bin/python3", -].filter((value): value is string => Boolean(value)); - -let cachedPinnedPathPython = ""; - -function canExecute(binPath: string): boolean { - try { - fsSync.accessSync(binPath, fsSync.constants.X_OK); - return true; - } catch { - return false; - } -} - -function resolvePinnedPathPython(): string { - if (cachedPinnedPathPython) { - return cachedPinnedPathPython; - } - for (const candidate of PINNED_PATH_PYTHON_CANDIDATES) { - if (canExecute(candidate)) { - cachedPinnedPathPython = candidate; - return cachedPinnedPathPython; - } - } - cachedPinnedPathPython = "python3"; - return cachedPinnedPathPython; -} - -function buildPinnedPathError(stderr: string, code: number | null, signal: NodeJS.Signals | null) { - const trimmed = stderr.trim(); - if (trimmed.startsWith("{")) { - try { - const payload = JSON.parse(trimmed) as { errno?: number; message?: string; name?: string }; - if (payload.errno === 2) { - return new FsSafeError("not-found", "file not found"); - } - if (payload.errno === 20 || payload.errno === 40) { - return new FsSafeError("path-alias", "path is not under root"); - } - if (payload.errno === 39) { - return new FsSafeError("not-empty", "directory is not empty"); - } - if (payload.errno === 1 || payload.errno === 13 || payload.errno === 21) { - return new FsSafeError("not-removable", "path is not removable under root"); - } - return new FsSafeError("helper-failed", payload.message || "pinned path helper failed"); - } catch { - // Fall through to the generic helper failure below. - } - } - return new FsSafeError( - "helper-failed", - trimmed || `Pinned path helper failed with code ${code ?? "null"} (${signal ?? "?"})`, - ); -} +import { canFallbackFromPythonError } from "./pinned-python-config.js"; +import { runPinnedHelper } from "./pinned-helper.js"; export function isPinnedPathHelperSpawnError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - - const maybeErrno = error as NodeJS.ErrnoException; - if (typeof maybeErrno.syscall !== "string" || !maybeErrno.syscall.startsWith("spawn")) { - return false; - } - - return ["EACCES", "ENOENT", "ENOEXEC"].includes(maybeErrno.code ?? ""); + return canFallbackFromPythonError(error); } export async function runPinnedPathHelper(params: { @@ -179,27 +11,16 @@ export async function runPinnedPathHelper(params: { rootPath: string; relativePath: string; }): Promise { - const child = spawn( - resolvePinnedPathPython(), - ["-c", LOCAL_PINNED_PATH_PYTHON, params.operation, params.rootPath, params.relativePath], - { - stdio: ["ignore", "ignore", "pipe"], - }, - ); - - let stderr = ""; - child.stderr.setEncoding?.("utf8"); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - - const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>( - (resolve, reject) => { - child.once("error", reject); - child.once("close", (exitCode, exitSignal) => resolve([exitCode, exitSignal])); - }, - ); - if (code !== 0) { - throw buildPinnedPathError(stderr, code, signal); + try { + await runPinnedHelper(params.operation, params.rootPath, { + relativePath: params.relativePath, + }); + } catch (error) { + if (error instanceof FsSafeError) { + throw error; + } + throw new FsSafeError("helper-failed", "pinned path helper failed", { + cause: error instanceof Error ? error : undefined, + }); } } diff --git a/src/pinned-python-config.ts b/src/pinned-python-config.ts new file mode 100644 index 0000000..45a6679 --- /dev/null +++ b/src/pinned-python-config.ts @@ -0,0 +1,54 @@ +export type FsSafePythonMode = "auto" | "off" | "require"; + +export type FsSafePythonConfig = { + mode: FsSafePythonMode; + pythonPath?: string; +}; + +let overrideConfig: Partial = {}; + +function parseMode(value: string | undefined): FsSafePythonMode | undefined { + if (!value) { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "never") { + return "off"; + } + if (normalized === "1" || normalized === "true" || normalized === "on" || normalized === "auto") { + return "auto"; + } + if (normalized === "required" || normalized === "require") { + return "require"; + } + return undefined; +} + +export function configureFsSafePython(config: Partial): void { + overrideConfig = { ...overrideConfig, ...config }; +} + +export function getFsSafePythonConfig(): FsSafePythonConfig { + return { + mode: + overrideConfig.mode ?? + parseMode(process.env.FS_SAFE_PYTHON_MODE) ?? + parseMode(process.env.OPENCLAW_FS_SAFE_PYTHON_MODE) ?? + "auto", + pythonPath: + overrideConfig.pythonPath ?? + process.env.FS_SAFE_PYTHON ?? + process.env.OPENCLAW_FS_SAFE_PYTHON ?? + process.env.OPENCLAW_PINNED_PYTHON ?? + process.env.OPENCLAW_PINNED_WRITE_PYTHON, + }; +} + +export function canFallbackFromPythonError(error: unknown): boolean { + return ( + getFsSafePythonConfig().mode !== "require" && + error instanceof Error && + "code" in error && + (error as { code?: unknown }).code === "helper-unavailable" + ); +} diff --git a/src/pinned-python.ts b/src/pinned-python.ts new file mode 100644 index 0000000..fe9c49a --- /dev/null +++ b/src/pinned-python.ts @@ -0,0 +1,654 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fsSync from "node:fs"; +import { FsSafeError } from "./errors.js"; +import { getFsSafePythonConfig } from "./pinned-python-config.js"; + +const PINNED_PYTHON_WORKER_SOURCE = String.raw` +import base64 +import errno +import json +import os +import secrets +import stat +import sys + +DIR_FLAGS = os.O_RDONLY +if hasattr(os, "O_DIRECTORY"): + DIR_FLAGS |= os.O_DIRECTORY +if hasattr(os, "O_NOFOLLOW"): + DIR_FLAGS |= os.O_NOFOLLOW +READ_FLAGS = os.O_RDONLY +if hasattr(os, "O_NOFOLLOW"): + READ_FLAGS |= os.O_NOFOLLOW +WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL +if hasattr(os, "O_NOFOLLOW"): + WRITE_FLAGS |= os.O_NOFOLLOW + +def split_relative(value): + if value in ("", "."): + return [] + if "\x00" in value or value.startswith("/") or value.startswith("//"): + raise OSError(errno.EPERM, "invalid relative path") + if value.startswith("..\\"): + raise OSError(errno.EPERM, "path traversal is not allowed") + parts = [part for part in value.split("/") if part and part != "."] + for part in parts: + if part == "..": + raise OSError(errno.EPERM, "path traversal is not allowed") + return parts + +def open_dir(path_value, dir_fd=None): + return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd) + +def walk_dir(root_fd, segments, mkdir_enabled=False): + current_fd = os.dup(root_fd) + try: + for segment in segments: + try: + next_fd = open_dir(segment, dir_fd=current_fd) + except FileNotFoundError: + if not mkdir_enabled: + raise + os.mkdir(segment, 0o777, dir_fd=current_fd) + next_fd = open_dir(segment, dir_fd=current_fd) + os.close(current_fd) + current_fd = next_fd + return current_fd + except Exception: + os.close(current_fd) + raise + +def parent_and_basename(root_fd, relative): + segments = split_relative(relative) + if not segments: + raise OSError(errno.EPERM, "operation requires a non-root path") + parent_fd = walk_dir(root_fd, segments[:-1]) + return parent_fd, segments[-1] + +def encode_stat(st): + mode = st.st_mode + return { + "dev": st.st_dev, + "gid": st.st_gid, + "ino": st.st_ino, + "isDirectory": stat.S_ISDIR(mode), + "isFile": stat.S_ISREG(mode), + "isSymbolicLink": stat.S_ISLNK(mode), + "mode": mode, + "mtimeMs": st.st_mtime * 1000, + "nlink": st.st_nlink, + "size": st.st_size, + "uid": st.st_uid, + } + +def reject_unsafe_endpoint(st): + mode = st.st_mode + if stat.S_ISLNK(mode): + raise OSError(errno.ELOOP, "symlink endpoint is not allowed") + if stat.S_ISREG(mode) and st.st_nlink > 1: + raise OSError(errno.EPERM, "hardlinked file endpoint is not allowed") + +def stat_path(root_fd, payload): + relative = payload.get("relativePath", "") + segments = split_relative(relative) + if not segments: + return encode_stat(os.fstat(root_fd)) + parent_fd, basename = parent_and_basename(root_fd, relative) + try: + st = os.lstat(basename, dir_fd=parent_fd) + if payload.get("rejectSymlink", True) and stat.S_ISLNK(st.st_mode): + raise OSError(errno.ELOOP, "symlink endpoint is not allowed") + return encode_stat(st) + finally: + os.close(parent_fd) + +def readdir_path(root_fd, payload): + dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", ""))) + try: + names = sorted(os.listdir(dir_fd)) + if not payload.get("withFileTypes", False): + return names + entries = [] + for name in names: + st = os.lstat(name, dir_fd=dir_fd) + entry = encode_stat(st) + entry["name"] = name + entries.append(entry) + return entries + finally: + os.close(dir_fd) + +def mkdirp_path(root_fd, payload): + dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")), mkdir_enabled=True) + os.close(dir_fd) + return None + +def remove_tree(parent_fd, basename): + st = os.lstat(basename, dir_fd=parent_fd) + if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode): + dir_fd = open_dir(basename, dir_fd=parent_fd) + try: + for child in os.listdir(dir_fd): + remove_tree(dir_fd, child) + finally: + os.close(dir_fd) + os.rmdir(basename, dir_fd=parent_fd) + else: + os.unlink(basename, dir_fd=parent_fd) + +def remove_path(root_fd, payload): + parent_fd, basename = parent_and_basename(root_fd, payload.get("relativePath", "")) + try: + try: + st = os.lstat(basename, dir_fd=parent_fd) + except FileNotFoundError: + if payload.get("force", True): + return None + raise + if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode): + if payload.get("recursive", False): + remove_tree(parent_fd, basename) + else: + os.rmdir(basename, dir_fd=parent_fd) + else: + os.unlink(basename, dir_fd=parent_fd) + return None + finally: + os.close(parent_fd) + +def rename_path(root_fd, payload): + from_parent_fd, from_base = parent_and_basename(root_fd, payload["from"]) + to_parent_fd, to_base = parent_and_basename(root_fd, payload["to"]) + try: + from_stat = os.lstat(from_base, dir_fd=from_parent_fd) + reject_unsafe_endpoint(from_stat) + if not payload.get("overwrite", True): + try: + os.lstat(to_base, dir_fd=to_parent_fd) + raise FileExistsError(errno.EEXIST, "destination exists", to_base) + except FileNotFoundError: + pass + os.rename(from_base, to_base, src_dir_fd=from_parent_fd, dst_dir_fd=to_parent_fd) + os.fsync(from_parent_fd) + if from_parent_fd != to_parent_fd: + os.fsync(to_parent_fd) + return None + finally: + os.close(from_parent_fd) + os.close(to_parent_fd) + +def create_temp_file(parent_fd, basename, mode): + prefix = "." + basename + "." + for _ in range(128): + candidate = prefix + secrets.token_hex(6) + ".tmp" + try: + fd = os.open(candidate, WRITE_FLAGS, mode, dir_fd=parent_fd) + return candidate, fd + except FileExistsError: + continue + raise RuntimeError("failed to allocate pinned temp file") + +def write_path(root_fd, payload): + parent_fd = walk_dir(root_fd, split_relative(payload.get("relativeParentPath", "")), bool(payload.get("mkdir", True))) + temp_fd = None + temp_name = None + basename = payload["basename"] + mode = int(payload.get("mode", 0o600)) + overwrite = bool(payload.get("overwrite", True)) + max_bytes = int(payload.get("maxBytes", -1)) + data = base64.b64decode(payload.get("base64", "")) + try: + if max_bytes >= 0 and len(data) > max_bytes: + raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, len(data))) + if not overwrite: + try: + os.lstat(basename, dir_fd=parent_fd) + raise FileExistsError(errno.EEXIST, "destination exists", basename) + except FileNotFoundError: + pass + temp_name, temp_fd = create_temp_file(parent_fd, basename, mode) + view = memoryview(data) + while view: + written = os.write(temp_fd, view) + if written <= 0: + raise OSError(errno.EIO, "short write") + view = view[written:] + os.fsync(temp_fd) + os.close(temp_fd) + temp_fd = None + os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) + temp_name = None + os.fsync(parent_fd) + result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False) + return {"dev": result_stat.st_dev, "ino": result_stat.st_ino} + finally: + if temp_fd is not None: + os.close(temp_fd) + if temp_name is not None: + try: + os.unlink(temp_name, dir_fd=parent_fd) + except FileNotFoundError: + pass + os.close(parent_fd) + +def copy_path(root_fd, payload): + source_fd = os.open(payload["sourcePath"], READ_FLAGS) + parent_fd = None + temp_fd = None + temp_name = None + try: + source_stat = os.fstat(source_fd) + if not stat.S_ISREG(source_stat.st_mode): + raise RuntimeError("fs-safe-not-file") + if source_stat.st_dev != int(payload["sourceDev"]) or source_stat.st_ino != int(payload["sourceIno"]): + raise RuntimeError("fs-safe-source-mismatch") + basename = payload["basename"] + mode = int(payload.get("mode", 0o600)) + overwrite = bool(payload.get("overwrite", True)) + max_bytes = int(payload.get("maxBytes", -1)) + if max_bytes >= 0 and source_stat.st_size > max_bytes: + raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, source_stat.st_size)) + parent_fd = walk_dir(root_fd, split_relative(payload.get("relativeParentPath", "")), bool(payload.get("mkdir", True))) + if not overwrite: + try: + os.lstat(basename, dir_fd=parent_fd) + raise FileExistsError(errno.EEXIST, "destination exists", basename) + except FileNotFoundError: + pass + temp_name, temp_fd = create_temp_file(parent_fd, basename, mode) + written_bytes = 0 + while True: + chunk = os.read(source_fd, 65536) + if not chunk: + break + written_bytes += len(chunk) + if max_bytes >= 0 and written_bytes > max_bytes: + raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, written_bytes)) + view = memoryview(chunk) + while view: + written = os.write(temp_fd, view) + if written <= 0: + raise OSError(errno.EIO, "short write") + view = view[written:] + os.fsync(temp_fd) + os.close(temp_fd) + temp_fd = None + os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) + temp_name = None + os.fsync(parent_fd) + result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False) + return {"dev": result_stat.st_dev, "ino": result_stat.st_ino} + finally: + os.close(source_fd) + if temp_fd is not None: + os.close(temp_fd) + if temp_name is not None and parent_fd is not None: + try: + os.unlink(temp_name, dir_fd=parent_fd) + except FileNotFoundError: + pass + if parent_fd is not None: + os.close(parent_fd) + +def run_operation(operation, root_path, payload): + root_fd = open_dir(root_path) + try: + if operation == "stat": + return stat_path(root_fd, payload) + if operation == "readdir": + return readdir_path(root_fd, payload) + if operation == "mkdirp": + return mkdirp_path(root_fd, payload) + if operation == "remove": + return remove_path(root_fd, payload) + if operation == "rename": + return rename_path(root_fd, payload) + if operation == "write": + return write_path(root_fd, payload) + if operation == "copy": + return copy_path(root_fd, payload) + raise RuntimeError("unknown operation: " + operation) + finally: + os.close(root_fd) + +for line in sys.stdin: + try: + request = json.loads(line) + result = run_operation(request["operation"], request["rootPath"], request.get("payload") or {}) + response = {"id": request["id"], "ok": True, "result": result} + except Exception as exc: + response = { + "id": request.get("id") if isinstance(locals().get("request"), dict) else None, + "ok": False, + "code": exc.__class__.__name__, + "errno": getattr(exc, "errno", None), + "message": str(exc), + } + print(json.dumps(response, separators=(",", ":")), flush=True) +`; + +type PinnedPythonOperation = + | "copy" + | "stat" + | "readdir" + | "mkdirp" + | "remove" + | "rename" + | "write"; + +type PendingRequest = { + reject(error: unknown): void; + resolve(value: unknown): void; +}; + +type PinnedPythonWorker = { + child: ChildProcessWithoutNullStreams; + pending: Map; + stderr: string; + stdoutBuffer: string; +}; + +let nextRequestId = 1; +let worker: PinnedPythonWorker | null = null; + +export function __resetPinnedPythonWorkerForTest(): void { + const currentWorker = worker; + worker = null; + if (!currentWorker) { + return; + } + currentWorker.pending.clear(); + currentWorker.child.kill("SIGTERM"); +} + +const PYTHON_CANDIDATE_DEFAULTS = [ + "/usr/bin/python3", + "/opt/homebrew/bin/python3", + "/usr/local/bin/python3", +]; + +function canExecute(binPath: string): boolean { + try { + fsSync.accessSync(binPath, fsSync.constants.X_OK); + return true; + } catch { + return false; + } +} + +function resolvePython(): string { + const configured = getFsSafePythonConfig().pythonPath; + if (configured) { + return configured; + } + for (const candidate of PYTHON_CANDIDATE_DEFAULTS) { + if (canExecute(candidate)) { + return candidate; + } + } + return "python3"; +} + +function assertPinnedHelperSupported(): void { + if (process.platform === "win32") { + throw new FsSafeError( + "unsupported-platform", + "fd-relative pinned filesystem operations are not available on Windows", + ); + } + if (getFsSafePythonConfig().mode === "off") { + throw new FsSafeError("helper-unavailable", "Python helper is disabled"); + } +} + +function isSpawnUnavailable(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const maybeErrno = error as NodeJS.ErrnoException; + return ( + typeof maybeErrno.syscall === "string" && + maybeErrno.syscall.startsWith("spawn") && + ["EACCES", "ENOENT", "ENOEXEC"].includes(maybeErrno.code ?? "") + ); +} + +function mapWorkerError(response: Record): Error { + const code = typeof response.code === "string" ? response.code : ""; + const errno = typeof response.errno === "number" ? response.errno : undefined; + const message = + typeof response.message === "string" && response.message + ? response.message + : "pinned helper failed"; + const tooLarge = message.match(/fs-safe-too-large:(\d+):(\d+)/); + if (tooLarge) { + const [, limit, got] = tooLarge; + return new FsSafeError("too-large", `file exceeds limit of ${limit} bytes (got at least ${got})`); + } + if (message.includes("fs-safe-not-file")) { + return new FsSafeError("not-file", "not a file"); + } + if (message.includes("fs-safe-source-mismatch")) { + return new FsSafeError("path-mismatch", "source path changed during copy"); + } + if (code === "FileNotFoundError" || errno === 2) { + return new FsSafeError("not-found", "file not found"); + } + if (code === "FileExistsError" || errno === 17) { + return new FsSafeError("already-exists", message); + } + if (errno === 39) { + return new FsSafeError("not-empty", "directory is not empty"); + } + if (errno === 1 || errno === 13 || errno === 21) { + return new FsSafeError("not-removable", "path is not removable under root"); + } + if (code === "NotADirectoryError" || code === "OSError" || errno === 20 || errno === 40) { + return new FsSafeError("path-alias", message); + } + return new FsSafeError("helper-failed", message); +} + +function rejectPending(error: Error): void { + if (!worker) { + return; + } + setWorkerRef(worker, false); + for (const pending of worker.pending.values()) { + pending.reject(error); + } + worker.pending.clear(); + worker = null; +} + +function handleWorkerLine(line: string): void { + if (!worker || !line.trim()) { + return; + } + let decoded: unknown; + try { + decoded = JSON.parse(line) as unknown; + } catch { + rejectPending(new FsSafeError("helper-failed", `pinned helper returned invalid JSON: ${line}`)); + return; + } + if (typeof decoded !== "object" || decoded === null || !("id" in decoded)) { + rejectPending(new FsSafeError("helper-failed", "pinned helper returned invalid response")); + return; + } + const response = decoded as { id?: unknown; ok?: unknown; result?: unknown }; + const id = typeof response.id === "number" ? response.id : undefined; + if (id === undefined) { + return; + } + const pending = worker.pending.get(id); + if (!pending) { + return; + } + worker.pending.delete(id); + if (worker.pending.size === 0) { + setWorkerRef(worker, false); + } + if (response.ok === true) { + pending.resolve(response.result); + return; + } + pending.reject(mapWorkerError(decoded as Record)); +} + +function getWorker() { + assertPinnedHelperSupported(); + if (worker) { + return worker; + } + const child = spawn(resolvePython(), ["-u", "-c", PINNED_PYTHON_WORKER_SOURCE], { + stdio: ["pipe", "pipe", "pipe"], + }); + worker = { child, pending: new Map(), stderr: "", stdoutBuffer: "" }; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + const current = worker; + if (!current) { + return; + } + current.stdoutBuffer += chunk; + for (;;) { + const newline = current.stdoutBuffer.indexOf("\n"); + if (newline < 0) { + break; + } + const line = current.stdoutBuffer.slice(0, newline); + current.stdoutBuffer = current.stdoutBuffer.slice(newline + 1); + handleWorkerLine(line); + } + }); + child.stderr.on("data", (chunk: string) => { + if (worker) { + worker.stderr = `${worker.stderr}${chunk}`.slice(-4096); + } + }); + child.once("error", (error) => { + const mapped = isSpawnUnavailable(error) + ? new FsSafeError("helper-unavailable", "Python helper is unavailable", { cause: error }) + : error instanceof Error + ? error + : new Error(String(error)); + rejectPending(mapped); + }); + child.once("close", (code, signal) => { + const stderr = worker?.stderr.trim(); + rejectPending( + new FsSafeError( + "helper-failed", + stderr || `pinned helper exited with code ${code ?? "null"} (${signal ?? "?"})`, + ), + ); + }); + process.once("exit", () => { + child.kill("SIGTERM"); + }); + setWorkerRef(worker, false); + return worker; +} + +function setRefable(value: unknown, ref: boolean): void { + if (!value) { + return; + } + const method = ref ? "ref" : "unref"; + const refable = value as { ref?: () => void; unref?: () => void }; + refable[method]?.(); +} + +function setWorkerRef(currentWorker: PinnedPythonWorker, ref: boolean): void { + setRefable(currentWorker.child, ref); + setRefable(currentWorker.child.stdin, ref); + setRefable(currentWorker.child.stdout, ref); + setRefable(currentWorker.child.stderr, ref); +} + +export async function runPinnedPythonOperation(params: { + operation: PinnedPythonOperation; + rootPath: string; + payload: Record; +}): Promise { + const requestId = nextRequestId++; + const currentWorker = getWorker(); + if (typeof currentWorker.child.stdin?.write !== "function") { + throw new FsSafeError("helper-unavailable", "Python helper stdin is unavailable"); + } + setWorkerRef(currentWorker, true); + return await new Promise((resolve, reject) => { + currentWorker.pending.set(requestId, { + reject, + resolve: (value) => resolve(value as T), + }); + const request = JSON.stringify({ + id: requestId, + operation: params.operation, + rootPath: params.rootPath, + payload: params.payload, + }); + currentWorker.child.stdin.write(`${request}\n`, (error) => { + if (error) { + currentWorker.pending.delete(requestId); + if (currentWorker.pending.size === 0) { + setWorkerRef(currentWorker, false); + } + reject(error); + } + }); + }); +} + +export function assertPinnedPythonOperationAvailable(): void { + const currentWorker = getWorker(); + if (typeof currentWorker.child.stdin?.write !== "function") { + throw new FsSafeError("helper-unavailable", "Python helper stdin is unavailable"); + } +} + +export function validatePinnedOperationPayload(payload: Record): void { + if (typeof payload.relativePath === "string") { + validatePinnedRelativePath(payload.relativePath); + } + if (typeof payload.relativeParentPath === "string") { + validatePinnedRelativePath(payload.relativeParentPath); + } + if (typeof payload.from === "string") { + validatePinnedRelativePath(payload.from); + } + if (typeof payload.to === "string") { + validatePinnedRelativePath(payload.to); + } +} + +export function isPinnedHelperUnavailable(error: unknown): boolean { + return error instanceof Error && + "code" in error && + (error as { code?: unknown }).code === "helper-unavailable"; +} + +function validatePinnedRelativePath(relativePath: string): void { + if (relativePath.length === 0 || relativePath === ".") { + return; + } + if (relativePath.includes("\0")) { + throw new FsSafeError("invalid-path", "relative path contains a NUL byte"); + } + if ( + relativePath.startsWith("/") || + relativePath.startsWith("//") || + relativePath === ".." || + relativePath.startsWith("../") || + relativePath.startsWith("..\\") + ) { + throw new FsSafeError("invalid-path", "relative path must not escape root"); + } + for (const segment of relativePath.split("/")) { + if (segment === "..") { + throw new FsSafeError("invalid-path", "relative path must not contain '..'"); + } + } +} diff --git a/src/pinned-write.ts b/src/pinned-write.ts index ba4b592..7a9afb2 100644 --- a/src/pinned-write.ts +++ b/src/pinned-write.ts @@ -1,5 +1,3 @@ -import { spawn } from "node:child_process"; -import { once } from "node:events"; import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -7,6 +5,12 @@ import { Transform, type Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { FsSafeError } from "./errors.js"; import type { FileIdentityStat } from "./file-identity.js"; +import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js"; +import { + assertPinnedPythonOperationAvailable, + runPinnedPythonOperation, + validatePinnedOperationPayload, +} from "./pinned-python.js"; type PinnedWriteInput = | { kind: "buffer"; data: string | Buffer; encoding?: BufferEncoding } @@ -18,6 +22,18 @@ function byteLength(input: string | Buffer, encoding: BufferEncoding | undefined : input.byteLength; } +function assertSafeBasename(basename: string): void { + if ( + !basename || + basename === "." || + basename === ".." || + basename.includes("/") || + basename.includes("\0") + ) { + throw new FsSafeError("invalid-path", "invalid target path"); + } +} + function assertWithinMaxBytes(bytes: number, maxBytes: number | undefined): void { if (maxBytes !== undefined && bytes > maxBytes) { throw new FsSafeError( @@ -62,161 +78,27 @@ async function pipelineWithMaxBytes( await pipeline(stream, destination); } -const LOCAL_PINNED_WRITE_PYTHON = [ - "import errno", - "import os", - "import secrets", - "import stat", - "import sys", - "", - "root_path = sys.argv[1]", - "relative_parent = sys.argv[2]", - "basename = sys.argv[3]", - 'mkdir_enabled = sys.argv[4] == "1"', - "file_mode = int(sys.argv[5], 8)", - 'overwrite_enabled = sys.argv[6] == "1"', - "max_bytes = int(sys.argv[7])", - "", - "DIR_FLAGS = os.O_RDONLY", - "if hasattr(os, 'O_DIRECTORY'):", - " DIR_FLAGS |= os.O_DIRECTORY", - "if hasattr(os, 'O_NOFOLLOW'):", - " DIR_FLAGS |= os.O_NOFOLLOW", - "", - "WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL", - "if hasattr(os, 'O_NOFOLLOW'):", - " WRITE_FLAGS |= os.O_NOFOLLOW", - "", - "def open_dir(path_value, dir_fd=None):", - " return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)", - "", - "def walk_parent(root_fd, rel_parent, mkdir_enabled):", - " current_fd = os.dup(root_fd)", - " try:", - " for segment in [part for part in rel_parent.split('/') if part and part != '.']:", - " if segment == '..':", - " raise OSError(errno.EPERM, 'path traversal is not allowed', segment)", - " try:", - " next_fd = open_dir(segment, dir_fd=current_fd)", - " except FileNotFoundError:", - " if not mkdir_enabled:", - " raise", - " os.mkdir(segment, 0o777, dir_fd=current_fd)", - " next_fd = open_dir(segment, dir_fd=current_fd)", - " os.close(current_fd)", - " current_fd = next_fd", - " return current_fd", - " except Exception:", - " os.close(current_fd)", - " raise", - "", - "def create_temp_file(parent_fd, basename, mode):", - " prefix = '.' + basename + '.'", - " for _ in range(128):", - " candidate = prefix + secrets.token_hex(6) + '.tmp'", - " try:", - " fd = os.open(candidate, WRITE_FLAGS, mode, dir_fd=parent_fd)", - " return candidate, fd", - " except FileExistsError:", - " continue", - " raise RuntimeError('failed to allocate pinned temp file')", - "", - "root_fd = open_dir(root_path)", - "parent_fd = None", - "temp_fd = None", - "temp_name = None", - "try:", - " parent_fd = walk_parent(root_fd, relative_parent, mkdir_enabled)", - " temp_name, temp_fd = create_temp_file(parent_fd, basename, file_mode)", - " written_bytes = 0", - " while True:", - " chunk = sys.stdin.buffer.read(65536)", - " if not chunk:", - " break", - " next_size = written_bytes + len(chunk)", - " if max_bytes >= 0 and next_size > max_bytes:", - " raise RuntimeError(f'fs-safe-too-large:{max_bytes}:{next_size}')", - " view = memoryview(chunk)", - " while view:", - " written = os.write(temp_fd, view)", - " if written <= 0:", - " raise OSError(errno.EIO, 'short write')", - " view = view[written:]", - " written_bytes = next_size", - " os.fsync(temp_fd)", - " os.close(temp_fd)", - " temp_fd = None", - " if overwrite_enabled:", - " os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)", - " temp_name = None", - " else:", - " os.link(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)", - " os.unlink(temp_name, dir_fd=parent_fd)", - " temp_name = None", - " os.fsync(parent_fd)", - " result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)", - " print(f'{result_stat.st_dev}|{result_stat.st_ino}')", - "finally:", - " if temp_fd is not None:", - " os.close(temp_fd)", - " if temp_name is not None and parent_fd is not None:", - " try:", - " os.unlink(temp_name, dir_fd=parent_fd)", - " except FileNotFoundError:", - " pass", - " if parent_fd is not None:", - " os.close(parent_fd)", - " os.close(root_fd)", -].join("\n"); - -const PINNED_WRITE_PYTHON_CANDIDATES = [ - process.env.OPENCLAW_PINNED_WRITE_PYTHON, - "/usr/bin/python3", - "/opt/homebrew/bin/python3", - "/usr/local/bin/python3", -].filter((value): value is string => Boolean(value)); - -let cachedPinnedWritePython = ""; - -function canExecute(binPath: string): boolean { - try { - fsSync.accessSync(binPath, fsSync.constants.X_OK); - return true; - } catch { - return false; +async function inputToBase64( + input: PinnedWriteInput, + maxBytes: number | undefined, +): Promise { + if (input.kind === "buffer") { + assertWithinMaxBytes(byteLength(input.data, input.encoding), maxBytes); + return ( + typeof input.data === "string" + ? Buffer.from(input.data, input.encoding ?? "utf8") + : input.data + ).toString("base64"); } -} - -function resolvePinnedWritePython(): string { - if (cachedPinnedWritePython) { - return cachedPinnedWritePython; + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of input.stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); + bytes += buffer.byteLength; + assertWithinMaxBytes(bytes, maxBytes); + chunks.push(buffer); } - for (const candidate of PINNED_WRITE_PYTHON_CANDIDATES) { - if (canExecute(candidate)) { - cachedPinnedWritePython = candidate; - return cachedPinnedWritePython; - } - } - cachedPinnedWritePython = "python3"; - return cachedPinnedWritePython; -} - -function parsePinnedIdentity(stdout: string): FileIdentityStat { - const line = stdout - .trim() - .split(/\r?\n/) - .map((value) => value.trim()) - .findLast(Boolean); - if (!line) { - throw new Error("Pinned write helper returned no identity"); - } - const [devRaw, inoRaw] = line.split("|"); - const dev = Number.parseInt(devRaw ?? "", 10); - const ino = Number.parseInt(inoRaw ?? "", 10); - if (!Number.isFinite(dev) || !Number.isFinite(ino)) { - throw new Error(`Pinned write helper returned invalid identity: ${line}`); - } - return { dev, ino }; + return Buffer.concat(chunks, bytes).toString("base64"); } export async function runPinnedWriteHelper(params: { @@ -229,80 +111,78 @@ export async function runPinnedWriteHelper(params: { maxBytes?: number; input: PinnedWriteInput; }): Promise { - const child = spawn( - resolvePinnedWritePython(), - [ - "-c", - LOCAL_PINNED_WRITE_PYTHON, - params.rootPath, - params.relativeParentPath, - params.basename, - params.mkdir ? "1" : "0", - (params.mode || 0o600).toString(8), - params.overwrite === false ? "0" : "1", - params.maxBytes === undefined ? "-1" : String(params.maxBytes), - ], - { - stdio: ["pipe", "pipe", "pipe"], - }, - ); - - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding?.("utf8"); - child.stderr.setEncoding?.("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; + if (getFsSafePythonConfig().mode === "off") { + return await runPinnedWriteFallback(params); + } + assertSafeBasename(params.basename); + validatePinnedOperationPayload({ + relativeParentPath: params.relativeParentPath, }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - - const exitPromise = once(child, "close") as Promise<[number | null, NodeJS.Signals | null]>; - try { - if (!child.stdin) { - const identity = await runPinnedWriteFallback(params); - await exitPromise.catch(() => {}); - return identity; - } - - if (params.input.kind === "buffer") { - const input = params.input; - await new Promise((resolve, reject) => { - child.stdin.once("error", reject); - if (typeof input.data === "string") { - child.stdin.end(input.data, input.encoding ?? "utf8", () => resolve()); - return; - } - child.stdin.end(input.data, () => resolve()); - }); - } else { - await pipeline(params.input.stream, child.stdin); - } - - const [code, signal] = await exitPromise; - if (code !== 0) { - const tooLarge = stderr.match(/fs-safe-too-large:(\d+):(\d+)/); - if (tooLarge) { - const [, limit, got] = tooLarge; - throw new FsSafeError( - "too-large", - `file exceeds limit of ${limit} bytes (got at least ${got})`, - ); + if (params.input.kind === "stream") { + try { + assertPinnedPythonOperationAvailable(); + } catch (error) { + if (canFallbackFromPythonError(error)) { + return await runPinnedWriteFallback(params); } - throw new Error( - stderr.trim() || - `Pinned write helper failed with code ${code ?? "null"} (${signal ?? "?"})`, - ); + throw error; } - return parsePinnedIdentity(stdout); + } + const payload = { + base64: await inputToBase64(params.input, params.maxBytes), + basename: params.basename, + maxBytes: params.maxBytes ?? -1, + mkdir: params.mkdir, + mode: params.mode || 0o600, + overwrite: params.overwrite !== false, + relativeParentPath: params.relativeParentPath, + }; + try { + return await runPinnedPythonOperation({ + operation: "write", + rootPath: params.rootPath, + payload, + }); } catch (error) { - child.kill("SIGKILL"); - await exitPromise.catch(() => {}); + if (canFallbackFromPythonError(error)) { + return await runPinnedWriteFallback(params); + } throw error; } } +export async function runPinnedCopyHelper(params: { + rootPath: string; + relativeParentPath: string; + basename: string; + mkdir: boolean; + mode: number; + overwrite?: boolean; + maxBytes?: number; + sourcePath: string; + sourceIdentity: FileIdentityStat; +}): Promise { + assertSafeBasename(params.basename); + validatePinnedOperationPayload({ + relativeParentPath: params.relativeParentPath, + }); + return await runPinnedPythonOperation({ + operation: "copy", + rootPath: params.rootPath, + payload: { + basename: params.basename, + maxBytes: params.maxBytes ?? -1, + mkdir: params.mkdir, + mode: params.mode || 0o600, + overwrite: params.overwrite !== false, + relativeParentPath: params.relativeParentPath, + sourceDev: params.sourceIdentity.dev, + sourceIno: params.sourceIdentity.ino, + sourcePath: params.sourcePath, + }, + }); +} + async function runPinnedWriteFallback(params: { rootPath: string; relativeParentPath: string; diff --git a/src/root.ts b/src/root.ts index fce9c74..b961bce 100644 --- a/src/root.ts +++ b/src/root.ts @@ -10,7 +10,8 @@ import { pipeline } from "node:stream/promises"; import { FsSafeError } from "./errors.js"; import { sameFileIdentity } from "./file-identity.js"; import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js"; -import { runPinnedWriteHelper } from "./pinned-write.js"; +import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js"; +import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js"; import { expandHomePrefix } from "./home-dir.js"; import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js"; import { @@ -19,7 +20,11 @@ import { isPathInside, isSymlinkOpenError, } from "./path.js"; -import { helperReaddir, helperStat, runPinnedHelper } from "./pinned-helper.js"; +import { + helperReaddir, + helperStat, + runPinnedHelper, +} from "./pinned-helper.js"; import { resolveRootPath } from "./root-path.js"; import { getFsSafeTestHooks } from "./test-hooks.js"; import type { DirEntry, PathStat } from "./types.js"; @@ -567,7 +572,14 @@ class RootHandle implements Root { } async stat(relativePath: string): Promise { - return await helperStat(this.rootReal, relativePath); + try { + return await helperStat(this.rootReal, relativePath); + } catch (error) { + if (canFallbackFromPythonError(error)) { + return await statPathFallback(this.context, relativePath); + } + throw error; + } } async list(relativePath: string, options?: { withFileTypes?: false }): Promise; @@ -576,9 +588,16 @@ class RootHandle implements Root { relativePath: string, options: { withFileTypes?: boolean } = {}, ): Promise { - return options.withFileTypes === true - ? await helperReaddir(this.rootReal, relativePath, true) - : await helperReaddir(this.rootReal, relativePath, false); + try { + return options.withFileTypes === true + ? await helperReaddir(this.rootReal, relativePath, true) + : await helperReaddir(this.rootReal, relativePath, false); + } catch (error) { + if (canFallbackFromPythonError(error)) { + return await listPathFallback(this.context, relativePath, options.withFileTypes === true); + } + throw error; + } } async move( @@ -586,11 +605,23 @@ class RootHandle implements Root { toRelative: string, options: { overwrite?: boolean } = {}, ): Promise { - await runPinnedHelper("rename", this.rootReal, { - from: fromRelative, - overwrite: options.overwrite ?? false, - to: toRelative, - }); + try { + await runPinnedHelper("rename", this.rootReal, { + from: fromRelative, + overwrite: options.overwrite ?? false, + to: toRelative, + }); + } catch (error) { + if (canFallbackFromPythonError(error)) { + await movePathFallback(this.context, { + fromRelative, + overwrite: options.overwrite ?? false, + toRelative, + }); + return; + } + throw error; + } } } @@ -1213,22 +1244,30 @@ async function copyFileInRoot( const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode); await serializePathWrite(pinned.targetPath, async () => { - const sourceStream = createBoundedReadStream(source, params.maxBytes); - const identity = await runPinnedWriteHelper({ - rootPath: pinned.rootReal, - relativeParentPath: pinned.relativeParentPath, - basename: pinned.basename, - mkdir: params.mkdir !== false, - mode: pinned.mode, - overwrite: true, - maxBytes: params.maxBytes, - input: { - kind: "stream", - stream: sourceStream, - }, - }).catch((error) => { + let identity; + try { + if (getFsSafePythonConfig().mode === "off") { + await copyFileFallback(root, params, source); + return; + } + identity = await runPinnedCopyHelper({ + rootPath: pinned.rootReal, + relativeParentPath: pinned.relativeParentPath, + basename: pinned.basename, + mkdir: params.mkdir !== false, + mode: pinned.mode, + overwrite: true, + maxBytes: params.maxBytes, + sourcePath: source.realPath, + sourceIdentity: { dev: source.stat.dev, ino: source.stat.ino }, + }); + } catch (error) { + if (canFallbackFromPythonError(error)) { + await copyFileFallback(root, params, source); + return; + } throw normalizePinnedWriteError(error); - }); + } try { await verifyAtomicWriteResult({ root, @@ -1428,6 +1467,145 @@ async function mkdirPathFallback(resolved: { resolved: string }): Promise await fs.mkdir(resolved.resolved, { recursive: true }); } +function pathStatFromStats(stat: Stats): PathStat { + return { + dev: Number(stat.dev), + gid: Number(stat.gid), + ino: Number(stat.ino), + isDirectory: stat.isDirectory(), + isFile: stat.isFile(), + isSymbolicLink: stat.isSymbolicLink(), + mode: stat.mode, + mtimeMs: stat.mtimeMs, + nlink: stat.nlink, + size: stat.size, + uid: stat.uid, + }; +} + +async function statPathFallback(root: RootContext, relativePath: string): Promise { + const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true }); + try { + return pathStatFromStats(await fs.lstat(resolved.resolved)); + } catch (error) { + if (isNotFoundPathError(error)) { + throw new FsSafeError("not-found", "file not found", { + cause: error instanceof Error ? error : undefined, + }); + } + throw error; + } +} + +async function listPathFallback( + root: RootContext, + relativePath: string, + withFileTypes: boolean, +): Promise { + const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true }); + try { + const names = await fs.readdir(resolved.resolved); + const sortedNames = names.toSorted(); + if (!withFileTypes) { + return sortedNames; + } + const entries: DirEntry[] = []; + for (const name of sortedNames) { + entries.push({ + name, + ...pathStatFromStats(await fs.lstat(path.join(resolved.resolved, name))), + }); + } + return entries; + } catch (error) { + if (isNotFoundPathError(error)) { + throw new FsSafeError("not-found", "directory not found", { + cause: error instanceof Error ? error : undefined, + }); + } + throw error; + } +} + +async function movePathFallback( + root: RootContext, + params: { + fromRelative: string; + toRelative: string; + overwrite: boolean; + }, +): Promise { + const source = await resolvePathInRoot(root, params.fromRelative); + await resolvePinnedRootPathInRoot(root, { + relativePath: params.fromRelative, + policy: PATH_ALIAS_POLICIES.strict, + }); + const target = await resolvePathInRoot(root, params.toRelative); + await resolvePinnedRootPathInRoot(root, { + relativePath: params.toRelative, + policy: PATH_ALIAS_POLICIES.unlinkTarget, + }); + try { + await assertNoPathAliasEscape({ + absolutePath: target.resolved, + rootPath: target.rootReal, + boundaryLabel: "root", + }); + } catch (error) { + throw new FsSafeError("path-alias", "path alias escape blocked", { + cause: error instanceof Error ? error : undefined, + }); + } + + let sourceStat: Stats; + try { + sourceStat = await fs.lstat(source.resolved); + } catch (error) { + if (isNotFoundPathError(error)) { + throw new FsSafeError("not-found", "file not found", { + cause: error instanceof Error ? error : undefined, + }); + } + throw error; + } + if (sourceStat.isSymbolicLink()) { + throw new FsSafeError("symlink", "symlink not allowed"); + } + if (sourceStat.isFile() && sourceStat.nlink > 1) { + throw new FsSafeError("hardlink", "hardlinked path not allowed"); + } + + if (!params.overwrite) { + try { + await fs.lstat(target.resolved); + throw new FsSafeError("already-exists", "destination exists"); + } catch (error) { + if (error instanceof FsSafeError) { + throw error; + } + if (!isNotFoundPathError(error)) { + throw error; + } + } + } + + try { + await fs.rename(source.resolved, target.resolved); + } catch (error) { + if (isNotFoundPathError(error)) { + throw new FsSafeError("not-found", "file not found", { + cause: error instanceof Error ? error : undefined, + }); + } + if (hasNodeErrorCode(error, "EEXIST")) { + throw new FsSafeError("already-exists", "destination exists", { + cause: error instanceof Error ? error : undefined, + }); + } + throw error; + } +} + async function writeFileFallback( root: RootContext, params: { diff --git a/test/fs-safe.test.ts b/test/fs-safe.test.ts index 06c0eae..5ffb5fd 100644 --- a/test/fs-safe.test.ts +++ b/test/fs-safe.test.ts @@ -1,9 +1,9 @@ import { appendFileSync } from "node:fs"; -import { mkdtemp, readdir, readFile, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { FsSafeError, root as openRoot } from "../src/index.js"; +import { configureFsSafePython, FsSafeError, root as openRoot } from "../src/index.js"; import { __setFsSafeTestHooksForTest } from "../src/test-hooks.js"; const tempDirs: string[] = []; @@ -15,6 +15,7 @@ async function tempRoot(prefix: string): Promise { } afterEach(async () => { + configureFsSafePython({ mode: "auto", pythonPath: undefined }); __setFsSafeTestHooksForTest(undefined); const { rm } = await import("node:fs/promises"); await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); @@ -49,6 +50,25 @@ describe("@openclaw/fs-safe", () => { }); }); + it("can disable the Python helper and keep root operations available", async () => { + configureFsSafePython({ mode: "off" }); + const rootPath = await tempRoot("fs-safe-python-off-"); + const sourceRoot = await tempRoot("fs-safe-python-off-source-"); + const sourcePath = path.join(sourceRoot, "source.txt"); + const root = await openRoot(rootPath); + await writeFile(sourcePath, "copied"); + + await root.mkdir("nested"); + await root.write("nested/file.txt", "hello"); + await root.copyIn("nested/copied.txt", sourcePath, { maxBytes: 16 }); + await expect(root.stat("nested/file.txt")).resolves.toMatchObject({ isFile: true }); + await expect(root.list("nested")).resolves.toEqual(["copied.txt", "file.txt"]); + await root.move("nested/file.txt", "nested/moved.txt"); + await expect(root.readText("nested/moved.txt")).resolves.toBe("hello"); + await root.remove("nested/copied.txt"); + await expect(root.exists("nested/copied.txt")).resolves.toBe(false); + }); + it("applies per-root defaults", async () => { const rootPath = await tempRoot("fs-safe-defaults-"); const root = await openRoot(rootPath, { @@ -204,13 +224,7 @@ describe("@openclaw/fs-safe", () => { if (filePath !== sourcePath) { return; } - const createReadStream = handle.createReadStream.bind(handle); - Object.defineProperty(handle, "createReadStream", { - value: (...args: Parameters) => { - appendFileSync(sourcePath, "567890"); - return createReadStream(...args); - }, - }); + appendFileSync(sourcePath, "567890"); }, }); @@ -221,6 +235,43 @@ describe("@openclaw/fs-safe", () => { await expect(readdir(rootPath)).resolves.toEqual([]); }); + it("rejects pinned copy when the source path is swapped after identity capture", async () => { + if (process.platform === "win32") { + return; + } + const { runPinnedCopyHelper } = await import("../src/pinned-write.js"); + const rootPath = await tempRoot("fs-safe-copy-source-swap-root-"); + const sourceRoot = await tempRoot("fs-safe-copy-source-swap-source-"); + const sourcePath = path.join(sourceRoot, "source.txt"); + await writeFile(sourcePath, "original"); + const sourceIdentity = await stat(sourcePath); + await rm(sourcePath); + await writeFile(sourcePath, "replacement"); + + try { + await runPinnedCopyHelper({ + rootPath, + relativeParentPath: "", + basename: "copied.txt", + mkdir: true, + mode: 0o600, + overwrite: true, + maxBytes: 1024, + sourcePath, + sourceIdentity: { dev: sourceIdentity.dev, ino: sourceIdentity.ino }, + }); + throw new Error("expected pinned copy source swap to fail"); + } catch (error) { + if (error instanceof FsSafeError && error.code === "helper-unavailable") { + return; + } + expect(error).toMatchObject({ code: "path-mismatch" }); + } + await expect(stat(path.join(rootPath, "copied.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it("removes symlink leaves without following them", async () => { const rootPath = await tempRoot("fs-safe-remove-"); const root = await openRoot(rootPath); diff --git a/test/pinned-python.test.ts b/test/pinned-python.test.ts new file mode 100644 index 0000000..9d0dd27 --- /dev/null +++ b/test/pinned-python.test.ts @@ -0,0 +1,231 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configureFsSafePython, root } from "../src/index.js"; +import { + canFallbackFromPythonError, + getFsSafePythonConfig, +} from "../src/pinned-python-config.js"; +import { + __resetPinnedPythonWorkerForTest, + runPinnedPythonOperation, +} from "../src/pinned-python.js"; +import * as configSubpath from "../src/config.js"; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +type FakeChild = EventEmitter & { + kill: ReturnType; + ref: ReturnType; + stderr: EventEmitter & { + ref: ReturnType; + setEncoding: ReturnType; + unref: ReturnType; + }; + stdin: EventEmitter & { + ref: ReturnType; + unref: ReturnType; + write: ReturnType; + }; + stdout: EventEmitter & { + ref: ReturnType; + setEncoding: ReturnType; + unref: ReturnType; + }; + unref: ReturnType; +}; + +const tempDirs = new Set(); +const originalEnv = { + FS_SAFE_PYTHON: process.env.FS_SAFE_PYTHON, + FS_SAFE_PYTHON_MODE: process.env.FS_SAFE_PYTHON_MODE, + OPENCLAW_FS_SAFE_PYTHON: process.env.OPENCLAW_FS_SAFE_PYTHON, + OPENCLAW_FS_SAFE_PYTHON_MODE: process.env.OPENCLAW_FS_SAFE_PYTHON_MODE, + OPENCLAW_PINNED_PYTHON: process.env.OPENCLAW_PINNED_PYTHON, + OPENCLAW_PINNED_WRITE_PYTHON: process.env.OPENCLAW_PINNED_WRITE_PYTHON, +}; + +function restoreEnv(): void { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +async function tempRoot(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.add(dir); + return dir; +} + +function makeChild( + write?: (line: string, callback?: (error?: Error | null) => void) => void, +): FakeChild { + const child = new EventEmitter() as FakeChild; + child.ref = vi.fn(); + child.unref = vi.fn(); + child.kill = vi.fn(); + child.stdout = Object.assign(new EventEmitter(), { + ref: vi.fn(), + setEncoding: vi.fn(), + unref: vi.fn(), + }); + child.stderr = Object.assign(new EventEmitter(), { + ref: vi.fn(), + setEncoding: vi.fn(), + unref: vi.fn(), + }); + child.stdin = Object.assign(new EventEmitter(), { + ref: vi.fn(), + unref: vi.fn(), + write: vi.fn((line: string, callback?: (error?: Error | null) => void) => { + write?.(line, callback); + callback?.(); + return true; + }), + }); + return child; +} + +function makeRespondingChild(): FakeChild { + const child = makeChild((line) => { + const request = JSON.parse(line) as { id: number }; + queueMicrotask(() => { + child.stdout.emit( + "data", + `${JSON.stringify({ id: request.id, ok: true, result: { ok: true } })}\n`, + ); + }); + }); + return child; +} + +function makeFailingChild(): FakeChild { + const child = makeChild(); + queueMicrotask(() => { + const error = Object.assign(new Error("spawn ENOENT"), { + code: "ENOENT", + syscall: "spawn python3", + }); + child.emit("error", error); + }); + return child; +} + +afterEach(async () => { + configureFsSafePython({ mode: "auto", pythonPath: undefined }); + __resetPinnedPythonWorkerForTest(); + restoreEnv(); + spawnMock.mockReset(); + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("Python helper configuration", () => { + it("reads environment mode and python path aliases", () => { + delete process.env.FS_SAFE_PYTHON_MODE; + delete process.env.OPENCLAW_FS_SAFE_PYTHON_MODE; + delete process.env.FS_SAFE_PYTHON; + delete process.env.OPENCLAW_FS_SAFE_PYTHON; + delete process.env.OPENCLAW_PINNED_PYTHON; + delete process.env.OPENCLAW_PINNED_WRITE_PYTHON; + + expect(getFsSafePythonConfig()).toEqual({ mode: "auto", pythonPath: undefined }); + + process.env.FS_SAFE_PYTHON_MODE = "off"; + process.env.FS_SAFE_PYTHON = "/tmp/python-a"; + expect(getFsSafePythonConfig()).toEqual({ mode: "off", pythonPath: "/tmp/python-a" }); + + delete process.env.FS_SAFE_PYTHON_MODE; + delete process.env.FS_SAFE_PYTHON; + process.env.OPENCLAW_FS_SAFE_PYTHON_MODE = "required"; + process.env.OPENCLAW_PINNED_WRITE_PYTHON = "/tmp/python-b"; + expect(getFsSafePythonConfig()).toEqual({ + mode: "require", + pythonPath: "/tmp/python-b", + }); + + configureFsSafePython({ mode: "auto", pythonPath: "/tmp/python-c" }); + expect(getFsSafePythonConfig()).toEqual({ mode: "auto", pythonPath: "/tmp/python-c" }); + expect(configSubpath.getFsSafePythonConfig()).toEqual({ + mode: "auto", + pythonPath: "/tmp/python-c", + }); + }); + + it("only allows helper-unavailable fallback outside require mode", () => { + const error = Object.assign(new Error("missing"), { code: "helper-unavailable" }); + + configureFsSafePython({ mode: "auto" }); + expect(canFallbackFromPythonError(error)).toBe(true); + + configureFsSafePython({ mode: "off" }); + expect(canFallbackFromPythonError(error)).toBe(true); + + configureFsSafePython({ mode: "require" }); + expect(canFallbackFromPythonError(error)).toBe(false); + }); +}); + +describe("persistent Python helper worker", () => { + it("reuses one worker and unreferences it while idle", async () => { + const child = makeRespondingChild(); + spawnMock.mockReturnValue(child); + configureFsSafePython({ mode: "auto", pythonPath: "/tmp/fake-python" }); + + await expect( + runPinnedPythonOperation<{ ok: boolean }>({ + operation: "stat", + rootPath: "/tmp/root", + payload: { relativePath: "a.txt" }, + }), + ).resolves.toEqual({ ok: true }); + await expect( + runPinnedPythonOperation<{ ok: boolean }>({ + operation: "stat", + rootPath: "/tmp/root", + payload: { relativePath: "b.txt" }, + }), + ).resolves.toEqual({ ok: true }); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(child.ref).toHaveBeenCalled(); + expect(child.unref).toHaveBeenCalled(); + expect(child.stdin.write).toHaveBeenCalledTimes(2); + }); + + it("falls back in auto mode but fails closed in require mode", async () => { + const rootDir = await tempRoot("fs-safe-python-policy-"); + await fs.writeFile(path.join(rootDir, "file.txt"), "ok"); + + spawnMock.mockImplementation(makeFailingChild); + configureFsSafePython({ mode: "auto", pythonPath: "/tmp/missing-python" }); + const autoRoot = await root(rootDir); + await expect(autoRoot.stat("file.txt")).resolves.toMatchObject({ + isFile: true, + }); + await expect(autoRoot.list("")).resolves.toEqual(["file.txt"]); + await fs.writeFile(path.join(rootDir, "move.txt"), "move"); + await autoRoot.move("move.txt", "moved.txt"); + await expect(fs.readFile(path.join(rootDir, "moved.txt"), "utf8")).resolves.toBe("move"); + + __resetPinnedPythonWorkerForTest(); + spawnMock.mockClear(); + spawnMock.mockImplementation(makeFailingChild); + configureFsSafePython({ mode: "require", pythonPath: "/tmp/missing-python" }); + await expect((await root(rootDir)).stat("file.txt")).rejects.toMatchObject({ + code: "helper-unavailable", + }); + }); +});