The txt truth files this PR deletes were named as the CI gate for the filetype examples. The gate is integ/truth/*/filetype.json now.
58 KiB
CLAUDE.md
MIRAGE is a package that allows you to mount anything as a filesystem and make it usable by AI Agents.
Repo Layout
This monorepo hosts two sibling implementations:
python/— the Python package (mirage/,tests/,pyproject.toml,uv.lock).typescript/— the TypeScript monorepo (packages/core,packages/browser,packages/node, etc.).docs/,examples/,.github/— shared across both.
Run Python commands from python/, TypeScript commands from typescript/.
TypeScript packages
typescript/packages/corecontains runtime-agnostic primitives and shared logic. Code incoremust work in both browser and Node.js runtimes; do not put browser-only or Node-only APIs there.typescript/packages/browsercontains browser-only resources, commands, and workspace wiring. It depends on@struktoai/mirage-core.typescript/packages/nodecontains Node.js-only resources, commands, and workspace wiring. It depends on@struktoai/mirage-core.- Put shared TypeScript behavior in
coreonly when it works in both runtime packages. Put runtime-specific behavior inbrowserornode.
Python/TypeScript Parity
- Keep Python and TypeScript layout, architecture, and semantics mirrored as much as practical.
- When changing one implementation, check the other for the matching pattern or feature. If one side is more correct, use it to improve the weaker side instead of copying a bad design.
- For major Python or TypeScript changes, consider adding or updating integration coverage under
integ/. - The layout half of that rule is gated.
scripts/check_layout_parity.pydiffs the module-name sets of everymirage/<pkg>/against its TypeScript counterpart (core/node/browser unioned onto one namespace, plus cli/server/agents by prefix), folding camelCase, hyphens and a leading underscore so a rename reads as a rename rather than a missing module;__init__.pyandindex.tsare skipped because only Python needs one per directory. Intentional differences live inspec/layout_exceptions.jsonwith a reason, and a stale entry fails as loudly as a new gap.--strict(what CI runs) does not demand zero: it fails when the count moves off the committedbaselinein either direction, so new drift is blocked and closing a divergence has to be locked in by lowering the number. Run it without--strictfor the full advisory report; that report is how layout work gets scoped. - mirage ships no filetype renderers, and no factory for them. Parquet, ORC, feather/arrow/ipc and hdf5/h5 rendering are gone, along with the
parquet/hdf5/pdfextras, thehyparquet/apache-arrow/h5wasmdependencies, and the wholecommands/builtin/filetype_factory/package in both languages (with itsfiletype_read/filetypeReadop knobs). A file with an unregistered extension is read as raw bytes. The one surviving extension point is registration on a mount: a command or op carrying afiletyperesolves as(name, filetype)before(name, resource)before(name,).examples/{python,typescript}/filetype/register a.tallyrenderer end to end and are gated in CI againstinteg/truth/*/filetype.json;tests/commands/custom/test_filetype_fns.pyandtest_unregister_removes_all_filetypescover the unit path.
Module Layout
Packages split by role, one module per concern, the same way in both languages:
types.py— data shapes only: frozen dataclasses, type aliases, Literal unions (e.g.runtime/types.pyholdsRunArgs/RunResult/EvalValue/EvalResult/ScriptSource). No logic.errors.py— the package's exception types (e.g.runtime/errors.pyholdsEvalError,policy/errors.pyholdsPolicyError).config.py— configuration knobs and their coercion (e.g.runtime/config.pyholdsRuntimeConfig, which fails loud on unknown fields).mixin.py— opt-in capability mixins: stateless, no constructor, abstract methods only (e.g.runtime/mixin.pyholdsEvaluatorMixin). Capability is detected by type (isinstance), never by probing for a method.base.py— the package's core ABC and nothing else (e.g.runtime/base.pyis justRuntime).
History
Command history is a recording, not a command log. A hidden Observer records every top-level command as timestamp-ordered events (COMMAND, CLEAR, DELETE, op events); the user-facing surfaces are just views of those events.
- Observer + ObserverStore. The
Observerowns a storage-agnosticObserverStore(append/write/readAll/readMatching/clear/close), not a mount. Stores:RAMObserverStore(core, default),DiskObserverStoreandRedisObserverStore(node). RAM is just the default, history can persist to disk or Redis. - Two views over the same events.
/.bash_historyis a read-only view mount (HistoryViewResource) rendered in GNU bash histfile format (#<epoch>line then the command), socat/grep/tail/findwork on it for free. Thehistoryshell builtin (GNU-c -d -a -n -r -w -s -p+ count) routes through the same mount, so file and builtin never disagree. - Recording scope. Top-level lines record; nested evals (
$(),eval,source,xargs) run withrecord: false, so their inner ops bubble to the parent and no spurious command is logged (mirrors GNU's line reader). - Snapshots. History is captured as events into snapshot state and restored on load.
- Format is GNU bash, not zsh (
#<epoch>, not: <ts>:<dur>;<cmd>).
CLIs
An installed CLI is a typed program tree (CLISpec) bound to a head word on the
workspace. It is dispatched by name, never by operand path: the VFS is how an
agent discovers state, the CLI is how it acts.
-
An account CLI consults no mount;
gitis the one that does. The two are different tiers, told apart byconfig_model. An account CLI (slack,linear,gws, …) declares one, initializes from it, and reaches a service, so a mount would be a second source of truth for the same account.gitdeclares none, because there is nothing to authenticate to, and its subject is a repository that lives on a mount, so it reads that repository through the op dispatcher like any command. That is what makesgitwork on a RAM mount, a disk mount or an object store without knowing which. It reaches the dispatcher throughCLIDoors, one door per state plane:dispatchandstat_pathfor data,nsfor the name plane (mount boundaries, links, attr overlay), andsession_viewfor session state. The mount prefix isns.mounts.root_of, not a fourth field.handle_cli/handleCliputs the record oninv.doors; the field is None/absent outside a workspace, and a verb that never reads it cannot touch a mount, so this stays opt-in per verb rather than ambient. The door is one field read rather than a parameter list the dispatcher inspects, because every leaf takes exactly oneCLIInvocationand nothing is threaded through keyword injection. Every field is spelled the wayCommandOpts(commands/config.py) spells it, so one fact reached from a CLI leaf and reached from a command handler has one name; meta-tests pin that (tests/commands/cli/test_doors_parity.py, and a compile-time mapped type incommands/cli/types.test.ts). Do not give an account CLI a mount, and do not givegitaconfig_model. Nuance: an account CLI verb MAY read an unrelated workspace file the user named on the line throughinv.doors.dispatch(himalaya's--attachreads the attachment path this way); what it must not do is treat a mount as a second view of its own account's data. -
The lifecycle is host-side only.
register_cli/unregister_cli(workspace.py,workspace.ts) are called by the embedding program, never by a line the agent types, and there is noinstall/uninstallshell builtin. Keep it that way: an agent must not be able to take away the tools it was given. Shadowing is the one thing it can do (define a shell function with the same name), which is bash's own rule, reversible withunset -f, bypassable withcommand <name>, and visible intype -a. A deployment that needs a head word pinned enforces that in the policy layer'spre_execute, not in the CLI registry. -
Precedence is written down once, in
_layers/layers(workspace/route/route.py,route.ts): shell builtin, namespace command, function, CLI, mount.routetakes the first match (the winner, which is what dispatch runs) androute_all/routeAlltakes all of them (every layer, which is whattype -aprints). The generator is lazy so the winner still costs one probe. Do not add a second precedence list. -
A CLI that mimics a real program is gated against that program.
ntnis the worked example: every case ininteg/cli/ntn.jsonis asserted twice, once by the shared battery inside a mirage workspace and once byinteg/ntn_conformance.ts, which runs the same shell line with the real npmntnbinary pointed at the same fake throughNOTION_API_BASE_URL. A golden both agree on is by construction what the official CLI prints, so the grammar cannot drift from upstream without a red build. Four rules keep it honest. The binary's version is pinned (the script refuses any other). Both runs share one environment, declared asenvon the target ininteg/targets.jsonand read from there by both hosts and by the conformance runner: an option that reads a variable renders differently with and without it, so two environments would mean two different lines were being compared. That map must carryNOTION_API_VERSION, and the runner fails loudly if it does not, because unsetntnresolves the newest version by fetching developers.notion.com at startup and every case starts depending on the network. A case the upstream binary cannot answer carries aconformance_skipstring saying why, never a silent omission; there are none today, and the only two cases the runner passes over are the ones whose line reads a/notionmount path, which no bare binary can have. And the harness spawns asynchronously: the fake is served by the same event loop, so a synchronous spawn deadlocks the loop that has to answer the child's request, which is the FUSE self-touch trap in another costume. -
UsageStyleis how a mimicking CLI answers in its original's voice, and it is read off the ROOT spec at every level, never off the node, because a program answers in one voice throughout. It lives incommands/spec/types.py(types.ts), not beside the CLI tree, because the help renderer is the spec's and cannot import upward.ARGPARSEis the default;GITrewords the unknown-option refusal and its exit code;CLAPadditionally governs help layout (bare description line,[OPTIONS]/<COMMAND>,Options:notFlags:, subcommands in declaration order rather than sorted) and the missing-operand refusal.ntnis the CLAP one. Adding a dialect means adding a member here, not a second knob: help layout, refusal wording and exit code are one decision about whose program this imitates.Three spec fields exist only to serve those foreign usage lines, and all three hold bare names with no brackets, because only the renderer knows the dialect that wraps them:
Operand.name(PAGE_ID, rendered<PAGE_ID>required and[PAGE_ID]not),Operand.required(the parser reports the empty slot rather than each leaf re-discovering and rewording it), andOption.metavar(VERSION, rendered--notion-version <VERSION>; derived from the long spelling when absent, which covers most options and is why only the four upstream overrides declare one).Option.envis a fourth and is not a synonym fordefault: an env-sourced value counts as supplied, so clap echoes it in a usage line where a defaulted one is invisible, and it is read from the session rather than frozen into the spec. The executor fills it, so a leaf reads one flag instead of a flag and a fallback. -
Imitating a Rust CLI means imitating serde_json, so the engine parser does not get to decide.
ntn apiechoes its JSON parse failures verbatim, and those words are serde_json's (EOF while parsing an object at line 1 column 1), which neitherjson.loadsnorJSON.parsecan produce and which the two of them word differently from each other anyway. Socommands/cli/builtin/ntn/serde.py(serde.ts) is a scanner whose only job is that message, and it is the authority on validity too: the engine parser runs only on input the scanner already accepted, because the two disagree about what JSON is (python acceptsNaN, serde refuses it, and silently sendingNaNis worse than either). Three rules that are easy to get wrong and are pinned by a probed table intest_serde.py/serde.test.ts: columns count bytes, not characters (["é"xfails at column 6); a newline advances the line and zeroes the column; and the recursion limit is 128, refused at the opening bracket of the 128th container. Do not "simplify" this to a try/except around the engine parser. -
ntn apihas three body sources, and both the order and the exit codes are observable. stdin,--dataand inlinepath=value/path:=jsoninputs are each validated in that order, and only then is "more than one source" reported, so a malformed pipe outranks a malformed--dataand both outrank the conflict. The exit codes are two families, neither of them argparse's 2: a body that arrived malformed is 1 (error: Invalid JSON from stdin), while a line the CLI will not interpret at all is 5 (the conflict, an unparseable inline input, an empty--data), and those carry a secondhint:line. There is no object check anywhere:--data '[]'posts the array, and any body source at all makes the call a POST even when what it carries is empty, so--data '{}'posts rather than falling back to GET on falsiness.name==valuestays a query parameter whatever the method is. -
Discoverability is part of shipping a CLI, and it comes from the spec, so it works for a user's own registered CLI exactly as for a builtin one.
man <cli>andman <cli> <verb>...render throughnode_help/nodeHelp, the same renderer--helpuses, so a manual cannot drift from the program; baremanlists installs under# clis.typereports an installed CLI as its own kind (type -tprintscli, a sixth word beside bash's five, because reusingfilewould promisetype -pa path that does not exist).whichprints the bare name, never a fabricated path.
Notion data sources
The notion backend speaks Notion-Version: 2025-09-03, the generation that
split a database into a container plus one or more data sources. The split is
not cosmetic and it is not optional:
- The database object no longer carries
properties. The column schema lives only on the data source, soGET /v1/databases/{id}answers withdata_sources: [{id, name}]and nothing to render a schema from. Do not synthesize one back onto the container; the fake deliberately omits it so anything that still reads a column list off a database fails loudly. - The mount nests accordingly, because that is where the data is:
databases/<Title>__<db-id>/database.jsonis the container, anddatabases/<Title>__<db-id>/<Name>__<ds-id>/data_source.jsonis the schema, with the row pages under the data source. A row page therefore sits at depth 4 underdatabases/, not 3;readdir/read/statall key on that in both languages. The name stutters for a single-source database because Notion names the auto-created data source after its database, which is honest and disappears the moment a database holds two. - A row's parent is its data source:
{type: "data_source_id", data_source_id, database_id}. The database id rides along because Notion kept emitting it, and the fake stores rows keyed by database, which is the same fact one derivation away. /searchrejectsfilter.value = "database"at this version; the searchable schema-bearing object isdata_source. Listing databases is therefore "search data sources, take the distinct parents, retrieve each", which costs one extra call per database and is the only way to get the title and url the directory is named and rendered from.- ids are not interchangeable: a data source id is not its database id.
The fake derives one from the other (
d5000000+ the database's tail) rather than reusing it, precisely so an id mix-up cannot pass.
Mount boundaries
A mount root is not an ordinary directory, and a mount nested inside
another mount's tree is invisible to the backend that owns the parent:
the child's keys live in a different resource, so the parent's readdir
never lists it. Two mechanisms follow from that, and they are separate.
MountViewis how a command sees the boundaries (ops/types.py,ops/types.ts), and it is offered the wayLinkViewis: a command opts in by naming amountsparameter,execute_cmd/executeCmddelivers it only to handlers that do, and there is no list of boundary-aware commands anywhere. It carriesdescendants(mount roots strictly under a path),is_root, androot_of. A traversal command that renders lines does not need it: the executor's fan-out (workspace/executor/fanout.py) already reruns find/du/tree/grep -r per mount and concatenates the output. A command whose output is one binary object cannot be merged that way, which is whytarreads the boundaries itself.- Crossing into a descendant mount is refused, not attempted.
tarandzipboth keep the mountpoint as a directory entry and drop its contents with GNU's own--one-file-systemwording (<name>/: file is on a different filesystem; not dumped; Info-ZIP has no message of its own for this, so zip borrows the wording under its ownzip warning:prefix). This is deliberate: descending would archive by accident exactly what the mount-root refusal below forbids on purpose. MountRootPolicyrefuses a mount root in a source slot fortar -c,zipandcp, on top of the POSIX EBUSY rules it already enforces forrm/rmdir/mv/mkdir/touch/ln. Real tar and cp allow it; mirage does not, because the mount table is the deployment's configuration and reading a whole backend into one object is neither what the operand looks like it costs nor something an agent should be able to do to data it was given a view of. Only positional operands are tested, which is whyCommandContextcarriesoperandsbesidepaths:tar -xf a.tar -C /mntextracts INTO a mount and must stay legal, whiletar -cf a.tar /mntmust not.positional_scopes/positionalScopes(executor/command/routing) is what tells the two apart, since classification turns every path-shaped word into a PathSpec whether it filled an operand slot or a flag's value. Mode matters too: onlytar -creads its operands from the filesystem, sois_create_mode/isCreateModegates the refusal. Under-tand-xan operand is a member selector matched inside the archive, and refusing one that happens to spell a mount root would deny an ordinary listing.
CommandSpec and Operand are not a scratchpad
Both dataclasses are shared by every command in the repo, so a field
added for one command is a field every other command's author has to
read past, and a fourth one turns the grammar into a pile of per-command
dialects. Do not add a field to CommandSpec, Operand or Option
unless POSIX and argparse both already have the concept, and then name
it after theirs, not after the mechanism it trips inside the parser.
Both, not either. The test is literal, not a judgement call: write the equivalent line in argparse and run it. If argparse parses it, the concept is borrowed and the field is allowed. If argparse cannot express it, the behavior belongs to the one command that needs it and must be handled in that command, not in the shared grammar.
POSIX alone is not enough, and python3 is exactly why: POSIX specifies
an option whose argument is a program (sh -c command_string [command_name [argument...]], and python3's own synopsis
python [option] ... [-c cmd | -m mod | file | -] [arg] ...), so an
either-or rule would license the Option field this section exists to
refuse. argparse is the narrower gate because it is the parser this
spec layer is modelled on, so a concept it cannot express is one the
grammar has no shape for.
Both halves of python3's command line are the worked example, and they come out on opposite sides:
- Allowed:
Operand.remainder. It isnargs=argparse.REMAINDER, which is POSIX's own option order (the first operand ends option parsing; GNU's permuting default is the extension, andPOSIXLY_CORRECT=1 ls a -1shows the difference). argparse spells it on the positional slot, so mirage spells it onOperandtoo, andCommandSpecdoes not change at all. - Not allowed: an option whose argument is a program.
python3 -c 'code' -u xmust hand-uto the code, butadd_argument("-c"); add_argument("rest", nargs=REMAINDER)answersunrecognized arguments: -u. CPython's own command line is parsed in C for exactly this reason. There is no concept to borrow, so this does not become anOptionfield.
A CLISpec is a CommandSpec (python: subclass; TypeScript:
extends), and every level of a CLI tree parses with the ordinary spec
machinery. Moving a command to the CLI tier therefore does not exempt it
from this rule, because it still parses with the same Option and
Operand. The CLI tier is for a program tree (a verb the line selects,
like git status or ntn api), not for a program with an unusual
option grammar.
An option that chdirs: operand_base
tar -C is not a flag the command reads once, it is a chdir for the path
operands typed after it, and it is cumulative (-C d1 x -C ../d2 y
reads d1/x and d1/../d2/y). That is a property of the line, so it is
declared in the spec (CommandSpec.operand_base / operandBase, tar's
only) and resolved by the one component that walks the line
positionally: parse_command / parseCommand tracks the base as it
scans and reports it per word as word_bases / wordBases, which
classify_parts then resolves each operand against. Doing it anywhere
later is too late: the classifier has already produced absolute
PathSpecs, and an operand resolved against the wrong base makes the
router see a phantom cross-mount span (which is what
tar -czf /work/out.tgz -C /work/check my_paper used to fail as).
Only path operands and the option's own value move; every other
path-valued flag keeps resolving against the session cwd, which is what
GNU does with -f.
Symlinks
Symlinks are namespace state, not backend state. The Namespace node table
owns them (target stored verbatim as typed), no resource stores or reports one,
and no backend readdir or stat can see one. Three consequences, in the order
they bite:
- A new resource needs no symlink code at all. Links live above every backend, so a backend author never implements, stores, or forwards one. This is the whole point of keeping them in the namespace; do not push link awareness down into a resource or an accessor.
- A command consumes a fact by reading the
optsfield, nothing else. Every namespace fact (links,stat_overlay,stat_path,readdir_path,child_mounts,mounts) ridesCommandOptsinto every handler, identically in both languages; the generic that wants one readsopts.linksand the rest ignore it, so there is no opt-in registry, spec field, or signature convention that can fall out of step.LinkViewbundles every link fact (stat_at,children,subtree,resolve,exists,target_stat) so a command that grows a new need adds a field read, not a new keyword threaded throughexecute_cmd, the builder and the generic. Families reading links today:ls,stat,find,du,file— in the generics, so a bespoke wrapper that delegates (as all of them now do) inherits link awareness for free.existsandtarget_statanswer through the op dispatcher, not one backend's stat, so a link that points into another mount resolves correctly. - A CLI verb that walks a tree itself has to lstat through the same door,
which on that tier is
CLIDoors.ns.links. A walk built onstat_pathalone dereferences, so a link reads as its target and a broken one reads as absent.git's worktree walk did both until it opted in, which is whygit addstored a symlink as a regular file holding its target's bytes (real git stores mode120000with the target as the blob) and whygit statusnever listed a broken one. - Merge links in the generic, above the native-op/walk fork.
findanddueach have two paths: a backend with a native op (find_core,du_size/du_entries) and a backend walked byreaddir. Link merging lives in one shared place per family (link_resultsingeneric/find.py,link_leavesingeneric/du.py) that both paths call. Merging inside only one path makes a mount's symlink behavior depend on whether its backend happens to ship a native op, which is the worst kind of divergence to debug.
Follow policy is two symmetric tables in workspace/route/constants.py, both
read off the raw command line (operand rewriting happens before flag parsing):
NO_FOLLOW_COMMANDS lists commands that lstat (rm, mv, ln, readlink,
rmdir, unlink, stat, file, du, find, tar, zip), with
DEREFERENCE_FLAGS naming the flag that turns following back on (-L).
tar and zip are in that list for a different reason and deliberately carry
no DEREFERENCE_FLAGS entry: they dereference too, but their planner has to be
the one doing it. Rewriting the operand in the router hands the planner a
target it can no longer tell was reached through a link, so tar stored a
regular file where GNU stores a symlink member, and neither archiver could
apply its own cross-mount refusal or ELOOP wording. tar -h and zip -y are
read by scan_operand instead. find states its policy as
a leading -P/-H/-L option instead, last one wins, so it lives in
LAST_WINS_LINK_OPTIONS;
NO_FOLLOW_FLAGS is the mirror, for a following command that a flag makes lstat
(ls -l and ls -d report a command-line link itself, while a bare ls
dereferences a link to a directory, and ls -L overrides both).
Those tables only cover the operand; honoring -L below it is the generic's
job, not the router's.
Rendering derives from one fact: link_stat builds
the row with FileType.SYMLINK and the target under FileStat.extra
(LINK_TARGET_KEY), so lrwxrwxrwx, the name -> target column, -F's @,
and file's "symbolic link to" all follow from it without a second lookup.
du sizes a link at its target string's length. This is not a divergence:
mirage's du counts bytes, which is GNU's --apparent-size --block-size=1
(du -b) mode, and in that mode GNU reports a symlink as len(target) too. The
familiar 0 comes from GNU's default 1 KiB block mode, where a short target
sits inline in the inode and occupies no data blocks (stat reports
size=15 blocks=0 for it). mirage has no block mode at all, so 0 is not an
option it can express; comparing against it would also make every regular file
look wrong (a 6-byte file is 6 in bytes, 4 in 1 KiB blocks).
FUSE
- The mount layer is split core/adapter in both languages.
MountCore(python/mirage/fuse/core.py,typescript/packages/node/src/fuse/core.ts) owns all filesystem semantics in POSIX terms and imports nothing from mfusepy or@zkochan/fuse-native;MirageFSinfs.py/fs.tsis the libfuse adapter and owns only the callback signatures plus errno translation. Core methods raise ordinary exceptions; adapters classify them throughclassify_error/classifyErrno(fuse/errors.py,fuse/errors.ts), which is one shared table, not per-methodexceptarms. Put new filesystem behavior in the core, not the adapter. - One
backendfield per mount:vfs | fuse | fskit(MountBackendinmirage/types.py, besideMountMode).vfsis the default and means the mount lives only inside mirage's own filesystem;fuseandfskitalso register a real mountpoint, withmountpointpinning where. There is nofuse=Trueboolean any more, and noautobackend.Mount(..., backend=MountBackend.FSKIT)routes through macFUSE 5.x's FSKit shim (no kernel extension). Rules live infuse/backend.pyand are enforced at mount time: macOS-only, mountpoint must be under/Volumes, and every mounted resource must setSIZES_ALWAYS_KNOWN(FSKit has nodirect_io, so a size-unknown resource would serve silent empty files).resolve_backendrejectsvfs: reaching it means a kernel mount was requested. In YAML the keys arebackend:andmountpoint:. TypeScript serves fskit too:fuse.nodelinks/usr/local/lib/libfuse.2.dylibby absolute path (the bundledlibosxfuse.2.dylibis a stub with that install name), sobackend=fskit+volnamereach macFUSE 5.x's own libfuse viaappendMountOptionsinmount.ts. Verified live. Caveat: a TS fskit mount intermittently wedged on a write op in testing (probed from child processes), and a dead FSKit volume blocks mount-table enumeration system-wide until the macFUSE appex process is killed; treat fskit from TS as read-mostly and experimental. A mount is ready only whenos.path.ismountsays so, never when the/Volumesentry merely exists: macFUSE creates that directory while mounting and leaves it behind if the FSKit handoff fails, which reads as a live mount and then fails with ENOENT on the first read. Python fskit mounts have the full write surface, and only because offuse/darwin.py. The FSKit shim finalizes every created item through macFUSE's Darwin-onlysetattr_xand routes rename throughrenamex; mfusepy leaves thosefuse_operationsslots as reserved NULLs, so without the extension module create/mkdir fail with ENOSYS after the op already applied and rename never reaches userspace (verified by libfuse wire trace: CREATE success, then SETATTR -78). Do not remove theinstall_macfuse_extensions()call inmount.py, and keep the struct tail in sync with macFUSE's fuse.h if mfusepy changes layout. Pinned ininteg/fuse/truth_fskit.jsonon macFUSE 5.3.3 / macOS 26. TS fskit stays read-mostly: fuse-native's compiled op table cannot gain new C callbacks from JS. Upstream shim caveats that remain for both: exec-until-first-read fails (macfuse#1181) and root readdir cache cannot be invalidated (macfuse#1165).integ/fuse/fskit.pyis the only coverage of a real FSKit mount, and it only runs on a Mac with macFUSE 5.x: on theinteg-fskit-macosjob a hosted runner installs and enables everything but the mount request never reaches macFUSE, so the job reports that one known timeout signature as a skip and stays green; any other failure there is real. - Directory and unknown sizes.
getattrreportsst_size0 for directories and for API-backed size-unknown files that have not been opened recently. Reads stay correct because Python mounts withdirect_io(kernel reads to EOF regardless of st_size) ANDattr_timeout=0(post-open fstat routes togetattr(path, fh), which serves the real size of the open-hydrated content); prefetched bytes live in a 30s TTL cache (PREFETCH_TTL) so release-then-stat does not refetch. All three pieces are load-bearing: withoutattr_timeout=0,wc -cprints 0, BSDcpcopies 0 bytes, andtail -cdumps the whole file; withoutdirect_io,catreads 0 bytes on macOS. Do not "fix" getattr to report real sizes eagerly (one API fetch perls -lentry), and do not report fake sizes: stat-only tools (tar,rsync,test -s) seeing 0 matches procfs precedent. TypeScript uses the same recipe:@zkochan/fuse-nativedoesn't serialize adirect_iooption, somount.tsappends it to the option string at runtime (appendDirectIO; a pnpm patch would not reach consumers), plusattrTimeout: '0'+ fgetattr. The old 100 MiB sentinel is gone; do not reintroduce it. - TS FUSE mounts are served by the mounting process's event loop. Never touch your own mountpoint synchronously (
readFileSync,statSync,execFileSync) from the process that created the mount: the call deadlocks the loop that must answer it, the kernel times out, and every later op fails withDevice not configured/ENOTCONN— which looks exactly like a broken mount. Probe from a child process or use async APIs (seeexamples/typescript/fuse/helper.ts). Python is immune (FUSE loop runs on a thread). FileStat.sizemust be the rendered content's byte length orNone, never a storage-side or source-side number. A confidently wrong size is worse than an unknown one: over FUSE it makeswc -c/ls -llie and risks truncated copies, whileNonerides the unknown-size machinery above. Postgresrows.jsonl(on-disktable_size_bytesvs rendered JSONL), Dify documents (uploaded source size vs rendered segment text), Gmail messages (sizeEstimatevs rendered.gmail.json), Drive-rendered google-apps files (Drive storage size vs rendered.gdoc/.gsheet/.gslideJSON; raw binary downloads keep Drive's size), and Microsoft Graph folders (OneDrive/SharePoint report an aggregate subtreesizeon the folder facet, not any content length) made this mistake; their storage/source numbers now live inextra(size_bytes/source_size/size_estimate). Do not reintroduce it in new backends. Graph'sfolder.childCountrides along inextratoo, and is whatfind -emptyreads for a directory.- macOS allows only one FUSE mount per process. The second mount dies with
fuse: cannot register signal source(mfusepy registers libfuse signal handlers, which only the first mount in a process can claim). Multi-mount scenarios (integ/fuse/fuse.pymounts two) pass only on Linux; do not debug them as regressions on macOS. A failed run leaks the first mount: list withmount | grep MirageFS, clean withumount <mountpoint>. - Windows (WinFsp) conventions differ in three ways, all handled in the python mount path (
_prepare_mountpoint,_await_ready, the teardown branches): the mountpoint must NOT exist (WinFsp creates it; an existing dir fails with "mount point in use"),os.path.ismountnever sees WinFsp directory mounts (readiness = bare existence), and there is nofusermount(WinFsp unmounts when the serving process exits). Ownership: mount withuid=-1,gid=-1(WinFsp builtin: files owned by the mounting user); never report raw POSIX ids into the SFU/Cygwin SID mapping, andos.getuiddoes not exist there (MirageFS caches a guarded uid/gid once). Behavior quirk: Windows cannot stat without opening a handle, so size-unknown files hydrate on first stat and report their real size even "pre-open" (multi-mount per process works). Theinteg-fuse-windowsjob is advisory (not in the gate).
Development Setup
This project uses uv for Python dependency management. Install dependencies with:
cd python && uv sync --all-extras --no-extra camel
camel is declared as conflicting with openai (and other extras) in pyproject.toml, so uv sync --all-extras fails. Exclude camel to keep the openai stack.
Running examples
Examples under examples/python/ load .env.development from the repo root (cwd-relative). To keep cwd at the root while using the python/ venv, invoke the venv interpreter directly:
./python/.venv/bin/python examples/python/s3/s3.py
Avoid uv --directory python run ... for examples — it changes cwd to python/ and breaks load_dotenv(".env.development").
Backward Compatibility
- No need to consider backward compatibility for the code.
Create a PR
When asked to create a PR, please follow the following steps:
- Run
pre-commit run --all-filesfrom the repo root to lint and format the code. - Run
cd python && uv run pytestto run the Python tests. - Run
git add -Ato add all changes. - Run
git checkout -b <branch-name>to create a new branch. - Run
git commit -m "<commit-message>"to commit the changes. - Run
git push origin <branch-name>to push the changes to the remote repository. - Run
gh pr create --title "<pr-title>" --body "<pr-body>"to create a PR.
Commands
Linting and Formatting
After making major changes, run pre-commit from the repo root to ensure code quality:
./python/.venv/bin/pre-commit run --all-files
Invoke the venv's pre-commit binary directly (not via uv --directory python run) so cwd stays at the repo root — otherwise git ls-files only lists files under python/ and examples/ gets silently skipped.
Type Conventions
- Paths must always be represented as
PathSpec, never raw strings. All functions that accept or return paths uselist[str | PathSpec]wherestris for text arguments andPathSpecis for paths. Never pass a path as a plainstr— wrap it inPathSpec.
Rules
- Shell-style commands (cat, grep, du, find, head, tail, wc, ls, etc.) follow POSIX / Unix coreutils semantics as much as possible; match BSD/GNU behavior and document any deliberate divergence. Pin exact GNU behavior with docker (
debian:stable-slim) before changing command semantics. find -sizeis strict and rounds up. GNU+Nkeepsceil(size/unit) > N,-Nkeepsceil(size/unit) < N, bareNkeepsceil(size/unit) == N(so-size -1kmatches only empty files and+0cexcludes empty ones). The parsers (_parse_size/parseSize) translate this once into inclusive byte bounds; backend cores just keepmin_size <= size <= max_sizeand must not re-interpret the spec. Deliberate divergence: directories count as size 0 (GNU compares the inode size, e.g. 4096 on ext4), which matches whatfindsees over a mirage FUSE mount.find's row for the start point is the generic's, not the backend's. GNU lists a start point before descending into it, and every native find op used to decide that row from its own listing, which is only a proxy for existence: an object store holding no keys under the prefix and no directory marker reported nothing at all for a directorytest -dandtreeboth saw, and ssh called every directory non-empty so-emptynever matched one. The generic stats the start point through the dispatcher (resolve_start, which asks both channels a backend can answer on viaresolve_path_stat, since on a prefix store a directory is the set of keys under it rather than an object), takes one readdir for-empty(dir_empty, wired by the builder), and then replaces whatever row the backend produced for it (with_root_row/withRootRow). A native find op may still emit the start path and they all do, because it is the answer when no dispatcher probe is wired (a command constructed outside a workspace); with one wired it is discarded. A new backend only has to report descendants.findclassifies walked entries throughstat, never by name. The walk's one in-band proof is a trailing slash on a cold listing (box, gdrive and dropbox mark folders that way, and no backend renders a file with one); every other entry is classified by the index-backedstatthat the same readdir just populated, so the lookup is RAM, not another API call. There is no per-backendis_dir_name/isDirNamehint: those heuristics guessed wrong as soon as a child's name was user-controlled (email and gmail attachments and slack uploads carry whatever name the sender gave them, and were reported as directories, sofind -type fmissed them). Do not reintroduce name-based classification in a backend; if stat misclassifies an entry, fix that backend's stat.- An archiver walks a directory operand; it does not read it.
tarandzipdecide every member first (plan_create/planCreateingeneric/tar/create.*,plan_zip/planZipingeneric/zip_cmd.*) and only then write, which is what lets an exclusion prune a whole subtree and keeps the ordering stable. Both plans are built on one traversal,scan_operand/scanOperand(generic/archive/walk.*), which merges three sources no single one can see: the backend walk (reusing find'swalk_find/walkFind, so an archiver classifies an entry throughstatexactly as find does, never by name), the namespace's symlinks, and the mount table. It reports paths, never names, because naming is exactly where the two formats disagree; the two things they disagree about in the traversal itself are parameters (dereference,recurse), so a third archiver adds a caller, not a second walk. Members are named fromPathSpec.raw_path, sotar -C d xstoresx, notd/x. A directory is its own member, with GNU's trailing slash and no content, which is the only record an empty directory leaves and the reason extraction has tomkdirfor one. A symlink is a symlink member (SYMTYPE, target inlinkname), never a file of its target's bytes, unless-hsays to follow it. Two links to one target are not a loop, and both are archived; the only loop is oneresolverefuses to resolve, since the namespace already walks the chain under a hop limit and raisesCycleErrorat the end of it. That arrives as a fatalProblemcarrying GNU'sToo many levels of symbolic links, reported per member with the directory entry kept, rather than as an exception that aborts the plan. Every-Cis checked, not just the last: GNU chdirs at each one and fails at the first it cannot enter, so the option accumulates (multiple=True) and the planner walks the list. Two deliberate divergences from GNU, both documented in place: siblings are sorted rather than emitted in readdir order (the same choicedumakes, for the same reason), and a descendant mount is never crossed (see "Mount boundaries"). Everything else is pinned against GNU tar 1.35 ondebian:stable-slim: the leading-slash warning,Cowardly refusing to create an empty archive(exit 2), a per-operandCannot statplus one trailer (exit 2, and the other operands still archive), a-Cit cannot enter (exit 2, no archive written), andarchive cannot contain itself; not dumped(exit 0). A backend error must never reach the user as itself: an unreadable operand is reported in virtual path space with tar's wording, because the rawIsADirectoryErrorleaked the host path behind a disk mount. zipis Info-ZIP, which inverts tar's two defaults. A directory operand contributes only its own entry unless-rsays to descend, and a symlink is followed unless-ysays to store the link, where tar always descends and always stores unless-h. Both are just therecurse/dereferencearguments to the shared scan. The rest is pinned against Info-ZIP 3.0 ondebian:stable-slim: a leading slash is stripped in silence (tar warns, zip does not),-jjunks to the basename and drops directory entries entirely,-xis anchored on the whole stored name (d/sub/*matches,sub/*does not) where tar's--excludeis unanchored, an unreachable operand is\tzip warning: name not matched: <name>and does not stop the run, and a run that matched nothing printszip error: Nothing to do!, exits 12, and writes no archive.-qsilences the warnings but never that error. Two deliberate divergences:-xtakes one pattern per occurrence (mirage's spec has no variadic option value, and-x a -x bsays the same thing), and theadding:line carries no(deflated N%)suffix, since the ratio depends on the compressor and would differ between the two languages.- Tar formats come from a library, not from hand-rolled block code, and
bzip2 is read-only in TypeScript. Python builds archives with stdlib
tarfile; TypeScript usesmodern-tarbehindtar_helper.ts, which keeps theTarEntryshape (name/data/isFile/isDir/linkname) the two call sites already speak and is the only place the dependency is named. Do not go back to writing ustar blocks by hand: the version that did truncated any name past 100 bytes instead of using the ustarprefixfield or a PAX header (so a deep member extracted to the wrong path), and read a PAX/GNU extension block as if it were a member (sotar -ton any archive GNU or Python wrote listed a phantom././@PaxHeaderrow).writeTar/readTarare async for this reason. Compression is a registry (registerCompressionCodec): gzip is built in viaCompressionStream, and a codec may be decompress-only, which is the one deliberate py/ts divergence here. Python reads and writes.tar.bz2becausebz2is stdlib; TypeScript only reads one, because every JavaScript bzip2 compressor is GPL (compressjs,archive-wasm) and an Apache-2.0 package cannot ship that, whileseek-bzip(MIT) decodes.tar -cjtherefore exits 1 withtar: bzip2 not supported, the same answer browser core already gives for an unregistered codec. If a permissively licensed bzip2 compressor appears, addingcompressto that one codec closes the gap with no other change. - The TypeScript
walkFindanswers in mount-relative keys; the Pythonwalk_findanswers in virtual paths. TS's stands in for a backend's native find op, so a caller that needs virtual paths (tar does, to name members and compare against mount prefixes) lifts them withmountPrefixOfthe wayfindGenericdoes. That lift lives once, ingeneric_bind/archive_io.*, which is where both archivers get their walk. This asymmetry is real and has bitten once: a unit test on an unprefixed mount cannot see it, so cover a prefixed mount too. duhas one backend contract:sizeandentries. Each backend exposescore/<backend>/du/size.py(recursive byte total for one path) andcore/<backend>/du/entries.py(per-file breakdown), wired asdu_size/du_entrieson the adapter.entriesreturns(entries, total)where entries are leaf files only, in mount-relative path space, with no summary row; the generic lifts them onto virtual paths (to_virtual, viamount_prefix_of) and re-spells them as the operand was typed (respell_raw). A backend that returns backend-key paths, or appends its own roll-up row, makes two mounts holding the same filename render identical lines. Do not reintroduce a second shape; the old flat-listdu_multicontract is gone.duprints a line per directory, derived not walked. GNU prints one line per directory with its recursive total, post-order (children before parents), plus one per file under-a. Backends only ever report leaf files, so the generic derives the directory rows by summing each leaf into every ancestor (rollup, same name both languages), then emits post-order with siblings sorted. Two deliberate divergences: GNU orders siblings byreaddir(filesystem-dependent), mirage sorts them; and an empty directory is invisible to mirage because no leaf points at it. Sizes are bytes, not GNU's 1 KiB blocks, since an object store has no block size.--max-depthprunes only what is printed, never the walk, because every printed total still covers the whole subtree. Verify changes with the differential harness againstdebian:stable-slim: paths, exit codes and stderr must match GNU exactly.duusage errors exit 1, not 2.duis absent fromUSAGE_EXIT, which is correct: GNU du exits 1 for-swith-a("cannot both summarize and show all entries"),-swith--max-depth("warning: summarizing conflicts with --max-depth=N"), and a bad depth ("invalid maximum depth 'x'"). All three are raised byparse_flags/parseDuFlagsbefore any I/O, mirroring GNU's option-parse order: the depth is parsed as the option is read, so a bad depth wins over the conflict checks. An unreadable operand is not a usage error: GNU names it (du: cannot access 'x': No such file or directory), prints every other operand, and exits 1, and still prints0 totalunder-cwhen every operand failed. With no operand at all, du measures the working directory; it never says "missing operand".duwalks are bounded. Backends with no native du op are walked onereaddirat a time, which on an API tree is one request per directory.CommandIO.max_du_entriescaps that walk; when it trips,duprints what it accounted for, writes a notice to stderr and exits 1 (GNU's behavior for a tree it could not fully read), rather than hanging or silently reporting a wrong number. Slack sets a low cap (DU_MAX_ENTRIES) because it exposes a directory per conversation per day against a ~50/minute rate limit.- Async-native by default. I/O uses
aiofiles/redis.asyncio/aioboto3, and command pipelines are async generators. - Python unit tests mirror src 1:1 where reasonable. Try to have a matching
tests/<path>/test_a.pyfor each source filemirage/<path>/a.py.__init__.py, pure type-stub modules, and trivial re-exports are fine to skip; modules with real logic should have one. - Do not add
__init__.pyfiles undertests/. Tests are namespace packages and pytest discovers them without__init__.py. Don't create one when adding a new test directory. - Monkeypatching a backend command module in tests: the command imports its helpers by value (
from mirage.core.<backend>.read import read_bytes), so to intercept them you must rebind the name inside the command module, not the core source module. But the command module is hard to reach: the backend package re-exports the command function in__init__.py(from .cat import cat), which shadows the submodule of the same name, soimport mirage.commands.builtin.<backend>.cat as mod,from ...<backend> import cat as mod, and even pytest's string targetmonkeypatch.setattr("mirage.commands.builtin.<backend>.cat.read_bytes", fake)all resolve to the function, not the module (AttributeError). The command is also wrapped by@command, socat.__globals__is the decorator's module. Reach the real command-module namespace through the unwrapped function and patch the dict:monkeypatch.setitem(cat.__wrapped__.__globals__, "read_bytes", fake). - Avoid add any comments or docstrings on the top of the file.
- A nested function must capture the scope around it. Nesting is for closures: the def reads a name from the enclosing function, or binds one through a parameter default (
def f(m, _n=n), the loop-variable idiom). Everything the architecture nests is that shape — op factories (ops/generic/factory.py), provision builders, the read-through cache, and every decorator's wrapper — and those stay. What is banned is the nesting that buys nothing: a helper written inside a function although it reads only its own arguments, which rebuilds a function object per call and hides a testable unit where no test can reach it. Put that one at module level. Enforced for Python bytests/test_nested_functions_are_closures.py. - Add type to Args for docstring.
- Do not add comment after each line of code in the format of "# 10MB - trigger segmentation for files larger than this". The most you can add is "# 10MB".
- For all imports you need to put to the top of the file. Don't have imports within each function.
- No circular imports. If putting an import at the top would cause a cycle, that's a sign the dependency direction is wrong — fix the design (dependency injection, splitting modules, moving the shared piece to a leaf), don't paper over it with function-local lazy imports. Verify by checking that running
cd python && uv run python -c "import <every changed module>"succeeds without ImportError. - Never silently swallow exceptions.
try: ... except: pass(orexcept SomeError: pass) hides real bugs. If you genuinely need to ignore an error, log it (logger.debug(...)) or document loudly why it's safe. Default behavior should be: let the exception propagate. Especially never silently swallowRuntimeError— it usually signals something deeper (event loop in wrong state, recursion limit, etc.) that you need to actually fix. - Never call
asyncio.run()inside a sync function that might be invoked under an outer event loop. It will raiseRuntimeError: asyncio.run() cannot be called from a running event loop. If you need async behavior from a sync API, either: (a) make the calling functionasync, (b) operate on the underlying sync state directly (e.g. write to a dict instead of calling an async setter), or (c) use a sync alternative of the same library (e.g.redis.Redisinstead ofredis.asyncio.Redis). Do NOT wrap withtry/except RuntimeError: pass— that masks the bug AND leaks the unawaited coroutine. - Please don't change any file name unless I ask you to do so.
- Don't add too many printings or comments in the code.
- Don't add README.md unless I ask you to do so.
- Use uv add to install new dependencies.
- Command handlers take
(accessor, paths, texts, opts)— the same four positionals in both languages. The dispatcher (Mount.execute_cmd/Mount.executeCmd) constructs oneCommandOptsper invocation carrying stdin, the flag bag, cwd, the mount prefix, the index, and every namespace fact; the provision path builds the same bag withcommand/specset (ProvisionFnhas the same four-positional shape). Handlers never declare a flag or an injected fact as a parameter —tests/commands/test_no_dead_flag_params.pypins the exact signature. When a wrapper genuinely needs a flag value itself (e.g. a search push-down), read it through a spec-boundFlagView(fl = FlagView(opts.flags, spec=SPECS["grep"])thenfl.as_bool("F"),fl.as_int("m"),fl.as_str("type"),fl.as_list("e")) or a shared domain accessor likepattern_arg— never rawflags.get(...)/ isinstance chains, and never a rawkwargs/_extraread either (tests/commands/test_no_raw_flag_reads.py). To override a flag before delegating, passdataclasses.replace(opts, flags=bag)down, never a hand-builtCommandOpts. A PATH-typed flag reaches a python command as aPathSpec, not a string — the executor promotes it (workspace/executor/command/flags.py), so read it withfl.as_paths(name);as_strreads it as absent and the operand is silently never used. TypeScript's bag carries the resolved virtual-path string instead, so its twin isfl.asStr(name). - Generic commands own flag interpretation. Backend wrappers are wiring only (glob resolution, backend I/O injection, pass-through of
textsandopts); all flag semantics live in the generic command for that family, mirroring the TS generics. Adding or changing a flag should touch the spec and the generic, not N wrappers. Every literalFlagViewquery name must be a dest of a spec bound in the same module —tests/commands/test_flag_query_names.pyandcommands/flag_query_names.test.tsfail on a typo'd spelling without needing the code path to run. - Generics parse flags once into a frozen struct. Each generic defines a
@dataclass(frozen=True, slots=True)flag struct plus a module-levelparse_flags(fl, ...)(mirroring the TSparseFlagsstruct); the function body reads only struct attributes, never string keys. Construct the FlagView with the command's spec (FlagView(flags, spec=SPECS["grep"])) so a typo in a flag name raises KeyError instead of silently reading as False/None. - Never annotate anything as
object— not a parameter, not a return, not a type argument (dict[str, object],Callable[..., object]).objectreads as "we did not decide": it accepts bytes where JSON was meant and a PathSpec where a flag value was meant, so every use site pays for it with an isinstance chain back to the set the author had in mind. Name the real type instead:- a parsed command-line flag is
FlagValue(mirage.commands.spec.types) —**flags: FlagValue,Mapping[str, FlagValue],FlagValue | Nonefor a single raw read. The TypeScript side has always called itFlagValuetoo (commands/spec/types.ts); keep the two spellings identical. - a decoded JSON payload or an API field is
JsonValue(mirage.types). It is recursive, so it is spelled as a forward-reference string until the floor is 3.12 — a union with it must be quoted:Awaitable["JsonValue | X"]. - a path is
str | PathSpec, a backend handle isaccessor: Accessor(mirage.accessor.base), an index isindex: IndexCacheStore | None(mirage.cache.index), a stat function isStatFn(mirage.types). Ignored variadics are still typed (*texts: str). - a sentinel is a one-member
Enum, neverobject(); that keeps it distinguishable from the real values sharing the variable.tests/commands/test_no_object_annotations.pyenforces this and carries the only exemptions: four Python protocol methods (__setattr__,__contains__,Mapping.pop) whose signatures the language fixes, and one guard whose whole job is to catch a value the annotations already claim cannot arrive. Adding to that allowlist needs the same kind of reason.
- a parsed command-line flag is
- Every session write goes through
SessionView.set. A deployment refuses a name (AWS_*, a credential) with apre_sessionrule, and a writer that reachessession.envdirectly makes that rule advisory.exportand a plain assignment always cleared the gate; the expansion-time writers did not, so${X:=d},$((X=5)),(( )),printf -vandfor ((X=0; ...))each wrote past every policy. The view is threaded into expansion explicitly rather than read from ambient state, and defaults toNone(correct outside a workspace, where the env is the only state there is). Reads ($X) stay sync; only the writers await.tests/workspace/expand/test_session_gate.pyandinteg/session/writers.jsonhold the line in both languages. - The record client is a substrate, not a session detail. Sessions, the
namespace node table and workspace metadata are three tables that persist the
same way, so the keyed-record clients live in
workspace/record/(DiskRecordClientwith anO_CREAT|O_EXCLlockfile andrename(2),S3RecordClientwith If-Match CAS) and import none of the three. They used to live inside the session package, which made the other two import upward into it; a layering test in each language fails if that returns. The two clients deliberately do not merge: different concurrency contract, different blob layout, different key shape.