ScriptFS creates programmable overlays on local directories. Each source
directory is bind-mounted into a Podman container, exposed through a FUSE
filesystem, exported by Samba, and mounted back on the host.
scriptfs /path/to/config.jsonThe CLI stays in the foreground and unmounts the SMB shares and stops the
container on Ctrl+C.
- Motivation
- Architecture
- Configuration
- Provider API
- Filesystem behavior
- Runtime lifecycle
- Host requirements
- Development
Agents already work effectively with filesystem operations: they can discover directories, search paths, read contextual instructions, and write results. ScriptFS uses that familiar interface to present the exact workspace an agent needs without requiring every resource to exist as a regular repository file.
For example, a ScriptFS overlay can:
- expose only the relevant part of a large repository and hide files that would distract the agent
- add topic-specific
AGENTS.mdfiles to selected directories without modifying or committing them to the source repository - adapt a repository for Claude Code by automatically presenting
AGENTS.mdasCLAUDE.mdand.agents/skills/as.claude/skills/in the mounted view. For example,.agents/skills/code-review/SKILL.mdbecomes available at.claude/skills/code-review/SKILL.md, along with its supporting files, without creating or copying any of these paths into the source repository - override generated configuration or documentation while preserving transparent access to unmatched source files
- expose dynamic systems such as chats, databases, queues, or service APIs as a navigable directory tree
A dynamic tree can provide readable files for queries and messages, writable
files for commands or updates, and an AGENTS.md at its root explaining the
available paths and operations. This lets an agent interact with external data
using the same filesystem tools it already understands. Hide and overlay rules
shape the mounted view, but they should not be treated as a security boundary.
local source directories and provider modules
|
Podman bind mounts
|
ScriptFS overlay inside Linux container
|
Linux FUSE mount
|
Samba SMB share
|
host SMB client and local mount point
ScriptFS uses FUSE inside the Linux container, where /dev/fuse is provided by
the Podman machine. It deliberately does not mount FUSE directly on the host.
In particular, direct FUSE mounting on macOS would require macFUSE and its
invasive system extension, which is disabled by default and requires additional
system configuration and approval.
Samba provides a less invasive host boundary using the SMB clients already available on macOS, Linux, and Windows. This keeps provider and overlay behavior inside one consistent Linux runtime and avoids requiring a platform-specific filesystem driver on every host. The tradeoff is that some metadata, caching, locking, and permission behavior is determined by the host SMB client rather than FUSE alone.
At runtime, ScriptFS bind-mounts source directories, provider project roots, and directory proxy targets into the container. File proxies mount their parent directories so atomic replacement of the target remains visible. Provider project mounts include ancestor dependency directories, preserving ordinary npm, pnpm, and workspace dependency layouts; dependencies must be compatible with the Linux runtime. These mounts are available to trusted provider code, not just the paths exposed in the overlay. The TypeScript overlay resolves each path against ordered rules, falls back to the source when permitted, and serves the resulting filesystem through FUSE. Samba exports each configured overlay as a separate share, and the host adapter mounts those shares at the configured mount points.
{
"$schema": "./node_modules/scriptfs/scriptfs.schema.json",
"filesystems": [
{
"name": "code",
"source": "/path/to/my/codebase",
"mountPoint": "/path/to/mounted-codebase",
"readOnly": false,
"rules": [
{
"match": "components/*/AGENTS.md",
"provider": {
"type": "module",
"module": "./providers/component-agents.mjs",
"export": "componentInstructions",
"options": {
"topic": "frontend components"
}
}
},
{
"match": "GeneratedCatalog/**",
"root": "GeneratedCatalog",
"opaque": true,
"provider": {
"type": "module",
"module": "./providers/catalog.mjs",
"options": {
"endpoint": "https://example.invalid"
}
}
},
{
"match": "GeneratedCatalog/**/action.txt",
"file": {
"mode": 128,
"size": 0,
"sizeMode": "zero",
"seekable": false
},
"provider": {
"type": "module",
"module": "./providers/catalog.mjs",
"export": "actionProvider"
}
},
{
"match": "CurrentSettings.json",
"provider": {
"type": "file",
"path": "./settings.json"
}
},
{
"match": "ReferenceData/**",
"root": "ReferenceData",
"opaque": true,
"provider": {
"type": "directory",
"path": "./reference-data"
}
},
{
"match": "**/*.private",
"hide": true
}
]
}
]
}Paths are resolved relative to the config file. Provider modules may be file
paths or installed package names. match uses picomatch glob syntax. Later
rules win for path operations. Virtual paths, rule roots, and glob patterns use
POSIX / separators, regardless of the host platform. Backslashes in source
and proxy filenames remain literal characters rather than directory separators.
Directory enumeration skips metadata callbacks for hidden or superseded exact
entries.
Literal punctuation such as +, @, and embedded ! is preserved in generated
exact entries and inferred roots; only actual picomatch glob expressions are
treated as patterns. Backslashes in patterns escape characters, including
another backslash.
- A provider rule adds or overrides matching paths.
rootidentifies the provider-relative root for a generated subtree.opaqueprevents fallback to the source when the provider does not return a path or implement an operation.- A hide rule removes matching source or generated paths.
- Rules without
opaquepreserve normal reads and writes for unmatched source paths.
examples/config.json exercises every configuration
field:
- writable and read-only filesystems
- additive, opaque, exact, glob, and hide rules
- default and named module exports with provider options
content,explicit,zero, andunboundedfile-size modes- seekable and non-seekable files
- transparent host file proxies, opaque directory proxies, and merged source/proxy directories
- synthesized ancestors, generated symlinks, and hidden generated entries
- whole-file creation and positional resources with stable identities
- file/directory lifecycle, synchronization, metadata, and shutdown callbacks
- every container setting
Its source and proxy targets are under examples/fixtures, while its generated
mount points are ignored. After building, run it on macOS or Linux with:
pnpm build
node dist/cli.js examples/config.jsonThe example reserves host SMB port 14445; change or remove smbPort if that
port is unavailable. On Windows, use drive letters for mountPoint and set
smbPort to 445.
The providers are deliberately local and require no service credentials. Generated data is in memory and resets when the session stops. Source and proxy writes modify the fixture files on disk. The example's memory-provider sync callbacks acknowledge its current state and log the operation; they do not provide disk durability.
| Mounted path | Demonstrates |
|---|---|
components/Button/AGENTS.md |
Additive instructions from the default export and provider options; no source file is required for access checks. |
GeneratedCatalog/ContentSized.txt |
Read/write whole-file contents with content-derived size. New files directly under GeneratedCatalog use the writeFile creation fallback. |
GeneratedCatalog/FixedSize.bin |
Initially 16 F bytes; positional writes and truncation really update the retained buffer and reported size. |
GeneratedCatalog/CommandSink.txt |
Write-only, zero-sized, non-seekable command sink. Truncating an existing sink does not send an empty command. |
GeneratedCatalog/GeneratedStream.bin |
Unbounded, seekable generated S bytes. Always bound reads; do not use cat, readFile, or copy the whole file. |
GeneratedCatalog/SequentialStream.bin |
Finite, non-seekable generated Q bytes; repeated or skipped offsets fail at the FUSE boundary. |
Tools/Memory |
Synthesized Tools ancestor; a mutable tree with create, mkdir, rename, unlink, rmdir, metadata, stable file/directory handles, and lifecycle logging. |
Tools/Memory/latest |
Generated relative symlink to data.txt. |
*.native |
Native source I/O with custom open/create, sync, and release hooks. |
ProxiedFile.txt, ProxiedDirectory |
Fixed file proxy and opaque writable directory proxy. |
MergedDirectory |
Source-only children remain visible, overlapping children come from the proxy, and new children use their parent directory's backing. |
*.private |
Hidden in both source and generated trees. |
examples/mount-readonly |
Separate read-only share. |
With the CLI running in another terminal, this bounded workflow only changes in-memory data:
cat examples/mount/components/Button/AGENTS.md
printf 'updated\n' > examples/mount/GeneratedCatalog/ContentSized.txt
printf 'run\n' > examples/mount/GeneratedCatalog/CommandSink.txt
node --input-type=module <<'JS'
import { mkdtemp, open, readFile, rename, unlink, rmdir } from "node:fs/promises";
const root = "examples/mount/Tools/Memory";
const directory = await mkdtemp(`${root}/demo-`);
const original = `${directory}/original.txt`;
const renamed = `${directory}/renamed.txt`;
const handle = await open(original, "wx+");
try {
await handle.write(Buffer.from("hello"), 0, 5, 0);
await handle.truncate(3);
await handle.sync();
console.log("size:", (await handle.stat()).size);
} finally {
await handle.close();
}
await rename(original, renamed);
console.log("contents:", await readFile(renamed, "utf8"));
await unlink(renamed);
await rmdir(directory);
const stream = await open("examples/mount/GeneratedCatalog/GeneratedStream.bin", "r");
try {
const bytes = Buffer.alloc(16);
await stream.read(bytes, 0, bytes.length, 100);
console.log("bounded stream sample:", bytes.toString());
} finally {
await stream.close();
}
JSHost SMB clients may impose their own symlink, seek, metadata, and directory-sync semantics. The provider and end-to-end suites also exercise these operations directly through Linux FUSE, including open handles retained after unlink or replacement. Watch the CLI's forwarded logs for memory and source lifecycle callbacks and the memory provider's shutdown notification.
| Key | Required | Default | Meaning |
|---|---|---|---|
$schema |
No | None | JSON Schema path used by editors; ScriptFS ignores it at runtime. |
filesystems |
Yes | None | Non-empty array of source filesystems and their mounted overlay views. |
container |
No | {} |
Runtime image, SMB connection, rebuild, and logging configuration. |
| Key | Required | Default | Meaning |
|---|---|---|---|
name |
Yes | None | SMB share name containing only letters, numbers, _, and -. |
source |
Yes | None | Local source path, resolved relative to the config file. |
mountPoint |
Yes | None | Host mount directory, resolved relative to the config, or a drive letter on Windows. |
readOnly |
No | false |
Mount the source read-only and reject mutations through this overlay. |
rules |
No | [] |
Ordered provider and hide rules. Later matching provider rules win path operations. |
Each filesystem must use a distinct mountPoint and a share name that is unique
case-insensitively, matching SMB name resolution. Share names must not be
global, homes, or printers in any letter case; Samba reserves those
configuration sections.
| Key | Required | Default | Meaning |
|---|---|---|---|
match |
Yes | None | Virtual-path pattern using picomatch glob syntax. |
provider |
Yes | None | Module, host-file proxy, or host-directory proxy used for matching paths. |
root |
No | Static prefix of match |
Virtual subtree root used to calculate context.relativePath and expose generated roots. |
opaque |
No | false |
If true, do not fall back to the source when the provider does not supply a path or operation. |
file |
No | {} |
Default metadata and I/O behavior applied to matching provider files. |
Provider rules add or override matching paths. Rules are evaluated in array order, with later matching provider rules taking precedence for path operations. This allows a broad generated subtree rule to be followed by more specific rules for command files or positional streams.
Source fallback.
For a nonopaque module rule, an implemented getattr returning undefined
for an existing source node selects native source backing for its operations.
Omitting getattr instead keeps content-only and lifecycle-only overlays active.
Content-only overlays.
For existing source files served by custom I/O callbacks without getattr,
rule file defaults override inherited source metadata. Unless the rule specifies
a size or another size policy, readFile supplies the content-derived size,
including when the source file is empty or has a different length. Unspecified
native attributes and identity are retained. Lifecycle-only hooks keep native
file sizing; directories, symlinks, and missing paths do not derive metadata from
file content callbacks.
Creation. New entries use source backing when their parent is a matching source-only directory; exact generated-entry rules and provider-owned directories still dispatch creation to the provider. Lookup errors propagate rather than triggering fallback.
Directory enumeration. Directory enumeration includes the selected directory provider's entries and matching immediate children contributed by later rules, including wildcard rules. Earlier directory providers remain superseded, and hide rules and winning child metadata still determine which entries are visible.
Inferred roots.
An opaque rule's inferred root can expose a missing parent directory even when
the glob does not match the parent itself, such as Generated/*.txt. This
fallback does not override an existing source directory or a directly matching
provider rule. Set root explicitly when the rule should own that directory.
| Key | Required | Value | Meaning |
|---|---|---|---|
match |
Yes | Pattern | Virtual paths to remove from directory listings and lookups. |
hide |
Yes | true |
Identifies the rule as a hide rule. |
A hide rule does not use provider, root, opaque, or file.
| Key | Required | Default | Meaning |
|---|---|---|---|
type |
No | "module" |
Provider kind. It may be omitted when module is present. |
module |
Yes | None | ECMAScript module path relative to the config, or an installed package name. |
export |
No | "default" |
Export containing the provider object. Use this to select a named provider export. |
options |
No | undefined |
Arbitrary JSON-compatible value passed unchanged to every callback as context.options. |
For example, "export": "catalogProvider" loads
export const catalogProvider = ... from the module. The provider can define
its own shape for options; ScriptFS does not interpret that value.
| Key | Required | Value | Meaning |
|---|---|---|---|
type |
Yes | "file" |
Transparently proxy one existing host file. |
path |
Yes | File path | Target resolved relative to the config file and validated at startup. |
File proxies are normally paired with an exact match.
Their targets must be regular files, not symbolic links. Configure the resolved
target path or use a directory proxy when links need to remain visible.
| Key | Required | Value | Meaning |
|---|---|---|---|
type |
Yes | "directory" |
Transparently proxy an existing host directory tree. |
path |
Yes | Directory path | Root resolved relative to the config file and validated at startup. |
Directory proxies normally use a subtree glob such as ReferenceData/** and
set root to the virtual directory exposed by the rule.
| Key | Required | Default | Meaning |
|---|---|---|---|
mode |
No | 420 (0644) |
POSIX permission bits. JSON uses decimal numbers, so 0644 is written as 420. |
size |
No | Mode-dependent | Non-negative logical file size, required for useful explicit behavior. |
sizeMode |
No | explicit when size is set; otherwise content |
How ScriptFS reports the logical size. |
seekable |
No | true |
Whether reads and writes may use non-sequential positions. |
sizeMode accepts:
content: derive size by callingreadFileexplicit: reportsizezero: report zero for command sinks or/dev/null-like filesunbounded: report a large logical size for generated-on-demand positional reads
Provider metadata returned by getattr can override these defaults for an
individual path.
| Key | Required | Default | Meaning |
|---|---|---|---|
image |
No | localhost/scriptfs-runtime:0.0.1 |
Podman runtime image to build or reuse. |
rebuild |
No | false |
Rebuild image before starting even if it already exists. |
smbHost |
No | 127.0.0.1 |
SMB hostname or address used by the host mount adapter. |
smbPort |
No | Podman-assigned loopback port | Fixed host port mapped to the container's SMB port. |
logLevel |
No | info |
silent, info, or debug; debug also logs FUSE operations. |
Windows currently requires smbPort: 445; macOS and Linux can use a fixed
non-privileged port or omit the key.
A rule can transparently proxy an existing host file or directory without a module:
{
"match": "CurrentSettings.json",
"provider": {
"type": "file",
"path": "./settings.json"
}
}{
"match": "ReferenceData/**",
"root": "ReferenceData",
"opaque": true,
"provider": {
"type": "directory",
"path": "./reference-data"
}
}Proxy paths are resolved relative to the config. Startup fails before mounting
if a file target is not a file or a directory target is not a directory.
File-proxy targets that are symbolic links are rejected at startup. If a target
is later replaced by a symlink or directory, fresh lookups fail with
EOPNOTSUPP and directory listings omit it; already-open descriptors retain the
original file. This prevents a host symlink from being resolved against unrelated
paths in the overlay. Replacing the target with another regular file remains
supported. Directory proxies continue to expose their entries' symlinks.
Reads, writes, creates, truncates, renames, and deletions operate directly on
the target. Renames are supported within a directory proxy or within the same
module-provider rule. Renames across provider rules or between a provider and
the source fail with EXDEV rather than silently modifying the wrong target.
A fixed file proxy cannot be deleted or renamed to another virtual path: its configured name is permanent, while its contents remain writable. This also prevents SMB clients from deleting that target before attempting an unsupported replacement rename. Editors using temporary-file-and-rename saves must use in-place writes for those paths.
Opaque directories do not merge source listings. Hide rules also filter generated entries and generated subtree roots.
Providers are ECMAScript modules. defineProvider is an identity helper that
supplies TypeScript inference:
import { defineProvider, directoryMetadata, fileMetadata } from "scriptfs";
const directories = new Map([
["", ["Datasets", "AGENTS.md"]],
["Datasets", ["Batch1"]],
["Datasets/Batch1", ["Record1"]],
["Datasets/Batch1/Record1", ["data.txt", "action.txt"]],
]);
export default defineProvider({
getattr({ relativePath }) {
if (directories.has(relativePath)) return directoryMetadata();
if (relativePath.endsWith("/action.txt")) {
return fileMetadata({ size: 0 });
}
if (relativePath === "AGENTS.md" || relativePath.endsWith(".txt")) {
return fileMetadata();
}
},
readdir({ relativePath }) {
return directories.get(relativePath);
},
readFile({ relativePath }) {
return `Generated contents for ${relativePath}\n`;
},
async writeFile(contents, context) {
await performAction(context.relativePath, contents);
},
});Select a named export and pass arbitrary JSON-compatible options in the configuration:
{
"provider": {
"type": "module",
"module": "./provider.mjs",
"export": "catalogProvider",
"options": {
"catalogName": "Example catalog"
}
}
}type: "module" is optional when module is present. Provider callbacks
receive the configured value as context.options.
Callbacks may be synchronous or asynchronous. Implement the operations your provider needs:
| Purpose | Callbacks |
|---|---|
| Discovery | getattr, readdir, readlink |
| File acquisition and release | open, create, release |
| Whole-file I/O | readFile, writeFile |
| Positional I/O and truncation | read, write, truncate, ftruncate |
| File synchronization | flush, fsync |
| Directory lifecycle | opendir, fsyncdir, releasedir |
| Metadata and access | fgetattr, fsetattr, access, chmod, chown, utimens |
| Namespace changes | mkdir, unlink, rmdir, rename |
Context includes the complete virtual path, provider-relative path, provider options, underlying source path, an abort signal, and operation-specific data. The abort signal fires when the ScriptFS container begins shutting down, allowing providers to cancel pending work.
The native binding's automatic callback timeout is disabled: operations wait for provider callbacks to settle, so slow acquisitions cannot lose their handles before normal release. Bound external requests and honor the shutdown signal: a callback that never settles can block subsequent operations.
Source files and built-in proxies use native descriptors for positional I/O,
descriptor truncation, and fsync/fdatasync, preserving an open file's identity
when its pathname is replaced.
Native backing supports regular files, directories, and symbolic links.
FIFOs, sockets, and device nodes are omitted from directory listings; direct
lookups and file opens fail with EOPNOTSUPP. Native file acquisition also
guards against replacement by a FIFO during open, so unsupported nodes cannot
block unrelated namespace operations.
Lifecycle-only module hooks retain this native source I/O and receive their
own resources in context.handle; source-fallback create hooks must actually
create the source file. Native and provider resources are both released.
For a new whole-file node without a create callback, ScriptFS calls
writeFile(Buffer.alloc(0), context) with previousContents: undefined before
opening it. The provider must make that entry visible to getattr and readdir;
this also persists files that are created and closed without a write.
Implement create when empty writes have command side effects or namespace
creation needs different behavior. Positional-only providers must materialize
new entries in create or a creation-aware open callback; a write callback
alone cannot create a node. Existing command-file truncation remains separate
from writes.
Creation and subsequent handle callbacks receive the caller's actual open flags, including the access mode, append, and synchronization flags. Source and proxy descriptors preserve those flags as well.
The native backend verifies an acquired handle's identity before applying
O_TRUNC through descriptor truncation. A stale open fails without truncating
the replacement file. Provider open callbacks receive the original flags but
should acquire their resources without performing truncation themselves; leave
that operation to ftruncate or the supported truncation fallback.
File creation also preserves the caller's permission bits, including execute
and special bits, after the kernel applies the caller's umask. File-type bits
are removed before dispatch to providers or native creation. Samba's separate
create mask = 0666 policy still applies to files created through SMB.
A failed create can leave a file behind. If native creation succeeds but a
subsequent provider open or metadata callback fails, ScriptFS reports the error
and releases acquired resources without deleting the backing pathname. This
avoids deleting concurrent writes or a replacement file; checking identity
before unlinking would still leave a replacement race.
Whole-file module-provider buffers are shared by resource identity when supplied,
or by virtual path otherwise. Captured sizes stay consistent across handles
sharing a buffer, including handles opened before a pending write is flushed,
even when open returns distinct objects without an explicit identity.
Buffered changes take precedence over positional reads until persisted, and ordinary finite-file truncations are flushed even without a subsequent write. No-op truncations of empty finite files do not retain a stale empty buffer that could hide or overwrite subsequent backing changes.
Truncation is separate from writes so action-style files do not receive an empty write before their real contents. Truncating an already zero-sized command sink does not send an empty command.
Writable command/action files should report their intended current size
explicitly—usually size: 0—so SMB clients do not preserve or pad generated
read contents during a replacement write.
Zero-sized whole-file sinks can be written without a read callback or an initial
truncation. Their writeFile callback receives an empty previousContents
buffer; nonempty files still require readable contents to preserve bytes outside
a partial write.
After a successful flush, the next write to a still-zero-sized sink starts with
an empty buffer, even if its timestamps have not changed. Pending chunks remain
combined until flushed; if writeFile fails, those pending bytes remain available
for retry.
For non-seekable sinks using sizeMode: "zero", sequential writes append to the
current pending payload. After a successful explicit or automatic flush, the
next payload starts without padding for bytes already consumed. Descriptor
offsets still advance and out-of-sequence writes remain invalid.
Concurrent non-seekable writers contribute chunks in arrival order; seekable
files and finite files using explicit or content sizes retain positional writes.
Positional providers should return a stable per-open resource from open or
create, and use context.handle rather than resolving the pathname again
after unlink or replacement. Bounded, seekable handleless providers are
snapshotted for detached I/O; an unbounded provider needs a stable handle for
those operations.
For handleless positional providers supplying a stable identity, reads and
writes check that a known pathname still names the original resource before
each callback. External replacement or removal returns ESTALE at the FUSE
boundary when no known surviving alias exists, rather than redirecting I/O to
the replacement. Linux cached reads may surface that failure as EIO instead.
Providers must coordinate changes that race within a callback itself.
Positional providers returning an open resource must implement ftruncate
for descriptor truncation, using context.handle; otherwise that operation
fails with an unsupported-operation error rather than truncating a replacement
by pathname. Providers whose writes use only writeFile may instead use
buffered descriptor truncation.
Positional reads with whole-file writes. Providers combining read with
writeFile can omit readFile for finite, seekable files. ScriptFS assembles
bounded chunks to preserve untouched bytes and populate previousContents.
Existing open resources are used when readable; otherwise temporary read-only
resources are opened and released.
This includes buffering writes, truncations, and unlink snapshots for
O_WRONLY or append-only handles, without changing the caller's original flags.
Handleless assembly rechecks a supplied identity before each chunk, including
when buffering writes or taking unlink snapshots, and rejects a mid-read
replacement or removal with ESTALE.
Without an explicit readFile, unbounded or nonempty non-seekable files cannot
be buffered for whole-file operations and return EOPNOTSUPP.
Whole-file reads with positional writes. Providers combining readFile
with write retain a read snapshot after unlink or replacement, but writes and
ftruncate still reach their stable open resource. Successful mutations update
the snapshot across shared handles; failed mutations leave it unchanged. These
providers must implement ftruncate to support descriptor truncation.
File metadata supports four size policies, either from getattr or from a
rule's file defaults:
| Policy | Reported size and use |
|---|---|
content |
Derives size from readFile. |
explicit |
Uses the numeric size. |
zero |
Reports zero, suitable for command sinks and /dev/null-like files. |
unbounded |
Reports a very large logical size while positional read generates data on demand. Consumers must bound their reads. |
seekable: false rejects non-sequential reads and writes. These handles use
FUSE direct I/O so kernel read-ahead and cached reads cannot bypass the
per-handle position checks. Sequential positional requests remain supported
for SMB clients. Per-rule defaults are useful for static policy, while provider
metadata can choose behavior dynamically for each path.
Seekable positional readers may return short chunks: ScriptFS continues reading at the next offset until the FUSE request is filled or the reader returns an empty buffer to signal EOF. Errors from continuation reads are propagated. Non-seekable readers retain direct-I/O semantics and return each chunk without additional reads.
Identities must remain stable for updates and change when a resource is replaced. A new identity never inherits the previous resource's pending buffer. Module file identities are scoped to their provider rule so unrelated providers can reuse local identifiers and different rules retain independent decorations. Hard-linked aliases within one view share resource identity; callbacks may use another surviving name for that same resource.
Before reading backing contents, truncating by pathname, or flushing a buffer,
ScriptFS checks that a known name still identifies the original resource.
If an external replacement leaves no known surviving name, these operations
return ESTALE rather than reading or overwriting the replacement. Buffered
bytes remain available to existing handles, but cannot be persisted through a
stale name.
Providers needing descriptor-based persistence after external replacement should use positional callbacks with stable open resources. Identity checks are not atomic with external updates: providers must coordinate concurrent changes within their callbacks.
Operations on a file, including asynchronous flushes, are serialized so concurrent writes cannot overwrite a newer buffer. Renaming a file or directory updates its open handles. If a parent rename makes an open file match a hide rule, new path-based access is hidden, but existing descriptors retain their read, write, truncate, and flush behavior, including whole-file buffering.
Opened handles retain their selected backing even if a pathname is replaced. Existing source and proxy hard links share a FUSE inode and page cache, keeping reads, writes, truncation, and shared memory mappings coherent across aliases. Removing or replacing one name does not detach the other names or existing descriptors.
Native link counts, including zero for an unlinked open file, are preserved;
providers may also supply nlink in their metadata. Providers exposing
hard-link aliases must report an accurate nlink; omitting it means one link
for a regular file. Generated file handles report zero links after their last
name is unlinked or replaced through the mount, while remaining readable
through those handles.
Whole-file providers with stable identities share buffers across observed
hard-link aliases and continue persisting through a surviving name. If other
links exist but no surviving name is known, pathname-based I/O returns ESTALE
until a matching alias is observed, rather than silently discarding writes.
Before snapshotting a resource for unlink or replacement, ScriptFS rechecks its known names and drops missing or replaced aliases. Removing the last surviving name therefore preserves the original open resource without requiring a prior lookup of externally changed names.
Creating new hard links through the mounted view is not supported.
Retained O_PATH inode metadata also accounts for links removed by unlink or
replacement through the mount. Once an inode is known to have zero links,
it cannot be reattached to a new file that recycles its backing inode number.
Retained descriptors keep the removed inode's metadata, and pathname-based
metadata changes through them fail rather than modifying the replacement.
Before releasing the final backing handle, ScriptFS captures its current
metadata so surviving O_PATH descriptors retain updates made after unlink,
including file-size changes from writes.
If a backing name disappears externally, retained O_PATH descriptors continue
returning cached inode metadata without requiring another pathname lookup.
Known surviving aliases refresh that metadata; without a backing descriptor or
surviving name, external metadata changes, including link counts, cannot be
refreshed.
Implement fgetattr with an explicit resource-specific size to refresh metadata
on opened provider resources. Without it, resource metadata is captured at open
and updated for writes and truncations through that resource; it cannot discover
external changes. Native handles always obtain metadata from their descriptors.
Metadata-only module overlays using native source I/O, and nonopaque module
directories backed by source directories, inherit unspecified attributes and
identity from their native backing. Their getattr and fgetattr callbacks
may override other attributes, but cannot replace the native identity. Fully
generated resources continue to use provider-supplied identities.
Without fgetattr, explicit metadata-only overrides are captured when the
native handle opens; unspecified attributes continue to come from its live
descriptor. Opening the file therefore does not discard its decorations,
and replacing its pathname cannot substitute the replacement's metadata.
Successful writes and truncations update captured sizes across handles sharing
the resource, including sizes declared by a rule's file defaults. Descriptor
metadata changes update the explicitly changed captured attributes.
Explicit fgetattr callbacks remain authoritative.
Use fsetattr(changes, context) to change mode, ownership, or timestamps through
context.handle, including after unlink or external pathname replacement.
Captured attributes are updated across generated handles sharing a stable
resource identity, even when each open returns a different resource object.
Without a safe descriptor or provider implementation, detached metadata changes
are rejected instead of modifying a replacement. Supplied resource metadata
should include a stable identity so replacement can be distinguished from an
update to the same resource.
Handleless fsetattr and ftruncate callbacks check that identity before
dispatch, just like handleless positional I/O. This also applies to callbacks
overriding native metadata operations: an implicit native I/O descriptor does
not make a callback using context.sourcePath safe after replacement. Return
and use a stable context.handle for descriptor-based provider mutations.
Provider chown callbacks receive -1 for an owner or group that should remain
unchanged; other unsigned user and group IDs are preserved.
Nonopaque directory proxies use source backing for source-only children and proxy backing for overlapping children. New children are created in the proxy when their parent exists there; children of source-only directories are created in the source.
The same selection applies to file creation, whole-file writes, directory creation, and rename destinations. No proxy parent directories are created implicitly, so existing directory identities and open handles remain stable.
A directory with distinct source and proxy backings has these restrictions:
| Operation | Result |
|---|---|
| Rename the directory or replace it | EXDEV, before changing either backing. |
| Remove it while it has visible children | ENOTEMPTY. |
| Remove it when visibly empty | EOPNOTSUPP. |
These restrictions prevent partial renames and directories reappearing after a successful removal. Source-only, proxy-only, and opaque directories retain their ordinary backing operations.
Files and symlinks that shadow a distinct source directory entry cannot be
unlinked (EOPNOTSUPP) or renamed away (EXDEV), since doing so would make the
source entry reappear after a successful removal. This also applies when the
two backing entries are hard links.
In-place writes and replacement of an overlapping proxy file remain supported, as do no-op renames between hard links. Aliases of the same backing directory entry, including bind mounts, retain ordinary namespace operations.
Nonopaque module overlays receive the same conservative protection when they
explicitly supply metadata for a path that also exists in the source and use
custom I/O or namespace callbacks. Removing such a path returns EOPNOTSUPP,
and renaming it away or replacing such a directory returns EXDEV, before
invoking the mutation callback.
Module callbacks cannot establish that removing their own resource also removes
the source entry, so even callbacks that wrap source operations are subject to
this restriction. Use opaque: true for these paths when their namespace must
be mutable without source fallback. Safe file replacement and same-resource
no-op renames remain supported.
Content-only and lifecycle-only source overlays that omit getattr, and
metadata-only overlays using native I/O and native namespace operations,
retain their normal source behavior.
Directory enumeration snapshots its entries on the first read and keeps that listing until the directory handle closes. Concurrent additions or removals therefore cannot shift pagination past unrelated entries; reopen the directory to obtain a fresh listing.
If a backing directory is externally replaced, namespace operations through its old inode are rejected as stale or missing rather than redirected to the replacement. An existing listing snapshot remains readable, while a first read through a stale directory is rejected.
Removing or replacing a directory through the mount preserves its open
descriptors and reports zero links, including generated directories without an
opendir resource or explicit identity. Recreating its name does not redirect
those descriptors to the new directory.
Directory removal and replacement check the visible overlay contents first:
generated children and synthesized subtrees make a directory nonempty even
when its backing directory is physically empty. These operations return
ENOTEMPTY without invoking a provider's removal callback or changing the
backing directory.
Directory renames that would redirect a visible descendant to a different
provider rule fail with EXDEV, whether that descendant is open or closed.
Source-backed descendants retain nonopaque source fallback at their new paths.
Renames also reject directories with nested generated roots or visible descendants owned by another provider, even when those descendants are closed. This preflight prevents a successful rename from moving only the native portion of a logical tree; ordinary single-provider subtrees remain movable.
Multi-component generated roots synthesize their missing ancestor directories. Existing native ancestor directories retain their backing metadata and identity, so they can still be enumerated and opened alongside the generated descendants.
Symlinks can be supplied by readlink, or by returning kind: "symlink" and
target from getattr; source and directory-proxy symlinks are forwarded
through FUSE. SMB clients may impose additional symlink rules.
No-follow ownership and timestamp changes update the backing link itself, including dangling links, without modifying its target.
Symlink lookup also captures the target for that inode. Retained Linux
O_PATH descriptors therefore keep the original target after unlink or
replacement, while fresh pathname lookups observe the new target. A generated
symlink target change creates a new FUSE inode even if its provider identity
is unchanged.
Access checks use provider access callbacks when supplied. Otherwise,
generated entries and synthesized directories are checked using their metadata,
while source and proxy paths use native access checks. Filesystem-wide
readOnly still rejects every write request.
Source and proxy directory handles forward fsync/fdatasync to their backing
descriptors. Virtual directories can implement opendir, fsyncdir, and
releasedir; without a sync implementation or native backing, directory
synchronization returns an unsupported-operation error rather than success.
| Error | FUSE behavior |
|---|---|
Provider ENOSYS |
Translated to EOPNOTSUPP, so one unsupported path cannot disable synchronization or other operations for the entire mount. |
| Other recognized native errors | Preserved, including ENOSPC for full backing storage and EDQUOT for exhausted quotas. |
| Unknown errors | Logged and returned as EIO. |
Storage statistics report the source filesystem's actual capacity and available space. Requests targeting a built-in proxy use its backing filesystem instead. Paths not owned by a built-in proxy, including generated nodes and the ordinary share root, use source statistics: these describe backing storage, not a provider's external-service quota or in-memory limit.
Providers needing a separate quota should expose it as a generated status file.
The runtime build applies version-checked compatibility patches to the pinned native binding for descriptor metadata dispatch and distinct access/modification timestamps, direct-I/O open flags, and 64-bit storage counters. It also preserves zero link counts for unlinked open resources. Source drift fails the build rather than silently skipping a patch.
Timestamp callbacks receive Date objects with millisecond precision, including
dates before the Unix epoch. Pathname and descriptor updates and returned
metadata preserve signed timestamps.
The CLI lifecycle is also available programmatically:
import { loadConfig, startScriptFs } from "scriptfs";
const session = await startScriptFs(await loadConfig("./scriptfs.json"));
console.log(session.mounts);
await session.wait(); // rejects if the runtime container exits unexpectedly
await session.stop();Direct startScriptFs() calls resolve relative source, proxy, and mount-point
paths against the working directory at startup without mutating the supplied
configuration. loadConfig() instead resolves paths relative to the config
file; use it to resolve config-relative provider module paths as well.
Both entry points use the schema-normalized configuration, including removal of
unknown fields, before interpreting rules or preparing container mounts.
The container receives a unique name before creation and its ID is recorded
before it is started, so startup failures such as an occupied SMB port also
remove the unstarted container. The name also permits rollback when cancellation
interrupts podman create before it returns an ID.
Cancellation during startup interrupts pending Podman commands and rolls back
created containers and completed mounts. Interrupted commands receive SIGTERM,
then SIGKILL after one second if needed; rollback waits for the child to exit.
Explicit cancellation skips failure-log retrieval.
| Operation | Timeout |
|---|---|
| Container readiness | 60 seconds; the deadline also interrupts stalled commands. |
| Other short startup and cleanup commands | 60 seconds. |
| Runtime image build | 10 minutes. |
| Failure-log retrieval | 10 seconds. |
| Host mount and unmount | No imposed timeout. |
An in-progress host mount command is allowed to finish so its result can be recorded before rollback.
Shutdown errors are reported rather than ignored: if a host mount is busy, the
container stays available. Release the busy mount's users and call
session.stop() again to retry unfinished cleanup. Successful cleanup is
idempotent.
session.wait() resolves during an intentional stop and rejects if the runtime
container exits unexpectedly. Once cleanup completes, the CLI reports unexpected
runtime exits and exits with a nonzero status without requiring another signal.
If startup and rollback both fail, the exported ScriptFsStartupError exposes
error.session: after releasing busy mount users, call
error.session.stop() to retry cleanup. This preserves ownership of resources
even when startup could not return a normal session. If creation did not return
an ID, that recovery session's containerId is the preassigned container name.
Changes made directly on the host, including atomic proxy-target replacement, are visible after the Podman VM and SMB client's metadata caches invalidate; they are not guaranteed to appear instantaneously.
On macOS 15.7.9, AppleHV/virtiofs has been observed to violate hard-link semantics for directories shared with the Podman machine. Replacing one link can make a surviving link and an already-open descriptor return the replacement's contents even though the paths still report distinct inode numbers. The same standalone filesystem probe behaves correctly on macOS 26.6.2 and 27.0 with the same Podman server, VM kernel, and architecture, confirming that the failure occurs below ScriptFS.
This can break source or proxy files with multiple hard links and causes the
hard-link end-to-end regressions to fail. Running
pnpm test:e2e:rebuild reproduces the problem with the existing test suite,
including the surviving-link and open-handle replacement cases. Do not ignore
those failures or rely on affected mounts for data-preserving hard-link
operations. Upgrade macOS, run the source from storage local to the Podman VM,
or use another machine provider whose hard-link behavior has been verified.
- Podman with a running Linux machine and
/dev/fusesupport. - macOS uses
mount_smbfs. - Linux uses
mount -t cifs. - Windows support is isolated behind the host mount adapter. The current
adapter accepts drive-letter mount points such as
S:and requires SMB port445; broader Windows directory-mount support can be added without changing provider modules or overlay semantics.
Runtime containers receive /dev/fuse and SYS_ADMIN, with SELinux label
separation and AppArmor confinement disabled for that container only
(label=disable, apparmor=unconfined). The default AppArmor container policy
denies mounts even with SYS_ADMIN. This exception allows FUSE on AppArmor-enabled
Linux hosts without changing host-wide Podman configuration or enabling
--privileged; it also means provider code must be trusted. Hosts enforcing a
mandatory policy that forbids this exception are not supported by the current
launcher.
Runtime image builds use the effective local npm registry configuration on
all supported hosts, including Windows installations with an npm.cmd launcher.
SMB clients may apply host-specific ownership and mode semantics. In
particular, macOS mount_smbfs does not reliably forward chmod and chown
from the mounted share. ScriptFS forwards those operations through FUSE when
the client sends them, but applications should not depend on POSIX ownership
changes through a macOS SMB mount.
New regular files are created with read/write permissions only; ScriptFS strips execute bits supplied by SMB clients. Directory creation retains its requested execute/search permissions. The runtime preserves caller-masked creation modes without applying an additional container-daemon umask.
The runtime image is built on first use. It installs Linux FUSE, Samba, and the
container-only @cocalc/fuse-native binding. The host package has no native
FUSE dependency.
src/container/fuse-binding.ts defines and loads the native binding;
fuse-adapter.ts translates overlay operations into FUSE callbacks.
src/runtime/command-runner.ts executes external commands, while
src/provider-helpers.ts contains the public provider and metadata helpers.
Native inode handling lives in container/fuse-inode-backend.h, with its
corresponding .test.c suite. patch-fuse-binding.mjs prepares the pinned
binding; .inc marks source fragments rather than standalone compilation units.
Test filenames identify their scope: overlay-filesystem, filesystem-io,
podman-runtime, and fuse-binding-patch cover the corresponding components.
The e2e suites distinguish CLI mounting (cli-mount.test.ts) from broader
runtime regressions (runtime-regressions.test.ts).
pnpm install
pnpm check
pnpm test:e2e
pnpm test:e2e:rebuildCheckouts use public npm by default. Configure a mirror only in your local
.npmrc (already ignored by Git) or user npm configuration. Runtime image builds
forward that effective registry setting. Keep registry-specific tarball URLs
out of pnpm-lock.yaml so it remains portable between registries.
Development requires Node.js >=22 and pnpm >=12.4.0 <13, as declared in
package.json's engines. Use any installed pnpm version in that range;
the project does not pin or automatically download a particular pnpm release.
pnpm-lock.yaml locks application dependencies, not the package manager itself.
test:e2e reuses the existing runtime image when available.
test:e2e:rebuild rebuilds it first. Both targets launch the built CLI and
exercise real source passthrough, module providers, file and directory proxies,
hidden paths, whole-file and positional I/O, create, replace, append, seek,
truncate, rename, delete, directory lifecycle, metadata callbacks, provider log
forwarding, unmount, and container cleanup.
Regression coverage also exercises overlapping rules, atomic host replacement
of file proxies, installed provider dependencies, asynchronous flushes,
nonzero truncation, open-handle renames, symlinks, cancellation, and busy-mount
cleanup retries. Unit tests inject command failures without requiring Podman.
For a release candidate, run pnpm run ci on the final worktree: portable checks
alone, or integration tests against an older runtime image, are not the release
gate. Filesystem fixes should extend operation-sequence regressions across
relevant provider types, checking original and replacement contents, retained
metadata, and cleanup ownership rather than only successful callback returns.
The alias matrices deliberately vary lookup timing and pending writes. A quiet
audit or passing suite is evidence for these cases, not proof that arbitrary
provider callbacks are race-free.
The Makefile provides equivalent convenience targets:
make start-podman
make check-deps
make pnpm-install
make check
make test-e2e
make test-e2e-rebuild
make examplemake install-deps can install Node.js and Podman with Homebrew, then installs
a compatible pnpm release through npm using the effective registry configuration.
On other systems, install Node.js 22 or newer, pnpm in the supported range, and
Podman, then run make check-deps.
make start-podman leaves a working Podman runtime alone. Otherwise it starts
the VM marked as default, or the only existing VM. If no machines exist, it
initializes the default VM first. It selects the started VM's connection and
verifies that Podman is reachable. To select a specific VM, use
make start-podman PODMAN_MACHINE=my-vm; ambiguous selections list the available
names rather than creating another VM. Failures are reported without
resetting or deleting any VM; make check-deps only checks readiness.
.github/workflows/ci.yml runs on pushes, pull requests, and manual dispatch.
It uses Node.js 22 and pnpm 12.4.0 as a fixed CI baseline, caches the pnpm store,
installs with the frozen lockfile, and runs pnpm check on Ubuntu 24.04 and macOS.
Ubuntu also installs Podman and CIFS support, builds a fresh runtime image, and runs the real FUSE/SMB end-to-end suite as root so the Linux host can mount SMB shares. The image build runs before the tests to keep a cold build outside their startup timeout. Integration uses the same per-container AppArmor settings as ordinary startup, without a CI-only Podman policy override. Registry configuration is preserved for both dependency installation and runtime image builds.
Hosted macOS runs portable checks only: its nested-virtualization restriction
precludes the Podman machine required by the end-to-end suite. Run
pnpm test:e2e:rebuild on a development Mac to cover the real macOS SMB adapter.
The workflow has read-only repository permissions and does not publish packages.
Copyright (c) 2026 Marat Abdullin.