Compare commits

...

515 Commits

Author SHA1 Message Date
Zecheng Zhang cddef939a7 fix(policy): recompile a scripted default profile, load config scripts after rebasing 2026-08-21 23:55:08 -07:00
Zecheng Zhang 72ae0b2945 refactor(policy): move the profile model and script evaluation into the policy layer 2026-08-21 22:52:24 -07:00
Zecheng Zhang 8b9bd5ae9b feat(policy): roles written by a script
A profile may state `script:` instead of its document. The script runs
once per role at ensure_sessions_loaded, on a sandboxed engine built for
the role, and what it returns is validated as an ordinary permission
document, so explain and the resolver need no second case.

The engine comes from the script, never from the workspace's runtime
world: that world serves agent code, is mutable after construction, and
drops entries silently when an optional dependency is missing, so a role
resolved out of it would stop working for reasons unrelated to the role.
monty on both hosts, `runtime:` to name another.

Failure refuses the whole set before any result is kept, so one broken
role cannot leave the roles ahead of it written and the ones behind it
still scripts.
2026-08-21 20:50:25 -07:00
Zecheng Zhang 8a7645890a Merge pull request #882 from strukto-ai/feat/runtime-stat-door
One stat struct at the runtime door, and the surfaces that project it
2026-08-21 18:24:29 -07:00
Zecheng Zhang 75e3d28253 fix(runtime): monty and pyodide report the mount's mode and mtime
python monty's path_stat answered from its own tree, where a
materialized file is a MemoryFile: 0o644 whatever the mount held, and an
mtime of the moment it was fetched. It answers from the door's row now,
and an unknown stamp stays epoch zero rather than becoming the host
clock.

pyodide's seed carried neither field, so every seeded node stamped
Date.now() and a chmod the shell made was invisible. The row's mode and
stamp ride through the seed as chmod and utime, Emscripten's own
spelling, and only a seeded node is restamped: a file the guest creates
during the run really was modified now.

ts monty's Path.stat stays declined. That binding converts whatever its
os callback returns structurally, so a stat reaches the guest as a dict
and st.st_size raises AttributeError, and the JS package exports no
StatResult to build the real thing with. Its conformance row now carries
that reason instead of the open one.
2026-08-21 16:14:06 -07:00
Zecheng Zhang de9ffc8974 feat(runtime): the door builds one stat struct for every guest
VFSStat is declared once per language and built at the door out of the
mount's own FileStat, so preview1, monty and Emscripten project it
instead of each translating a row of their own. stat takes nofollow, so
a guest's lstat is one door question rather than a surface reaching past
the door, and the TS bridge now answers stat with the raw row it used to
project. python's GuestStat is gone.

A readdir row carries the mode and stamp its stat already answered, and
None on the two slash-marked rows that never stat.

The mtime unit stays split on purpose: epoch nanoseconds are past 2**53,
so a JS number cannot hold them exactly.
2026-08-21 16:13:06 -07:00
Zecheng Zhang f5d9ff2a83 Merge pull request #880 from strukto-ai/feat/permissions-decisions
feat(policy): decision ledger, explain, and whole-line judging
2026-08-21 15:24:37 -07:00
Zecheng Zhang 4d2bed916d test(integ): cross-check explain against the run, and type the answer word
Every case on a target carrying a document now reads the line through
ws.explain before running it, and asserts three things: the dry run
records no question, a refusal it predicts is the refusal that arrives,
and a refusal that arrives was predicted. The last one is found by
looking for one of the document's own rule reasons in what the run
printed, and is the direction a prediction cannot check on its own; it
is what catches a line explain wrongly calls allowed.

Seven cases carry explain_blind with the reason the command plane cannot
reach their verdict (a runtime-expanded glob, a refusal from the op door,
a function the same line defines), named rather than silently skipped.

The battery's three answer words are also a real type in both harnesses
now. They were compared as open strings, so everything that was not
allow_once fell through to a session-wide allow and a misspelled word
passed the case while testing the most permissive answer there is.
2026-08-21 14:52:35 -07:00
Zecheng Zhang bab2820b66 fix(policy): judge coded policies, redirect targets and the rest of the line
Three defects in the pre-pass, all of which let a line run half-way.

Coded policies were never prejudged: the pass returned early when the
session carried no document, and MountRootPolicy is always registered.
Removing that guard was not enough, because a coded policy denies with
no document rule to point at, and the pass read 'no rule' as 'no
verdict'.

The dry run never passed a statement's redirect targets to the gate, so
explain answered ALLOW for 'echo x > /protected'.

An ask the host answered inline ended the scan, so a later command's
deny ran behind an approval.

Two latent bugs surfaced once the pass applied to every workspace.
Redirects were attributed to the statement's first child, but 'a && b >
f' wraps the whole list, so the target belonged to the last command of
the chain and not the first. And fork tracking read a subshell as 'no cd
applies' where bash means 'no cd escapes', which judged '(cd d && tar -c
..)' at the session cwd and made '..' read as a mount root.

A one-command line is now left to the per-command gate. Nothing runs
before it, so there is no hold to buy, and the gate refuses from inside
the shell where the line's own redirections still apply.
2026-08-21 14:52:28 -07:00
Zecheng Zhang 680a8a36eb fix(policy): drop the dead approval types and settle CI formatting
The four approval types left in policy/types.py after the ledger
refactor were referenced only by each other, and their TypeScript twins
were already gone.

The rest is what CI's pre-commit rejected: yapf, isort and prettier on
the files this branch added, which a local --all-files run skips while
they are still untracked, plus two eslint rules (prefer-optional-chain,
no-unnecessary-condition).
2026-08-21 14:52:19 -07:00
Zecheng Zhang d267692294 Merge pull request #881 from strukto-ai/feat/readdir-link-marks
feat(runtime): the readdir row carries the link mark
2026-08-21 13:55:01 -07:00
Zecheng Zhang 4cd8c64fa2 fix(runtime): mark a listing from the directory it actually read
A readdir of /data/alias is dispatched at /data/real and answers with
that directory's entries, but the marks were read off the typed path,
so every link inside an aliased directory came back unmarked: a wasi
guest read a directory link there as a directory and walked into it.
link_names_under resolves a link prefix now, the way the listing itself
is resolved. link_stats_under needs no such resolution, since it is
handed the path the router already rewrote.

The python door also reads the marks after the listing rather than
before, so a directory that will not list fails as readdir rather than
as the mark read.
2026-08-21 13:17:34 -07:00
Zecheng Zhang 41fb0c1911 feat(policy): decision ledger, explain, and whole-line judging
Outcome collapses to the document's three verbs (allow, ask, deny).
Decision.rule tells the two allow-list refusals apart, and the exit code
separates 127 (head word not visible) from 126 (line not covered).

ws.explain answers what every command of a line would do without running
it, spending no answer and recording no question. The gate is split so
the dry run and the real run read the same tables.

A line is judged before any of it runs, so an ask holds the whole line
and approving it replays the line exactly once. A head word the session
cannot see stays bash. The hold reaches as far as the text does: a path
the runtime computes is judged at the gate, which is documented and
pinned.

ws.decisions replaces the approvals machinery. One record type and one
store: a Decision with no outcome is a question waiting, one with an
outcome is settled, and scope says how far the answer reaches. Both
persist with the session, where pending questions used to live in a
private map that did not survive a restart. Approver, RecordApprover,
CallbackApprover, Approvals, Grant, ApprovalRequest, GrantScope,
request_id and Session.grants are gone; on_ask is a plain coroutine.

Two load errors refuse a document that cannot behave as written: a rule
naming a builtin the allow list never installs, and an ask an outranking
deny already covers at the same anchor. A rule naming a verb its CLI
does not declare is refused at create_session, where the CLI exists.
2026-08-21 12:22:24 -07:00
Zecheng Zhang f3bfa49cf5 feat(runtime): the readdir row carries the link mark
The name plane reaches the runtime tier as a third resolver question,
link_children per directory, so the door marks a listing from one table
read instead of a readlink per entry. TS row building moves off the
bridge onto the door, which is where python already built it, and the
wasi readdir reports a link as a link: a guest scandir called a
directory link a directory and a file link a file, because CPython
trusts d_type and never fell back to lstat.

Python monty keeps its readlink probe: its predicates materialize one
path rather than a parent listing, and readlink is the gated channel,
where a raw table read would answer for a path the door hides.
2026-08-21 12:22:08 -07:00
Zecheng Zhang ee0be359a8 Merge pull request #879 from strukto-ai/feat/guest-link-verbs
Serve symlink, readlink and setattr to guests
2026-08-21 09:59:54 -07:00
Zecheng Zhang f57be539e5 fix(workspace): prove a directory from its parent's listing
The symlink EEXIST check asked whether anything was at the name through
two channels that both answer for a path nobody created. A postgres
schema directory lists tables/ and views/ before anything has asked
whether that schema is there, so the non-empty readdir called every
unused name taken. A qdrant group directory is guarded by the existence
of its collection, not of the group, so stat returned a directory row
for any path under a live one. ln -s inside either mount refused with
File exists, and the cases reading that link failed behind it.

Presence now asks in the order of what a channel proves: the namespace,
then the mount root, then a backend row that is not a directory, then
the name in its parent's listing. A directory row on its own proves
nothing. A directory is proven the way the hierarchy kit proves one, by
appearing in its parent's listing, which is also the only way a prefix
store can answer for a directory that is nothing but a set of keys. A
file, an existing link, a mount root and a real directory all still
refuse.

The two tests that pinned the old channel silenced stat on a RAM mount
to stand in for a prefix store. That double no longer stands in for one,
so it drops the keyless name from the listing too, which is what such a
store really answers.
2026-08-21 09:22:28 -07:00
Zecheng Zhang b448775596 fix(workspace): the door owns every verb that names a link
Three codex comments on the guest link surface turned out to be one
missing rule. The dispatcher already answered symlink, readlink and the
unlink of a link; rename, a no-follow stat and the existence check were
not there, so each one reached a backend that has never heard of the
name. LINK_ENTRY_OPS plus one predicate says which verbs the node table
answers, and ln, mv, git and FUSE read the rule instead of keeping their
own copy of it.

A rename of a link answered ENOENT and moved nothing, on pyodide, wasi,
FUSE and os.rename. A no-follow stat answered ENOENT everywhere, so the
wasi lstat rebuilt the row from the target string and reported epoch
zero: a guest's own utime persisted and stayed invisible. FUSE was a
third copy of that mistake, reporting the mount's construction time and
the mounting user for every link. And symlink never looked at the name
it was given, so ln -s over a live file exited 0 and left the bytes
under a link nothing could read, and a guest could bury a mount root.

Two more of the same family, found while probing rather than reported:
a rename onto an existing link left the link shadowing the file that
had just landed, and prepare_mv wrote the node table directly, which
made a link rename the one write in the shell no admission policy could
see. Both go through the door now, and ln -f is GNU's own algorithm
(remove the destination, then link), so it replaces a regular file the
way coreutils does.

git checkout relied on symlink overwriting a link to retarget one, so
restoreEntry removes the old name unconditionally rather than skipping
that when the path is already linked.
2026-08-21 08:17:18 -07:00
Zecheng Zhang a5cb9730b7 feat(runtime): serve symlink, readlink and setattr to guests
A guest could not create a symlink, read one, or stamp a mode or a time.
The three verbs reach the name plane rather than a backend, so they work
on a mount whose store holds none of them, and wasi, quickjs, pyodide and
monty all route through the same ops. Each surface gains only what its
engine already has: qjs-wasi has no symlink, readlink or lstat, so the
quickjs bootstrap gains os.utimes and nothing else, and pyodide has no
os.link.

pyodide's journal grew a phantom setattr per created file and split two
writes markWrite would have coalesced, because Emscripten finalizes a
create through node_ops.setattr with the same shape a guest chmod has.
Comparing fields is not enough on its own, since the create genuinely
lowers 0o777 to 0o666, so MirageFs.fresh marks the node mknod just made
and changedAttrs drops a write that moves nothing.

readlink answered EINVAL for every miss; POSIX splits them, and a
caller's except FileNotFoundError depends on it. Absence is probed on the
failure path only, on both channels a backend can answer on (a prefix
store keeps no directory object, so stat misses what readdir lists, and a
listing has to be non-empty), through the same admission gate the op
would pass, since a policy denying stat must not be reachable through a
readlink. A refused channel is not absence: it answers EINVAL, which
asserts nothing the policy is withholding.

os_patch._link_target swallowed every OSError as "not a link", which
would have hidden that ENOENT from os.lstat.
2026-08-21 05:56:27 -07:00
Zecheng Zhang 43e8a67180 Merge pull request #877 from strukto-ai/fix/os-verb-routing
fix(os): route every path-taking os verb through the workspace
2026-08-21 04:04:52 -07:00
Zecheng Zhang 4950f88558 Merge pull request #876 from strukto-ai/feat/permissions-explain
feat(permissions): one role per session, mounts narrow instead of gating
2026-08-21 04:04:36 -07:00
Zecheng Zhang d2250fe4c9 fix(permissions): let an approved carve-out reach the op door, and hold the root mount's own paths
A broad path deny and a deeper ask are ranked at the command door but
not at the op door, which read the deny list alone. An approved
carve-out was admitted and then refused underneath, so it could never
authorize the redirect it was written for. The op door now applies the
same law, depth before verb, and honours the grant the line holds; the
redirect binds its judged targets across the write, which happens after
the command's gate window closes.

A mount section keyed by the root mount compared against '//', so a
workspace mounted at / could name nothing inside its one mount.
2026-08-20 23:01:49 -07:00
Zecheng Zhang 0e59a6c659 fix(os): lstat a mounted link from its own node row
os.lstat rebuilt a link's stat_result from the target string, so uid and
gid came back as the process defaults and a chown -h (os.lchown, or
os.chown with follow_symlinks=False) could not be observed afterwards.
It reads the namespace link row now, which is where every other
no-follow surface already looks, and ls -l and os.lstat agree again. The
readlink probe stays in front of the table read and is the gate, since
the node table has no session: a hidden link is still absent.

posix_mode answers LINK_MODE for a symlink row, because the bits on a
link are never consulted. chmod -h still stores them and still shows
nothing.
2026-08-20 22:46:18 -07:00
Zecheng Zhang d1eea898b8 fix(os): keep the process patch off while a backend serves an op
A disk mount whose root sits at or under its own virtual prefix hands
the host a path is_mounted answers True for, so the patched os module
routed the backend's own physical path back into the same backend and
the process wedged instead of raising. ops/host_io.py is the bypass:
the two patched doors read it, and the two places a backend actually
runs (execute_op, execute_cmd) plus the watch delta walk set it.
Streams are wrapped, since a backend opens its file on the first
__anext__. It is a process-global depth rather than a ContextVar
because aiofiles reaches the host through loop.run_in_executor, which
drops the context.

os.readlink off a mount hands back the host's answer untouched, so a
bytes path answers bytes rather than a str of them.

One truth file now runs twice, the second time with -X utf8=0, the
mode where pathlib passes io.open's "locale" sentinel.
2026-08-20 21:04:08 -07:00
Zecheng Zhang da2c910fd0 fix(permissions): answer every operand's ask, and scope discovery to reachable verbs
A line whose operands were asked about by different rules presented only
the deepest, so one nod ran the whole line. Every ask that wins a subject
of its own is now reported and the door requires all of them, one at a
time, spending a once grant only when the line is fully answered.

The dsh read-only twin carried mount modes and hidden paths but not the
source's command rules or hidden variables, so a role-denied CLI verb ran
there. A mode bounds a mount; an account CLI reaches a service.

man narrowed a CLI tree to the verbs the allow list reaches, through the
same visibility question the bare listing already asked, generalized from
a head word to a verb path.
2026-08-20 20:49:50 -07:00
Zecheng Zhang 5639b62790 fix(os): route every path-taking os verb through the workspace
make_os_module patched 10 os names, so every other os call inside a
Workspace block reached the host filesystem with a virtual path in hand.
runtime/verbs.py and verbs.ts now classify every guest filesystem verb
once: 23 route through an op door, 14 refuse with a condition, 30 pass
through to the host, and an unclassified name defaults to ENOTSUP.

patch_process installs the routing as attributes on the real os module
instead of swapping sys.modules["os"], so a plain module-level import os
routes and pathlib, shutil and glob follow. os.stat answers a real
os.stat_result, which is what shutil.copystat and os.path.samefile need.
2026-08-20 20:13:46 -07:00
Zecheng Zhang 5a44983251 fix(permissions): judge a line subject by subject, and keep a mount section inside its mount
Three codex findings, each reproduced before the fix and after.

A line is now judged one subject at a time. One global best match let a
deeper ask on the destination answer for a deny on the source, so
`cp /protected/secret /review/deep/out` came back as an approval request
and a nod meant for the destination carried the protected file out. The
matcher gained `subjects`, `rule_reach` and `rule_applies`; `decide`
resolves each subject by depth as before and then combines across them by
verb, since every path a line names has to survive it. The carve-out law
is unchanged on the operand it was written for.

A mount section's name pattern is anchored to the mount when the role
compiles. Both places those entries are read from have lost the section by
then: the hidden set is one list for the whole session, and the op door
matches a rule's paths without consulting rule.mount. So
`mounts./repo.paths.hide: ["*.pem"]` hid `/other/key.pem`, and a path-only
deny under `/repo` refused a read of it.

Also in this commit:

- `Outcome` moves to `policy/types`, `Decision` to a new `policy/config`,
  and the verb ordering to `policy/constants`, in both languages.
- `integ/fixtures/config/accepted.json` becomes `blocks.json` and the
  permission verbs get a file each (`allow`, `ask`, `deny`), with the
  whole rule grammar per verb where there had been one case.
- integ gains the two combinations the audit found missing: a
  multi-operand line, and a mount section's pattern stopping at its mount.
- `examples/{python,typescript}/permissions/` is a worked role document:
  two roles over three mounts, top-level and mount rules at different
  anchor depths, and one mount denied for one role and hidden for the
  other. Output is identical in both languages and gated against
  `integ/truth/permissions.json`.
- `parseSessionProfile` is exported, since a TypeScript embedder could not
  declare a role without it.
2026-08-20 19:28:23 -07:00
Zecheng Zhang 325e8d33a2 fix(permissions): score the entry that matched, and read entries by the same law
Codex review on #876, four findings, all reproduced first.

- A rule was scored by its deepest path entry, so an entry that says
  nothing about the operand lent it depth: an ask on /repo/* and
  /else/very/deep/* outranked a deny anchored at /repo/private/* and an
  approval reopened it. match_rule now reports the depth of the entry
  that actually matched.
- io_refusal read every deny before any ask while decide ordered by
  anchor depth, so a line admitted under an approved deeper ask was
  refused at every entry the carve-out was written for. Both gates now
  share better_match.
- with_inline returned early when no base role resolved, so an inline
  allow list was accepted when a workspace happened to declare no
  default role. refuse_allow is called on both paths.
- PolicyError is not a ValueError, so an unknown profile name left the
  sessions router as a 500 instead of a 422.

anchor_depth moves beside is_glob in utils/hidden, since both gates need
it and rule sits below decide.

Also: parity.sh still asserted the old model (naming a mount confines
the session), which is what turned CI red; it now pins that an unnamed
mount keeps its own mode and that exclusion is a hide. Drops a dead
"not allowed to access mount" row from the TS fuse errno table, whose
error class this branch removed.
2026-08-20 17:06:12 -07:00
Zecheng Zhang df179bbfce Merge pull request #875 from strukto-ai/feat/k2-wave2e
feat(hierarchy): move lancedb and qdrant onto the kit with per-mount routes
2026-08-20 16:14:18 -07:00
Zecheng Zhang 8d04d90ddb Merge pull request #874 from strukto-ai/feat/k2-wave2d
feat(hierarchy): move discord, slack, gmail and email onto the kit
2026-08-20 16:14:03 -07:00
Zecheng Zhang e4e332abb6 feat(hierarchy): move discord, slack, gmail and email onto the kit 2026-08-20 07:45:05 -07:00
Zecheng Zhang 2b5c2b42f3 feat(permissions): one role per session, mounts narrow instead of gating
A role is now the whole permission document a session runs under. There
is no workspace `permissions:` block, no `permissions:` on a mount, and
no `extends`, so reading `profiles.<name>` is reading everything that
role may do. `mounts:` states infrastructure only.

Two rules decide a line, and they are the whole law. A rule naming no
path is read by verb, deny before ask, wherever in the document it is
written. A rule carrying paths, and every hide, is read by anchor depth:
the deeper entry wins, ties break by verb. `decide()` holds both.

Naming a mount narrows it; it is not an allowlist. A mount a role does
not name keeps its own mode. Keeping a session away from one is a hide,
which answers ENOENT rather than a refusal that names what the role
cannot see (EACCES on a create, since a silent success would leave a
file the session cannot find).

Three ways a command still learned a hidden path was there, all fixed
above the backend rather than per command:

- MountView splits into `descendants` (every mount below a path, for
  pruning) and `visible_descendants` (session-filtered, for naming), so
  `tree`, `tar` and `zip` stop drawing or naming a hidden mount while
  still refusing to cross it.
- `du` decided existence from the bound accessor, which knows nothing of
  hides. It asks the dispatcher first and its content probe now counts
  only entries the session may see.
- TypeScript `readlink` treated only PolicyDenied and EINVAL as silent,
  so ENOENT printed a raw path. It matches python's `except OSError`.

dsh's read-only twin copied only the source session's mount modes, which
used to be the allowlist; it now carries the hides too.
2026-08-20 07:37:00 -07:00
Zecheng Zhang d77bd73f3b feat(hierarchy): move lancedb and qdrant onto the kit with per-mount routes 2026-08-20 06:55:09 -07:00
Zecheng Zhang eb4448d055 Merge pull request #873 from bytecii/fix/py-ts-parity-and-dedupe
fix(parity): github ref default, NAME_MAX byte budget, browser registry onto node's config shape
2026-08-20 06:51:46 -07:00
bytecii 3f42dbaf6c fix(gate): see exported and quoted rename maps, and selftest the patterns
The rename gate matched only lines beginning with `const` and only bare
object keys, so `export const S3_BROWSER_RENAME` -- added in the same
change -- was invisible to it, as were `'api_key': 'apiKey'` and a
camelCase map name. A gate blind to a form in use is worse than no gate:
it reports success over the blind spot.

The const pattern now allows an `export` prefix and matches the name
case-insensitively; the pair pattern accepts a bare or quoted key in
either quote style. Sweeping the repo again with the wider patterns finds
nothing new, so the original sweep was complete.

`--selftest` carries one fixture per declaration form in use plus the
negatives, and runs ahead of the scan in CI. It fails on the old patterns
(4 of 10 cases) and passes on these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:20:14 -07:00
bytecii ca89dc5970 fix(parity): github ref default, NAME_MAX byte budget, browser registry onto node's config shape
Two py<->ts divergences, one structural alignment, one swept-and-gated class
of config noise.

1. GitHub `ref` defaulted to the literal "main" in python where TypeScript
   resolves `config.ref ?? repoInfo.default_branch`, so every repository whose
   default branch is something else 404d on the one tree fetch the mount is
   built on and read as empty. `GitHubConfig.ref` is now optional, and
   `ensure_ref` resolves it lazily beside the existing `ensure_default_branch`
   -- python's constructor deliberately does no network. Every reader that
   needs a concrete ref goes through it. `mirage.accessor.github` is fully
   annotated and leaves mypy's untyped-defs allowlist.

2. `sanitize_label` capped characters where NAME_MAX counts bytes, which the
   constant's own comment already spelled out. gdocs/gsheets/gslides rendered
   ~367-byte filenames for a CJK title and gmail/email ~269; ext4 and APFS
   reject those. `sanitize_label`/`sanitizeLabel` grew a byte budget and every
   compose site passes what the date, id and suffix leave. Found alongside: TS
   email's hand-rolled copy of the sanitizer used JS's ASCII-only `\w` (so
   non-ASCII subjects became underscores there and stayed intact in python),
   and three sites re-composed `<subject>__<id>.<ext>` independently of
   readdir, so a search hit could name a path that does not exist.

3. The browser registry hand-rolled `normalizeFields` per entry and cast
   through 7 `*BrowserCtorConfig` interfaces -- hand-written twins of the real
   config types. It now delegates to a `normalizeXConfig` beside each config,
   as node's registry does; the interfaces and 22 double casts are gone.

4. 95 rename entries that only restated `snakeToCamel` deleted across 24
   files, with `scripts/check_normalize_renames.py` and a CI step to keep them
   from returning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:34:03 -07:00
Zecheng Zhang f5b2e1e677 Merge pull request #872 from bytecii/refactor/browser-resources-onto-base 2026-08-19 23:33:10 -07:00
bytecii a9c1aba03c refactor(browser): the resource layer inherits BaseResource instead of restating it
Every resource in `packages/node` (28/28) and `packages/core` (8/8) extends
`BaseResource`; in `packages/browser` none of the 19 did. They declared
`implements Resource` and hand-rolled the base's own members, which cost
behavior rather than style:

- `Workspace` hands a mount's index config to `resource.setIndex?.()`
  (`workspace/workspace/workspace.ts`). `setIndex` exists only on
  `BaseResource`, so that optional call silently no-opped: mounting a browser
  backend with `index: {...}` ignored it -- no custom ttl, no Redis index --
  where node and python honored it.
- each hand-rolled `close()` returned `Promise.resolve()` without closing the
  index it had built, the exact leak `BaseResource.close` documents ("a mount
  configured `index: {type: redis}` holds a client that nothing else closes").
- the eager `new RAMIndexCacheStore(...)` in every constructor built a store
  even for resources that never read one; the base builds it lazily.
- `workspace/mount/storage.ts` carries a WeakMap serial-number fallback
  written for this gap. It stays for third-party resources, but no
  first-party browser resource depends on it now.

Two smaller browser-only gaps close alongside:

- `deltaHook` reached box, dropbox, gdrive, github and s3 in node but not in
  browser, so watch/delta was silently absent there. The five core
  `buildDeltaHook`s take only an accessor and import nothing runtime-specific.
  Verified before wiring s3: the presigned-URL shim implements
  `ListObjectsV2Command`, honors `ContinuationToken`, and returns
  `IsTruncated`/`NextContinuationToken`, so the listing walk paginates.
- browser kept a `linear/config.ts` duplicating `core/linear/config.ts` field
  for field, and inlined in `registry.ts` the rename maps core already exports
  as `normalizeLinearConfig` / `normalizeGitHubConfig`. Both now come from
  core, as node takes them, which also retires the restated
  `LinearBrowserCtorConfig`. Python keeps linear's config in
  `core/linear/config.py` with no `resource/linear/config.py`, so this matches
  the python layout too.

Deliberately unchanged: discord, slack and notion configs are not drift.
Browser discord/slack take `{proxyUrl, getHeaders}` against core's `{token}`
because a browser cannot hold a bot token, and browser notion takes an MCP
`authProvider` where core takes an `apiKey`; browser s3's config is the
presigned variant. Repointing any of them at core would break browser auth.

`base_contract.test.ts` pins both halves: a sweep asserting every exported
`*Resource` inherits `BaseResource`, and a behavioral pair on the mount index
config. Against the pre-change tree it fails with `TypeError: r.setIndex is
not a function`.

Python needed no change -- all its resources already extend `BaseResource`
(the 14 s3 aliases inherit through `S3Resource`).

Layout baseline 257 -> 256: dropping browser's duplicate linear config closed
a divergence, and the gate fails on improvement too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:20:18 -07:00
Zecheng Zhang da52187c00 Merge pull request #870 from strukto-ai/feat/k2-wave2cd
K2 final wave: trello, linear, notion and gcal onto the hierarchy kit
2026-08-19 22:12:31 -07:00
Zecheng Zhang 7b30f49f4b fix(integ): turn off git auto maintenance in the fixture repo
Recent git detaches maintenance run --auto after a commit, and the stray
process drops .git/objects/maintenance.lock into the tree while a test
is mirroring it file by file, so the walk reads a path that vanishes
mid-copy and dies with ENOENT.
2026-08-19 21:39:29 -07:00
Zecheng Zhang 5f928ceb5b Merge pull request #871 from bytecii/fix/code-point-budgets
fix: measure the gmail excerpt and daemon description budgets in code points
2026-08-19 21:24:51 -07:00
Zecheng Zhang 250dd54cf7 fix(kits): resolve containers through the index, not per-directory find chains
Review fixes for #870. make_readdir gains entry_listers: a directory
proven by its parent's listing gets its own index entry resolved by the
kit (parent warm, each level cached once), so a traversal never re-fetches
ancestor listings per directory; trello and linear drop their find chains
and ride child-json sizes on the parent entries' extra. make_stat,
make_readdir and make_unlink substitute a call-local RAM index when handed
none, so entry resolution works for callers without a cache.
2026-08-19 21:23:19 -07:00
Zecheng Zhang 268fc8e71d refactor(kits): K2 final wave: trello, linear, notion and gcal onto the hierarchy kit 2026-08-19 20:28:01 -07:00
Zecheng Zhang d0b8fd472a Merge pull request #865 from strukto-ai/feat/permissions-commands
feat(permissions): allow, ask and deny commands, with an approval door
2026-08-19 20:27:07 -07:00
Zecheng Zhang 3b94a3895e fix(permissions): judge redirect targets and walked-into mounts, spec-classify whole-line words, unwrap builtin, fail closed on runtime walks and globs, key exact-line grants on their rule 2026-08-19 18:50:15 -07:00
Zecheng Zhang cabd51d0e5 Merge pull request #868 from strukto-ai/feat/k2-wave2ab
K2 wave 2a+2b: postgres, mongodb and the google trio onto the hierarchy kit
2026-08-19 16:32:46 -07:00
Zecheng Zhang 2088a0cd2d feat(permissions): guard command I/O with the session's path rules
Bind the admitted line as an EntryGate and check every content and
mutation op a command performs below its operands, with GNU-pinned
refusals per walker (grep, rg, ls, find, du, cp, tar, zip, tree).
Native find/du/copy yield to the guarded walk while a rule is in
force; stat and exists stay open (deny is present-and-refused).

Workspace permissions=, profiles=, create_session and Mount now accept
plain documents, validated internally like the YAML and TS doors.

Pin that two installs of one CLI spec are two subjects: tiers key on
the installed head word, each install runs under its own account
(hm_duo cases on cli-himalaya, second and third GreenMail accounts
provisioned per reset). Fixes the TS crash on an allow-only commands
doc (compileCommands and tightenCommands defaulted deny).
2026-08-19 16:22:03 -07:00
bytecii db8677981f fix: measure the gmail excerpt and daemon description budgets in code points
Two more of the divergence PR #869 fixed for `sanitizeLabel`, found by
sweeping the remaining string budgets from the python side. Both measure
and slice `String.length`, which counts UTF-16 units, where the python
twin measures `len` and slices, which count code points.

`extractExcerpt` carried four unit-counted offsets: the `EXCERPT_MAX`
cut, the `indexOf` hit, `pattern.length` and `flat.length`. A 200-emoji
body is 200 code points and 400 units, so python returned all 200 and
typescript returned 117 -- and the windowed branch sliced at an odd unit
index, leaving a lone surrogate at each end that encodes as U+FFFD in
the grep line. Every offset now runs over `Array.from(flat)`; `indexOf`
still searches in units, so its result is converted once rather than the
search being rewritten.

`describeResource` had the same pair against python's
`raw[:_DESCRIPTION_MAX - 1]`. Every PROMPT mirage ships is ascii today,
so it is latent rather than live, and is fixed for consistency instead
of left as a known-divergent twin.

Both sides get parity pins mirroring the sanitize ones: an input python
leaves whole survives intact, and an over-budget astral input cuts on a
code-point boundary with no U+FFFD once the bytes are written. The
excerpt pins run through the exported `format_grep_results`, so they
exercise the real function, and every expected string was read off the
python implementation first. Reverting either fix fails exactly the two
new astral cases per language.

`describeResource` is exported for its pin; its python twin was already
importable. The two new python suites close mirror gaps without moving
MIRROR_BASELINE, which already counted both basenames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:56:42 -07:00
Zecheng Zhang 9f916cd6c6 refactor(kits): K2 wave 2a+2b: postgres, mongodb and the google trio onto the hierarchy kit 2026-08-19 15:55:41 -07:00
Zecheng Zhang 204c6453d4 Merge pull request #869 from bytecii/refactor/dedupe-configs-msgraph
refactor: collapse duplicated config, sanitizer, render and drive-op bodies
2026-08-19 15:36:15 -07:00
bytecii 2955dd9fc0 fix: measure sanitizer budgets in code points, and type the jsonl fixtures
Two review findings.

`sanitizeLabel` measured and sliced `String.length`, which counts UTF-16
units where python's `len` counts code points. 50 ascii characters
followed by 26 Deseret letters reads as 102 units and 76 code points, so
typescript truncated a label python left whole -- and `slice` landed
inside a surrogate pair, leaving a lone high surrogate that encodes as
U+FFFD in the filename. Both now measure `Array.from`. `sanitizeName`
one function above had the identical pair against python's
`cleaned[:MAX_LEN]` and is fixed the same way rather than left as a
known-divergent twin. The regex half was already right: `UNSAFE_CHARS`
carries the `u` flag, so only the budget was counting units.

The CI break was mine: dropping the type parameter from
`jsonlBytesByCreatedAt` to satisfy no-unnecessary-type-parameters turned
on excess-property checking, which the tests tripped by passing fresh
object literals. They now declare their fixtures as `NormalizedComment[]`,
which is what the production call sites pass anyway, and the `it` titles
pick up the function's new name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:12:44 -07:00
Zecheng Zhang be10d073a4 feat(permissions): mapping rule grammar, hidden operands dropped before policy, globs and subtree commands judged, blank and relative paths refused 2026-08-19 10:49:55 -07:00
bytecii 1f4b1f5bfd refactor: collapse duplicated config, sanitizer, render and drive-op bodies
Three cleanup items from the round-2 duplication survey. No behavior
change except one py/TS divergence fix, called out below.

Runtime-agnostic config modules move into core. The seven
`resource/<name>/config.ts` modules under browser and node were
byte-identical to each other -- `diff` reported zero difference -- and
hold nothing runtime-specific, so CLAUDE.md puts them in core. The six
google ones land at `core/src/resource/<name>/config.ts`, where python
already keeps them and nine other backends already keep theirs;
`GitHubConfig` lands in `core/src/core/github/config.ts` next to
`GhConfig`, mirroring `python/mirage/core/github/config.py`, which
closes a layout divergence.

One sanitizer replaces five copies. `sanitize_title` (gdocs, gsheets,
gslides) and `_sanitize` (gmail, email) were the same function differing
only in the empty-input fallback and the length budget, so both become
arguments of `sanitize_label` in `utils/sanitize`, which already owned
the regexes and `sanitize_name`. The gzip stream helpers move to a new
`mirage/utils/compress.py` -- the twin of `utils/compress.ts`, another
closed layout divergence -- so gunzip stops carrying its own copy of
the decompressor. `jsonl_bytes_by_created_at` replaces linear's and
trello's `to_jsonl_bytes` on both sides, and databricks' two spellings
of `_is_directory` become one in `_helpers`.

onedrive and sharepoint were true name-only twins in exactly two files,
`exists` and `truncate`; both now come from `make_exists` /
`make_truncate` in msgraph, the module that already owns everything
else these two share. Their other sixteen core modules genuinely
differ in how they address items and are left alone.

Divergence fixed: the four typescript sanitizer copies used `/[^\w\s\-.]/g`,
and javascript's `\w` is ascii-only, so a CJK title rendered as a row of
underscores where python -- whose `\w` is unicode-aware -- kept it.
`utils/sanitize.ts` already spelled the class as `\p{L}\p{N}_`, so
adopting it moves typescript onto python's behavior. No test or integ
case pinned the old output; both sides now pin the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:17:21 -07:00
Zecheng Zhang 8d76ca0547 Merge pull request #866 from bytecii/refactor/s3-alias-base
refactor(resource): collapse the 42 S3-alias resource bodies onto a shared base
2026-08-19 02:28:20 -07:00
Zecheng Zhang 215e292a58 fix(permissions): read literal words in the whole-line gate, admit the implied cwd operand, carry the agent with the execution 2026-08-19 02:09:31 -07:00
bytecii 9a59b5e405 refactor(resource): collapse the 42 S3-alias resource bodies onto a shared base
Every S3-compatible provider (MinIO, Ceph, R2, Wasabi, ...) is an
S3Resource reached through a provider-shaped config, and each one
hand-wrote the same class body: store the config, retag ops and commands
from `s3` onto the alias kind, build redacted state, no-op loadState.
That body was duplicated 28 times in TypeScript (14 providers x browser +
node, differing only in the prompt constant) and 14 times in Python.

The config half was already shared -- `resource/s3_alias.{ts,py}` holds
`S3AliasConfig` plus the region/fixed endpoint rules -- so the resource
half now joins it in the same module rather than a new file:

- TypeScript gains `S3AliasResource<TConfig, TRedacted>` in each runtime
  package's `s3_alias.ts`. It cannot live in core: `S3Resource` is
  defined per package and the two are different hierarchies (browser
  `implements Resource`, node `extends BaseResource`).
- Python gains `S3AliasResource` in `resource/s3_alias.py`, absorbing
  `__init__` and `get_state`. The 14 `load_state` no-ops went with it --
  `S3Resource.load_state` was already a documented no-op, so they never
  did anything.

A base class rather than a factory, so `MinIOResource` stays a real class:
its name still works as a type, `instanceof` and subclassing still work,
and the prompt stays a plain field (`override readonly prompt =
MINIO_PROMPT`). `MINIO_PROMPT` itself is untouched -- it lives in
`prompt.ts` and is still re-exported from the package index, so importing
it directly is unchanged.

`toS3Config` and `redact` pass as functions rather than as the existing
`Alias<C, R>` object because four providers (gcs, oci, supabase, wasabi)
have region/endpoint rules that fit neither `makeRegionAlias` nor
`makeFixedAlias` and write their own pair; the two functions are what
every provider has either way.

The one deliberate break: the unread per-provider `minioConfig` /
`minio_config` field is now `aliasConfig` / `alias_config`. Nothing
outside each class read it except seven Python tests, updated here.

52 files, -843 lines net. Verified: node 2399 tests, browser 212, full
Python suite and mypy (1973 files) clean, 11/11 typecheck, eslint/knip/
prettier clean, and layout parity (259), case targets (2289), spec parity
(93) and barrel surface all at baseline. The existing 81-test
`s3_aliases.test.ts` battery already asserts exactly what moved -- alias
kind, retagged ops, retagged commands, redacted state -- for all 14
providers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 01:06:32 -07:00
Zecheng Zhang 7ca2a713ef fix(permissions): gate whole-line runtimes, follow links for policy scopes, keep grants across restart
- one admission gate (workspace/node/admission) for the tree and for a line a runtime takes whole: visibility, the policy chain, the approval door, per parsed command
- policy scopes carry the typed operands and, for a command that follows links, the targets they resolve to
- the default session hydrates its grants; TS approvals name the agent of the call that asked
- request ids are sha256 digests; the runtime and fuse integ harnesses build Deny(reason)
- the same in TypeScript
2026-08-18 23:49:52 -07:00
Zecheng Zhang 8e18c5687b Merge pull request #863 from bytecii/fix/rm-r-invalidates-subtree
test(cache): service-free pins for the recursive-delete subtree evict
2026-08-18 23:27:34 -07:00
Zecheng Zhang a040a125bb refactor(permissions): match package, constants modules, session codecs
- policy/match is a package: pattern (grammar), allow (allow lists), rule (RuleMatch, match_rule, match_op)
- policy/constants holds POLICY_DENIED_EXIT, the default reasons, WILDCARD, EXACT_LINE_DECISIONS
- workspace/session/constants holds DEFAULT_PROFILE, PROFILE_INHERITED_FIELDS and the session field lists; workspace/session/serialize holds the rule, commands and grant record codecs
- the same split in TypeScript
2026-08-18 22:59:05 -07:00
bytecii 4a47e2f8d2 test(cache): service-free pins for the recursive-delete subtree evict
#864 fixed the bug and pinned it in `integ/resources/cache/subtree_evict.json`,
which needs a live minio or mongo to run. These three add the same guarantee
where a plain `pytest` / `vitest` run will catch it.

`tests/workspace/test_cache_invalidation.py` gets the two shell-visible
symptoms end to end against moto S3, which is the file's existing pattern: a
warm `ls` caches the nested listing, `rm -r` takes the parent, and the reads
that follow used to answer from cache without touching the bucket -- `cat`
printing a deleted file's bytes at exit 0, and `ls` rendering a directory the
store no longer had. `find` went to the backend and disagreed with both in the
same session.

`tests/core/ram/test_rm.py` and `core/ram/rm.test.ts` are a mirrored pair one
layer down: the real `rm_r` driven through a real `CacheManager` over a RAM
index and file cache, asserting the nested listing and the nested body are
both gone. RAM needs no service, so the wiring from a backend mutator through
`cache/context` to the manager is covered on every run rather than only when
the integ battery has a container.

All three were checked falsifiable against the merged fix: reverting
`make_remove_prefix` to `invalidate_after_unlink` fails the two moto tests,
and reverting `core/ram/rm.ts` fails the vitest one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:51:24 -07:00
bytecii 6594de90b7 Merge pull request #864 from bytecii/fix/recursive-delete-subtree-cache
fix(cache): a recursive delete or directory rename evicts its subtree
2026-08-18 22:40:40 -07:00
Zecheng Zhang 2a881db3d4 Merge remote-tracking branch 'origin/main' into feat/permissions-commands
# Conflicts:
#	python/mirage/workspace/session/permissions.py
#	typescript/packages/core/src/workspace/session/permissions.ts
2026-08-18 21:58:17 -07:00
Zecheng Zhang bcd36714b5 feat(permissions): allow, ask and deny commands, with an approval door
- PermissionsPolicy binds the mount, workspace and profile commands tiers
- an unlisted command is 127 before pre_command; the deny arm is 126 or the operand voice
- Ask stops at ws.approvals: RecordApprover by default, CallbackApprover blocks, grants live on the session and are spent through the manager
- integ target ram-commands with the answer field; runners take permissions on targets and mounts
2026-08-18 21:55:51 -07:00
bytecii 808b792a02 fix(cache): a recursive delete or directory rename evicts its subtree
`invalidate_subtree` had exactly one caller, the watcher. No `rm -r`,
recursive prefix delete or `rename` reached it in either language, so
every listing and body cached *below* a deleted or moved directory kept
being served: `ls` printed a deleted directory's contents and `cat`
returned a deleted file's bytes, both exit 0, until the index TTL
expired. Reproduced against live minio with the bucket verified empty,
so the reads were provably cache rather than survival.

Each mutation site reported `invalidate_after_unlink`, which evicts the
path's own listing and its parent's -- complete for a file, blind for a
subtree, because everything beneath was cached under its own key and
nothing above it evicts one.

`cache/context` gains an `invalidate_subtree` beside the two it already
had, and `CacheInvalidator` gains the method. It delegates to the
manager rather than walking: ancestors can be assembled from
`invalidate_after_write` calls because the caller knows the chain, but
only the caches know which keys lie beneath a path. Then one rule in
both languages -- an op that destroys or moves a whole subtree evicts
that subtree -- so every `rm_r`/`remove_prefix` and both endpoints of
every `rename` swap over. It is a strict superset of
`invalidate_after_unlink`, so a file path behaves exactly as before.

`unlink`, non-recursive `rmdir` and `copy` are deliberately untouched.

This came out of the kit plan's "delete-side `invalidate_ancestors`
asymmetry sweep", whose premise was wrong: `invalidate_ancestors` exists
because one op can materialize several levels at once, which no delete
does on a real-directory backend, and the keyed-store delete that does
need it already had it. box and gdrive only looked like they were
missing it -- their `mkdir -p` invalidates per created level in a
bespoke loop.

Also corrects two `emulate_truncate` docstrings that still named
s3/ssh/ram/redis as the emulating backends; #858 left dropbox as the
only one.

Pinned by `integ/resources/cache/subtree_evict.json`, 16 cases across
s3/s3-prefix/gridfs/gridfs-prefix, captured empirically on both hosts
and checked falsifiable by reverting the object_store half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:52:45 -07:00
Zecheng Zhang 7829fce724 Merge pull request #862 from strukto-ai/fix/ts-fuse-driver-error
fix(fuse): tell a driverless machine which driver to install
2026-08-18 21:51:52 -07:00
Zecheng Zhang 53078e465d fix(fuse): name the driver for the platform, not WinFsp everywhere
fuse-native targets macOS and Linux; its legacy Windows path builds
against the unmaintained Dokany-based fuse-shared-library-win32, not
WinFsp, and only Python mounts FUSE on Windows. A hint that listed all
three drivers therefore told a Windows reader to install something that
cannot make the load succeed.

driverHint reports macFUSE on darwin, fuse3 on linux, and says outright
that TypeScript FUSE is macOS and Linux only anywhere else.
2026-08-18 20:41:19 -07:00
Zecheng Zhang 2ee8df4c91 fix(fuse): tell a driverless machine which driver to install
fuse-native dlopens its binding against libfuse while it loads, so a
machine that has the package but no driver fails with ERR_DLOPEN_FAILED,
which is not a resolution failure. loadOptionalPeer rethrew that raw, so
the reader got "Library not loaded: /usr/local/lib/libfuse.2.dylib" with
nothing to act on, and the message it does produce for a missing package
says to install the package, which is the wrong advice when the driver is
what is missing.

loadOptionalPeer now takes an optional systemHint: with one, a load
failure is wrapped with it and keeps the original as its cause; with
none it still rethrows unchanged, so every other caller behaves exactly
as before. mount passes the hint naming macFUSE, fuse3 and WinFsp, which
is what Python's load_fuse already reports (#829).

First test for the loader: nothing covered it.
2026-08-18 20:34:23 -07:00
Zecheng Zhang ca283c5c89 Merge pull request #860 from bytecii/fix/dropbox-list-folder-walk-cap
fix(dropbox): bound the emptiness probe's walk, not just its page
2026-08-18 20:23:03 -07:00
Zecheng Zhang d40c30803d Merge pull request #859 from strukto-ai/codex/main-parity-sweep
Fix permissions parser parity
2026-08-18 20:22:48 -07:00
Zecheng Zhang fd54246a00 Merge pull request #829 from Jackpkn/fix/mfusepy-oserror-guard
fix: catch OSError when mfusepy cannot find libfuse
2026-08-18 20:22:33 -07:00
Zecheng Zhang cfad943919 fix(permissions): a mount ceiling must be a mode name, and both parsers refuse the same shapes
Python raised a bare TypeError out of parse_mount_mode when a mount
ceiling was not a string, which is not the ValueError the config
loader's contract promises; TypeScript already guarded it on the
adjacent line. TypeScript read a Set, a Date or any class instance as
an empty mapping, so a mounts Set compiled to a session granting
nothing at all. Both languages now answer the same message for a
scalar, where one said "a list of strings" and the other "a mapping".

create_session's mounts is Sequence[str], which is what the validator
accepts since it stopped iterating arbitrary iterables, and TypeScript's
deny list check moves into a shared asList mirroring python's _list.
2026-08-18 19:48:10 -07:00
Zecheng Zhang 697fd9f0ea fix(fuse): report every mfusepy import failure as a missing driver
mfusepy resolves libfuse while it is imported and raises AttributeError,
not OSError, when the library it finds has the wrong major version. That
case leaked out of load_fuse instead of arriving as the RuntimeError
naming the extra and the drivers.

test_fs's _fuse_available only checked the platform, so the one test that
really mounts still failed where no driver is installed. Probing the
import takes tests/fuse from 1 failure to 0 on a driverless machine.

Pin that load_fuse installs the macFUSE extensions: the FSKit write
surface depends on them being declared before the operations struct is
built, and the loader is now the only place that declares them.
2026-08-18 17:49:25 -07:00
Zecheng Zhang a7cb96de56 Merge pull request #858 from bytecii/feat/k1-wave2
refactor(kits): K1 wave 2 — hf onto object_store, opfs onto the ops factory
2026-08-18 17:33:44 -07:00
bytecii 1a209f8e6c fix(dropbox): bound the emptiness probe's walk, not just its page
`list_folder` took a `limit` that it passed to /files/list_folder as
that endpoint's page-size hint and then looped on `has_more`,
accumulating every entry. So `limit` capped the page, not the walk.

Four callers used it as a bounded emptiness probe and got the opposite
of what they wanted -- one request per child of the folder to answer a
yes/no: dropbox rmdir and rename, in both languages.

Same defect and same fix as Google Drive in #854: the page hint keeps
its own name (`page_size` / `pageSize`, still spelled `limit` on
Dropbox's wire) and the new `limit` is the walk cap, breaking the
continuation loop once that many entries are in hand. The four probe
call sites are unchanged -- `limit=1` now denotes the bound they always
meant. readdir and watch pass no cap, so they still walk to completion.

The pin has to run the real `list_folder`, because the bug lives inside
it: a fake substituted for it answers in one call whatever it is handed,
so it cannot tell a bounded probe from an unbounded one, and asserting
only on the recorded cap passes against the broken form. The fakes
stand in for the transport instead, page the way the live API does, and
record both the cap each caller asked for and the request count -- the
count is what fails (3 vs 1) before the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:27:01 -07:00
Zecheng Zhang 0842e0522c Fix permissions parser parity 2026-08-18 17:14:31 -07:00
Zecheng Zhang 5ad703c4db refactor(fuse): load mfusepy lazily 2026-08-18 17:03:44 -07:00
bytecii 68c93c29b7 refactor(kits): K1 wave 2 — hf onto object_store, opfs onto the ops factory
Finishes the K1 wave the object_store/hierarchy kits opened in #848.

- object_store driver gains `markers_supported` and makes `move_file`,
  `move_prefix` and `copy_file` optional. A store that cannot hold a
  directory marker now says so once instead of every factory guessing,
  and `make_rename`/`make_copy` refuse at build time rather than
  emitting an op that dies on the first call.
- hf moves onto the kit: one driver (`resource: "hf"`) replaces 11
  hand-written cores per language. The Hub refuses `create_dir` and has
  no server-side move or copy, so it declares `markers_supported=False`
  and leaves move/copy unset — mkdir becomes a no-op and rename/cp stay
  unwired (ENOTSUP), which is what they already did.
- The write factories translate a driver's not-found into ENOENT on the
  PathSpec. Driver primitives speak keys, so the store's own error names
  a backend key; only the factory holds the path the user typed, and
  that is the only spelling a message may carry.
- opfs ops move to `makeGenericOps`, deleting 22 hand-written files.
  `mkdirParents` is set because OPFS resolves one directory handle per
  segment.
- Five python ops tables passed `emulate_truncate=True` to a table that
  already has a native truncate, so the flag never fired; TypeScript
  never had them. Dropped (dropbox's is real and stays).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:44:36 -07:00
Zecheng Zhang 5101e53dab Merge pull request #857 from strukto-ai/feat/permissions-doc
feat(permissions): permissions document and profiles replace guards
2026-08-18 16:41:05 -07:00
Zecheng Zhang 3d46358548 Merge pull request #854 from bytecii/fix/rmdir-enotempty
fix(core): rmdir refuses a non-empty directory at the op, not just in the builder
2026-08-18 16:13:08 -07:00
Zecheng Zhang 1fdb10b637 fix(permissions): default profile shapes the workspace session, deny rules take string lists
Review round 1 on #857:
- profiles.default reaches the workspace's own session: the manager's
  default_profile applies it at construction and re-stamps the narrowing
  after hydration; compile_profile folds the infrastructure prefixes,
  apply_profile / narrow are the one apply path with create_session
- a deny rule's commands/paths must be lists of strings, reason a string
- rename policy/spec -> policy/rule (SpecPolicy -> RulePolicy) and
  workspace/session/profile -> workspace/session/permissions
- drop the guards-is-refused leftovers (fixture case, loader test arms)
2026-08-18 16:00:17 -07:00
bytecii 607499f532 fix(gdrive): bound the TypeScript rename conflict probe, and pin both
The `limit` added to Drive listing in the previous commit reached python's
rename but not its TypeScript twin, which still asked for `pageSize: 1`.
`listFiles` follows every `nextPageToken`, so that capped the page rather
than the walk and made the probe issue one request per child of the
destination folder before answering ENOTEMPTY -- more requests than the
default page size, not fewer. Same defect the Graph probe had, and a
divergence the previous commit introduced.

Neither language pinned it, which is why it survived. Both fakes now
record the `limit` each caller asked for, and each rename test asserts
the conflict probe was bounded. Confirmed failing against `page_size=1` /
`pageSize: 1` in both languages before restoring.

Not fixed here, and pre-existing: dropbox's `list_folder` / `listFolder`
takes `limit` as the API's page-size hint and then walks every
`has_more` cursor, so the four `limit=1` emptiness probes in
dropbox rmdir and rename have this same shape. It needs a walk cap
distinct from the page hint, because `readdir` and `watch` call the same
helper wanting the full listing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:38:47 -07:00
Zecheng Zhang a9347d168e Merge pull request #856 from bytecii/fix/search-pushdown-multi-operand-rest
fix(search): gate the remaining grep/rg/find push-downs on operand count
2026-08-18 15:26:40 -07:00
Zecheng Zhang 21875bbd08 feat(permissions): permissions document and profiles replace guards
- CommandRule + DEFAULT_DENY_REASON in policy/types; SpecPolicy matches
  paths through classify_paths/path_hidden (wildcard_regex gone)
- SessionProfile / WorkspacePermissions / MountPermissions document in
  workspace/session/profile, resolver (inherit, tighten, rebase,
  bound_hidden, compile_profile) in workspace/session/resolve
- Workspace(permissions=, profiles=), Mount(permissions=),
  create_session(profile=, permissions=), YAML permissions:/profiles:
- workspace and mount hides ride Session.bound_hidden, never persisted
- guards= / Policies.add(GuardSpec) / YAML guards: removed
2026-08-18 14:12:39 -07:00
bytecii 94a1c01a68 fix(search): gate the remaining grep/rg/find push-downs on operand count
gmail, slack, discord and email answered a search push-down from
paths[0] and silently dropped every other operand, so
`grep -rw foo /slack/channels/a /slack/channels/b` searched only `a`
and reported the result as if it covered both. Route all four through
the shared gate the companion PR added for langfuse/mongodb/postgres
(`pushdown_operand` / `pushdownOperand`) rather than adding a second
one.

The gate grew a `honored` parameter: these four backends legitimately
require `-w` (provider search is word-based, so `-w` is what makes a
verbatim answer faithful), and email honors `n`/`l`/`w`/`o`/`m`
because it narrows-then-scans locally with the real compiled pattern.
Every other shaping flag now defers to the generic scan.

Fixed along the way:

- email/rg.py returned exit 1 (no match) when it could not resolve a
  folder, reporting "nothing matched" for a search it never ran. It
  now falls through to the generic scan.
- email find's push-down answered the mount root with an empty result
  and exit 0; it now walks like any other unservable path.
- email TS greped each message as a single line, so -n reported 1 for
  every hit and printed the whole message as the matching line. It now
  splits like python's splitlines().
- slack TS was missing python's `target != "files"` and
  `search_available` terms; `SlackTransport` gained an optional
  `searchAvailable?()` so node can answer from its token while the
  browser transport leaves it unimplemented (read as "try it").
- gmail had no unresolved-glob check at all, so an unexpanded glob
  reached the native search.

slack/discord `coalesce_scopes` is deleted rather than promoted to
"always coalesce": `build_query` emits only `in:#channel`, so folding
a date directory into its channel drops the date and widens the search
to the whole channel -- and it widened a single leaf operand the same
way. It also never fixed the cross-channel drop it looked like it
addressed.

Unit tests mirror langfuse/test_grep.py (a multi-operand invocation
must not reach the native fetch), and 13 integ cases pin the behavior
byte-identically on both hosts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:34:05 -07:00
bytecii fa22d38ee4 fix(core): rmdir deletes the marker, not the prefix; bound the Graph emptiness probe
Review follow-ups on the rmdir series, plus the one real CI failure.

object_store `make_rmdir` now deletes the directory's own marker key
rather than the prefix. The emptiness listing proves nothing about the
moment of the delete, so a prefix delete stayed a recursive delete: a
child created in between was destroyed anyway, which is the subtree loss
this function exists to stop in a smaller window. Deleting the one key
that spells "empty directory" cannot reach a child whatever arrived
after the probe. The `is_root` arm of the guard goes away with it -- a
root holding no key has no marker, and `delete_file("")` is an invalid
S3 request where `delete_prefix("")` was a harmless no-op, so the empty
root falls through as the no-op it already was. `is_root` still gates
the ENOENT refusal, which is where it belongs.

`drive_root_empty` / `driveRootEmpty` sends one bounded request instead
of walking `graph_list`, which follows every `@odata.nextLink` and so
downloaded an entire folder listing to produce one boolean. `$top`
through that helper would be worse rather than better -- it shrinks each
page, so the walk pages more times, not fewer -- so the probe issues its
own `graph_get` with `$top=1&$select=id`. `graph_list` stays where a
caller needs every child, which is the copy/move conflict machinery.

The onedrive and sharepoint stubs now register the query with the URL,
which makes each of them a pin on the boundedness itself: a regression
to the paginating helper stops matching. A new test in both languages
wraps `list_children` in a generator that creates a child after the
listing drains, then asserts both that the late child survived and that
exactly one key was deleted.

redis `test_rmdir_not_empty` asserted the old bare wording and now
asserts the errno, matching its ram twin. That was the only real CI
failure on 3e85cd0; the test is gated on REDIS_URL, so it skipped
locally and ran in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:11:49 -07:00
bytecii a3112af01d fix(core): rmdir refuses a non-empty directory at the op, not just in the builder
`rmdir` checked emptiness in the generic_bind builder, so the whole shell
battery passed while six backends per language implemented the core rmdir
op as a recursive delete. Three callers reach an op without passing a
builder -- MountCore (FUSE), Ops/ws.fs, and the runtime VFS (os.rmdir /
os.remove inside a sandbox) -- and all three destroyed the subtree.

The slot aliasing took two forms: s3 and gridfs took the object store
kit's `make_remove_prefix`, while nextcloud, gdrive, onedrive and
sharepoint each sent the same request their own `rm_r` sends (WebDAV
DELETE on a collection, Drive files.delete on a folder, and Graph DELETE
on a driveItem are all recursive). On a keyed store the worst case was
the mount root, which resolves to the bare key prefix: `rmdir` there
emptied the whole bucket. TS `ram` was worse than its python twin in
another way -- it dropped the directory key with no checks at all,
leaving every child keyed but unreachable.

Each backend now probes with the cheapest thing it already has: the kit
reuses `list_children` and stops at the first non-marker entry, msgraph
reuses `drive_root_empty`, and gdrive reuses the probe `rename` already
makes. `make_rmdir` also reports ENOENT for a prefix holding no key at
all, which the shared prefix delete cannot do because `rm -r` owns that
refusal through its own `-f`.

Errors are one vocabulary now. ram, redis and databricks_volume raised a
bare OSError/Error whose only signal was its wording, which `classify`
cannot name at all: no errno for ws.ops or the sandbox runtimes, and EIO
over FUSE unless the adapter's message needle happened to catch it. Box
and ssh had the same gap from the other direction, refusing correctly but
letting an untyped service error through; both now name the condition,
and dropbox's hand-rolled OSError becomes the shared helper, so
`enotempty` is the only spelling left outside the vocabulary modules.
The needle stays, but only for SFTP 3's single failure code, which is not
an OSError and carries nothing else -- it no longer covers for the repo's
own backends.

TS MountCore.rmdir drops its emptiness pre-check, matching python's: it
cost a listing per call on an API mount and its catch swallowed whatever
readdir raised. Its two existing FUSE tests still pass, which is the
proof the op layer covers what the pre-check did.

Also: nextcloud mkdir invalidates ancestors unconditionally. opendal's
create_dir is MKCOL over every missing level whatever `parents` says, so
a bare `mkdir a/b/c` materialized a chain while every ancestor above the
parent kept serving a cached listing that hid it.

Gates, so this class cannot come back: a provenance comparison over every
backend's wired CommandIO in python (object identity misses the factory
form -- one factory called twice yields two distinct closures running one
body) and a source scan in TypeScript, where core cannot import from node
or browser. Both were confirmed to fail against each reverted form.

integ pins the op layer through `integ/runtime`'s `facade` step and a
guest rmdir, the only integ surfaces that bypass the builders; all four
cases fail without the fix on both hosts. The step gained an `errno`
expectation because `throws_contains` reads the message and the two
languages word one condition differently -- python renders the strerror,
the TS FsError carries only the path.

Verified: full py suite, mypy clean over 1888 files, TS 9105 + 2553
tests, pnpm -r typecheck across 9 projects, pre-commit, layout and spec
parity on baseline, and both integ runtime hosts green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 12:11:49 -07:00
Zecheng Zhang 3820f0028d Merge pull request #855 from strukto-ai/examples/ssh-runtime
examples: load S3 data on a remote box through the ssh runtime
2026-08-18 11:54:37 -07:00
Zecheng Zhang 7d40d85e35 examples: load S3 data on a remote box through the ssh runtime 2026-08-18 11:53:01 -07:00
Zecheng Zhang 982bc425b1 Merge pull request #852 from strukto-ai/runtime/ssh-provider
feat(runtime): add the ssh sandbox provider
2026-08-18 11:36:21 -07:00
Zecheng Zhang 3abbfe4845 Merge pull request #853 from strukto-ai/refactor/builtin-tiers
refactor: builtin tiers, per-command packages, BUILTINS table
2026-08-18 11:34:50 -07:00
Zecheng Zhang 913f532fe2 docs: give the ssh runtime page an in-house logo 2026-08-18 10:43:21 -07:00
Zecheng Zhang e32a76d073 refactor(man): document words, not resources; ManHit becomes ManEntry
A man page renders a name's spec, which is the same however many mounts
register the name, so the page drops its RESOURCES section and the bare
listing is by kind of word (# commands, # clis) instead of by mounted
resource kind. ManHit (mount + registration + is_general) is replaced by
ManEntry (name + spec): the manual carries no mount and no resource, and
handle_man no longer takes the session it only read cwd from.
2026-08-18 10:40:38 -07:00
Zecheng Zhang 94a1b249db Merge pull request #851 from bytecii/fix/search-pushdown-multi-operand
fix(search): gate the grep/rg push-down on operand count
2026-08-18 10:39:56 -07:00
Zecheng Zhang c897f1d3e5 fix(runtime): default the ssh username and use the agent when no key is named 2026-08-18 10:39:33 -07:00
Zecheng Zhang dbb46ddfb2 refactor(shell): file every builtin in a BuiltinGroup, derive the tier sets 2026-08-18 10:20:05 -07:00
Zecheng Zhang 60f0c85ce2 fix(builtins): unignore the env builtin package, gitignore's env/ hid it 2026-08-18 10:05:16 -07:00
Zecheng Zhang 1ea4428f32 refactor(builtins): one package per shell builtin, dispatch through a BUILTINS table
Every shell builtin now lives in its own package under
workspace/executor/builtins (<word>/<word>.py plus constants.py and
types.py where there is content; builtins sharing one engine share a
package named after it: condition, lookup, declare, dirs, script,
metadata, links, alias, mapfile, control), mirrored in TypeScript.
vars.py/vars.ts are gone; the shared refusal and identifier helpers
moved to shared.

BuiltinCall/BuiltinFn (builtins/types) and the BUILTINS table
(builtins/table, 46 words) replace the if-chain in command_dispatch:
each package exposes a <word>_builtin(call) adapter holding the old arm
verbatim, and _run_argv/runArgv does one lookup. Tests move 1:1 with
their modules; the tests-mirror ratchet drops 191 -> 186.
2026-08-18 10:00:08 -07:00
Zecheng Zhang 334b2a7768 refactor(shell): split ShellBuiltin into grammar and tool tiers 2026-08-18 10:00:02 -07:00
Zecheng Zhang 5d6f49bd06 feat(runtime): add the ssh sandbox provider 2026-08-18 09:57:01 -07:00
bytecii 2e7da4bc2d fix(search): gate the grep/rg push-down on operand count
A search push-down asks the backend one whole-container question and
prints its entire answer, so it can only stand in for a line naming
exactly one operand. Given two it answered for the first and dropped the
rest in silence: `rg pat /lf/traces /lf/sessions` reported only traces,
and no diagnostic said sessions had gone unsearched.

Running the push-down once per operand is not the fix. Several scopes map
to the same container search -- langfuse routes both `sessions` and one
`session` to "search every session" -- so two operands in one family
would print that container twice. A multi-operand line therefore defers
to the generic scan, which searches each operand in turn the way GNU
does, and which every backend without a push-down already used.

The gate is written once rather than per wrapper. `pushdown_operand` /
`pushdownOperand` (regex push-down: mongodb, langfuse) and
`literal_pushdown_operand` / `literalPushdownOperand` (LIKE/ILIKE:
postgres) return the sole concrete operand or None, folding the operand
count in beside the glob, pattern-list and shaping-flag rules the six
wrappers were each open-coding as a five-clause boolean. Twelve gates
collapse to twelve calls, so they cannot drift apart again.

postgres was not in the original report and has the same bug through the
same `search_pushdown_ok` gate; it is fixed here because it shares the
helper. gmail, email, slack, discord and email's `find` are also
affected and are deliberately left alone: slack and discord already
carry a `coalesce_scopes` mechanism that makes "coalesce every operand"
the likely answer there rather than "defer", and email's `rg` returns
exit 1 instead of falling through, so each needs its own decision.

Behavior is pinned empirically on both hosts against a real Langfuse
v3 stack, MongoDB and Postgres (13 integ cases; 6 of the 8 database ones
and all 5 langfuse ones fail on the unfixed tree). The two `lone_operand`
cases pass either way on purpose -- they guard against over-deferring.
The multi-operand shapes match GNU grep 3.11 and ripgrep 14.1.1: two
directory operands without -r are two `Is a directory` lines and exit 2,
and a repeated operand is searched twice rather than deduped.

Also fixes a misdirected test: `test_unresolved_glob_defers_to_generic`
built its PathSpec without a `pattern`, so `has_unresolved_glob` returned
False and the deferral it asserted actually came from `/traces/*` routing
to `invalid`. It now uses `/sessions/*`, which routes to a searchable
scope, so only the glob half of the gate can defer it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 07:53:40 -07:00
Zecheng Zhang a97ccd22b4 Merge pull request #850 from strukto-ai/docs/runtime-sandbox-providers
Organize sandbox runtime documentation
2026-08-18 07:02:18 -07:00
Zecheng Zhang 6a085ebe82 Merge pull request #849 from bytecii/cleanup/items-32-34-37 2026-08-18 06:42:59 -07:00
Zecheng Zhang 5d06281df3 docs: organize sandbox runtime pages 2026-08-18 06:34:26 -07:00
bytecii 9913916bac Merge pull request #848 from bytecii/refactor/k1-k2-kits
refactor: K1 object_store + K2 hierarchy kits — s3/gridfs + jaeger/langfuse/mem0 exemplars (py+ts)
2026-08-18 06:09:22 -07:00
bytecii 49fe6cf8f6 fix(cmp): GNU-pin the -n/-i count reader; fix py3.11 f-string; drop test docstrings
Three things, all on top of the items 32/34/37 branch.

1. Python 3.11 build fix. `python-install` failed with
   `SyntaxError: f-string expression part cannot include a backslash`:
   `f"...{held.count(ord(b'\n'))}"` is legal only from 3.12 (PEP 701) and
   pyproject declares >=3.11. The newline byte is a module constant now.

2. The cmp count reader, re-pinned on GNU diffutils 3.10. Codex flagged
   that `b` is not one of cmp's suffix letters (od takes it, cmp does
   not); probing the rest of the set found three more divergences in the
   same function, plus two of my own that the first version of
   integ/unix/cmp/{n,i}.json had pinned as golden:
   - cmp's letters are kKMGTPEZY with the B/iB variants -- no b/c/w and
     no lowercase past k, so `-n 1b` is exit 2. New CMP_SIZE_UNITS,
     separate from OD_SIZE_UNITS.
   - the ceiling is INTMAX, not UINTMAX: 7E passes, 8E/1Z/1Y do not, and
     overflow reports as the same invalid-value error, not od's "too
     large". Q/R are absent from diffutils 3.10's gnulib, so `0Q` is
     invalid where `0Z` is a valid zero.
   - the count reads at base 0: `-n 010` is 8 bytes, `0x400` is 1024.
     The decoder is one parse_base0/parseBase0 twin shared with od,
     which had the rule inline; int(s, 0) cannot stand in because py3
     rejects `010`.
   - the Try-help line carries the `cmp: ` prefix (diffutils routes it
     through error()). USAGE_HINT_PREFIX already knew this; the string
     was hand-written and bypassed it, so it is built by a named
     usage_hint/usageHint that extra_operand_error shares.
   - a bad SKIP1 names the whole SKIP1:SKIP2 pair, a bad SKIP2 names
     only itself, matching where xstrtoumax stopped.
   OD_COUNT_PATTERN becomes XSTRTOUMAX_PATTERN, since cmp shares it.
   n.json now pins `-n 1b` and `-n 010` directly. 36 mirrored unit tests
   per side, up from 19.

3. Removed the top-of-file docstrings from the three new python test
   files, per the CLAUDE.md rule.

Verified: both integ batteries 5648/0 on ram+disk, core vitest 677 files
9040 passed, python suite green, mypy 1874 files, typecheck/eslint/knip
clean, all six gates at baseline. py and ts also diffed against each
other over 42 parseCount inputs: identical accept/reject and identical
below 2**53, differing only in double representation above it (as od
already does), which is documented in the TS docstring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 06:08:54 -07:00
Zecheng Zhang 8a570cae01 Merge pull request #847 from bytecii/fix/ls-r-nested-mount-row
fix(ls): list a nested mount's root in its parent's -R listing
2026-08-18 05:53:03 -07:00
bytecii 383bd9a4d1 fix(langfuse): gate the grep/rg search push-down on shaping flags (codex #848)
The langfuse fast path ran for every invocation but only honored -e/-i/-F,
so shaping flags (-v, -c, -l, -n, -o, -m, context) were silently ignored
and always answered with path:record lines. Wire the shared
has_search_shaping_flags / hasSearchShapingFlags gate (and the
has_unresolved_glob guard) into all four wrappers, mirroring mongodb's
grep/rg: any flag the push-down cannot honor now defers to the generic
scan, which reads the full rendered files and applies exact semantics.

The summary-search trade-off itself stays: py grep/rg and ts grep all
searched listing summaries before this PR, the push-down exists to avoid
one fetch per listed trace, and the semantics are now documented at the
gate site and pinned in integ (a pattern that only occurs in observation
bodies exits 1 on the bare search; grep -rn defers and finds it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 05:34:54 -07:00
bytecii 88ef99016e fix(ls): list a nested mount's root in its parent's -R listing
`ls -R` on a directory holding a nested mount omitted the mount's own
row while still printing its group, so `ls -R /base` reported `top.txt`
and then `/base/nested:` for a mountpoint it had never named. GNU
(coreutils 9.7, pinned over a real tmpfs) lists a mountpoint as an
ordinary directory entry of its parent, `-R` or not.

The generic conflated two different things: listing a mount root as a
row, and descending into it. `-R` withheld the whole `child_mounts`
merge on a backend-served listing, on the theory that the cross-mount
fan-out contributed the nested mount whole. It contributes the group,
not the parent's row, so the row survived only by accident -- whenever
the parent's own backend happened to hold a key of that name. Splitting
the two: the merge is unconditional, and descent stops at a mount root,
read from the mount table (`MountView.is_root`) rather than inferred
from `child_mounts` being empty. A namespace-only directory *above* a
mount root is still descended, because nothing else renders it.

Same cause, three more shapes. `ls -R /` dropped `ghost` and both the
`/ghost:` and `/ghost/very:` groups whenever a backend served `/`;
`/base/sub`'s group dropped a mount at `/base/sub/deep`; and a relative
operand leaked shadowed content, printing `base/inner:` twice -- once
with the parent's hidden `leftover.txt`, once with the mount's own
listing -- because `_drop_shadowed_ls_groups` only recognizes an
absolute header. That group is now never produced rather than produced
and filtered.

So `ls` takes `mounts` beside `links` and `child_mounts`, and it names
the boundaries *this walk's readdir cannot cross*, not the boundaries
that exist. The distinction is the whole contract, because the two
entry points differ: `ls_generic` is bound to one backend and stops
there, while the cross-mount relay's readdir routes per path, crosses
a boundary itself, and must -- nothing runs behind a relay to
contribute that group the way the fan-out does. Python said this by
omission already, calling the generic with explicit keywords; the
TypeScript relay hands the generic a whole `CommandOpts`, so it now
builds one carrying every name-plane fact except the boundaries.
Without that, `ls -R /base /other` dropped `/base/inner:` entirely.

Two unit tests that passed a bare `child_mounts` now supply a mount
table, since the fact moved to its real source, and both relay suites
gain a case that hands the relay a full namespace.

`integ/crossmount/nested/basic.json` seq 950006 pinned the bug; it now
pins GNU's answer.

A mountpoint is not always a directory, so the row is stat'd, not
synthesized. Every workspace mounts `/.bash_history` as a whole mount
serving one file, and a hardcoded `FileType.DIRECTORY` made `ls -aF /`
print `.bash_history/` and `ls -l` render it `drwxr-xr-x`. No backend
can answer that question -- the parent's cannot see into the child, and
the child's own names its root `/` -- so it goes to the dispatcher via
`opts.stat_path`, which routes to whichever mount owns the path, and
the row is renamed the way the relay renames one. Directory stays the
fallback for a caller with no dispatcher, which is the only thing that
absence can mean.

The same fact settles what `-R` descends: the ls fan-out no longer
emits a block for a mount that is not a directory, which otherwise
printed `.bash_history` a second time as its own group. Only a
*confirmed* non-directory is dropped, so a root the dispatcher cannot
stat keeps its block rather than vanishing on a failed probe. GNU is
pinned the same way, over a `mount --bind` of one file onto another:
one ordinary row in the parent, no group of its own.
2026-08-18 05:10:42 -07:00
bytecii 5a9aadf289 refactor: close the cleanup plan — execute_node split, candidates() deletion, integ facets (+2 cmp fixes)
Items 32, 34 and 37 of the 37-item cleanup plan.

Item 37b: execute_node's two oversized arms (377 of ~820 lines) move to
siblings named for their node kind — workspace/node/declaration.{py,ts}
and workspace/node/assignment.{py,ts}. Verbatim move, two-line
delegations left behind; execute_node.py 1464 -> 715, .ts 1617 -> 842.
expand_array_items becomes a named cross-module seam. The setAttr
allowlist gates (py+ts) follow _stamp_export to its new module.

Item 34: delete candidates() from runtime/table.ts — zero non-test
callers, no Python counterpart. RUNTIMES stays (py derives NAMED from
it; TS can't, Runtime.name is an instance field).

Item 32: the missing integ facets — cmp/{n,b,i}, df/units, gzip/f,
ls/t, checksum/tag. mktemp was already covered (stale plan entry) and
the checksum --check companions are covered collectively, so the
per-command fact pinned instead is the --tag algorithm name.

The cmp facets found two real bugs, both GNU-pinned before the fix:
-b was ignored under -l (and the octal column is right-aligned to 3 in
both modes), and -i N:M crashed with a leaked ValueError. Fixing them
pulled in GNU size suffixes for -n/-i, cat -v byte rendering, the
byte/char wording switch, and the EOF notice as a stderr diagnostic
naming byte and line. Python's cross-mount relay re-derived flags
instead of calling parse_flags, which is how it kept reading -i as a
bare int; it now delegates like the TS relay already did.

cmp had no unit tests in either tree; 19 mirrored ones per side now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:04:56 -07:00
bytecii dd14575d3e feat(ts): K1 object_store + K2 hierarchy kits — s3/gridfs + jaeger/langfuse/mem0 exemplars
Mirrors the Python kits: core/object_store (ObjectStoreDriver +
14 op factories, s3/gridfs drivers, shared object_store command
overrides via requireOp) and core/hierarchy (Codec/Route/match_route,
readdir/stat/read factories) with jaeger, langfuse and mem0 migrated.
mem0 reshaped to python stems (scope/readdir/stat/read/client/search);
gridfs du/walk.ts absorbed into the driver; langfuse grep/rg share the
route table via SEARCH_KINDS, giving TS rg the search pushdown py
already had. Layout parity baseline ratchets 269 -> 260.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 21:48:11 -07:00
bytecii 14c43d02b5 style(py): pre-commit formatting pass over K1/K2 modules
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 21:47:26 -07:00
bytecii e39b44c92a fix(commands): typed returns for the object_store command factories
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 21:47:26 -07:00
bytecii 203c59bbdb refactor(core): K2 hierarchy kit — jaeger/langfuse/mem0 route tables (py) + integ pins
One route table per backend now generates detect_scope and drives the
readdir/stat/read skeletons in core/hierarchy; langfuse's grep/rg
push-down classifies through the same table instead of a second
hand-maintained ladder. The langfuse prompt-version int() crash becomes
ENOENT via the INT_JSON codec (pinned in integ), and the new
delete-side ancestor eviction is pinned under integ/resources/cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 21:47:25 -07:00
bytecii 48ef7c2b48 refactor(core): K1 object_store kit — s3+gridfs derive the op family from a driver (py)
The whole readdir/stat/exists/find/du/write/remove/rename/copy family
now derives once in core/object_store from an 8-primitive driver;
s3 and gridfs keep only their native calls (driver.py) plus read,
stream, and watch. The five duplicated command overrides collapse into
commands/builtin/object_store factories. Delete-side ops now invalidate
ancestor listings, closing the stale-prefix gap writes already handled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 21:47:25 -07:00
Zecheng Zhang 64228287b1 Merge branch 'main' into fix/mfusepy-oserror-guard 2026-08-17 21:46:24 -07:00
Zecheng Zhang 27e1b6a9df Merge pull request #846 from strukto-ai/fix/dsh-sandbox-and-workdir
fix(dsh): make the providers behave inside a real dsh composition
2026-08-17 21:46:01 -07:00
Zecheng Zhang 1170899c0c fix(dsh): narrow the read-only twin from the bound session, keep unbound calls one-shot 2026-08-17 21:02:19 -07:00
Zecheng Zhang 49a6219abb fix(dsh): make the providers behave inside a real dsh composition
A working directory dsh resolved on its own machine named nothing in the
mirage world, so pwd reported a path the agent could not reach and every
relative path failed; the same host path reached ctx.fs as the base for
relative file_path arguments. Both now fall back to the configured
directory. Because a per-call workdir or env forks a subshell, and dsh
sends both on every call, a configured sessionId never persisted
anything either: the managed DSH_* snapshot is seeded into the bound
session instead of riding along per call.

The sandbox policy dsh resolves per call is now enforced rather than
reported. Under read-only the shell runs with every mount granted read
(the null sink stays writable) and ctx.fs refuses a mutation with
FS_SANDBOX_DENIED. MirageFileSystem declares the same workspace-write
confinement the executor does, so the two seams over one world stop
giving dsh's tool layer opposite answers.

A foreground run keeps the output it produced before a timeout or abort
instead of returning an empty string, and reports a spill path when
truncation dropped bytes. Background output is bounded in bytes rather
than re-encoded per chunk (1 MB of output: 562 ms to 1 ms), console
retention follows the configured budget, listDir honours its abort
signal per entry, a failing declarative mount no longer reaches the
process as an unhandled rejection, and runtimes beside an adopted
workspace are refused instead of dropped.
2026-08-17 20:50:07 -07:00
Zecheng Zhang bc0adbd248 Merge pull request #844 from bytecii/fix/xmount-ls-headers
fix(ls): relay cross-mount ls so GNU headers and the global sort survive
2026-08-17 18:08:38 -07:00
Zecheng Zhang b44f1ad370 Merge pull request #843 from bytecii/cleanup/items-36-37
refactor: close the 37-item cleanup plan — printf engine split + duplication remainder
2026-08-17 18:08:24 -07:00
Zecheng Zhang 0fa6867bda Merge branch 'main' into cleanup/items-36-37 2026-08-17 16:42:17 -07:00
Zecheng Zhang 2904b5edb0 Merge branch 'main' into fix/xmount-ls-headers 2026-08-17 16:41:58 -07:00
Zecheng Zhang e0a4f51109 Merge pull request #845 from strukto-ai/fix/deepmerge-ts-advisory
fix(deps): override deepmerge-ts to >=8 for GHSA-ggr8-5vv4-36mx
2026-08-17 16:41:49 -07:00
bytecii 0ddfe19de6 fix(find): compute -empty in the search-backed factory (codex #843)
The TypeScript search-backed find never set `FindEntry.isEmpty` and left
`treeHasEmpty` out of `needsKind`, so `evalPredicate` -- which only
accepts `isEmpty === true` -- rejected every walked entry. `find -empty`
on chroma and dify returned nothing, including genuinely empty files and
directories. Python's factory has always done both; this is a py/ts
divergence, not a regression from the collapse: the pre-refactor
`core/chroma/find.ts` on main carries the identical `needsKind` line.

Mirrored from python. Directory emptiness answers off the walked list --
the whole subtree arrives in one `walk`, so a directory is empty exactly
when no other walked key sits under it, and no extra readdir is needed.
`walkFind` still has to ask readdir because it drives its own traversal.
Files fall back to size, and the stat is now cached so `-empty` combined
with `-size`/`-mtime` does not fetch twice.

Also passes `empty` into the factory's fallback `buildTree`, which python
does and TypeScript did not. Latent today because `findGeneric` always
supplies a prebuilt tree, but the shared factory should not disagree with
its twin.

Collapsing chroma+dify into one factory is what makes this a one-place
fix rather than two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:32:40 -07:00
Zecheng Zhang a11d8f6393 fix(deps): override deepmerge-ts to >=8 for GHSA-ggr8-5vv4-36mx 2026-08-17 16:26:09 -07:00
Zecheng Zhang 7ce7a8d4b9 Merge pull request #842 from strukto-ai/perf/integ-runtime-parallel-hosts
Run the two integ CLI hosts concurrently
2026-08-17 16:14:56 -07:00
Zecheng Zhang cc7d537026 Merge pull request #839 from strukto-ai/chore/integ-txt-to-json
Retire the integ truth txt files for JSON
2026-08-17 16:11:29 -07:00
Zecheng Zhang af897b95e8 Merge pull request #841 from bytecii/refactor/k3-client-migrations
refactor(api): K3 waves 2+3 — every hand-rolled HTTP client onto the core/api kit (py+ts)
2026-08-17 16:11:14 -07:00
bytecii 6f26fd6121 fix(ls): relay cross-mount ls so GNU headers and the global sort survive
`ls` with operands on different mounts printed one flat unheaded list.
It fanned out one native run per operand, and each run correctly saw a
single operand, so none could head its own block or sort against the
operands living on other mounts.

GNU decides ls's layout across the whole line: non-directory operands
print first as one globally sorted block, then directories in sorted
order, each named, and headers appear only when the line carried more
than one operand -- command-line order is discarded, so `ls b a` prints
exactly like `ls a b`. A fanout combiner would have to re-sort globally
and redo the file/dir split, duplicating ls's layout engine in both
languages. So ls moves to RELAY, where per-path primitives route through
the dispatcher and one shared generic sees every operand at once.

Relaying puts readdir and stat on different mounts, which the
single-mount path never does. A mount answers its own root with its own
name for it, so a nested mount's row rendered as `/`, and `-R` then
recursed without bound: the generic descends via
`_child_spec(path, entry.name)`, and parent + "/" is the parent again.
The relayed stat is renamed from the path it was asked about. Separately,
the namespace attr overlay is applied at the builder layer rather than
inside dispatch, so the relay composes it itself or a relayed row
silently loses a chmod.

`ls_options()` gives both entry points one flag-to-keyword mapping, since
LsFlags already names every field after the ls parameter it feeds.

integ/crossmount/ls/multi.json had pinned the flat output as golden;
re-pinned to GNU's, after verifying mirage matches it byte for byte on
that tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 04:14:40 -07:00
bytecii 9164339fb3 chore: lower the layout-parity baseline to 268
core/generic/find.py now mirrors core/generic/find.ts, so the gate's
divergence count fell by one. The ratchet fails on a drop as well as a
rise, so the improvement has to be locked in rather than spent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 02:37:20 -07:00
bytecii 2add9fa3ee refactor: close the cleanup plan — printf engine split + duplication remainder (#36, #37)
Item 37 (T3-6): extract the IEEE-754 printf engine out of
workspace/executor/builtins/text.{py,ts} into printf_format.{py,ts}.
text.py 776 -> 263 (-66%), text.ts 995 -> 266 (-73%). The move is
internal: no external importer reached the engine. printf keeps its own
escape tables because its grammar differs from echo's, matching how TS
already modelled it. Drops two orphans found in there: a duplicated doc
block on assignPrintfTarget and a trailing doc comment for handleRead,
which lives in vars.ts. New mirrored tests pin what the handle_printf
wrapper hides — the error list's order and count, and the
reuse-termination invariants — all GNU-pinned in debian:stable-slim.

The clause's second half, splitting execute_node, is deliberately left
out: every arm recurses through execute_fn and mutates shared
session/view state, so it needs its own review rather than this diff.

Item 36 (T3-4): add browser/src/resource/s3_alias.ts mirroring the node
twin and collapse the 14 browser alias configs onto it (793 -> 516
lines, net -158). The factory carries three knobs the survey actually
found: a derived endpoint rule, a region default (gcs and r2 report
auto), and forcePathStyle — which supabase alone pins true, the
difference a careless collapse would have flattened. Browser had no
equivalent of node's s3_aliases.test.ts; it does now, 47 tests over all
14.

Extract makeSearchBackedFind into core/generic/find.{py,ts} beside the
existing walkFind; chroma/dify find.py 139+139 -> 6+6 and find.ts
144+144 -> 21+21, with 13 new tests giving that logic its first direct
coverage. The python factory keeps the accessor as a TypeVar because
callable parameters are contravariant, matching the TS factory's <A>.

makeVectorSearch is not done, on evidence: the five search files are not
token-substitution-identical — qdrant and lancedb differ in config
vocabulary, score field and rendering — and mem0 has no TS twin, so a
five-backend shared factory cannot be mirrored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 02:14:50 -07:00
bytecii 886ad60064 fix(api): max_backoff caps server-asked waits; transport retry covers timeouts
Review round: a Retry-After (or body retry_after) above the policy's
max_backoff now gets the ceiling instead of stalling the command (restores
dify's retry_max_delay contract, matches the TS dify client), and
retry_transport also retries asyncio.TimeoutError, which a total timeout
raises without being a ClientConnectionError (TS already retries any fetch
rejection).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 01:52:27 -07:00
Zecheng Zhang 4277f017ba perf(integ): run the two CLI hosts concurrently
integ-runtime is the longest job in the Integ workflow, and one step is
essentially all of it: "Run every runtime through the CLI and both
daemons" was 27 minutes of a 30 minute job, while the two JSON-suite
steps beside it took 16 and 32 seconds. cli.sh spent that running the
python host to completion and then the typescript host, though the two
share nothing that matters: separate daemon ports, a MIRAGE_HOME each,
and only ram mounts reach the CLI at all, so no store is common to them.
The docker suite is the one thing they do share, and its six cases are
stateless execs (echo, exit, wc, uname) rather than writes, so two
`docker exec` sessions in the one container cannot collide.

So both hosts now run at once. A host's tally has to leave its subshell
through the filesystem, and the parent sums the two, keeps a missing
tally as a failure rather than a zero, and prints each host's log whole
instead of letting them interleave.

Measured locally against the same suites: 465s sequential, 273s
concurrent, both reporting 171 passed, 0 failed, 37 skipped. Pointing one
host at a CLI that does not exist still exits 1 and still attributes
every failure line to that host.
2026-08-17 01:46:35 -07:00
bytecii cdd866406b refactor(api): K3 waves 2+3 — every hand-rolled HTTP client onto the core/api kit (py+ts)
Grow the kit engine (read modes json/none/bytes/text/location, ByteWindow
Range+trim, raw bodies, session reuse/timeout, transport retry, TokenManager
seed) and migrate github/slack/trello/linear (wave 2) plus box/msgraph/
dropbox/google/dify/jaeger verbs, discord+slack file downloaders, and
gsheets writes (wave 3) in both languages. Extract per-backend constants
(github py, box py+ts, msgraph py+ts) and delete 9 mypy strict opt-outs.
SDK-backed clients (py langfuse/chroma/mem0, TS Octokit) and chunked-stream
generators deliberately stay as they are.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 00:35:18 -07:00
Zecheng Zhang 53174598d2 Merge pull request #837 from bytecii/cleanup/items-33-35
refactor(layout): items 33+35 — module homes and workspace/config placement (34 already closed)
2026-08-17 00:04:28 -07:00
Zecheng Zhang 697c04e07d Merge pull request #840 from bytecii/fix/vid-drop-and-xm-ls-headers
fix(s3): refuse a version-pinned read on the browser presigned client
2026-08-17 00:04:13 -07:00
Zecheng Zhang f368743310 fix(integ): make the redis examples self-contained, recapture their truth
integ/truth/python/redis.json asserted a `ls /data/` of five entries when
the example creates three. The two extras were leftovers in my local
redis db 0: example_redis.py hardcoded that URL and ignored $REDIS_URL,
db 0 is the polluted one here, and the example never cleaned up, so both
--emit runs saw the same garbage and captured it as truth. On CI's fresh
redis the listing is three entries and the check failed.

integ/truth/python/redis_vfs.json had the same five entries for a
different reason: it only ever passed because example_redis.py runs
earlier in the same job and left its files in the shared mirage:fs:
namespace. That is an ordering dependency nothing declared, and making
example_redis.py tidy up is what exposed it.

So both examples now read $REDIS_URL like their cache and index siblings
already did, name the key prefix they use, and delete it on the way out.
Running them in either order against a dirty db now gives the same output
as a fresh one, and both truth files are recaptured from a clean db:
redis.json 152 -> 139 matchers with one volatile, redis_vfs.json 20
matchers with none, which matches its TypeScript twin.

example_redis_fuse.py gets the $REDIS_URL read too; it has no truth file
but it carried the same hardcode. TypeScript needs no change: redis.ts
already clears the namespace and the TS truths are order independent.
2026-08-16 23:43:33 -07:00
bytecii 0cae9c1793 fix(s3): refuse a version-pinned read on the browser presigned client
The presigned-fetch shim's Get case read only Key and Range, so a
VersionId was silently dropped and the request returned whichever version
was current. A snapshot-pinned read (`revisionFor()`, set at read.ts:65
and stream.ts:50) therefore got the wrong bytes with no error and no log,
across all 15 browser resources that route through createBrowserS3Client
— s3, r2, gcs, minio, ceph, backblaze, wasabi, digitalocean, scaleway,
supabase, aliyun, tencent, oci, qingstor, seaweedfs.

A presigned URL is signed for a key, not a version: the provider is
`(path, operation, options)` and has no slot to carry one. So the shim now
refuses, the same treatment #742 gave Range. Head is guarded through the
same helper — stat does not pin today, but a version-pinned stat would be
wrong in exactly the same way.

Only fields that change the *answer* may refuse. MaxKeys stays ignored:
stat's directory probe sends it, and ignoring it returns more data but the
same answer — a test pins that counter-example so the rule does not get
over-applied later.

Behavior change worth calling out: browser snapshot restores that were
silently returning current-version bytes now fail loudly instead.

Verified the two refusal tests fail against the pre-fix code (guards
commented out: exactly those 2 fail, MaxKeys still passes). They assert
that neither the provider nor fetch runs, which is what separates refusing
from answering — the old path reached both and resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:05:10 -07:00
Zecheng Zhang 9a7be1a1e0 docs: point the filetype gate at the JSON truth files
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.
2026-08-16 23:02:57 -07:00
bytecii 0e749345e5 refactor(layout): items 33+35 — module homes and workspace/config placement
Block E items 33 (T3-3 module homes) and 35 (T3-2 workspace/config
placement) from the cleanup plan. Item 34 (T3-1 runtime layout) was in
scope but is already closed on main; the verification is in the PR body.

Layout parity gate: 286 -> 269 unexcused divergences, baseline lowered.

Item 33 — module homes:
  - py utils/fingerprint.py -> watch/fingerprint.py (all six consumers
    are watch machinery; TS is already at watch/fingerprint.ts)
  - py workspace/provision/rollup.py -> provision/rollup.py (no workspace
    imports; TS is at the leaf provision/rollup.ts)
  - py workspace/provision/builtins.py deleted (one function, one caller;
    TS keeps it module-level in provision_node.ts)
  - py 9 snapshot key enums -> new workspace/snapshot/keys.py
  - py IndexType -> cache/index/config.py, CacheType -> cache/file/config.py
  - ts node/cache/redis/file.ts -> node/cache/file/redis.ts, taking
    add.lua with it (read relatively by its only consumer and copied by
    an explicit tsup path, so tsup.config.ts moves with them)
  - ts new core/src/concurrency/limiter.ts, replacing the private
    Semaphore in accessor/dify.ts

NodeMetaKey deliberately stays beside NodeMeta: it names NodeMeta's own
fields, and importing the snapshot package from mount/namespace closes a
real cycle (namespace -> snapshot.keys -> snapshot/__init__ -> api ->
state -> namespace).

Item 35 — workspace/config placement:
  - ts core/workspace/workspace.ts -> core/workspace/workspace/workspace.ts
  - ts node/workspace/mount_spec.ts -> core/workspace/mount/spec.ts
  - ts new core/workspace/workspace/guard.ts (three call sites converged
    onto it from their own typeof === 'string' tests)
  - ts new node/workspace/workspace/kernel_mounts.ts (NodeWorkspace
    211 -> 169 lines, now delegations)
  - ts server/src/config.ts -> node/src/config.ts, exported from the node
    barrel so a mirage-node consumer can load a YAML workspace config

Three bugs fixed in the dify semaphore on the way, each pinned by a test
verified to fail against the old code: release() returned the permit to
the pool before waking the waiter (two holders at capacity 1), double
release inflated the count, and maxConcurrency < 1 was silently clamped
where python raises.

Also drops `cache: pnpm` from the ts-audit job added in #831. That job
runs `pnpm audit` and never installs, so the pnpm store does not exist;
setup-node's post-run save then fails path validation on every cache
miss and silently skips on a hit, which is why it was green on main and
red here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:51:12 -07:00
Zecheng Zhang eb1c404609 fix(integ): widen the truth matchers the runners disagree with
Three literals were host-dependent and CI proved it: the disk example's
stat line carries the mtime actions/checkout stamps on data/example.json,
its restore root is /tmp on Linux where it was /var/folders on macOS, and
the TS redis cache example echoes $REDIS_URL, which is db 1 locally and
db 0 on the runners.

All three got captured as literals because --emit runs both of its
captures on one machine, so it sees run-to-run volatility and is blind to
host-to-host volatility. That limitation is now in its docstring with the
three classes to widen by hand.

The workflow loops now keep going after a failure and fail the step at
the end, so one stale truth file reports every other one in the same run
instead of hiding the examples listed below it. Both runs this round
aborted early and hid nine Python and two TypeScript files.
2026-08-16 22:48:27 -07:00
Zecheng Zhang 7dfc56ad02 Merge pull request #838 from strukto-ai/refactor/watch-eviction-framing
refactor(watch): let the cache manager walk the ancestor chain
2026-08-16 22:44:31 -07:00
Zecheng Zhang c35fb1cf0e Merge pull request #830 from Beverly621/agent/fix-redis-cache-add-atomic
fix(cache): make Redis file cache add atomic
2026-08-16 22:26:27 -07:00
Zecheng Zhang b810ff931f test(integ): cover the /dev synthetic devices
No case in the corpus read /dev/null or /dev/zero. The only exercise
anywhere was four lines in examples/python/ram/ram.py that the truth
file did not assert.
2026-08-16 22:26:21 -07:00
Zecheng Zhang 1fab37ef48 chore(integ): replace check_lines.sh with a JSON example checker
check_lines.sh matched a handful of unanchored substrings and never
looked at the exit code: about 60 asserted lines against ~7300 lines of
example output, and a crashing example still reported OK.

check_example.py asserts the exit code, the order, and every line. The
truth file owns the command, so a workflow step is a path and cannot
drift from what the truth was captured against. Bulk blocks collapse to
a count plus sha256, so disk.py's 6150 lines are 127 matchers. --emit
regenerates by running twice and turning any differing line into a
digit-generalized regex or an unasserted one.

watch_delta and version_branching declare both a python and a typescript
run, and --emit refuses to write when they disagree, so a shared truth
file is now an enforced parity gate rather than a convention.
2026-08-16 22:26:09 -07:00
Zecheng Zhang c4485cb067 chore(integ): check the metadata scenarios against a JSON truth
metadata.py/.ts now return a dict per scenario and print one JSON line
for check_json.py, replacing the byte diff against truth_metadata.txt.
The moto and from_state chatter can no longer break the check, and
before/after are real booleans instead of the TypeScript side printing
Python's True/False spelling to satisfy a shared file.
2026-08-16 22:25:59 -07:00
Zecheng Zhang 5f8fe6f6a1 fix(workspace): share one event loop across a VFS block
patch_process built the patched os/open with no loop, so every call in a
`with ws:` block reached asyncio.run and got a throwaway loop. A resource
holding a connection pool bound it to the first one, and __exit__ closed
the workspace on yet another, so the close died with "Event loop is
closed". examples/python/redis_resource/example_redis_vfs.py exits 1 on
this today.

The block now opens one loop, and run_async_from_sync drives an idle
shared loop with run_until_complete instead of only honoring a running
one.
2026-08-16 22:25:49 -07:00
bytecii 5b75a70759 Merge pull request #835 from bytecii/refactor/api-kit
feat(core/api): K3 HTTP client kit + notion/discord exemplar migrations (py+ts)
2026-08-16 22:23:23 -07:00
Zecheng Zhang dc8bc48b06 refactor(watch): let the cache manager walk the ancestor chain
The watcher built its own ancestor list and evicted each entry with
invalidate_after_write, which is what CacheManager.invalidate_ancestors
already does. The two agree on the directory keys they drop, so the
watcher's copy only added a second stopping rule to keep in step plus a
file-cache remove per ancestor directory that can never hit.

Adds invalidate_ancestors to the CacheInvalidator protocol in both
languages and pins the reach against a real CacheManager rather than a
fake, since the walk now lives there.
2026-08-16 22:08:21 -07:00
Zecheng Zhang 0d5c6650a3 refactor(cache): load the redis add script from add.lua
Move the inline ADD_SCRIPT string into add.lua beside its module in both
languages, matching cas.lua and append.lua: python reads it through
importlib.resources and registers it (EVALSHA), typescript reads it
relative to import.meta.url and tsup copies it beside the bundle.
Registering the script drops the cast around the raw eval call.

The two inline copies had already drifted on indentation, so add a
tests/test_lua_parity.py guard over every lua pair in the repo: same
basename set, unique basenames (dist copies land flat), byte-identical
contents. cas.lua had no drift guard before; append.lua's now-redundant
one is removed.

Also fold the repeated data/meta key joins into helpers on both stores.
2026-08-16 21:49:00 -07:00
bytecii 78834034b4 fix(core/api): refuse a retry delay we could never wait out (py+ts)
Codex flagged that python's _header_delay takes float("NaN") and
float("Infinity") where the TS mirror guards with Number.isFinite. Real
divergence, but the consequence is worse than reported: asyncio.sleep()
does not raise on NaN, it never wakes, so a malformed Retry-After parks
the request forever instead of retrying.

The same hole is in both body paths, which codex did not flag. json.loads
accepts bare NaN/Infinity literals and overflows 1e999 to inf, so python's
_body_delay hangs the same way; JSON.parse rejects the literals but still
overflows 1e999 to Infinity, and setTimeout silently clamps that to 1ms,
turning the wait into a hot retry. So the guard belongs on all four paths,
not just the one reported.

Both languages now share one predicate: finite and non-negative. Negative
is in there because RFC 9110 delay-seconds is non-negative and a negative
value retries instantly in both runtimes; all of it falls back exactly as
an unparseable header already did. A header above max_backoff is still
honored uncapped -- the cap stays on the fallback only.

Verified by calling the functions rather than reasoning about them: NaN
and inf both hang asyncio.sleep, and node prints TimeoutOverflowWarning
and fires in 1ms for Infinity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:10:57 -07:00
bytecii 9c1916878a fix(integ): follow the _client rename into integ/ and the examples
The rename sweep in 0290b003c covered every surface tsc, mypy and pytest
enumerate, but integ/ is checked by none of them: integ/watch/backends.py
still imported mirage.core.github._client, so the watch battery died at
import and integ-data went red before a single case ran.

Swept the two stragglers (the live import, and a stale doc-comment path in
the browser PKCE example). Verified the whole tree is now clean of stale
module paths: the remaining _client hits are the attribute spellings and
the s3 _client_kwargs symbol the rename deliberately left alone.

Watch battery locally: 170 passed / 0 failed, with github -- the backend
whose import was broken -- fully exercised; s3, gridfs and nextcloud skip
for want of local services.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:10:41 -07:00
Zecheng Zhang 77b578ff82 Merge pull request #833 from strukto-ai/feat/bash-608-rest
feat(bash): let, umask, shopt, alias, mapfile, read flags, declare -g/-n, disown/wait, exec
2026-08-16 20:42:10 -07:00
bytecii 0290b003c7 refactor(api): client rename, per-backend constants, shared types, tenacity engine
Review round on the K3 kit, four moves:

_client.py/_client.ts -> client.py/client.ts across every backend, both
languages (the underscore said plumbing; the kit made the client a named
layer). The sweep covers dotted module paths, module-object imports
(postgres), monkeypatch string targets (including the multiline
commands.builtin.postgres.tail one), vi.mock specifiers, namespace-import
aliases, the mypy override list, and dify's test__client mirror; attribute
spellings like self._client and the s3 _client_kwargs symbol are
deliberately untouched.

API bases, versions and thresholds move out of the four migrated clients
into per-backend constants.py/constants.ts (discord, notion, google,
dropbox), following slack's existing convention; the remaining backends
move with their own K3 migrations.

ErrorOf and PageFetch move to mirage.types / core/src/types.ts as the
shared contracts. The python alias is quoted behind TYPE_CHECKING so
importing mirage.types still never loads aiohttp at runtime.

The python engine's retry loop is now tenacity (already a dependency,
and dify's client is the in-repo idiom): the attempt raises a
_RetryableStatus carrying the body text it already read, the wait
callback derives the delay from it synchronously, and exhaustion maps
through the same error_of hook as a plain error status. TypeScript keeps
the hand-rolled loop - there is no tenacity twin and core takes no new
dependency for a 15-line loop.

Verified again end to end: layout parity 290=baseline, spec trees regen
zero-diff, knip/eslint/tsc clean, core 8824 / node 2255 / browser 184,
py suite green (known local deepagents drift excepted), integ both hosts
7423 passed / 0 failed across the same eight targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:33:18 -07:00
Zecheng Zhang dcbc668c15 Merge pull request #834 from bytecii/feat/sandbox-runtimes
feat(runtime): smolvm microVM provider (py+ts) and confined sandlock python runtime (py)
2026-08-16 17:37:43 -07:00
Zecheng Zhang 9f93d672be Merge pull request #831 from bytecii/cleanup/items-31-32
cleanup items 31+32: nested-function rule, layout parity, ts-audit, ls facets (+2 GNU size-format bugs)
2026-08-16 17:33:49 -07:00
bytecii 77d44fd636 feat(core/api): K3 HTTP client kit + notion/discord exemplar migrations (py+ts)
Extract the plan's Phase 2 kit from the three named sources: msgraph's
retry engine, google's OAuth token manager, notion's cursor paginator.
mirage/core/api/{client,oauth,paginate}.py and the TS twins carry one
request engine (retry policy with header/body delay sources, error_of
hook so every backend keeps its own exception type and message bytes),
one subclass-hook TokenManager (lock in py, inflight-promise in ts),
and the results/has_more/next_cursor collector.

notion and discord ride the kit as the two exemplars, behavior-pinned:
discord GET still waits out 429s via the body's retry_after while
mutations surface immediately, plain errors stay ClientResponseError /
DiscordApiError, and the transports keep their fetch injection seams.
google and dropbox token managers become kit subclasses; box waits for
its own migration (dev-token/CCG/rotation modes).

notion._client and discord._client are strict-clean and leave the mypy
opt-out list. The aioresponses stream_writer shim, duplicated in the
onedrive and sharepoint conftests, hoists to tests/core/conftest.py.

Phase 0's last open item is closed by disproof, not code: databricks
write pre-checks its parent (check-and-raise, not mkdir -p), so it can
never stale an ancestor listing — #793 documented that exclusion as
deliberate.

Verified: layout parity 290=baseline, spec trees regen with zero diff,
knip/eslint/tsc clean, py+ts unit suites green, integ both hosts green
(py 4933+142, ts 7423; notion/discord/dropbox/gdrive/gcal/gmail, no
silent skips).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:31:01 -07:00
Zecheng Zhang 87273e1c95 fix(bash): quote mapfile callback records, wait -p after many ids, character-counted read -n, exec >> open
Also share the file-open umask rule between the plain and exec redirect
paths, and route the four new builtins through the existing option
scanner, refusal helper and single-quote renderer instead of their own.
2026-08-16 17:15:12 -07:00
bytecii 27d1c5a7bd fix(integ): mark the smolvm and sandlock suites optional so the strict floor skips them
integ-runtime sets INTEG_RUNTIME_STRICT=1, which turns an unmet suite
requirement into a failure unless the suite declares itself optional.
Every existing suite is satisfiable on a GitHub runner -- the workflow
even starts the docker container the docker suite needs -- so these two
are the first that genuinely are not: smolvm needs a hypervisor
(/dev/kvm) the hosted runners do not have, and sandlock needs its CLI
plus Landlock ABI v6 (Linux 6.12+), with no apt package to add cheaply.

Each carries an optional_reason naming what a host would need and when
the flag should come off, and the runner still prints the skip, so the
dropped coverage stays visible rather than reading as a pass.
2026-08-16 17:01:24 -07:00
bytecii 62dddc86fc fix(review): exact BigInt ceiling for TS sizes; divergence must explain
Two codex P2 findings, both verified against GNU before acting.

`humanScaled` computed `n * 10` as a double. That leaves the safe-integer
range a little under a petabyte, and the product rounds down before the
ceiling, landing on the wrong tenth: 1914029841632461 bytes rendered
`1.7P` in TypeScript where GNU (`numfmt --to=iec --round=up`) and the
integer-exact Python say `1.8P`. A real py/ts divergence reachable from
`ls -h`, `du -h` and `df -h` on a large enough tree. The scaling now runs
in BigInt, which is what Python's arbitrary-precision ints were doing all
along, so the two sides compute the same way rather than agreeing by
luck. Both GNU tables gain the row; reverting to the float product fails
it.

The `divergence` exemption tested only for the key's presence, so
`"divergence": null` or `""` bought an asymmetric matrix while explaining
nothing -- the one thing the key exists to supply, and it would read as a
considered decision in review. Both runners now require a non-empty
string, with the six falsy/ill-typed shapes pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:54:52 -07:00
bytecii 15a8dbe043 refactor(sandlock): split into a package, excuse the layout divergence, document both runtimes
Codex P1: a python-only module under runtime/python left
check_layout_parity.py --strict red (297 against a 296 baseline). Making
sandlock a package the way monty already is moves the divergence from a
module to a directory, which the exceptions file excuses as a subtree --
the honest shape here, since there is no TypeScript counterpart to
mirror and growth inside it is expected rather than drift.

Constants and config move out of the runtime module to match monty's and
smolvm's layout: constants.py carries the env var, the CLI hint, and the
system read set; config.py carries SandlockConfig.

Docs: smolvm joins the sandbox provider table and gets its own section on
both language pages, covering the refused-state probe and the fact that
one machine is one guest, so concurrent lines share a filesystem and a
process table. sandlock joins the python runtime table with a section on
what "process" reach does and does not buy, and on the environment it
deliberately does not inherit. Vendor marks added under the existing
docs/images/<name>-logo.svg convention.
2026-08-16 16:49:58 -07:00
Zecheng Zhang cbd5ee268b Merge pull request #832 from bytecii/chore/verify-deps-before-run
chore(typescript): refuse a script run against a stale node_modules
2026-08-16 16:33:16 -07:00
bytecii f697ba0103 ci(ts-audit): run the audit job on Node 24
The job was copied from the `test` job beside it, which pins 22. It
runs no project code -- `pnpm audit` resolves the lockfile and queries
the advisory API -- so it is free to track the newer line `test_integ`
is already on, and there is no reason for it to trail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:58:47 -07:00
Zecheng Zhang e1ede4022b feat(bash): let, umask, shopt, alias, mapfile, read flags, declare -g/-n, disown/wait, exec
Closes the priority items of #608 in both languages.

- let: (( )) as a builtin, ordered writes, last-value status
- umask: session mask applied to touch/mkdir/redirect creates
- shopt: full option table, plus nullglob/failglob/dotglob/globstar;
  extglob is refused rather than stored, since the parser has no mode
- alias/unalias: definition, expansion (expand_aliases, same-line rule,
  trailing-blank chaining), type/command -v reporting
- mapfile/readarray with -d -n -O -s -t -u -C -c
- read -a -d -n -N -t, plus the non-tty no-ops -p -s -e -i
- declare -g and declare -n: references resolve on every read and write
- disown, and wait -f/-n/-p
- exec: redirect-only form installs on the shell; the command form is
  refused, having no OS process to replace
2026-08-16 13:05:26 -07:00
bytecii 6814277cae feat(runtime): smolvm microVM provider (py+ts) and confined sandlock python runtime (py)
Two sandboxed execution options, both using the host CLI as transport so
neither adds an SDK dependency.

smolvm joins the RemoteSandbox family as a fourth provider alongside
docker/e2b/daytona, mirroring DockerRuntime's shape: `machine status
--json` is the liveness probe and each line is one `smolvm machine exec`
with the merged env, rebased cwd, real stdin, and separated stderr.
`machine exec` would start a stopped machine itself, but this family
never manages sandbox lifecycle, so connect() refuses anything but
"running" and names why — "unreachable" (VMM alive, guest agent dead)
and "frozen" (a fork base, paused by design) get their own recovery
hints rather than a bare not-running.

sandlock is a new python-tier engine: the same real CPython `local`
spawns, wrapped in `sandlock run -r ... -w ...` so Landlock and seccomp
bound where its I/O can land. It sits between the bridged engines and
`local` in the preference order — reach stays "process", because
confinement narrows which host paths the code touches while the
workspace gate still never sees the I/O, but a "process" runtime need no
longer be an unbounded one. Unlike `local` it does not inherit
os.environ, which would hand every live backend credential to confined
code.

Both stay out of DEFAULT_ENTRIES: sandlock is Linux-only, and binding it
ahead of monty would silently reroute python3 by host OS.
2026-08-16 13:04:16 -07:00
bytecii 576cf8b1ad chore(typescript): refuse a script run against a stale node_modules
A node_modules that predates the lockfile does not announce itself. It
surfaces as a type error inside whichever package the drifted dependency
types, in a file nobody touched, which reads as a source bug.

The worked example: `pnpm -r build` failing at
packages/dsh/src/fs.ts:88 with

  TS2345: Argument of type '"FS_TOO_LARGE"' is not assignable to
  parameter of type 'FsErrorCode'

on a checkout whose install predated 3bbb79121, the commit that bumped
@deepseek-ai/dsh-fs 0.0.1-rc.1 -> 0.1.0-rc.6 and with it the FsErrorCode
union that 88698fa08 then used. Source and lockfile were both correct;
only the install was behind, and the message pointed at neither.

pnpm can say that itself, so let it. `pnpm install` is the whole fix.

The setting has to live here rather than in .npmrc: inside a workspace
pnpm overwrites .npmrc settings from this file, so a
verify-deps-before-run= line there resolves in `pnpm config get` and is
still never consulted.

No effect on CI, which installs from the lockfile before it builds, and
none on a pull: verifyDepsBeforeRun is absent from pnpm's workspace-state
settings snapshot, so taking this commit does not itself invalidate an
up-to-date node_modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:38:54 -07:00
bytecii 44b430ef35 Merge remote-tracking branch 'upstream/main' into cleanup/items-31-32
# Conflicts:
#	spec/layout_exceptions.json
2026-08-16 12:34:53 -07:00
bytecii 09806cae36 fix(df): GNU spells the SI kilo lowercase
`df -H` is powers of 1000, and GNU prints that kilo as `k` -- mirage
printed `K`, which is the 1024 unit. Confirmed against a real `df -H` on
a 100 KiB tmpfs, which GNU renders `103k` while `df -h` on the same
mount renders `100K`; `numfmt --to=si` and `--to=iec` agree. Only kilo
differs in case, M and up are capitals in both tables.

Nothing pinned `df -H` at all, in either language, which is why the
letter could be wrong since the flag was written. Both sides now assert
the quota fixture's three numbers under `-h` and `-H` against GNU, and
reverting the unit table fails the `-H` assertion.

This is the SI half of the same `human_readable` engine the previous
commit fixed for `-h`; the rounding was already shared and already
correct here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:19:50 -07:00
bytecii cad71ae5d4 Merge pull request #827 from bytecii/refactor/survivor-renames
refactor(naming): survivor renames — github pushdown, gcal/msgraph _client, sharepoint resolve, mem0 detect_scope
2026-08-16 12:07:02 -07:00
Beverly621 8ef5acab38 fix(cache): make Redis file cache add atomic 2026-08-17 01:27:13 +08:00
Jackpkn 2d37010293 fix: catch OSError when mfusepy cannot find libfuse
mfusepy raises OSError("Unable to find libfuse") at import time when the
package is installed but no libfuse is present on the system. The three
guards around it caught only ImportError, so with the [fuse] extra
installed and no macFUSE/fuse3/WinFsp driver `import mirage` failed
outright -- mirage/__init__ reaches fuse/mount.py eagerly through
workspace -> runner -> kernel_mounts -> workspace/fuse.py -- and the
whole pytest suite failed to collect.

That makes the two commands CONTRIBUTING.md gives contributors
(uv sync --all-extras, then uv run pytest) unusable on any machine
without a libfuse driver installed.

The degraded path the guards already implement is unchanged and correct:
fs.py swaps in object as the base class, and MirageFS raises a clear
RuntimeError naming the extra and the OS drivers.

tests/fuse/test_darwin.py imported mfusepy unguarded at module level and
broke collection for the same reason. It now guards the import and skips
only the three tests that touch mfusepy; the rest exercise pure-ctypes
helpers and run anywhere.

TypeScript needs no matching change: packages/node loads
@zkochan/fuse-native through a dynamic import() inside mount(), so
importing the package never touches it.
2026-08-16 19:35:03 +05:30
bytecii 73f5bd7c21 chore: cleanup items 31+32 — nested-function rule, layout, CI audit, ls facets
Closes the two remaining small items of Block E. Both were largely
recon: much of what they filed had already been fixed, and the part
that had not turned up a real GNU divergence.

Item 31 (T3-7), rule/doc reconciliation
---------------------------------------

The `object`-parameter half is already done: #825's mypy inversion took
the package from the filed 28 down to 7, of which 3 are docstring prose
and the remaining 4 are protocol methods (`__contains__`, `pop`) whose
signatures typeshed dictates, all already listed in the no-object gate.
`utils/errors.py` reads `str | PathSpec` today, and `builders/sed.py` no
longer holds flag values at all -- item 22 moved that to the generic.

That left the nested-function rule, which the plan asked to decide
before gating. Measured: 39 nested defs, and **every one of them closes
over its enclosing scope** -- 38 by free variable, and `sed_helper._repl`
by binding through parameter defaults, the loop-variable idiom, with a
comment saying so. A flat "do not nest" is a rule the architecture
cannot keep (op factories, provision builders, the read-through cache,
every decorator's wrapper), so gating it as written would have meant 39
standing violations.

So the rule now says what it was reaching for: a nested def must capture
the scope around it, or it belongs at module level. That is mechanical
and enforceable -- `tests/test_nested_functions_are_closures.py` reads
free variables from `symtable` and parameter defaults from the AST. It
passes on the tree as-is and fires on a helper that reads only its own
arguments.

Layout: the eight test-only TS directories are flattened
(`ram/{cat,cut,grep,head,ls,tail,wc}/`, `ssh/ls/`), `awk_helper.ts` moves
up beside its nine siblings, and the `provision.ts` / `_provision.ts`
split is settled on Python's convention -- `_provision` for a backend's
own, bare `provision` for the shared ones (cli, generic_bind). That last
one was not cosmetic: Python already had `_provision.py` for github,
gmail, redis and email, so renaming **closed 4 real parity divergences**
(layout baseline 296 -> 292). The `findEval`/`findParse` line was
already done in #827.

Item 32 (T3-8), test/CI tidiness
--------------------------------

The two asymmetric `ls` conformance matrices are raised to symmetry, and
they pass: python `[ram,disk,redis]` vs typescript `[ram]` was stale
caution, not a divergence. Both runners now reject an asymmetric matrix
at load time unless the case carries a `divergence` key explaining why --
a case is a parity claim, and narrowing one side reads as coverage while
the side that still lists the backend goes green. Loading the corpus
under the new assertion proves no other case was asymmetric. The README
documented the override as "not yet needed"; it exists now.

Adds the `ts-audit` job, mirroring `test_python.yml`'s exactly (same
`continue-on-error`, same out-of-gate placement). Nothing ran `pnpm
audit` for the TS tree while `typescript/package.json` carried 27
hand-written CVE overrides that only a person remembering to check kept
current.

Integ facets for `ls`: `-A`, `-d`, `-r`, `-S`, `-h`, all pinned against
GNU coreutils 9.7 in docker. `-S` uses regular files only, because GNU
sorts a directory by its inode size while mirage counts it as 0 -- a
divergence CLAUDE.md documents deliberately.

The `-h` facet found a real bug
-------------------------------

`ls -h` disagreed with GNU three ways, and because the flag had zero
cross-backend coverage nothing caught it. GNU prints a count below one
unit with no suffix (`24`); mirage printed `24B`. GNU rounds *up* to the
precision shown (1025 bytes is `1.1K`); mirage gave `1.0K`. GNU drops
the decimal once the value reaches ten (`10K`); mirage gave `10.0K`.

One shared engine replaces both formatters in both languages -- GNU runs
`-h` and `-H` through one `human_readable`, and so do we now. Rounding
up can carry past the base (1048575 ceils to 1024K, which GNU shows as
`1.0M`), so the unit is re-chosen after rounding rather than once up
front. 21 GNU-read points are pinned as a table in each language.

Blast radius, all re-pinned: five integ cases that had recorded the `B`
suffix (du, discord, langfuse, email, gmail), and the du fan-out tests.
Those last ones needed care rather than a new number: they exist to
prove the total is humanized once instead of twice, and their 1500+1500
stops discriminating under correct rounding, since 3000 bytes and two
`1.5K` readings both render `3.0K`. They now use 1025+1025, where
single-rounding gives `2.1K` and double-rounding would give `2.2K`.

Verified: pre-commit clean, integ 8054/0 on ram+disk+redis in both
languages, conformance green in both, and the layout, spec, barrel,
docs, case-target and PathSpec gates all pass.

Not covered, still open on item 32: integ facets for `ls -t` (mtime
order is not stable across backends without a seeded fixture), mktemp,
gzip, the checksum `--check` companions, `df -H/-k/-a`, and `cmp
-n/-b/-i`; and the T2-7 helper unit-suite mirroring. Item 31 leaves the
provision *presence* sets unreconciled (Python has lancedb/qdrant, TS has
trello) -- that is a question about which backends should provision at
all, not about naming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:57:08 -07:00
bytecii 631524e6ef Merge pull request #828 from strukto-ai/docs/haystack
docs: add a Haystack integration page
2026-08-16 05:31:46 -07:00
bytecii 0a5cc8dbfc Merge upstream/main into refactor/survivor-renames
Conflicts, both from ratchets/config the two branches moved independently:

- python/pyproject.toml: #825 inverted the mypy config (global strict +
  an opt-out list of not-yet-annotated modules) and deleted the old
  opt-in allowlist my last commit had edited. Took upstream's file
  wholesale -- github.pushdown now gets strict from the global default
  and passes it, so the retarget commit is subsumed. Retargeted the one
  stale entry the rename left in the NEW list:
  generic.rm_command -> generic.rm_cmd (the module still needs its
  exemption: one no-any-return at rm_cmd.py:89), keeping its exempt
  status exactly as it was under the old name.

- spec/layout_exceptions.json: both sides lowered the baseline from 299
  (mine to 293, upstream's to 296). Neither value is right after the
  merge; ran the gate, which reports 290 -- the two closure sets are
  disjoint (299 - 6 - 3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 05:28:19 -07:00
bytecii e4d72adeb1 Merge pull request #825 from bytecii/feat/mypy-strict-and-py-mcp
feat(py): mirage mcp over stdio, and invert the mypy allowlist (items 29 + 28)
2026-08-16 05:17:49 -07:00
bytecii 140aeba674 fix: retarget strict mypy override to renamed github.pushdown module
The core/github/scope.py -> pushdown.py rename left the pyproject
per-module mypy override pointing at the old name, silently dropping
disallow_untyped_defs/warn_return_any coverage for the module.
(codex P2 on #827)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 05:03:41 -07:00
Zecheng Zhang c504f1dc82 docs: add a Haystack integration page 2026-08-16 04:54:53 -07:00
bytecii c3a88ad816 test: lock tests-mirror ratchet at 191 (resolve.py now pairs with test_resolve.py)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 04:42:26 -07:00
bytecii cb1b2c286b fix(agents): codex review + narrow resolve_glob to PathSpec
Four review findings, three of which the TypeScript side shared, so both
sides are fixed and tested together.

- file_version stamped the bytes handed to `write` while every later
  check reads them back. On a mount whose read op renders, the agent's
  own next write was refused as somebody else's change. Both sides now
  stamp what a later read returns.
- version stamps were keyed by the caller's spelling, but read and write
  follow the namespace symlink table, so `/alias` and `/target` got
  separate stamps and an edit through the other name skipped the
  staleness check. Keyed through `namespace.follow` now.
- the grep tool reported every exit code as success. Exit 1 is "no
  match" and stays a success; >1 (an unreadable path) is now an error.
- `Server[object, object]` discarded the SDK's context types. It is
  `Server[dict[str, Any], Any]`: the default lifespan yields an empty
  dict and nothing here reads a request payload. The no-object gate only
  swept function signatures, so it could not see a type argument in a
  variable annotation; extended to annotated assignments, which found
  one more (`sort_helper.seen`, now `set[DedupKey]`).

Also narrows `Resource.resolve_glob` from `list[str | PathSpec]` to
`list[PathSpec]`. The union is the argv type -- a command line really
does mix text words and paths -- and it had leaked a layer past the
split into a function that only ever takes paths. TypeScript's twin is
already `readonly PathSpec[]`, all 39 implementations take PathSpec, and
every call site passes one. Nothing checked that, because
`make_resolve_glob` returned `Callable[..., Any]`; it now returns a
`ResolveGlobFn` protocol, and `ResolveGlobOp` (the consumer side) drops
the same union. Re-widening any override now fails two ways.

`tests/cli/test_mcp.py` asserted on rendered `--help` text, which is laid
out to the terminal's width; it passed locally and wrapped away on CI.
It reads the declarations instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:39:29 -07:00
Zecheng Zhang be77377062 Merge pull request #824 from strukto-ai/fix/bash-608-assoc-attrs
feat(shell): associative arrays, declare attributes, readonly -f, jobs flags
2026-08-16 04:33:30 -07:00
bytecii 8e5d9a9c5b refactor(naming): command-layer consistency — narrow→pushdown, findEval/findParse→snake_case, history_cmd→history, rm_command→rm_cmd
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 04:22:24 -07:00
Zecheng Zhang bcad5d19e2 Merge pull request #826 from bytecii/fix/deepagents-floor
fix(deps): raise the deepagents floor to the API backend.py actually needs
2026-08-16 04:16:50 -07:00
bytecii 31b91ac885 Merge remote-tracking branch 'upstream/main' into feat/mypy-strict-and-py-mcp
# Conflicts:
#	python/mirage/commands/builtin/generic/tar/tar.py
#	python/mirage/commands/builtin/generic/unzip.py
2026-08-16 03:56:05 -07:00
bytecii 1f59264232 refactor(naming): survivor renames — github pushdown, gcal/msgraph _client, sharepoint resolve, mem0 detect_scope (plan §3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 03:55:00 -07:00
Zecheng Zhang b9b91c74e4 fix(bash): address review on assoc arrays and attributes
- coerce -i/-l/-u before the pre_session gate, so a rule judges the value that lands
- ${m[k]:=v} and ${a[3]:=v} land on the named element; [@]/[*] and a bad index refuse
- arithmetic writes land in evaluation order across bare and subscripted targets, and a bare array name reads as element 0
- -i resolves element references (resolver moved from session/elements into session/state, no cycle)
- test -v unquotes an associative subscript
2026-08-16 03:54:37 -07:00
Zecheng Zhang 6b6c8e51bc Merge pull request #821 from strukto-ai/fix/tar-grep-exec-gaps
fix(commands): tar/unzip member selectors and relay extraction, grep file filters, direct path execution
2026-08-16 03:44:53 -07:00
bytecii 622a963ad1 fix(deps): raise the deepagents floor to the API backend.py actually needs
3bbb79121 rewrote agents/langchain/backend.py to follow deepagents' new grep
cap -- GrepResult.truncated and the keyword-only max_count on grep/agrep -- and
bumped python/uv.lock and typescript/packages/agents/package.json to match. It
left python/pyproject.toml at deepagents>=0.6.12.

0.6.12 is the last release before that API. Its GrepResult is a two-field
dataclass, so constructing one with truncated=True raises TypeError, and
BackendProtocol.grep has no max_count for the override to match. Both landed in
0.7.0, not 0.7.6 as that commit's message says -- checked by reading
backends/protocol.py out of every published 0.6.x and 0.7.x wheel.

Nothing in CI resolves the floor, which is why it stayed green: uv.lock pins
0.7.6, and Test (Install) only exercises a base install with no extras. The gap
is reachable from a downstream pip install mirage-ai[deepagents].

Raised to >=0.7.6, matching the lock and what the TypeScript side already got.
The floor is the version we test against, which is 3bbb79121's own rationale.

No behavior change: backend.py is untouched and already conforms to the 0.7.x
contract on every clause it specifies -- a total cap rather than a per-file one,
truncated only when matches were actually dropped, and exactly max_count with
none dropped reported complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 03:41:52 -07:00
bytecii 3293cb5ff4 feat(py): mirage mcp over stdio, and invert the mypy allowlist
Two cleanup-plan items, both Python-side.

Item 29 -- `mirage mcp`. TypeScript shipped a six-tool stdio MCP server;
Python had none, so a pip-install user could not point Cursor or Claude
Desktop at a workspace and `mirage --help` differed by distribution.
Adding the entry point alone would have duplicated the tools, because
this side kept them private inside the Claude Agent SDK integration, so
the shared layer comes first:

  - agents/tool_descriptions.py -- the six strings, one copy.
  - agents/tool_operations.py -- MirageToolOperations, lifted out of the
    SDK server's private _MirageTools.
  - agents/file_version.py -- stale-write protection, which this side
    lacked entirely. TS stamps stored bytes; here the stamp covers the
    rendered bytes, because this read tool has always rendered and an
    edit must search what the agent was actually shown.
  - agents/mcp/server.py + cli/mcp.py -- the server and `mirage mcp`.
  - server/workspace_config.py -- config discovery (candidates, env
    names, walk up from cwd), which Python had nowhere, so every entry
    point had to be handed an explicit path.

The server is the low-level MCP Server rather than FastMCP: FastMCP does
not forward a version, and TS advertises one. Handlers are bound methods,
not decorated closures, so nothing nests.

Item 28 -- the mypy allowlist. 54 modules opted *in* to annotation
checking against 1826, so the default was unchecked and every new file
joined the unchecked side. The default is now strict, with a list of
what is not yet annotated that only shrinks. 166 annotations cleared
along the way; the remainder is named module by module.

Two real defects surfaced by the annotations, neither of them typing:

  - Workspace._original_open / _original_os were invented by assignment
    in lifecycle.patch_process, so unpatch without a patch raised
    AttributeError. Declared, and the restore is guarded.
  - sed_generic declared a non-optional writer while its own docstring
    and its `write_bytes is None` branch said otherwise; the builder
    passes None whenever the backend cannot write.

Tests keep the PathSpec rule instead of full strict: measured, full
strict on python/tests is 2374 errors, of which 634 are `str` where a
pydantic field declares SecretStr -- which pydantic coerces at runtime --
and most of the rest is the monkeypatched-fake pattern CLAUDE.md
sanctions. The rule that is violated for real is PathSpec, 19 times, and
scripts/check_test_pathspec.py now holds that line. One of the 19 was a
latent AttributeError: tests/e2e passes a str to s3 write_bytes, which
reads .mount_path, and the test skips without a live versioned bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 03:33:16 -07:00
Zecheng Zhang bba66f8291 Merge pull request #823 from bytecii/refactor/render-kit
refactor(render): K4 shared JSON render kit — one renderer for .json/.jsonl bodies (py+ts)
2026-08-16 03:26:01 -07:00
Zecheng Zhang 502b77e6c5 Merge pull request #822 from strukto-ai/fix/corpus-defects
fix: close ten agent-corpus defects in sed, awk, date, find and the fakes
2026-08-16 03:18:22 -07:00
Zecheng Zhang 4d3b046a64 feat(shell): readonly -f, declare -f/-F, and the jobs flags, both languages
readonly -f freezes a function: either definition syntax and unset (-f
or auto) refuse in GNU's voice, exit 1, and the old body stays; a
non-function operand is 'not a function'; the bare form lists the
frozen set. The set is a session field beside functions, inherited by
forks and child shells. declare -rf freezes the same way, -F prints
names, and -f prints a name row where GNU pretty-prints the body, which
mirage does not carry.

jobs gains -l -n -p -r -s, a jobspec filter (%N or N), 'no such job'
exit 1, and the usage refusal exit 2, all applied to mirage's own row
shape: -p prints the table id where GNU prints a pid, and -s lists
nothing since mirage jobs never stop.

Pinned against bash 5.2.37; 21 unit cases per language, 3 battery
cases on 22 targets, both hosts byte-identical.
2026-08-16 03:06:39 -07:00
Zecheng Zhang 3c9466219d test(node): source tests answer stat and readdir per op
The loader now stats a script before reading it, and a double that
returned the script bytes for every op handed stat a bytes object.
2026-08-16 02:57:48 -07:00
bytecii b1a3fdd4f2 refactor(render): K4 shared JSON render kit — one renderer for .json/.jsonl bodies (py+ts)
Phase 1 of the core/ restructure plan: introduce mirage/core/render/json.py
and core/render/json.ts (json_text/json_bytes, compact_json_text/bytes,
jsonl_bytes) with a cross-language byte-parity fixture, and migrate every
core/ backend render site onto the kit: langfuse + jaeger (generic render
modules deleted), mem0, discord, slack (history/search/users), gcal, gdocs,
gsheets, gslides, gmail, qdrant, email, and postgres-TS. linear/notion/trello
normalize helpers become thin delegates (their created_at sort stays local;
the ~90 CLI call sites keep their names for a follow-up audit). jq/format
and py-postgres (orjson) deliberately stay local.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:50:38 -07:00
Zecheng Zhang 63289ed201 fix: awk compound statements, find %Y/%m from stat, date ms truncation, notion validate-first
awk splits statements at brace depth zero and outside quotes, and a
`{ ... }` compound contributes its inner statements in place, so
`{{print $1}}` prints the first word the way gawk does instead of being
refused; the chroma golden that had pinned the old empty output is
corrected.

find -printf renders %m/%M from a reported mode (chmod overlay) with the
per-kind constants as the fallback, and %Y on a symlink row reports the
target's kind through the namespace (N when dangling).

TS parseDateExpr truncates fractional seconds instead of rounding into
the next second, matching Date parsing and Python.

The Notion fake validates every child of a block append before shifting
siblings or inserting, so a refused request mutates nothing.
2026-08-16 02:47:35 -07:00
Zecheng Zhang b0e044c7cf feat(shell): declare attributes -i -l -u -t and +attr, both languages
The value-shaping attributes apply in the session door on every write,
which is where bash applies them: -i evaluates the incoming text as
arithmetic (a bad expression refuses in the evaluator's voice, fatal for
a plain assignment and exit 1 for a builtin), -l/-u fold case, and each
applies per element on an array. A declaration pre-marks the name so its
own value coerces too, after the local snapshot so a function scope is
not leaked. -l and -u are exclusive; a cluster naming both sets neither.
n+=3 on an integer name adds rather than concatenates.

+attr turns an attribute off through the gated mark door; +r on a
readonly name and +a/+A on an array refuse as GNU does, and an unknown
letter refuses with the usage line, exit 2. -t and -n are stored and
printed; nameref aliasing is deliberately not wired, since it is a seam
through every expansion site and a partial alias is worse than none.

Pinned against bash 5.2.37; 51 unit cases per language, 7 battery cases
on 22 targets, both hosts byte-identical.
2026-08-16 02:41:37 -07:00
Zecheng Zhang 4906fb7a0f fix(integ): stat a script before reading it, order-free grep cases, keyed test directories
The loader read a script before it stat'd it, and only a real
filesystem answers a directory read with EISDIR: WebDAV serves the
collection's HTML as bytes (nextcloud ran it as a script), ssh's raw
error carries no errno, and a keyed store answers ENOENT. The stat
probe now runs first in both languages. The directory cases give hf a
key to see (hf holds no empty directory), and the multi-line grep -r
cases sort, since box lists folders first.
2026-08-16 02:30:26 -07:00
Zecheng Zhang 9a5e0fc9f4 Merge remote-tracking branch 'origin/main' into fix/corpus-defects 2026-08-16 02:23:49 -07:00
Zecheng Zhang 2ba312572b fix: close ten agent-corpus defects in sed, awk, date, find and the fakes
sed -i writes for every command, not only s and d; multi-file output is
concatenated raw; an escaped delimiter inside an address regex no longer
cuts the address short, and \%re% custom delimiters parse.

awk refuses constructs it cannot run (exit 2, "unsupported construct")
instead of echoing their source; simple assignment and OFS now execute
and unset names render empty.

date -d parses ISO, @epoch and the gnulib relative grammar in both
languages (TS printed NaN with exit 0), invalid input is exit 1 with GNU
wording; TS strftime learns %F %c %C %g %G %V %U %W %h %k %l %n %P %q %r
%R %t %x %X, and Python expands %q ahead of strftime.

find -printf is implemented (%p %P %f %h %d %s %y %Y %m %M %T escapes),
registered on the CommandSpec so the format is not read as a path, and
stats through the dispatcher when no overlay stat is wired.

Fakes: the GitHub server strips the internal files list from list-commits,
accepts refs/heads/x on /contents and /commits, and reports files as
objects on one commit; the GWS Drive server evaluates fullText contains
and answers 400 for an unknown query field; the Notion server honors
after on block append with the live API's response and validation shape.

Integ: new unix cases for each command fix, awk_ofs golden corrected,
and cli-gh/cli-gws/cli-ntn cases that drive every fake fix through the
CLIs; ntn conformance re-pinned against the real binary.
2026-08-16 02:23:49 -07:00
Zecheng Zhang b8d13f5572 feat(shell): associative arrays and array-element arithmetic, both languages
declare -A / local -A / readonly -A build a real map: keyed and pair
literals, element and += writes, unset of one key, declare -p rendering
with GNU's trailing space and key quoting, kind conversion refusals,
[[ -v ]] on elements, and read/printf -v into an element. Arithmetic
gains element lvalues (a[i]++, m[k]+=2) through an ElementOps seam the
session fills, so $((a[i])) and ((m[k]=5)) resolve without the
evaluator learning what a session is. Two pinned divergences: keys walk
in sorted order, and a bare declare -A prints =().

Pinned against bash 5.2.37; 49 unit cases per language, 15 battery
cases on 22 targets, both hosts byte-identical.
2026-08-16 02:07:22 -07:00
Zecheng Zhang daacd222ab fix(commands): address review on grep filter order, tar -- terminator, env -S shebangs, exec path guards
grep --include/--exclude resolve by command-line order (last matching
rule decides, no-match default follows the first rule), pinned against
GNU grep 3.11; the bag's insertion order carries it through a new
FlagView.typed_order. is_create_mode stops at --, so a member named
-c after the terminator no longer reads as create mode. env -S and
--split-string are consumed on a shebang line instead of naming the
interpreter. A slash-carrying head word joins the admission context
so a path-pattern guard sees the executed file.
2026-08-16 01:59:53 -07:00
Zecheng Zhang 283da2c8fb Merge pull request #820 from strukto-ai/fix/build-resource-sync
fix: make build_resource sync again, hydrate github lazily
2026-08-16 01:10:56 -07:00
Zecheng Zhang 220402243a Merge pull request #817 from strukto-ai/feat/watch-push
feat(watch): push event mapping, subtree invalidation, and a cache key fix
2026-08-16 01:01:52 -07:00
Zecheng Zhang 3e614781a6 fix(commands): tar/unzip member selectors and relay extraction, grep file filters, direct path execution 2026-08-16 00:52:36 -07:00
Zecheng Zhang 28f9eb6ed0 fix(integ): drop the awaits left on build_resource and to_workspace_kwargs
The revert missed integ/, so two CI jobs failed on code the unit suite
never imports: integ/root.py awaited to_workspace_kwargs twice (once
split across lines, which is why the line-based grep missed it), and the
github watch builder and the shared-battery adapter still called
GitHubResource.build.

Swept with an AST walk over every .py in the repo rather than a grep this
time, matching an Await whose call resolves to one of the reverted names;
it now reports none. Verified by rerunning the two failing steps:
integ/root.py exits 0, and the github battery is 23 passed 0 failed
against the fake.
2026-08-16 00:37:13 -07:00
Zecheng Zhang 10fd374287 docs(watch): document the push mappers
Push mode showed only the hand-written FileEvent path. It now leads
with the three mappers that exist, says why you import one rather than
ask the mount for it, and keeps the hand-written version for a backend
without one.

Slack gets the two traps written down: the day is bucketed in UTC while
the client shows local time, and a thread reply belongs to the parent's
day because chat.jsonl renders conversations.history, which returns
parents only. The matrix drops Slack from planned and gives redis its
own row.
2026-08-16 00:28:48 -07:00
Zecheng Zhang 99b4cb4f00 docs(slack): add a Slack watcher example
Hosts the endpoint Slack posts to, unwraps the event_callback envelope,
maps it with SlackEventHook and notifies the workspace, with a live
watch stream printing what comes out.

Verified against a real workspace: a message maps to
channels/incident__C0B0DB9K11T/2026-08-13/chat.jsonl, file_shared to
that day's files directory, and the mount serves both.
2026-08-16 00:28:47 -07:00
Zecheng Zhang 69d8263d37 refactor(watch): drop EventHook and event_hook, keep the mappers
The type and the method bought one line: reaching a hook from a mount
instead of from an import. Nothing needs that, because a push payload
has no vendor-neutral shape, so the caller always knows the backend it
is decoding for. The integ battery proves it: the event mode has to
build a watchdog payload before it can call anything, so the call site
was disk-specific with or without a generic accessor.

Consumers construct the hook they want:

    from mirage.core.slack.watch import SlackEventHook
    hook = SlackEventHook(resource.accessor)

build_event_hook goes with them, since the resource method was its only
caller. What stays is to_events, which is the whole feature: the path
arithmetic that has to live beside the backend.

delta_hook stays as it is. pull(root, checkpoint) is the same call for
every backend, so one case set drives ten of them in integ; that is the
generic reach event_hook never had.
2026-08-16 00:28:39 -07:00
Zecheng Zhang 08e10a503d Merge pull request #819 from strukto-ai/feat/github-fake-write-routes
feat(integ): a restricted-token GitHub fake, gh api --jq, and notion retrieve-block
2026-08-16 00:14:29 -07:00
Zecheng Zhang c0bcb851cd fix(github): track tree hydration explicitly, and report an unknown default branch as unknown
Both from review on #820.

ensure_tree decided hydration by whether accessor.tree held anything,
but an empty repository (or one holding only excluded gitlinks) hydrates
to {}. That read as never-hydrated, so every direct-tree command
refetched it, and twice per call once an index was wired: ensure_live_index
seeds an empty root, then the fallback fetch runs because the tree still
looks empty. Measured 2 fetches on the first ensure_tree and 2 more on
each later one; now 1 in every combination of empty/non-empty and
index/no-index. Tracked by accessor.tree_loaded, set by the constructor
when a tree is supplied and by every site that reseats one (refill_index,
the watch delta hook).

is_default_branch compared ref against a default_branch that is None
until something hydrates it, so it answered False for a mount pinned to
the default branch. A bare read hydrates only the tree, so that wrong
answer could last the life of the mount. It now returns None for not
known yet, and callers needing a definite answer await
ensure_default_branch first. TypeScript keeps a plain bool because
construction there fetches the fact; noted in the docstring.
2026-08-16 00:10:51 -07:00
Zecheng Zhang 39a3913ec2 Merge pull request #816 from strukto-ai/fix/bash-608-silent-accept
feat(shell): bash tier 2 stage 1, the export attribute, and identifier validation
2026-08-16 00:05:09 -07:00
Zecheng Zhang d7f952a62d test(integ): run the watch battery through the event hooks
The pull and push batteries both hand the watcher a FileEvent the
harness built, so the mapping half of the feature had no integ coverage
at all: nothing exercised to_events. Event mode reuses the same cases
and the same freshness checks, but triggers by handing the backend the
watchdog notification a real filesystem watcher would emit, so a
delivered change can only have come from the hook.

The per-case modes default was the pair [pull, push], which silently
skipped every case of the new mode and still reported OK. It is now
every mode, since a per-case list is how a case opts out.
2026-08-15 23:53:05 -07:00
Zecheng Zhang 687a5831ed fix(slack): bucket a thread reply into the parent's day
chat.jsonl renders conversations.history, which returns parents only,
so a thread reply appears in no day file at all. What changes is the
parent's row, whose reply_count and latest_reply the same listing
carries. Bucketing by the reply's own ts refreshed today's directory
and left the parent's day stale, silently, because notifying a path
that exists but did not change looks like success.

affected_ts now returns the parent's ts for a reply, both for a
broadcast reply (Slack puts those in the channel history too), and the
message's own otherwise. Deletes and edits read thread_ts from the
message they describe, not the envelope.

Also records why file_change and file_deleted stay unmapped: neither
carries a conversation or the share day, and files.info on a deleted
file errors, but the index already stores each blob's Slack id, so a
reverse lookup would name the path once the hook is handed the index.
2026-08-15 23:52:59 -07:00
Zecheng Zhang f2a86e3393 refactor(watch): drop the capability protocols, default the hooks on the base
SupportsEvents had exactly one caller in the repo and it was a test
asserting the protocol itself; SupportsChanges had none at all, not
even a test. TypeScript never had either one and its Resource just
declares deltaHook?() / eventHook?(), which is the same feature working
without them.

BaseResource now answers delta_hook() / event_hook() with None, so a
consumer checks the return value instead of the resource's type. None
is the common case, not an edge: 52 of 55 resources have no event hook
and 26 have no delta hook.
2026-08-15 23:52:52 -07:00
Zecheng Zhang 14f83d593a fix(cli): drop served-resource caches when a write leaf throws 2026-08-15 23:40:20 -07:00
Zecheng Zhang bc70f29323 fix(dsh): restore the mount cast eslint --fix stripped
`.some(isMountBlock)` is not a narrowing TypeScript can follow, so the
else branch still types `config.mounts` as holding a `MirageMountBlock`
and the assertion is what carries the fact. It was removed by an
eslint --fix run whose type information was momentarily unresolvable, so
the rule read the assertion as redundant; the build then failed in a
package the change had no business touching.
2026-08-15 23:33:43 -07:00
Zecheng Zhang 9bfba93036 fix(notion): serve retrieve-block, which an MCP client walks to 2026-08-15 23:21:07 -07:00
Zecheng Zhang 8cea528180 feat(gh): gh api --jq, rendered as gh 2.85 renders it 2026-08-15 23:21:03 -07:00
Zecheng Zhang 4a3635f869 feat(integ/github): let a fake serve a token that cannot create repositories 2026-08-15 23:20:54 -07:00
Zecheng Zhang af0fd8759f feat(typing): ship the py.typed marker
Our mypy gate reads the source tree, so it proves nothing to anyone who
installs us: without the PEP 561 marker a type checker refuses to read an
installed package's annotations and every mirage import resolves to Any.
Verified against the haystack integration, whose pyproject carries an
ignore_missing_imports override for exactly this reason; with the marker
its mypy run typechecks against the real signatures and passes.
2026-08-15 23:08:53 -07:00
Zecheng Zhang fd3a898e42 fix(shell): gate the prefix assignment, and keep a partial declaration's stamp
A prefix assignment is a session write like any other and the form
exports the name for the command, but the pre-check only ran the hidden
half and the seeding goes through seed_var, the ungated door. A
deployment refusing SECRET_* still saw `SECRET_K=leak printenv SECRET_K`
print the secret. The loop now asks pre_session with the value.

`declare -x NAME` on a name that already exists writes no value, so the
export mark was the only session write there was and it was stamped
directly, which let an agent export a host-seeded credential the
deployment had refused. It goes through view.mark now.

GNU keeps the valid operands of a declaration and reports the invalid
one, so `declare -x GOOD=1 1BAD=x` exits 1 and still answers
`declare -x GOOD="1"`. The stamp was gated on the aggregate status, so
GOOD came back unexported; it is driven by what actually stored.

Session payloads always carry var_attrs, since its presence is what
tells a session record from a bare process environment.

Adds a meta-test in each language that enumerates every ungated
set_attr/setAttr call site and requires each to name the gated write
that covers it, so the next one fails a test rather than a review.
2026-08-15 23:08:33 -07:00
Zecheng Zhang a9a7a58155 fix(google): accept a pre-minted access token, and stop a provider callable crashing snapshots
GoogleConfig demanded client_id + refresh_token and minted its own
tokens, while MsGraphConfig already took access_token as either a
SecretStr or a provider callable. Same OAuth problem, two answers in one
codebase, and the Google half locked out anything that cannot produce a
refresh token: a service account, or a host application that already
owns the OAuth dance. A consumer worked around it by monkeypatching
refresh_access_token, a private function.

GoogleConfig now takes the same access_token field. A supplied token
short-circuits the grant in TokenManager.get_token, and a provider is
read every call rather than cached, since the provider is the caller's
own cache. client_id and refresh_token become optional, with a validator
refusing a config that names no credential at all.

The second half is a bug that predates this and is already reachable on
shipped code: pydantic cannot serialize a function, so redacted_config_dump
raised PydanticSerializationError on any config holding a provider
callable, and took the whole snapshot with it. Verified against a
OneDrive mount, which is exactly the shape the haystack integration
builds. Secret fields are now excluded from the pydantic dump and written
back by the walk, and a callable redacts in both modes rather than being
revealed, which would freeze one expiring token into a snapshot that
outlives it. Redaction is already the contract that makes
requires_resource_override demand a fresh resource at load.
2026-08-15 22:54:44 -07:00
Zecheng Zhang 8575b9b2f4 fix(github): hydrate the tree lazily, make build_resource sync again
0.0.5 made build_resource a coroutine so GitHubResource could fetch the
repo tree before returning. That fixed a real defect (the two fetches ran
in __init__ over a blocking urlopen and froze the daemon's loop) but paid
for it with the one function every caller who describes a mount as data
comes through: the YAML loader, the daemon's create/load routes, clone,
and every embedder reaching it through mirage.sdk. The haystack
integration, our first outside consumer, broke on it.

Hydrating lazily removes a round trip instead of adding one. Nothing
seeded the index at build time, so the first readdir ran
ensure_live_index and refetched the whole tree, discarding the one the
constructor had just paid for; measured two git/trees calls where one
does. GitHubResource now names the repository and contacts nothing, and
the tree and default branch arrive through ensure_tree and
ensure_default_branch on first use, each behind a lock so concurrent
first reads cost one request. readdir and read already hydrated through
ensure_live_index; only find, du and grep's narrow read accessor.tree
directly and needed wiring.

normalize_resources now refuses a non-resource and names the mount. The
old failure was 'coroutine' object has no attribute 'set_index', raised
two frames away in install_mounts, naming a method the caller never
called and no mount.

TypeScript stays async: its factory type is uniformly
(config) => Promise<Resource> and two of its backends need it. Recorded
as a deliberate divergence rather than mirrored.
2026-08-15 22:39:02 -07:00
Zecheng Zhang 94679619b9 feat(slack): map Slack Events API deliveries onto mount paths
A message, reaction, pin or shared file names a channel by id and a
message by ts; the mount names a directory <name>__<C-id> and buckets
the day in UTC. That arithmetic is the backend's, so the hook lives
beside it rather than in the consumer, which would otherwise reimplement
it and get the UTC rule wrong for a fifth of every day with no error to
notice.

  core/slack/watch/  constants  types  payload  hook

Resolution is memoized: an event carries the id, the directory carries
the name, so conversations.info (plus users.info for a DM) runs once per
conversation instead of once per message against a ~50/minute tier. A
rename drops the entry, since the subtree moved.

Coarser answers where the notification cannot say more: a listing change
is UNKNOWN on channels/dms/users, and a shared file is UNKNOWN on that
day's files directory, because the rendered name needs metadata the
event does not carry.
2026-08-15 22:32:19 -07:00
Zecheng Zhang 7e50c53b2a refactor(watch): split the disk and redis watch modules into packages
Each was one file holding constants, helpers and a class, and disk also
held both halves of the feature: the delta-hook walk and the event hook
shared a file with nothing in common but the word watch.

  core/disk/watch/   constants  walk  hook
  core/redis/watch/  constants        hook

Same module names in both languages. The node barrels export only the
build hooks the resources import; tests reach the classes directly.
2026-08-15 22:32:11 -07:00
Zecheng Zhang 2797351a77 refactor(cache): name the cache key what it is
_virtual returned the mount-absolute key both caches are keyed by, but
read like it returned PathSpec.virtual verbatim, so it hid that its job
is re-deriving that key against the manager's own prefix. Renamed to
_cache_key / cacheKey, with the locals following.

Four Args docstrings said the parameter was a resource-relative path.
That was already stale and became wrong when the method started reading
only virtual.
2026-08-15 22:32:01 -07:00
Zecheng Zhang 2c6af5e43e fix(shell): model redirect opens in order, and two attribute gaps
Each open is visible to the next, so `set -C; echo x > a > a` creates
`a` on the first redirect and refuses the second. Probing every target
against one pre-command snapshot passed both and wrote the output. The
opens a refused statement already performed are applied, since bash
leaves the file existing and empty.

`declare -rx X=1` carries both attributes. Readonly answers first, so
the export stamp moved into a helper both branches call.

`export -n ARR=(b)` takes the attribute off an array. The store keeps
whatever the name carried, so an unapplied mark left it exported.

`set -v` echoes comments and blank lines, which the reader consumes
like any other line.
2026-08-15 21:47:49 -07:00
Zecheng Zhang 4ae682e2c1 fix(watch): address codex review on the disk and redis event hooks
- disk: read watchdog's `src_path`, not `path`. The docstring already
  claimed watchdog's field names and `dest_path` already matched, so
  every forwarded watchdog event mapped to nothing.
- disk: a move whose source is outside the mount is a CREATE at the
  destination. Discarding the event left a file in the mount that no
  listing knew about.
- disk: the mount root itself normalizes to `/`, not `/.`, matching
  what the TypeScript side already did.
- redis: an external mkdir/rmdir touches the single `<prefix>dir` set,
  and the message names the key, never the member. An empty directory
  has no `file:` key at all, so those changes were invisible. They now
  map to UNKNOWN at the mount root.
2026-08-15 21:29:24 -07:00
Zecheng Zhang 195b6efe55 feat(watch): push event mapping, subtree invalidation, and a cache key fix
Adds the push half of change detection, alongside the existing delta_hook
pull half.

- EventHook / SupportsEvents: a resource can map one service notification
  to FileEvents. Mirage owns no transport; the consumer runs the socket or
  webhook receiver and passes what arrived, so only the path arithmetic
  lives beside the backend.
- invalidate_subtree on CacheManager plus invalidate_prefix on every index
  store, wired to FileChangeKind.UNKNOWN in the watcher. A notification
  that names only a scope now drops everything below it, which is what
  UNKNOWN already promised in its docstring.
- disk and redis event hooks, both languages, with workspace-level tests.

Also fixes a pre-existing bug found on the way. CacheManager._virtual
inferred whether a caller's path was already mount-absolute by comparing
strings, so a mount-relative /day under a /d mount was read as absolute
and every eviction targeted the wrong key. An eviction that hits no key is
silent, which is why it survived.

The fix is to stop inferring: read only path.virtual and rebuild the key
against the manager's own prefix, exactly as Mount.execute_op already
does. That also explains why the roughly fifty from_str_path sites that
fabricate a resource_path are harmless: the dispatcher passes
path.virtual and re-derives, so a fabricated key only matters to a
consumer that reads the pair without going through a mount.

New adversarial suites in both languages cover mount/child pairs whose
names collide by prefix (/d holding day, /d holding d, /data holding
database), which is how the second case was found.
2026-08-15 18:43:09 -07:00
Zecheng Zhang 380c11ccb3 fix(integ): correct the noclobber exit expectation and excuse the hf drops
The refused-open case ends its line with ls on a file that correctly does
not exist, and GNU ls exits 2 for a missing operand, so the case expected
0 where both hosts rightly answer 2. Pinned against bash 5.2.37.

Excuse the two cases that cannot run on hf, which keys no directory
marker, so a case that makes a directory and then looks it up finds
nothing there. That puts the coverage gate back on its baseline.
2026-08-15 18:28:05 -07:00
Zecheng Zhang ad00f2e267 fix(shell): address self-review and codex findings
Persist variable attributes instead of promoting every scalar to
exported on reload, and keep an attribute-free payload readable as a
process environment.

Route `readonly NAME` and `local`/`declare` through the gated mark door,
so a pre_session rule can refuse the name and a bare declaration no
longer invents an empty value.

Refuse a noclobber redirect before the command runs, not after, and stop
noexec at execute_node plus each loop driver so it holds one construct
deep without spinning.

Restore the process view for prefix assignments and SessionProfile env,
which narrowing env_snapshot to the exported set had broken.
2026-08-15 16:36:08 -07:00
Zecheng Zhang 6ecac7c4b8 test(integ): cover the new set options and the export attribute
43 GNU-pinned cases: `assign/export_attr.json`, plus noclobber, noexec,
verbose and braceexpand under `setopt/`, which had errexit, noglob,
nounset, pipefail and xtrace but none of stage 1. The noexec cases are
what caught the subshell bug.

Both runners and `state_store` wrote into `ws.env`, which the read-only
projection refuses, so the battery could not start on either host. A
meta-test now scans for that write shape across both trees: mypy runs on
`mirage/` alone, so `integ/` and `tests/` are exactly where it recurs
unseen.
2026-08-15 14:53:14 -07:00
Zecheng Zhang 8dd49a2d2b feat(shell): the export attribute, and refuse names that are not identifiers
`export`, `export -n`, `declare -x`, `declare -p` and `set -a` now agree
on one attribute stored on the variable record.

`env` and `export -p` carry exported names only, which is what GNU does.
`$X`, arithmetic and `[[ ]]` still resolve against every variable, so the
process view and the shell view are separate functions in both languages;
TypeScript had collapsed them into one. Adds bash's third variable state,
declared and exported but unset, which `declare -x Z` reports and `env`
omits. Attribute writes with no value go through a gated door so the
policy layer still sees them.

`export`, `readonly`, `local` and `declare` refuse a word that is not a
valid identifier, each in its own voice, instead of exiting 0 and storing
a name that `$1BAD` can never spell back. Quoting decides whether an
empty operand survives to be refused.

Aligns TypeScript to Python where Python was right, including `env -i
FOO=bar`, which built the child environment without the attribute. Python
was wrong about PWD across `fork`, and both were wrong about OLDPWD.
2026-08-15 14:52:28 -07:00
Zecheng Zhang 8d93f0b0fd fix(shell): stop writing into the frozen session projections
`session.env` is a read-only projection of the variable records, so
`Object.assign(ws.env, ...)` throws instead of landing. dsh and the two
version tests still wrote through it.
2026-08-15 14:52:28 -07:00
Zecheng Zhang 40dbaef538 feat(shell): set -C, -n and -v in TypeScript, and stop set -n at a subshell
Mirrors the Python side of tier 2 stage 1: noclobber with `>|` as the
override, noexec, verbose, the `set -o`/`+o` listing and the remaining
option letters.

Also fixes `set -n` inside a subshell in both languages. `handle_subshell`
runs a second statement loop beside `execute_program`'s, so the check had
to be stated there too. Without it `set -n` worked at top level and did
nothing one paren deep.
2026-08-15 14:51:29 -07:00
Zecheng Zhang 9b1a0b17c4 refactor(shell): mirror the ShellVar record in TypeScript
Same design, same names, same semantics as the Python commit before it:
Session.vars holds one readonly ShellVar per name, env/arrays/readonlyVars
become projections, and the two local save/restore stacks collapse into
one that shadows the whole record.

The projections are frozen for the same reason Python's are mappingproxy:
an assignment into a plain object lands in a throwaway and vanishes, which
is the failure the store exists to remove. TypeScript catches those at
compile time rather than at runtime, which is how the seedVar(ws, ...)
mis-targeting that Python only surfaced under test was found here before
running anything.

attrLetters is pinned against the same bash 5.2.37 clusters in
variable.test.ts, so the two languages cannot drift on print order.

Full core suite 8266 passed, tsc/eslint/knip/prettier clean, layout parity
at baseline.
2026-08-15 14:51:29 -07:00
Zecheng Zhang ce082a719e refactor(shell): one ShellVar record per variable, replacing three parallel containers
A variable's facts were spread across session.env, session.arrays and
session.readonly_vars, with "scalar or array" encoded as which dict the
name lived in and mutual exclusion maintained by hand at every write.
The remaining #608 attributes would each have added another container
(-x -i -l -u -n -t, plus associative values and a third local stack):
thirteen name-keyed containers, any two of which could disagree about
one name. That has already cost bugs here twice -- printf -v wiping an
array, readonly_vars sitting permanently empty.

Session.vars now holds one frozen ShellVar per name (value plus
attributes) and env/arrays/readonly_vars are read-only projections of
it. Frozen because every writer already computed on a copy and handed
the result to the session door; the type now enforces what was
convention, so the door is the only way to change a variable and an
attribute write can finally be gated the way a value write is.

Two things the migration surfaced:

- The projections return mappingproxy, not a plain dict. A dict made
  session.env[k] = v write into a throwaway and vanish silently, and
  the suite went green on it -- the exact failure the record exists to
  remove. Failing loud is what located all 125 write sites.
- bash has a third state, declared-but-unset: readonly ONLY prints
  declare -r ONLY and ${ONLY-d} still expands to d, while readonly
  EMPTY= prints declare -r EMPTY="" and does not. ShellVar.value is
  therefore optional; None is that state, and export FOO and declare -i
  n need it too.

Attribute print order is pinned exhaustively against bash 5.2.37 over
all 72 ordered pairs: a/A, then i n r t x, then l/u. -a and -A are
derived from the value's type rather than stored, so they cannot
contradict the value they describe.

Also adds the Stage 1 tests that were missing: a leftover reference
broke set -C outright and the whole suite stayed green.
2026-08-15 14:51:29 -07:00
Zecheng Zhang 30a479e3e2 feat(shell): set -C noclobber, -n noexec, -v verbose, and the set -o/+o listing
set -C was unreachable by its own letter: only five letters were mapped to
option names, so -C fell into the ignore-unknown-letter branch while
set -o noclobber worked. Maps all nineteen bash letters, then implements
the three options that silently did nothing:

- -C refuses a truncating open onto an existing target (empty counts),
  leaves the file intact, exit 1. >| is a new operator that overrides it
  for one redirect; >> is unaffected. Existence is asked of the op
  dispatcher, and only when the option is on, so the ordinary redirect
  path costs no extra round trip.
- -n reads without executing, checked in the top-level statement loop,
  which gives bash's one-way trip for free: a later set +n never runs.
- -v echoes input as the reader consumes it. The unit is a line, not a
  statement, so set -v; echo a echoes nothing while set -v\necho a does.

set -o and set +o with no name print the option table in both spellings,
and +B is honoured rather than merely stored so the listing does not
report a state nothing acts on.

All pinned against GNU bash 5.2.37 on debian:stable-slim.
2026-08-15 14:51:29 -07:00
Zecheng Zhang 14f83208ab Merge pull request #815 from strukto-ai/fix/hf-readdir-enoent 2026-08-15 14:18:58 -07:00
Zecheng Zhang 948aaa03d5 Merge pull request #814 from strukto-ai/chore/core-dead-export-sweep 2026-08-15 14:18:41 -07:00
Zecheng Zhang b6b6fbc662 Merge remote-tracking branch 'origin/main' into fix/hf-readdir-enoent
# Conflicts:
#	integ/target_exceptions.json
#	integ/unix/ls/missing.json
#	python/mirage/core/hf_buckets/readdir.py
#	typescript/packages/node/src/core/hf/readdir.test.ts
#	typescript/packages/node/src/core/hf/readdir.ts
2026-08-15 10:43:25 -07:00
Zecheng Zhang 002cf1f345 chore(ts): sweep the dead exports out of core (D28)
With the barrel down to its public surface, knip can finally see core's
unused exports. 233 findings: 74 are the surviving index.ts lines, whose
consumers live in examples/, docs/ and integ/, outside knip's project
root, and are gated by scripts/check_barrel_surface.py instead.

Of the remaining 159, six turned out to be false as well. knip strips a
barrel line and then cascades into whatever that line alone kept alive,
so CommandContext, ExecuteResultContext, RAMWatchQueue and Watcher are
still reachable through index.ts, and S3BrowserOperation is re-exported
by packages/browser.

The other 153 are gone: 61 dead re-export lines in the sub-barrels, 52
symbols that lost the export keyword but are still used in their own
file, and 40 deleted outright.

knip.json is unchanged. `pnpm exec knip` is a pre-commit gate, and
includeEntryExports would fail it on the 74 barrel lines.
2026-08-15 10:31:32 -07:00
Zecheng Zhang bcf170afd9 Merge pull request #811 from strukto-ai/feat/watch-delta-hooks
feat(watch): ship delta_hook for ten more backends
2026-08-15 10:27:48 -07:00
Zecheng Zhang 8a7e68a945 Merge pull request #810 from strukto-ai/fix/dsh-fs-readbytes
Optional byte-range reads across backends, drop github_ci, and refresh the agent SDKs
2026-08-15 10:23:36 -07:00
bytecii 2c5b744ae8 Merge pull request #813 from bytecii/fix/api-readdir-enotdir
fix(api backends): a path under a file is ENOTDIR, not ENOENT (py+ts)
2026-08-15 10:22:17 -07:00
Zecheng Zhang 15340e6bb7 fix(ranges): keep databricks' honored window, and read hf's backwards content-range as past EOF
The Files API answers a honored Range with no Content-Range and the SDK
surfaces no status, so taking that header as the proof sliced an already
windowed body a second time. A body longer than the window is the only
proof available and the only case that matters.

Past the end huggingface echoes `bytes 99-2/3` and OpenDAL refuses to
parse it, which reached the caller as a crash rather than an empty read.
2026-08-15 07:02:05 -07:00
Zecheng Zhang bab44f2502 Merge remote-tracking branch 'origin/main' into feat/watch-delta-hooks
# Conflicts:
#	typescript/packages/browser/src/resource/github/github.ts
#	typescript/packages/core/src/index.ts
#	typescript/packages/node/src/core/nextcloud/watch.ts
#	typescript/packages/node/src/resource/github/github.ts
2026-08-15 05:58:36 -07:00
Zecheng Zhang 55d0f67667 style(ts): let prettier collapse the three merged import lines 2026-08-15 05:49:45 -07:00
bytecii bf08d02c60 Merge upstream/main into fix/api-readdir-enotdir
#805 shrank core's barrel to its public surface and moved every in-repo
import onto a module path, which collided with this branch in five files.

The barrel line this branch added for `listingError` is dropped: the new
gate refuses a name that only package source imports, and the four node
readdirs that wanted it now name `@struktoai/mirage-core/utils/errors`
like their siblings. `enoent` goes with it in hf and ssh, and
`enotdir`/`readdirError` in gridfs -- each was left over from the private
`listingError` wrapper this branch deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 05:49:18 -07:00
Zecheng Zhang c6d099156d Merge remote-tracking branch 'origin/main' into fix/dsh-fs-readbytes
# Conflicts:
#	typescript/packages/browser/src/commands/builtin/opfs/io.ts
#	typescript/packages/browser/src/core/opfs/read.ts
#	typescript/packages/browser/src/ops/opfs/read.ts
#	typescript/packages/browser/src/resource/github_ci/config.ts
#	typescript/packages/browser/src/resource/github_ci/github_ci.ts
#	typescript/packages/core/src/index.ts
#	typescript/packages/node/src/commands/builtin/redis/io.ts
#	typescript/packages/node/src/commands/builtin/ssh/io.ts
#	typescript/packages/node/src/core/hf/read.ts
#	typescript/packages/node/src/core/nextcloud/read.ts
#	typescript/packages/node/src/core/ssh/read.ts
#	typescript/packages/node/src/resource/github_ci/config.ts
#	typescript/packages/node/src/resource/github_ci/github_ci.ts
2026-08-15 05:46:13 -07:00
Zecheng Zhang 7fcdfd3ab4 Merge remote-tracking branch 'origin/main' into fix/hf-readdir-enoent
# Conflicts:
#	typescript/packages/node/src/core/hf/readdir.ts
2026-08-15 05:38:19 -07:00
Zecheng Zhang fbd902316c fix(hf): a path holding no keys is ENOENT, not an empty directory
hf was left out of #809, which fixed this for s3, gridfs and nextcloud.
readdir answered a missing path with an empty listing, so ls exited 0
where stat and every other backend report ENOENT.

What proves existence differs per store and was probed rather than
assumed. hf is a third rule: the tree API lists children only, with no
self-entry, and the service refuses a directory marker client-side
(create_dir=false), so a bucket directory exists exactly while it holds
a key and any entry at all proves it. The Hub answers a missing subpath
with 200 and [], which the lister reports as an empty result rather than
raising, so the existing NotFound arm never fired.

readdir now tracks whether the listing saw anything and refuses when it
saw nothing, with the mount root exempt and the errno coming from the
shared readdir_error / readdirError helper.

The TypeScript fake rejected with NotFound where the service answers [],
which would have made the new tests vacuous; it now matches. That fake
was also why the existing "raises ENOENT for a missing directory" test
passed without reaching the code under it.

integ: hf and hf-prefix join the seven ls/missing cases and their
exceptions are deleted. The baseline stays at 2297 because it counts
unexcused gaps only, and those pairs were excused.

Three cases only passed before because this bug masked them, all from hf
having no empty directory: rm_interactive_noops and rm_root_and_fs_flags
end with the directory emptied, and xm_mv_no_copy_source_kept lists a
directory that mkdir -p never keyed. hf is dropped from those three, with
a reason recorded for the one whose directory reports it as a gap.
2026-08-15 05:34:26 -07:00
Zecheng Zhang a552448784 fix(ranges): take the window on every backend that only asked for it
Sending a Range is a request, not an instruction: a server may answer
200 with the whole representation, and box, dropbox, gdrive, onedrive,
sharepoint and databricks all handed that back whole for what the
caller asked to be a window. They now carry the window as one value
and check the answer, the way slack and discord already did.
2026-08-15 05:33:53 -07:00
Zecheng Zhang 72f8b636b7 fix(gridfs): clamp a byte window to the stored length before the driver sees it
The node mongo driver validates a window against the file and refuses
every POSIX-shaped answer: a start past the end, an end past the end,
and an omitted end, which it defaults to 0 and then reads as ending
before the start. That last one broke every read-to-EOF on gridfs.
Python is unaffected because its driver seeks through a file object.
2026-08-15 05:33:53 -07:00
Zecheng Zhang 4d54f906ef Merge pull request #805 from bytecii/feat/barrel-decouple 2026-08-15 05:31:48 -07:00
Zecheng Zhang 593c8f4e40 style: yapf the new watch tests 2026-08-15 05:12:18 -07:00
Zecheng Zhang 764501282b fix(watch): address codex review on the github, dropbox and disk walks
Three P1s, each of which was present in both languages while the review
saw only one half.

github: the walk fetched a complete tree, diffed it and threw it away.
find, du and grep's scope counter read accessor.tree directly since the
re-key, so they answered from the tree the mount was built with until an
unrelated read refilled the index, and a pull that reported a CREATE was
followed by a find that could not see the file. A truncated tree is
still refused rather than adopted.

dropbox: path_display carries the server's casing and root_path the
user's, and dropbox paths are case-insensitive, so a configured /team
whose displayed path is /Team matched nothing, kept the root on the
front of every virtual path, and put every event outside the watch
scope. The comparison now folds case; the slice keeps the server's
casing below the root.

disk: an unreadable directory is not an empty one. The typescript walk
caught every readdir error and python's os.walk swallowed them by
default, so EACCES or EIO diffed into a DELETE for every child and a
CREATE for each once access returned. Only ENOENT is swallowed now.

Tests pin all three in both languages.
2026-08-15 04:53:40 -07:00
bytecii 8bcb72b446 fix(ts): carry CommandOpts, drop four dead lines, gate the barrel both ways
Rebasing onto #812 broke the build, and the break was one my own gate
could not see. #812 rewrote the redis example to take (accessor, paths,
texts, opts) and added a typecheck script for examples/typescript, so
tsc now says:

  redis/redis.ts(18,42): error TS2305: Module
  '"@struktoai/mirage-core"' has no exported member 'CommandOpts'.

check_barrel_surface.py only ever asked whether every line in index.ts
has a consumer. The other direction -- a consumer importing a name no
line carries -- it never asked, so the shrunken barrel could go stale
under any consumer change and only tsc would notice. tsc is not enough
on its own either: it reads examples and integ, but a name in a docs
page is prose to it. The gate now owns both halves.

Adding the second rule needed the first one's bookkeeping fixed.
own_exports() counted a runtime package's own names only when they came
from a relative module, so a name the index re-exports from a core
subpath was credited to core's barrel instead. That mattered because
node and browser both name DiscordConfig, LinearConfig, NotionConfig and
SlackConfig from their core subpaths -- every TypeScript consumer gets
them from the runtime package, and the four lines in index.ts were dead.
The rule is now "any module but the bare barrel supplies the name
itself", which drops those four.

Net: 80 names -> 77. Verified the new rule fires rather than trusting a
green run: deleting the CommandOpts line reproduces the failure with the
name reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 04:51:14 -07:00
bytecii 816bf4c9a2 Merge remote-tracking branch 'upstream/main' into fix/api-readdir-enotdir
# Conflicts:
#	integ/target_exceptions.json
#	integ/unix/ls/missing.json
2026-08-15 04:40:23 -07:00
bytecii f3e6840123 fix(errors): keep the fast path out of the generic walk (codex P2)
The shortcut folded into readdir_error assumed a path that exists proves
its ancestors are directories. A flat store breaks that: ram and redis
rename without creating the destination's ancestors, so they can hold
/missing/a.txt with /missing absent, and readdir of the orphan itself
came back ENOTDIR where resolution stops at the gap and the answer is
ENOENT. One level below (/missing/a.txt/x) was already right, which is
why the existing orphan test did not catch it.

readdir_error goes back to walking every component, the listed path
included. The shortcut moves to `listing_error` / `listingError` beside
it, whose docstring states the premise it needs -- a store that cannot
hold an orphan -- and which delegates to the walk for everything it does
not settle. The eight backends that qualify (s3, gridfs, nextcloud,
dropbox, databricks, msgraph, ssh, hf) call it, so the fast path is
still one shared implementation rather than one copy each; ram and redis
call readdir_error directly.

Both new behaviors are pinned in both languages: the orphan case against
readdir_error, and the one-probe count against listing_error.
2026-08-15 04:39:23 -07:00
Zecheng Zhang 4cad2bbeba fix(hf,nextcloud): windowed reads past EOF on the typescript host
Two OpenDAL behaviours the python binding never exposes, both fatal to the
typescript integ run and neither visible from a python-host run.

Its node binding takes an exact byte range and refuses to return fewer
bytes than asked for ("reader got too little data", expect 100 / actual 3),
where a POSIX read comes back short. Python reads through a file object, so
f.read(n) stops at EOF like any file. Both node backends now retry the read
unbounded and take the window themselves.

Past that, an offset beyond EOF answers 416, and OpenDAL reports the
store's whole response as message text rather than fields: the status is
only readable inside the string, the code is OpenDAL's own, and SabreDAV's
RequestedRangeNotSatisfiable has no spaces for the spaced spelling to
match. Widened isUnsatisfiableRange / is_unsatisfiable_range to that shape
in both languages, so the read comes back empty as POSIX expects.
2026-08-15 04:32:31 -07:00
bytecii e677715e96 fix(ts): keep the runtime configs exported, declare the Node floor
Two codex findings on #805.

P2: node and browser lost Mem0Config, OneDriveConfig and SharePointConfig
from their public API. Stage 1 deleted that block from both barrels as a
duplicate of `export * from '@struktoai/mirage-core'`, which was true when
core exported 1457 names; stage 4 shrank core to 80 and left the premise
false. The three resources still arrive through the star -- they are among
the 80 -- so only the config types went missing. Both barrels name them
module-by-module now. Measured: node and browser each shed ~1356 further
names through that star, which is the intended core shrink, and every one
is still reachable by module path; these three were the only names their
own barrels had ever listed.

P1: `with { type: 'json' }` in version.ts parses from Node 20.10, and the
tsup build used to inline package.json so any Node could load it. Nothing
declared a floor -- no package had an `engines` field at all -- so this
would have surfaced as a SyntaxError at import instead of an install
warning. All eight published packages now declare `node: ">=20.10.0"` and
the three docs lines saying "Node.js 20" say 20.10. Hard-coding the
version instead would break a deliberate parity: mirage/version.py reads
importlib.metadata for the same reason this reads the shipped
package.json.
2026-08-15 04:21:06 -07:00
bytecii 2867d091bb refactor(ts): shrink core's barrel to its public surface, and gate it
index.ts goes from 1589 lines / 1457 names to 102 / 80. What survives is
what the repo's own consumers -- examples, docs, integ, the spec
generator -- actually import; everything else is reached by module path,
as `mirage.resource.s3` is in Python.

gen-specs.ts stops enumerating `Object.keys(Core)` for command groups and
reads them from the modules that declare them, so core no longer needs
the reachability assertion: a directory scan cannot miss a group. node
and browser keep it, being bundled behind one entry. Spec output is
byte-identical.

The gate is scripts/check_barrel_surface.py, not knip's
includeEntryExports. knip's project root is typescript/, so the barrel's
only remaining consumers sit outside its graph -- it calls 74 of the 80
survivors unused -- and core's `./*` exports map makes every module an
entry, turning the flag into a repo-wide dead-export check (233 findings,
159 of them real and left for their own sweep). The script enforces the
two rules that matter instead: no package source imports the barrel, and
every name in it has a consumer. Both were checked to fail when violated.

Also fixes two examples that imported a type that does not exist
(HfDatasetsConfig, HfSpacesConfig; the real name is HfRepoConfig).
Nothing caught it because examples/typescript is never type-checked.
2026-08-15 04:21:06 -07:00
bytecii 46294e9b23 refactor(ts): import core by module path instead of through the barrel
Every cross-package import of `@struktoai/mirage-core` now names the
module it wants, the way `mirage.resource.s3` does in Python. The barrel
only existed because the package had one door; stage 2 gave it a subpath
map, so the door count no longer forces the name list.

Two things the barrel was hiding come out with it:

- The seven bundled CLI specs registered themselves at import time, so
  which CLIs a caller could resolve depended on what it happened to
  import. The registry now seeds them, and specs.test.ts pins that they
  resolve from the registry module alone.
- browser and node reached zod through core's re-export. They keep doing
  so, now through `resource/secrets` -- the module whose helpers are
  typed against that instance. Declaring their own zod resolved a
  second copy (4.4.3 next to core's 4.3.6), which type-checks but takes
  134s on one config file.
2026-08-15 04:21:06 -07:00
bytecii b40e054f14 build(ts): ship core as a module tree with a subpath exports map
`core/src/index.ts` is a 1589-line hand-written list because the package
had exactly one door: tsup bundled `src/index.ts` and nothing else was
reachable. This gives it the door Python already has — `./*` maps onto the
built tree, so a consumer can import one module the way Python does
(`from mirage.resource.s3 import S3Resource`) instead of going through a
barrel that has to name it first.

The map is a single wildcard, so a new module needs no build config and no
barrel edit. The `.` entry is unchanged, so nothing has to migrate yet.

tsup cannot do this: `bundle: false` leaves `from "../x.ts"` in the output
rather than rewriting to `.js`, and its dts step fails outright. `tsc`
already rewrites correctly (`rewriteRelativeImportExtensions` is on in
tsconfig.base), so core builds with `tsc -p tsconfig.build.json` plus a
clean step, since tsc overwrites but never deletes. Tests and their
helpers are excluded from the build but still checked by `tsc --noEmit`.

Unbundling surfaced three defects the bundler was hiding, each invisible
while esbuild stood between the source and the runtime:

- Core imports `node:module`, `node:path` and `node:worker_threads` but
  never declared `@types/node`; it typechecked only because core's own
  test files dragged the types in, so CLAUDE.md's rule that core work in
  both runtimes was unenforced. Declaring those three modules locally with
  only the members used makes the boundary compiler-enforced — adding
  `@types/node` would have let any module here reach for `node:fs` and
  still pass.
- `version.ts` imported `../package.json` with no `with { type: 'json' }`.
  tsup inlined the JSON; Node refuses it. Every CLI end-to-end test failed
  on this.
- One extensionless relative import (`./constants`), legal under
  `moduleResolution: Bundler` and unresolvable by Node.

tsup is no longer a core devDependency, which knip confirms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 04:21:06 -07:00
bytecii 681183d6bb refactor(ts): collapse the duplicate export blocks in the package barrels
Groundwork for giving core a subpath `exports` map, but each of these is
a defect on its own: a module exported from two places in one barrel is
two lines to keep in sync, and the second is easy to miss.

Six modules in `core/src/index.ts` were exported from two blocks each
(io/types, generic/cat, generic/head, grep_helper, runtime/table,
cache/index/config) — merged into one block apiece, no name added or
removed. `sed_helper` exported three symbols under both their own names
and a `sed`-prefixed alias; the aliases have zero consumers anywhere in
the monorepo, so they are gone.

`utils/key_prefix.ts` looked like a seventh, but its flat block and
`export * as keyPrefix` are both live — `node/src/core/gridfs/_client.ts`
imports the namespace. Left alone.

The runtime barrels each re-exported names their own
`export * from '@struktoai/mirage-core'` already covers: `MountMode` one
line below the star in browser, and the Mem0/OneDrive/SharePoint block in
both. Nothing shadows those names locally, so the star is the only
source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 04:21:06 -07:00
Zecheng Zhang bc10dba0ca fix(spec): record the read_range slot dify and ram gained
The spec is regenerated by a CI step of its own, not by a pre-commit hook,
so adding the slot to those two command_io tables left the committed
inventory a commit behind with nothing local to say so.
2026-08-15 04:20:57 -07:00
Zecheng Zhang d3c98f1a6b fix(ops): apply the byte window on backends the generic factory never sees
Two ways a windowed read was quietly answered with the whole file.

A backend that registers its own read op does not go through
makeGenericOps, so neither the native-range dispatch nor the read-and-slice
fallback reaches it. opfs declared readRange on its CommandIO and still
returned every byte through the op path, which is what the integ battery
caught; gcal, gdocs, gsheets and gslides had the same hole in both
languages and no coverage pointing at it. Each now reads the window off
kwargs: opfs forwards it (File is a Blob, so it slices natively), the four
rendered ones slice after building their bytes.

Sending a Range is a request, not an instruction. RFC 9110 lets a server
answer 200 with the whole representation, and slack and discord fetch
attachments from a CDN, so the header alone was never proof. downloadFile
now takes the window rather than a prepared header and trims anything that
did not come back 206 (window_if_unranged / windowIfUnranged). Reported by
codex.

The slack and discord fakes returned full bodies whatever the request, so
they could not have caught either half. Both now serve 206 with
Content-Range, and 416 past the end.
2026-08-15 04:20:57 -07:00
Zecheng Zhang 3bbb791217 chore(agents): upgrade the agent SDKs, and follow deepagents' new grep cap
Every agent SDK was resolving to the floor of its own range, so the adapters
were being tested against builds a month older than the pins implied. Two were
outside their caret entirely (@openai/agents 0.13.5 with 0.16.0 published,
pi-coding-agent 0.80.10 with 0.84.2). The examples workspace carries the same
pin set and was bumped with it.

deepagents 0.7.6 added max_count to BackendProtocol.grep/agrep. mypy caught the
incompatible override; TypeScript accepted the shorter signature structurally
and typechecked clean, so both sides are fixed. max_count is a total cap across
files, which is not what grep -m means, so it is applied to the collected
matches rather than pushed onto the command line, and truncated is only set
when matches were actually dropped.

dsh-shell moves to the 0.1.0 line to match dsh-fs. DeepSeek relicensed between
the two (BSD-3-Clause and restricted, to MIT and public), and the published
types are identical. That family tags its 0.1.0 line as next while latest still
points at 0.0.x, so the siblings are pinned in pnpm.overrides.

Clears a low advisory on @ai-sdk/provider-utils that reached examples through
mastra 1.51's @ai-sdk/ui-utils, which 1.59 drops. Both audits are clean.

Note: @opencode-ai/plugin 1.18.18 pulls ini@7, which needs node >= 24.15.0.
CI already resolves above that floor.
2026-08-15 04:20:57 -07:00
Zecheng Zhang 88698fa08b feat(ops): optional byte-range reads across backends, and drop github_ci
Adds an optional read_range slot to the op table. Backends that can fetch a
window do; the generic read op falls back to read-and-slice for the rest, so
no backend has to implement one. Wired in both languages for box, databricks,
dify, discord, disk, dropbox, gdrive, gridfs, hf, nextcloud, onedrive, opfs,
ram, redis, s3, sharepoint, slack and ssh.

Shared helpers live in utils/ranges (range_header for a raw push-down,
slice_window for rendered content, is_unsatisfiable_range to normalise a 416).
Backends that render their bytes take the window right after building them,
which is why dify and ram are on the native list too: a windowed read is
answered the same way everywhere, whatever is behind the mount.

Fixes a crash on hf and nextcloud: OpenDAL's reader seeks rather than sending
a header, so a window past EOF raised from the seek instead of surfacing a
416. Both now read as empty. Caught by the integ battery, not by unit tests.

Removes the github_ci resource, its commands, ops, docs and examples.

Integ: a shared ranges/read.json across 21 targets, plus per-backend cases for
slack, discord and dify, reached through ws.dispatch since no shell command
asks for a window.
2026-08-15 04:20:57 -07:00
Zecheng Zhang d312ce978f Merge branch 'main' into feat/watch-delta-hooks 2026-08-15 04:19:01 -07:00
Zecheng Zhang 6554c2d695 Merge pull request #812 from strukto-ai/chore/typecheck-examples-ts 2026-08-15 04:17:32 -07:00
Zecheng Zhang beade9ea3e fix(github): stat's parent listing key is already mount-absolute
The re-key made the index key mount-absolute, but TypeScript's stat kept
prepending the mount prefix when it derived the parent to list, so a
lookup that missed asked for /repo/repo and never populated the entry.
stat then reported ENOENT for a file that exists, and the read family's
implicit-directory probe found the name in the parent listing and
answered EISDIR, so cat on a freshly written file said 'Is a directory'.

Python computed the parent from the key directly and was already right.
2026-08-15 04:13:22 -07:00
bytecii fb2249af6d fix(api backends): a path under a file is ENOTDIR, not ENOENT (py+ts)
`ls <file>/x` reported "No such file or directory" on every API-backed
backend, where GNU (and mirage's own ram/disk/redis/opfs/s3/gridfs)
report "Not a directory". Each of those readdirs raised a flat
`enoent(path)` when its API 404'd, instead of deciding the errno the way
the store-backed backends do.

The API cannot make that call for them: Dropbox answers `path/not_found`
for a path under a file exactly as it does for a name that is simply
absent, Graph 404s both children listings alike, SFTP 3 has one code for
every unresolvable name, and the Databricks Files API answers 404 to
both. So the errno has to come from walking the ancestors, which is what
`readdir_error` / `readdirError` already does for ram, redis, disk, s3,
gridfs and nextcloud. Wired onto that helper: dropbox, databricks,
onedrive, sharepoint (through the shared msgraph `readdir_items`), ssh
and hf. The probes stay on the failure path, at one request per
component.

gdrive and box do not use the helper, and should not: both resolve a
path by walking it through the index cache, which IS the ancestor walk,
already done and already paid for. box already refused a non-folder
entry there; gdrive did not, so listing a file's id returned an empty
child set and the recursion above reported ENOENT. It now makes the same
check box makes, at no extra request.

Two side effects worth naming:

- The one-probe shortcut the three keyed stores had each copied into
  their own `_listing_error` moves into `readdir_error` itself, so all
  nine backends get it from one place and the three copies are deleted.
  It is behavior-preserving (a path that exists implies every ancestor
  of it is a directory, so the walk cannot reach any answer but ENOTDIR)
  and strictly cheaper: a `readdir` on a plain file is now one probe
  rather than one per component, and a missing path is one fewer.

- hf gets the ENOTDIR half only. Unlike s3 it can store no directory
  marker at all (see core/hf_buckets/mkdir), so "no keys under this
  prefix" covers both a path the repo does not have and a directory
  whose last file was removed. A stored key above the operand proves
  ENOTDIR outright; ENOENT would be a guess, and guessing it would turn
  `mkdir d; touch d/a; rm d/a; ls d` into an error. Dropped rather than
  guessed, so an emptied directory still lists as empty.

`integ/unix/ls/missing.json::ls_operand_under_a_file` regains the 13
targets it was missing. Verified on both hosts: every target runs its
whole battery with 0 failures. Adding those targets made the case-target
gate surface the siblings' gaps; box turned out to answer all six
sibling cases and both `operands.json` cases exactly as GNU does and was
simply absent from them, so it joins those too (baseline 2297 -> 2295).
hf's six sibling gaps are recorded in target_exceptions.json with the
reason above rather than closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 04:00:36 -07:00
Zecheng Zhang b7da661d18 fix(integ): restore ls/missing.json target coverage
The case file #809 added drops 31 (case, target) pairs its ls siblings
still exercise, which fails check_case_targets.py --strict on main.

- box passes all 7 cases on both hosts, so it was an omission; add it
- hf/hf-prefix report a missing path as an empty listing, so ls exits 0
  instead of ENOENT: the defect #809 fixed for s3/gridfs/nextcloud,
  recorded as an exception naming the bug
- ls_operand_under_a_file wants ENOTDIR, which the path-addressed
  backends cannot distinguish from ENOENT; excused with a reason

Back to the 2297 baseline with no baseline change.
2026-08-15 03:49:19 -07:00
Zecheng Zhang effa4ee1c5 Merge remote-tracking branch 'origin/main' into feat/watch-delta-hooks
# Conflicts:
#	typescript/packages/core/src/resource/onedrive/onedrive.ts
#	typescript/packages/core/src/resource/sharepoint/sharepoint.ts
#	typescript/packages/node/src/resource/box/box.ts
#	typescript/packages/node/src/resource/dropbox/dropbox.ts
#	typescript/packages/node/src/resource/gdrive/gdrive.ts
#	typescript/packages/node/src/resource/github/github.ts
#	typescript/packages/node/src/resource/gridfs/gridfs.ts
#	typescript/packages/node/src/resource/s3/s3.ts
2026-08-15 03:21:14 -07:00
Zecheng Zhang 1146305c24 chore(examples): typecheck examples/typescript in CI
Add a typecheck script so `pnpm -r typecheck` covers the examples
workspace, and fix the 32 errors it reports.

- 21 FUSE examples used MountBackend without importing it, so they
  failed at runtime with ReferenceError
- hf_datasets/hf_spaces imported config types that do not exist; both
  resources take HfRepoConfig
- redis provision helpers take (accessor, paths, texts, opts), matching
  the Python example
- postgres_fuse/postgres_vfs read a module-level env const inside main,
  where the undefined guard does not narrow
- s3_browser/presigner passed Record<string, unknown> as ListObjectsV2 input
- lancedb: registry.get may return undefined, and TextEmbeddingFunction
  .sourceField takes no argument
2026-08-15 02:59:27 -07:00
Zecheng Zhang 4eef9413f9 feat(watch): ship delta_hook for ten more backends
Adds pull change detection to s3, github, dropbox, disk, gridfs, the hf
family, ssh, gdrive, graph and box, so eleven resource families now
answer "what changed under this root since my checkpoint".

Two shared helpers keep a new backend from writing its own loop:
synth_dirs builds the directory rows a prefix store implies, and
ReaddirWalk descends through a backend's own readdir with a fresh
private index per pull.

Adding github to the integ battery surfaced two real bugs. Its index was
keyed repo-relative while the other eleven backends and CacheManager key
mount-absolute, so cache invalidation never reached a github mount at
all, silently, since evicting an absent key succeeds. And invalidate_dir
drops a row rather than expiring it, so github's readers took a dropped
index for real absence. Github now keys mount-absolute like everyone
else, its repo-relative path logic reads the git tree on the accessor
the way TypeScript's always has, and ensure_live_index refetches when
the index holds no listing.

Box fingerprinted on modified_at while the same listing already carried
sha1, so two writes in one second were indistinguishable. It now prefers
sha1.

The integ watch battery covers eleven targets at 238 cases, and the disk
example is gated in both languages against one shared truth file.
2026-08-15 02:58:04 -07:00
bytecii 1d0e3e1353 fix(s3,gridfs,nextcloud): a prefix with no keys is ENOENT, not an empty directory (#809)
* fix(s3,gridfs,nextcloud): a prefix with no keys is ENOENT, not an empty directory

`ls` on a path that does not exist printed nothing and exited 0 on the
keyed stores and on nextcloud, where every other backend exits 2 with
GNU's diagnostic. `stat` has always been right here; `readdir` was not.
It lists what is under a prefix and returns whatever it finds, and for
a prefix holding nothing that is an empty list rather than an error,
which `ls`'s probe_operand reads as an empty directory. So `ls` and
`stat` disagreed about whether the same path existed, and `ls` is the
one command in the family that reads the listing directly.

Seven lines diverged from ram/disk/GNU, all from that one hole: a
missing operand, a missing nested operand, a path below a file, the
same under -l, a second operand beside a good one (which also printed
a phantom header for the missing path), an unmatched glob, and a
directory listed after rm -r.

Each readdir now tracks whether the listing saw anything at all and
refuses when it saw nothing. What proves existence differs by store
and both were probed against a live server rather than assumed:

  s3, gridfs   an empty directory is a zero-byte marker keyed at the
               prefix itself, which the entry loop skips as a
               non-child, so the flag is set from the raw page before
               that skip
  nextcloud    PROPFIND lists the collection itself, so an empty
               directory yields one entry and only a missing path
               yields none. OpenDAL's WebDAV lister reports that as an
               empty result rather than raising, which is why the
               existing `except NotFound` never fired

The mount root is exempt everywhere: it exists because it is mounted.

The errno comes from the shared readdir_error / readdirError helper
that ram, redis and disk already use, so ENOTDIR-vs-ENOENT stays
decided in one place. One probe runs ahead of the walk: a path that is
itself a file is ENOTDIR whatever the ancestors say, because every
ancestor of a stored key is a prefix by construction. No probe runs on
a successful listing.

is_not_found moves from a private copy in s3/stat.py into s3/_client.py
so readdir can share it, mirroring TypeScript's isNotFoundError, which
has always lived in _client.ts.

Both nextcloud test fakes were lower fidelity than the server and would
have made the new tests vacuous: the python one omitted the collection
self-entry, and the TypeScript one rejected with NotFound where the
server answers []. Both now match what op.list actually returns.

integ/unix/ls/missing.json pins all seven, each oracled against GNU
coreutils on debian:stable-slim first. `ls <file>/x` carries a narrower
target list: databricks, dropbox, onedrive, sharepoint, ssh and gdrive
answer ENOENT there where GNU says ENOTDIR, a separate pre-existing gap
in backends this change does not touch.

test_ls_does_not_show_removed_file was pinning the bug. It wrote
/data/arch/c.txt with no mkdir, so no marker for arch/ ever existed and
removing the last key removes the directory too -- which is what stat
and ram have always reported. It now mkdirs explicitly and keeps a
sibling, so it pins invalidation rather than directory lifetime, and a
companion test pins the emptied-implicit-directory answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(errors): a path component resolves as the directory when both exist

readdir_error walked the ancestors asking is_file before is_dir, which
is not a distinction ram, redis or disk can make -- a path there is one
or the other. A keyed store can hold both an object `a` and a prefix
`a/`, which is a coexistence s3's stat already handles deliberately,
and traversal only ever reaches an intermediate component through the
directory. With an object `a` and a key `a/x.txt`, `ls /a/never` has to
report ENOENT because `never` is absent, not ENOTDIR because `a` is
also an object.

Fixed in the shared helper rather than the three keyed-store callers,
so s3, gridfs and nextcloud all get it. The order is immaterial where
the two are mutually exclusive, so ram, redis and disk are unaffected;
it is also one probe cheaper for a directory ancestor, which now stops
the walk without a second lookup.

Pinned in both languages at the helper (the coexistence case and its
mirror, where nothing coexists and ENOTDIR must survive) and at the
s3 and gridfs readdirs.

Reported by codex on #809.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 02:37:57 -07:00
bytecii ef5c41309b fix(rename): directories move whole, on every backend that fakes them (py+ts) (#808)
* fix(rename): directories move whole, on every backend that fakes them (py+ts)

The reported bug was dropbox refusing `mv <srcdir> <dstdir>` when
`<dstdir>/<srcname>` existed as an EMPTY directory: move_v2 rejects any
existing destination, and the conflict handler delete-and-retried only
for a destination file. rename(2) replaces an empty directory, and the
generic mv layer has already proved it empty before calling the backend,
so dropbox now mirrors msgraph's rename_replace -- list the folder one
entry deep, and keep the original error only when a child is there, so a
non-empty destination still reports GNU's "Directory not empty".

Sweeping every backend for the same question turned up three more, and
they are exactly the synthetic-directory keyed stores, where a directory
is not an object but a fact derived from keys and someone has to walk
the subtree by hand. In all four that walk handled files only:

  s3      no directory rename at all; raw botocore NoSuchKey reached the
          user. Now a prefix walk carries the mkdir marker and the whole
          subtree, and a source that is neither object nor prefix is
          ENOENT rather than backend text.
  gridfs  no directory rename at all; ENOENT for a directory ls had just
          listed. Now a prefix retag across every revision.
  ram     moved files, orphaned nested subdirectories. The orphans imply
          a phantom source tree, so the old name reappeared in its
          parent's listing and then stat as missing -- the shape
          check_dest_parents already refuses on the way in.
  redis   same code shape, same bug.

ram and redis get one _move_subtree that re-keys dirs, files, modified
and attrs uniformly; that also picked up nested files' mtimes, which
were being dropped across a directory rename.

Backends with real directories were already correct and are untouched:
disk (rename(2)), ssh (posix_rename), nextcloud (WebDAV MOVE), box,
gdrive, onedrive, sharepoint and dropbox (server-side folder move),
databricks (recursive copy + rm), opfs (recursive copy).

s3 classifies the source with HeadObject before copying anything rather
than falling back off a failed CopyObject. Stores disagree about a
missing source and a lenient S3-compatible one accepts the copy while
writing nothing -- the repo's own mock does exactly that -- so an
error-driven fallback would have deleted a source whose copy never
landed.

None of this was visible because a case file's `targets` is an opt-in
allowlist: a backend that cannot pass is deleted from the list and the
divergence becomes an omission nobody can see. dropbox was parked that
way, and s3/gridfs directory rename was never tested at all.
check_case_targets.py compares each case file against the modal target
set of its own directory and reports what it drops, on the same ratchet
as check_layout_parity.py: advisory by default, --strict in CI fails
when the count moves off the baseline in either direction, and a stale
excuse fails as loudly as a new gap.

empty_dir.json regains all six dropped targets, and dir_rename.json is
new -- nothing in the battery renamed a directory before. The ram and
redis unit tests already had a test_rename_dir_moves_children, but it
nested only one level, which is how the orphan survived; the new cases
add a subdirectory and fail without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(s3): a refused DeleteObjects key is not a successful move; gate per case

Two codex findings on #808, both real.

DeleteObjects answers 200 with per-key results and reports a key it
refused under "Errors" rather than raising, so reading only the absence
of an exception called the move a success while the whole source tree
survived beside the fresh copy -- a duplicated directory nobody is told
about. Both languages now collect those keys and fail. PermissionError /
EACCES because a refused delete is a lock or a policy in practice, and
because it is an fs error, so mv reports the operand and keeps going
rather than aborting the command line. Both trees are left in place,
which is what GNU mv leaves behind when the unlink half fails after the
copy half landed.

The mock's delete_objects returned None, which is why the gap was
invisible to a unit test; it now answers with Deleted/Errors the way the
real API does, and `undeletable` is how a test asks for the refused half.

The coverage gate keyed on the union of a file's cases, which hides the
exact omission it exists to catch: a sequence-style file states one
scenario across prep/act/verify cases, so dropping a backend from only
the verifying case leaves the union unchanged while the runner silently
skips the assertion. The unit is now one (case, target) pair, and the
baseline moves 836 -> 2297 with it. A "files" excuse still covers every
case in that file, since the usual reason is a property of the file.
Verified against the reported scenario: removing s3 from only
mv_dir_rename_subtree_followed now fails the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 02:37:35 -07:00
Zecheng Zhang 1636c74084 docs: drop the removed filetype renderers, and clean up dead pages and assets (#807)
Seventeen pages still documented parquet, ORC, HDF5 and feather
rendering, which neither language ships any more: the ops are absent
from the source, the extras are gone from pyproject, and the two example
files the pages linked to do not exist. Ten carried a whole Data Format
Support section, the rest advertised cat_parquet and friends inline.

Filenames and find examples stay. A .parquet object still sits on these
backends and still reads as raw bytes, so only the rendering claims are
untrue.

Also: home/python.mdx was a lone redirect stub with no nav entry and no
inbound links, four images had no reference anywhere in the repo, and
four pages carried no description.
2026-08-15 01:00:35 -07:00
Zecheng Zhang c32855fd91 docs: give each service its own brand icon, and drop the github_ci pages (#806)
The sidebar rendered six databases, three cloud stores and every CLI as
the same generic glyph, so the icon carried no information. Thirteen
brand marks now stand in: notion, linear, mongodb (also gridfs, which is
mongodb's), postgres, qdrant, chroma, redis, supabase, databricks, box,
cloudflare for r2, nextcloud and langfuse, applied across the home,
python and typescript trees.

Twelve come from simple-icons at the pinned 16.28.0 with the brand hex
filled in, which is the convention the existing logos already follow;
regenerating minio-logo.svg that way reproduces the committed file byte
for byte. Notion's brand hex is #000000, so it takes the neutral gray
the OpenAI mark already uses rather than vanishing in dark mode. Chroma
and langfuse are not in simple-icons and come from each project's own
mark instead.

Oracle, LanceDB and QingStor keep their generic icons: oracle was
removed from simple-icons upstream and the other two publish no usable
svg.

github_ci is gone from the docs, with its nav entries, its resource
matrix row and the resource index bullet.
2026-08-14 23:28:55 -07:00
bytecii 7a6ca4ff50 Merge pull request #800 from bytecii/feat/resource-state-contract
refactor(ts): put getState/loadState on the Resource contract, mirroring Python's base (A3)
2026-08-14 22:58:26 -07:00
bytecii 8564e62221 Merge pull request #802 from bytecii/fix/du-sep-dbx-copy-docs
fix(cache): rename evicts dst's own listing (13-backend sweep) + disk Windows separators + databricks copy invalidation + derived-ops docs
2026-08-14 22:55:55 -07:00
bytecii cd78c4304c fix(cache): rename evicts dst's own listing (13-backend sweep) + disk Windows separators + databricks copy invalidation + derived-ops docs (py+ts)
PR-0e from the restructure audit (bugs 8+9+G5). The new mv-onto-empty-dir
integ pin caught the rename write-flavor staleness in disk, ssh, onedrive,
and sharepoint; every backend rename now takes the unlink flavor on dst.
Dropbox folder-conflict mv and s3/gridfs directory rename are pre-existing
divergences excluded from the pin and filed as follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 22:16:13 -07:00
Zecheng Zhang 53f09f618c docs(cli): quote every CLI page description (#804)
gws was left as the only quoted description, which is the shape that
invites the next unquoted colon. Quoting the rest makes the frontmatter
uniform and immune to it. No parsed value changes.

This also re-targets the CLI pages for the docs build. The icons from
#801 never went live: that deploy aborted on the gws parse error and
dropped all sixteen files with it, and #803 only changed the two gws
pages, so the incremental build republished those alone.
2026-08-14 22:14:59 -07:00
Zecheng Zhang a3f3cadbb1 docs: quote the gws description so the frontmatter parses, and gate it (#803)
The description held an unquoted ": ", which YAML reads as a nested
mapping key, so Mintlify failed the whole deploy on both gws pages.

Nothing checked frontmatter before CI, so a one-character mistake only
surfaced as a failed deploy. check_docs_frontmatter.py parses every page
the way the docs build does and runs beside the other repo-root checks.
2026-08-14 21:53:40 -07:00
bytecii a66725bd67 fix(ts): absent secrets stay absent; a config-backed mount refuses to load as RAM
Both from the codex review of #800, both verified against Python first.

P2 — a null secret was being masked. `redactValueWithSchema` returns
null *before* it asks whether a field is secret, mirroring Python's
`if value is None: continue` in `_walk_config_dump`. The hand-written
qdrant and lancedb redactors masked unconditionally, and both keys are
`string | null`, so a keyless local Qdrant or an on-disk LanceDB got a
`<REDACTED>` marker for a credential it never had — making
`Workspace.load` demand a fresh config for a self-contained snapshot.
`RedactedConfig<T, K>` now keeps null in the redacted twin when the
source allows it, so the type says this too.

P1 — chroma has no credential, so its state carried no marker, so
`buildMountArgs` handed back a `RAMResource` and a /chroma mount loaded
as an empty local directory. The cause is broader than chroma:
TypeScript rebuilds *nothing* from state, because core cannot import
`buildResource` (it lives in node/browser), while Python reconstructs the
class from `resource_state["type"]` via its registry
(`_resource_class_for`). Until core gets a resource factory,
`resourceStateRequiresOverride` also honors an explicit
`needs_override`, and the six config-backed resources set it — so the
mount refuses to load rather than coming back empty. Python already
writes that field on four resources and reads it nowhere; TypeScript now
reads it, which also protects Python-written snapshots loaded in TS.

Tests: keyless lancedb/qdrant cases on both sides (Python passes as-is,
which is the proof the null rule was already its behavior), and the
registry sweep now asserts every config-backed backend demands a
resource at load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:42:53 -07:00
Zecheng Zhang a1a769848e docs: fix the README hero snippet, resync the mirrors, and give each CLI page its own icon (#801)
* docs(readme): fix the hero snippet and resync every mirror

The hero snippet called ws.command(...), which exists in neither
language: registration is the standalone command() plus mount.register.
It is replaced with a Python example that mounts ram, redis and slack
side by side, captures python with monty, and installs a CLI, all of it
run against the published 0.0.5 packages first.

Two more corrections. The filetype sentence promised parsed PDF pages,
which the filetype removal took away, so it now says a format renders
however you register it. DeepSeek Harness joins the coding agents row.

The eleven mirrors are regenerated from the root rather than patched,
which also closes drift they had accumulated: a stale backend list, the
old CLI + daemon integrations line, a missing Grok Build entry and a
Codex link pointing at the wrong docs path.

* docs(cli): give each CLI page its own icon

Every CLI page shared icon: terminal, so the sidebar was nine identical
rows. Each now takes the icon its service already uses elsewhere in the
docs: slack, discord, github for gh, google for gws, envelope for
himalaya, book for ntn and chart-gantt for linear (matching the notion
and linear setup pages, since Font Awesome carries no brand mark for
either), and git-alt for git. gws and himalaya also get their names
spelled GWS and Himalaya; the rest stay lowercase because that is the
head word you type.

* examples(filetype): register through the public mount accessor

The example reached into ws._registry.mount_for, but ws.mount is public
and returns the same MountEntry. Output is unchanged, so the CI truth
file still matches.
2026-08-14 21:41:01 -07:00
bytecii d69fae4dd1 refactor(ts): one ResourceStateBase, not two
The interface I added to `resource/base.ts` was a byte-identical copy of
a private one that already sat in `workspace/snapshot/types.ts` feeding
`ResourceState`. Snapshot types now import the exported one, so the
Resource contract and the snapshot format name one shape.

The doc comment now says what the two keys are for rather than
paraphrasing: `type` is the registry name Python's `_resource_class_for`
looks up before falling back to the mount's `resource_class` import path,
and `config` is what `resourceStateRequiresOverride` scans. It also
records why the literal twin of Python's `dict[str, Any]` does not work
here — a TS interface has no implicit index signature, so
`Record<string, unknown>` would reject every named `XResourceState`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:11:50 -07:00
bytecii d74348bedc refactor(ts): put getState/loadState on the Resource contract, mirroring Python's base
`snapshot/state.ts` cast every mount's resource into `{ getState }` and
called it unconditionally, so a resource without one was a runtime crash
at save time rather than a compile error. postgres and mongodb — both
registry-mountable, in node and browser — had none, and snapshotting such
a mount died with `resource.getState is not a function`.

Structure follows Python's, which already had all of this:

- `BaseResource` gains `getState()` returning `{type: this.kind}` and a
  no-op `loadState()`, the twins of `BaseResource.get_state` /
  `load_state`. `kind` moves onto the base as abstract so the default can
  spell itself, mirroring Python's `name`.
- `Resource` declares both non-optionally, so the two casts in
  `snapshot/state.ts` go away.
- The eight config-backed resources that had no state — chroma, dify,
  qdrant (core), postgres, mongodb (node + browser), lancedb — now carry
  their config, redacted. Inheriting the bare default would be worse than
  crashing: with no `<REDACTED>` marker,`resourceStateRequiresOverride`
  returns false and `buildMountArgs` substitutes an empty RAMResource,
  turning a live database mount into an empty directory on load.
- Redacted fields were taken from Python's own `secret_field_names`, not
  guessed: qdrant/lancedb `apiKey`, postgres `dsn`, mongodb `uri`, dify
  `apiKey` (Python masks it in `get_state` rather than on the model),
  chroma none.
- `HfResource` re-narrows `getState` to abstract so the bare default
  cannot reach a Hub resource. Python has no shared Hub base; its four
  resources each spell `get_state`.
- Test fakes that stood up a `Resource` by hand now extend `BaseResource`
  like real resources do, so they inherit the default instead of carrying
  a stub. The three that cannot (two object literals, and the bare class
  that exists to have no `storageId`) spell the pair.
- mem0/onedrive/sharepoint swap `Record<string, unknown>` for named state
  types, joining the ~35 resources that already had them.

Tests: a registry-derived sweep over the six database backends asserts
getState/loadState exist and the credential is masked, plus an end-to-end
`toStateDict` over a postgres mount. Python gains the twin sweep in
`tests/resource/test_state_round_trip.py` — it passes as-is, which is the
point: this closes a TypeScript-only gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:30:13 -07:00
bytecii 85fd527ee2 fix(discord): dirname-parsed scope, tombstoned attachments, date-less grep paths, smart head revival (py+ts) (#797)
* fix(discord): parity batch — dirname-parsed scope, tombstoned attachments, date-less grep paths, smart head revival (py+ts)

Phase 0 PR-0d from the restructure plan (bugs 5+6+7 plus one found
during the scope investigation).

1. Scope detection contract (bug 6). Python's detect_scope was async
and resolved guild/channel ids through the index cache; the TypeScript
twin parses them out of the `name__id` dirnames the tree itself mints.
The index store is an exact-key lookup, so the async machinery could
never resolve anything the dirname doesn't already carry — but it
COULD miss (cold cache), and then grep/rg raised "cannot resolve guild
ID" internally and fell back to a per-file scan with a spurious
stderr warning, on paths whose ids were sitting right in the operand.
Python now mirrors slack-py/discord-ts: sync, dirname-parsed, with the
same field set (level, use_native, names, container). Consequences
picked up on purpose:
- members-container paths no longer push down to guild message search
  (use_native false, matching TS) — message search cannot answer a
  grep over member JSONs.
- the "root-level search not yet supported" special case is gone; a
  root operand falls through to the generic scan like TS and errors
  GNU-shaped.
- bare names (no `__id`) now skip the push-down silently instead of
  depending on cache temperature.

2. coalesce_scopes ported to TypeScript. Python (like slack) coalesces
concrete same-channel chat.jsonl operands into one channel-wide native
search under -w; TS discord never had it, so the same command produced
formatted search lines on python and raw JSONL scan lines on TS.
TS gains coalesceScopes with python's exact semantics.

3. Tombstoned attachments filtered (bug 5). Discord listed any
attachment with an id; slack requires id + download URL + size because
tombstoned/access-restricted payloads carry an id but nothing to read
— they showed as phantom files that ENOENT on read, with unknown
sizes. Both discord twins now carry slack's three-part guard, and the
integ fake gained a `tombstoned` seed knob to prove it end to end.

4. Date-less grep result paths (bug 7). A search hit without a
timestamp rendered `.../channel//chat.jsonl`. Both twins now derive
the day from the message snowflake (the same day readdir buckets it
under); a hit with neither timestamp nor parseable id points at the
channel dir, slack-style.

5. Smart head was dead code (found during 6). head.py gated its
native first-N-messages fetch on scope.level == "file" — a level no
detect_scope in either language has ever produced — so every head ran
the full-day fetch. Revived on level "messages", plus the end-of-day
bound the branch always needed: with `after`, a short day spills into
the next one until `limit` is met, so unbounded revival would print
lines chat.jsonl does not contain. Output is byte-identical to the
generic path by construction (single renderer). TS has no head
override; its scan produces the same bytes.

Also removed the vestigial 'file' member from the TS DiscordLevel
union.

Tests
- py unit: test_scope.py rewritten against the sync API (mirrors
  scope.test.ts case-for-case), test_grep_pushdown.py reworked to
  `name__id` paths with new cold-cache + bare-name pins, test_head.py
  new (smart gate, day-boundary spill, generic fallbacks), readdir +
  formatters guards. Full suite green.
- ts unit: scope.test.ts +4 (coalesce), readdir.test.ts +1
  (tombstone), search.test.ts +2 (snowflake fallback, date-less),
  grep.test.ts single-file scan repointed + coalesce-native case.
  Core suite 8205 passed.
- integ: fixture gains a tombstoned attachment and a second releases
  day; new scope.json cases pin channel-native grep -w with empty
  stderr, coalesced single-file grep -w, and head day boundaries.
  Post-fix: discord + cli-discord = 62/62 on BOTH hosts. Pre-fix
  (sources stashed, pins kept): TS fails 3 (ghost listed in ls/find,
  raw-line grep), python fails 2 (ghost in ls/find) — python's grep
  pins pass pre-fix only because the battery's shared workspace
  pre-warms the index; the cold-cache symptom is pinned at unit level.
- pnpm -r typecheck clean, pre-commit clean (mypy: no issues in 1819
  files).

Known family-wide follow-ups deliberately not taken here: native
search push-down ignores -r on directory operands (slack too), and a
coalesced/date-level push-down can return hits outside the operand
date set (slack too). Both predate this change and need one decision
across the chat family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(discord): codex round — coalesce only message files, smart-head guards (py+ts)

Review round on #797, all four findings taken:

- P1: coalescing keyed on `not use_native` swallowed file_blob operands
  — grep/rg -w on an attachment became a channel-wide MESSAGE search
  that says nothing about the requested bytes (pre-existing python
  behavior the port had copied into TS). coalesce_scopes/coalesceScopes
  now require every operand to be a chat.jsonl messages scope, and the
  call sites gate on level == "messages".
- P2: smart head forwarded any count as one Discord page; the endpoint
  caps limit at 100, and zero/negative (all-but-last-N) forms cannot be
  a single page. Counts outside 1..100 keep the generic path.
- P2: an empty day rendered "\n" instead of b"" — the branch now reuses
  history_jsonl_bytes, the same renderer read() uses, so empty is empty
  and the bytes match the generic path by construction.
- P2: head -v bypassed the ==> path <== header; verbose keeps the
  generic path.

Pins: py unit +5 (file-blob scan, -v, >100/negative counts, empty-day
b""), ts unit +3 (coalesce blob/mixed null, blob-scan no-search),
integ +3 (attachment grep -w scans bytes, head -v header, empty-day
head). Pre-fix: TS fails the attachment pin; python additionally fails
the -v and empty-day pins (got the predicted lone "\n"). Post-fix:
discord + cli-discord = 65/65 on both hosts; typecheck + pre-commit
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 17:42:57 -07:00
Zecheng Zhang d4f2ff5de2 Release 0.0.5 (#799)
* Release Python 0.0.5a1

* Release TypeScript 0.0.5-alpha.1

* Release TypeScript dsh 0.0.1-alpha.1

* test(version): let the --version assertions accept a prerelease

The five TS assertions pinned the output to \d+\.\d+\.\d+, which cannot
express a prerelease, so every one of them failed the moment the version
became 0.0.5-alpha.1. Python asserts the same output with startswith and
was unaffected. The shape is still asserted, now with an optional
prerelease suffix.

* Release 0.0.5 (mirage-dsh 0.0.5 line at 0.0.1)
2026-08-14 17:42:13 -07:00
Zecheng Zhang 5cfb4598b8 feat(ts): install a CLI from a file reference in yaml (#798)
* feat(ts): install a CLI from a file reference in yaml

`clis: {tally: {cli: ./tally.mjs:TALLY}}` now resolves in TypeScript, so
a deployment can ship its own program tree without a host program
calling registerCli. Mirrors the `":" in name` branch of Python's
`cli_spec_for`, one layer up: `cliSpecFor` lives in core, which has no
filesystem and is synchronous, so the ref resolves in the config layer.

The referenced file is imported by Node, so .mjs, .js and .ts all work;
a .ts spec rides Node's strip-only type stripping and is refused with a
hint when it uses a construct that needs codegen.

* fix(ts): node's specifier rule for cli refs, and a real spec check

Both from review.

A bare specifier carries slashes in Node (`@scope/pkg`, `pkg/subpath`),
so copying Python's `"/" in source` test read every scoped or subpath
package as a file, rebased it under the config directory and reported it
missing. A path is now what Node calls a path: relative or absolute,
plus a bare filename carrying a module suffix.

A class name is not proof a tree is usable. Dispatch reads subcommands,
aliases and options at every level, so a value that only answers to the
name crashed on the first line an agent typed instead of failing the
create. The shape is checked recursively instead.
2026-08-14 16:38:10 -07:00
Zecheng Zhang 50a68fc0d4 feat(dsh): stream background output, sandbox facts, spill, and the runtime reach marker (#796)
* refactor(runtime): replace confined with a three-value reach marker

A runtime now declares reach: vfs | process | remote, stating whether
the workspace dispatch gate is its only door or the code can act
around it (host process doors, another machine). The default is
process, the no-promise claim, so a custom runtime must narrow its
reach explicitly. The dsh sandbox claim reads the aggregate: every
runtime at vfs means workspace-write, anything wider means no claim.
Mirrored in Python (RuntimeReach in runtime/types.py) with reach
declared on monty, quickjs, wasi, vfs, local, and RemoteSandbox.

* fix(pyodide): seal the js module so the guest has no host door

Pyodide's default exposes the host globalThis as the `js` module,
which under Node handed guest code js.process (host env, confirmed
reading HOME) and js.fetch (network) — doors around the workspace
bridge that made the runtime's reach='vfs' claim false and
contradicted its own 'no network' docstring. Pass a null-prototype
jsglobals to loadPyodide: `import js` still resolves but the host
globals are unreachable through it, while pyodide's internals (which
capture their globals at load time) and the FS bridge are unaffected.
Pinned by jsglobals.test.ts.

* feat(dsh): stamp ShellSandboxInfo on run results and process handles

When the world is fully workspace-bound (vfsOnly), run() and start()
now fill dsh's optional sandbox field: mode workspace-write,
enforcement 'full' (the VFS gate is unbypassable, so unlike an OS
sandbox on an old kernel there is no promised effect it fails to
govern), denied false (mirage has no out-of-band denial channel; a
refused write fails in-band as an ordinary command error), and
runnerFailed false (the executor is the runner). The process handle
stamps it on settle. Omitted when any runtime reaches beyond the
workspace.

* feat(dsh): stream background command output through a JobConsole

Adds a public ExecuteOptions.sink: pass a JobConsole and the line's
output streams into it as each statement finishes, instead of being
returned whole (the result then carries only the exit code). This
reuses the executor's existing internal sink mechanism, so a compound
line flushes per statement and stdout/stderr keep their channels.

MirageShellProcess is rewritten over that seam: start() runs the
command with a console as its sink and a follow loop drains it into
the read buffer, so readOutput() delivers output incrementally and
stdout/stderr interleave in order (stderr opened by a marker) rather
than stderr being concatenated at the end. The unread backlog is
bounded to stdoutMaxBytes (tail kept, lossy flagged) so a reader that
never drains cannot grow it without limit.

* feat(dsh): spill the full stream to a workspace file on overrun

Adds an opt-in spillDir config. When a background command's streamed
output overruns its delta budget, the full stdout and stderr are
written to files under that workspace directory and readOutput()
points at them (stdoutSpillPath/stderrSpillPath), so a reader can
recover what the delta dropped by reading the spill through the same
VFS. Memory stays bounded: each channel buffers only until the first
overrun, then flushes to its file and appends from there. A write
failure (no writable mount at the path) disables the sink and leaves
the paths undefined, the honest 'no safe path' answer. Default unset,
so nothing spills unless a deployment asks for it.

* docs(dsh): custom backends, background streaming, and the reach model

Corrects the sandbox-claim wording to the reach model (workspace-write
when every runtime reaches only the vfs, dropped when one reaches the
host), and adds a Custom backends section (registerResourceFactory,
host-side before the workspace builds) and a Background commands
section (per-statement streaming, bounded backlog, spillDir).

* test(dsh,core): satisfy lint on the streaming tests

Narrow spill paths with an explicit guard instead of a non-null
assertion (forbidden in the dsh package), and drop the now-unnecessary
ExecuteResult casts the sink overload already implies.

* style: prettier formatting on the streaming changes

* fix(core): drain a buffered line into the sink

A sink only saw output the command-tree walk emitted, so a whole-line
runtime, the syntax gate, a policy denial and a failed line all answered
with bytes in hand that a streaming caller never read. executeLine now
moves any buffered result into the console on every path, in one place
rather than five, and the result stays empty as it already did when the
line streamed.

* fix(dsh): bound the console store, make the spill dir idempotent

Capping the delta did not bound memory: reading a chunk advances a
cursor but frees nothing, so an uncapped store held every chunk of a
noisy background command for the life of the process. The store now
carries a retention budget, and the drain reports a trimmed chunk as
lossy and stops the spill, since a file missing the middle of a stream
is worse than no file.

The spill directory is created through ensureDirPath, which walks the
ancestors and accepts a refusal for a directory that now exists, so two
commands overrunning at once do not cost the loser its spill.
2026-08-14 14:19:06 -07:00
Zecheng Zhang 23d6264a24 feat(shell): redis console store with cross-language streaming integ (#795)
* feat(shell): redis console store with cross-language streaming integ

* feat(shell): console config block, shared console battery, redis store package

* refactor(shell): job_table package, and codex fixes on the redis console

Split job_table into types/constants/table in both languages, mirroring
the console package.

Redis console review fixes: the minted key prefix is public (JobConsole
.store plus RedisConsoleStore.key_prefix) so an external reader can be
handed a console's address; keys expire ttl_seconds after the last
append (default one day) instead of accumulating; the ending chunk is
terminal in the store itself, so an emit racing a kill past the local
guard is dropped server-side; and the TS loader validates the console
block's value types the way Pydantic already did.
2026-08-14 14:18:54 -07:00
bytecii ebec2d6926 refactor(ts): four shared factories mirroring the Python originals (#609 item 24 / T2-9) (#792)
* refactor(ts): four shared factories mirroring the Python originals (#609 item 24 / T2-9)

TypeScript had grown four copy-drift sites where Python already has one
shared module. Each is extracted onto the Python shape, so the two trees
line up module-for-module.

builtins/shared.ts (twin of workspace/executor/builtins/shared.py). Three
independent errorResult()s with three different signatures — capacity.ts
hardcoded the 'df' command, links.ts hardcoded exit 1, only metadata.ts
took both — so a GNU exit-code fix could not be shared. They collapse onto
result/ok/fail/finish, plus operandText/absPath/splitFlags/splitValueFlags/
expandOperands. Drops links.ts's re-implemented abs+splitFlags and
capacity.ts's import of splitValueFlags from metadata.ts (df reaching into
chmod/touch). `Result` moves here from scope.ts, where Python keeps it in
shared.py; the other 12 builtins change only their import line.

The chmod/chown/chgrp/touch handlers each tracked an exitCode variable
that was only ever set to 1 next to an errors.push, so finish() derives it
from `errors` exactly as Python does and the variable is gone.

workspace/executor/interpreter.ts. python/handle.ts and js/handle.ts were
the same 130 lines apart from the label, the unavailable-error class, the
payload flag, Python's -m guard and -x, and js's .mjs module flag.
makeInterpreterHandler takes those as a spec; both handle.ts keep their
path, their exported name and their signature, so callers and
runtime.test.ts's dynamic import are untouched. The .mjs check moves into
the js wrapper, which is where Python's js.py keeps it. Kept in
workspace/executor rather than beside commands/builtin/general/
interpreter.ts because no commands/ module imports workspace/ today and
this should not be the first.

makeRm. generic/rm_command.ts already existed and gcal already used it;
the gdocs/gsheets/gslides bodies were verified character-identical to it
modulo indentation, so the three 79-line files become 20-line bindings.

core/google/tree_ops.ts (twin of core/google/tree_ops.py). makeStat and
makeUnlink over a backend's readdir; the six modules stay as real files
exporting `stat`/`unlink`, so the barrel and the provision/io importers do
not move. The three local eisdir() copies give way to the shared one in
utils/errors.ts: isFsError keys on `code`, which is unchanged, and its
message (the bare virtual path) is what Python's IsADirectoryError(raw)
already produced.

No behavior change intended. -995/+147 across the 26 edited files.

* fix(js): honor the explicit `-` stdin operand, like python3 and node

`js - a b` read a file literally named `-` and failed with
"js: /-: No such file", while `python3 - a b` on the same tree read the
program from stdin. Python has no such split: resolve_source's
STDIN_OPERAND branch (interpreter.py) is unconditional, so both
interpreters honor the operand, and js.py inherits it by taking the
default Argv0Rules. Real `node -` agrees — it reads the program from
stdin and keeps the following words as argv.

Verified against Python by calling resolve_source directly for the js
call shape: `js - a b` yields mode=stdin, args=['a', 'b'], no script
path. Both quickjs hosts then build scriptArgs the same way (an absent
prog leaves the args alone), so the operand lands identically.

Found while extracting the shared interpreter handler in the previous
commit; kept separate because it is a behavior change, not a move.

The unit test fails without this fix (exit 1, not 0). The integ case
pins py=ts.

* test(ts): mirror the Python suites for the two new shared modules

Python has tests/workspace/executor/builtins/test_shared.py and
tests/core/google/test_tree_ops.py; TypeScript had neither twin, and both
modules are now shared chokepoints rather than per-command code.

shared.test.ts ports test_shared.py case for case: the result triple
(ok/fail/finish, including finish keeping a carried IOResult's writes and
deriving the exit code from `errors`), operandText/absPath, the three
splitFlags cases, the three splitValueFlags cases, and the expandOperands
glob expansion over a real RAM mount.

tree_ops.test.ts covers makeStat over a stub readdir: the synthetic
owned/shared roots answer as directories without a readdir round trip, an
indexed document is served straight from the index, a miss warms the
parent and then raises ENOENT, and a call with no index raises ENOENT
without reaching the backend.

It deliberately does not port test_tree_ops.py's one case — that a readdir
raising a non-fs error propagates out of stat. TypeScript swallows it in a
bare catch and reports ENOENT instead, which is a real pre-existing
divergence in the T1-G "honest errors" family; pinning current TS behavior
here would entrench it. Filed separately.

* refactor(ts): keep the interpreter opt interfaces module-private

Nothing outside interpreter.ts imports them — both handle.ts wrappers pass
object literals — so knip reported them as unused exports.

* fix(google): only an absent parent may collapse into ENOENT (py+ts)

Every Drive-family stat/read/unlink warms a cold index by listing the
parent, then retries. Python narrows that call to FileNotFoundError, so
an auth failure or a dropped connection reaches the caller; TypeScript
swallowed it in a bare `catch {}` and reported "No such file" instead.
Five TS sites did this (google/tree_ops stat+unlink, gdrive stat, gdrive
read, gdocs/gsheets/gslides read) — seven catch blocks in all.

tree_ops.unlink was wrong in the other direction: Python called readdir
completely unguarded, so removing a path under a missing directory
reported the parent rather than the operand. GNU names the operand:

    $ rm nodir/file.txt
    rm: cannot remove 'nodir/file.txt': No such file or directory

(pinned against coreutils in debian:stable-slim). Both sides now use the
shape Python's make_stat already had: swallow a not-found parent, let
everything else through. TypeScript spells it with the existing
isEnoent(), the twin of `except FileNotFoundError`.

gdrive/readdir warms a parent unguarded in both languages — already
aligned, left alone.

Tests fail without the fix. Two of them mirror Python suites that had
pinned this since before the TS port (test_stat_propagates_parent_
refresh_failure, test_read_propagates_parent_refresh_failure); the
tree_ops pair is new on both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(cache): one entry_or_warm for every cold-index lookup (py+ts)

Twelve call sites across eight backends had grown their own copy of
"ask the index, list the parent if cold, retry, else ENOENT", each with
its own idea of what a failed listing means. They now share one function
in cache/index/warm, so exactly one catch in the tree decides that.

The previous commit narrowed seven of those catches by hand; this makes
the narrowing structural instead, and sweeps the five that commit did
not reach:

  box/read (x2)     bare catch, py narrowed  -> divergence
  gmail/read        bare catch, py narrowed  -> divergence
  dropbox/read (x2) bare catch, py caught (FileNotFoundError,
                    DropboxApiError) -- but readdir already turns the
                    API's 409 for a missing path into ENOENT, so that
                    second arm only ever swallowed live API failures.
                    Python had the same bug here, from the other side.

gdrive/readdir warms a parent unguarded in BOTH languages; already
aligned, deliberately left alone.

Also folds eight private eisdir() copies into the shared helper. The
copies built `EISDIR: ${p}` and never stamped virtualPath, and
execute.ts renders errorVirtualPath(err) straight into stderr, so a
directory read printed

    cat: EISDIR: /docs/owned: Is a directory

where Python printed the GNU form. Two TS tests asserted on that
message with /EISDIR/ and so had been holding the bug in place; they now
assert the stamped code and virtualPath, which is what Python's
IsADirectoryError(virtual) carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(integ): pin the Drive-family cold-lookup error paths on both hosts

Five cases over the gapps and gdrive targets, all curated by running the
battery and reading the real output rather than guessing the strings:

  gapp_cat_dir_is_gnu       cat /docs/owned          -> Is a directory
  gapp_cat_missing_parent   cat .../nope/x.gdoc.json -> names the operand
  gapp_ls_missing_parent    ls  .../nope             -> ENOENT, exit 2
  gapp_rm_missing_parent    rm  .../nope/x.gdoc.json -> names the operand
  gdrive_cat_dir_is_gnu     cat /data/sub            -> Is a directory

clear_cache forces the cold index, which is the branch entry_or_warm
now owns. Both wordings match GNU pinned in debian:stable-slim
(`cat: <path>: Is a directory`, exit 1).

Two things these cases do NOT prove, so nobody reads more into them:

  - rm builds its own diagnostic from p.rawPath, so it reports the
    operand whatever the backend error carries. The unlink
    operand-naming fix is pinned by test_tree_ops.py, not here.
  - `cat` on a directory is answered by the read generic off stat, so
    the eisdir consolidation is pinned by the gdrive/dropbox unit tests
    (which assert virtualPath, the field errorVirtualPath reads), not by
    these.

They sit at seq 930030+ deliberately: at 520100 their clear_cache wiped
the index that gdocs_prov_cat (930023) expects warm, and that case
started failing on hits=1 vs hits=0.

Verified: gapps + gdrive, 2193 passed / 0 failed, on the typescript-node
host and again on the python host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: formatters over the warm-helper sweep, + the py eisdir message pin

isort regroups the hand-inserted `partial` / `entry_or_warm` imports and
prettier rewraps one dropbox thunk; no behavior change.

Also completes the eisdir work on the Python side: the gdrive read test
asserted only the exception type, so it would not have caught the "EISDIR: "
prefix the deleted TS copies were adding. It now asserts the message is the
bare operand, which is the string the TS twin pins through virtualPath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(gate): lower the layout-parity baseline to 301

The ratchet fails on a drop as well as a rise, so a closed divergence has
to be locked in. Net -1 from three real movements:

  -1  core/google: python-only ['tree_ops']        tree_ops.ts now exists
  -1  workspace/executor/builtins: python-only ['shared']   shared.ts now exists
  +1  workspace/executor: typescript-only [... 'interpreter']

The +1 is the deliberate layering call from this PR: the shared interpreter
handler lives under workspace/ rather than beside Python's twin in
commands/builtin/general/, because no commands/ module imports workspace/
today and this should not be the first. Recording it here so the net -1 does
not hide that one improvement paid for one new divergence.

cache/index/warm does not appear: it landed on both sides, so it is neutral.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(spec): js/node end option parsing at the first operand, like python

`node - -e x` ran `x` instead of the piped program, and `node s.js -m`
flipped the interpreter into module mode instead of handing s.js its own
-m. Both real interpreters disagree, pinned locally:

    $ echo 'console.log(process.argv.slice(1))' | node - -e 'nope'
    [ '-', '-e', 'nope' ]
    $ echo 'import sys; print(sys.argv)' | python3 - -c 'nope'
    ['-', '-c', 'nope']

(node 22.8.0, CPython 3.13)

`Operand.remainder` (argparse nargs=REMAINDER) already exists and both
parsers already honor it in one line — the first operand ends option
parsing, so every word after it is an operand verbatim. `python`/`python3`
have carried it all along; js/node simply never got it, in EITHER
language, so this is a symmetric py+ts spec fix rather than a TS-only one.

Verified by calling parse_command directly: js/node/python/python3 now
agree on `- -e PROG` and on `s.js -m --module`, while a flag BEFORE the
first operand is still the interpreter's (`js -m -e CODE a`).

One consequence beyond the reported case: remainder also turns off
lenient_dash_operands, so `js -Z foo` now reports an invalid option
instead of silently treating `-Z` as the script path (which produced
"js: /-Z: No such file"). Real node agrees — `node -Z foo` prints
"node: bad option: -Z". Nothing in integ or the suites relied on the old
leniency.

Reported by codex on #792. Its premise was wrong in one respect — it said
to mark the rest operand "as the Python specs do", but Python's js/node
spec is Operand(type="str") with no remainder, so Python had the same bug.
Fixing TS alone would have created a divergence.

Tests: 4 parametrized parser cases per language over the real js/node/
python/python3 specs, plus a quickjs end-to-end case. All 4 py and all 5
TS fail without the spec change (python/python3 pass either way, which is
what makes them the control). integ/runtime/quickjs.json `stdin_operand`
gains the -e and -m steps.

Verified: pytest 14159 passed / 0 failed; pnpm -r typecheck clean;
core suite 8129 passed (the one python_repl timeout is the known
Pyodide-boot-under-load flake, 10/10 alone); runtime/run.ts quickjs
11 passed / 0 failed on the typescript host; spec drift, spec parity
(93 match), layout parity (301 = baseline) and width table all clean.

The python host's quickjs suite needs the MIRAGE_QUICKJS_HOME WASI blob
that CI downloads, so that leg is covered by test_integ.yml rather than
locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(quickjs): end qjs's own option parsing before the program's argv

The python host's quickjs runtime appends the program's arguments
straight onto the qjs command line:

    argv += ["-e", args.code, *named, *args.args]

Unlike CPython's -c, qjs's -e does NOT end option parsing, so any
program argument that spells a qjs switch was read as one. Pinned
against the build CI uses (quickjs-ng v0.15.1 qjs.c): the option loop
runs while `*argv[optind] == '-'`, breaks only on a bare `--`, and
scriptArgs start at `argv + optind`.

That is what made integ-runtime red on the previous commit, and only on
the python host:

    node - -e 'console.log("flag")'   ->  flag        (second -e wins)
    node - -m                         ->  (empty)     (module mode flipped)

Both are now `--`-separated, so they land in scriptArgs where the parser
already put them. qjs consumes the `--` itself, and the -e branch still
wins over any filename, so a named program keeps scriptArgs[0].

Surfaced by the remainder fix in the previous commit rather than caused
by it: before that, mirage's own parser swallowed those words, so they
never reached the runtime. Any program argument spelling a qjs switch
had this hole -- `node script.js -m` included.

TypeScript needs no twin. Its quickjs runtime is in-process and assigns
scriptArgs as a global (`quickjs.ts:387`), never building a qjs command
line, which is why the TS host passed the same integ steps. Checked the
other python runtimes too: wasi.py and local.py both go through CPython
`-c`, which DOES end option parsing (pinned: `python3 -c prog -e FLAG -m`
puts `-e FLAG -m` in sys.argv), and monty passes argv as a data global.
quickjs was the only one.

Verified with the real engine, after downloading the pinned
qjs-wasi.wasm: `runtime/run.py quickjs` on the python host is 11 passed
/ 0 failed with this change and reproduces CI's exact two-line failure
without it; the whole python runtime battery is 99 passed / 0 failed
(bridge/docker/wasi skip on unmet env). pytest 14192 passed / 0 failed
-- 33 more than the previous run, because the @live quickjs tests now
execute instead of skipping.

The three new unit tests pin the argv shape without needing the build,
and all three fail without this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:27:37 -07:00
bytecii 4adf2c33af fix(flat-store): implicit dirs, ancestor cache invalidation, exact-only negated -name, ssh revalidation (py+ts) (#793)
* fix(find): s3+gridfs synthesize implicit dirs, honest -empty start (py+ts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cache,find): refresh ancestor listings, keep negated -name exact, dedup shadowed paths

A deep write on a keyed store materializes every missing level of the key at
once, but only the immediate parent's listing was invalidated, so `ls` served
the pre-write view until the 600s index TTL expired. That needed both
invalidation surfaces: `cache.context.invalidate_ancestors` only fires when a
cache manager is pushed (the command path: tee, cp, touch), while a shell
redirect, FUSE, and the ops facade go through the dispatcher's own post-write
bookkeeping. s3/gridfs `write` gets the first, `CacheManager.invalidate_ancestors`
the second. databricks_volume is left alone deliberately: its write requires
the parent to exist, so it cannot materialize a level.

Nextcloud compiles `-name` to a SQL LIKE, a deliberate superset that the
client-side keep() re-filters. Negation has nothing to re-filter with:
NOT(superset) withholds rows the true predicate keeps, so `! -name '*_b.txt'`
dropped `axb.txt`, which GNU keeps. Only an exact comparison is negatable now,
and a `[` or `\` pattern is not pushed down in either direction.

find on s3/gridfs could print one path twice when a file key and a synthesized
directory name it (`data/a` beside `data/a/b.txt`), which these stores allow
and a filesystem does not; all four implementations dedup like hf already did.

The TS integ runner grows the generic consistency adapter python already had:
adapters expose a shadow workspace over the same store, so scenario cases stop
silently skipping for s3/gridfs/dropbox/ssh/hf, and a target that cannot build
one says so instead of dropping its cases in silence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ssh): fingerprint the TS stat by mtime so ALWAYS revalidates

The python ssh stat carries `fingerprint=mod_str or None` (the remote
mtime); the TypeScript twin hard-coded `fingerprint: null`. Under
ConsistencyPolicy.ALWAYS, reconcile.probe reads that fingerprint, so
TypeScript returned Verdict.UNKNOWN, mayServeCached said yes, and the
cached copy kept being served after the server had replaced it.

Surfaced by the generic consistency adapter in this PR: with only
onedrive/sharepoint registered, `consistency_always_revalidates` had
never run against ssh on the TypeScript host. It now does, and failed
with "v1\nv1\n" against python's "v1\nv2\n".

The remote mtime is the only cheap change token SFTP offers, which is
why python uses it; directories get it too, matching python's single
FileStat. Audited the rest of the class: the other hard-coded
`fingerprint: null` sites (core/dify/stat.ts, node/nextcloud/watch.ts)
both match python's `fingerprint=None`.

Not fixed here: the same function formats `modified`/`atime` with
`new Date().toISOString()` (`.000Z`) where python emits second-precision
`Z` via epoch_to_iso. Aligning that means exporting epochToIso from
core's public index, which is a wider surface change than this fix
needs.

integ `consistency_always_revalidates` on ssh is the regression test —
it fails on the old code and passes on the new; ran the ssh target 3x
(2250 passed, 0 failed each) since the fingerprint is a 1-second-
resolution mtime and I wanted the granularity risk ruled out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:21:26 -07:00
Zecheng Zhang 573b471593 feat(shell): job console for streaming background output (#611)
* feat(shell): job console for streaming background output

Background jobs stored their output in two byte fields that were only
readable once the job ended, so nothing could watch a running job and a
reader had no way to say where it had got to.

Replace them with a JobConsole: an append-only log of timestamped chunks
addressed by sequence number, where a reader's whole state is one
integer. Readers can join late, read from any position, and disappear
without the console noticing. The job's ending is an in-band CONTROL
chunk, so "is it done" and "what did it print" are the same question.

- mirage/shell/console/: config, store protocol, RAM store, JobConsole.
  The RAM store keeps a waiter registry keyed by event loop, so a
  reader parked on another thread and loop is woken safely.
- Job drops stdout/stderr for a console. All writes to a job's status,
  exit code, and ending happen in one place, so the table has a single
  writer and the polling _refresh is gone.
- kill and kill_all are async and join on the console, so a killed job
  is settled before kill returns.
- Thread the console down the executor as an output sink. Sequencing
  constructs (loops, lists, groups, subshells, conditionals) pass it to
  their children, so each statement lands as it finishes instead of the
  whole construct arriving at the end. Capture sites (command
  substitution, pipe stages, redirects) do not inherit it and keep
  receiving their output as a value.
- TypeScript: connect the job's AbortController to the executor, which
  already checked the signal but was never given one. kill now stops a
  running job, so it joins like Python instead of settling the job
  itself.
- Drop the unreachable "no job table" branch in handle_background: the
  workspace always has one, so the console is never optional.

Snapshots keep the existing stdout/stderr byte format; a restored job
rebuilds a finished console from it.

* feat(shell): bare `wait` adopts job output, plus compound-job integ coverage

The battery had no case that backgrounded a compound construct: all
eight existing job cases background a simple command, so the path this
branch rewrote was untested. Adding those cases surfaced a real bug.

`wait` with no operand waited for every job and then discarded what they
printed, while `wait <id>` returned it. A real shell has nothing to
adopt because its jobs share the terminal and have already printed;
mirage jobs print to their console, so the shell has to surface it or
the output is stranded. This was pre-existing, not introduced here.

- Bare `wait` now concatenates every unreaped job's console in job-id
  order, then reaps. Not just the running ones: a job that finished
  before the line was reached still has output nobody has read, and
  whether it finished in time is a scheduling accident. Reaping keeps a
  second `wait` from reprinting, and matches GNU, where `jobs` prints
  nothing once `wait` has returned.
- 18 integ cases in bash/jobs/compound.json covering backgrounded for,
  while, if, &&, subshell and brace groups, plus the capture sites that
  must not leak (command substitution, pipe stages, redirects) and
  stderr routing. Both hosts: 1629 passed, 0 failed.
- Unit coverage for bare `wait` in both languages.

The existing "completed jobs cleared after listing" test now uses
`wait %1`, which waits without reaping, so it still exercises what it
was written for; bare `wait` gets its own case.

* fix(shell): reparse `((` as nested subshells when it is not arithmetic

`((echo a); echo b)` failed with a syntax error. tree-sitter-bash lexes
`((` as the arithmetic opener and the lexer cannot back out, so a
subshell that immediately opens another subshell never parses. Bash
resolves the same ambiguity by trying the arithmetic command first and
reparsing as nested subshells when that fails.

Do the same: on a tree that already has an error, split the `((` openers
sitting inside the error and keep the retry only if it parses cleanly.
Commands that parse today are untouched, so no working command's offsets
move, and the original error is preserved when no reparse helps.

The guard is the subtle part. Splitting only openers inside an ERROR
subtree is NOT sufficient: tree-sitter's error region swallows
neighbouring tokens, so a valid `((i++))` next to a bad opener also
reports as errored, and splitting it silently turns arithmetic into a
subshell running `i++` as a command. That is a wrong parse rather than a
rejected one, which is worse. An arithmetic construct has to close with
`))`, so a line containing no `))` anywhere cannot hold a real
arithmetic opener and splitting it is provably safe; lines that mix both
keep their error.

Covered by unit tests in both languages, including the mixed line that
must stay rejected, plus four integ cases in bash/subshell/nested.json.
Both hosts: 1633 passed, 0 failed.

* fix(shell): judge each `((` opener on its own span, not on the error region

The first cut bailed out whenever the line contained `))` anywhere,
which left `i=1; ((i++)); ((echo x); echo $i)` rejected even though bash
runs it. That guard was sound but far too broad.

Judge each opener separately instead, by parsing its balanced span on
its own: `((i++))` stands alone cleanly and is left untouched, while
`((echo x); echo $i)` does not and gets split. This is what makes a
valid arithmetic command safe when it shares a line with a broken
opener, which scope alone cannot do because tree-sitter's error region
covers both. The span scan skips parens inside quotes and backslash
escapes, so a literal `")"` cannot throw off the depth, and an
unbalanced span is assumed arithmetic and left alone.

Newly working, all matching GNU:

  i=1; ((i++)); ((echo x); echo $i)        -> x 2
  ((echo ")"); echo b)                     -> ) b
  ((echo a); echo b); ((echo c); echo d)   -> a b c d
  ((echo a) && (echo b))                   -> a b

`(((echo d)))` still errors, which matches GNU: bash rejects it too.

Four more integ cases in bash/subshell/nested.json, both hosts 1637
passed 0 failed, plus unit tests in both languages.

* fix: address codex review — wait reaping, control-chunk retention, parse byte offsets

- Targeted `wait %N`/`fg` now reap the job after adopting its output, so a
  later bare `wait` cannot replay the same console (GNU bash deletes a job
  waited on by id; pinned with docker bash 5.2). Job numbering restarts at 1
  once the table empties, matching GNU, so repeated `cmd & wait %1` keeps
  working after reaps.
- RAM console stores never trim the terminal CONTROL chunk: evicting it left
  wait_finished()/follow() blocked forever under byte budgets smaller than
  the outcome payload.
- Python parse: _balanced_end/_is_arithmetic now scan the encoded bytes, so
  tree-sitter's byte offsets index correctly when multibyte text precedes an
  ambiguous `((` opener (e.g. `echo é; ((echo a); echo b)`).
- TS parse: hoist isArithmetic to module scope (no nested functions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: rename misnamed left_stderr to right_stderr in handle_background

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(shell): kill settles the job instead of joining a runner that may never notice the abort

`kill` aborted the job then joined on the console's finish. But the abort
is only observed where something checks it (the executor between nodes,
and commands that take the signal), so a job sitting inside one long
command never sees it and `kill` waits forever on exactly the runaway job
it is trying to stop. Every existing kill test used a runner that does
observe the abort, so the join looked safe.

kill now settles the job itself — abort, mark KILLED/137, emit `Killed`,
finish the console — and does not join. The console drops emits after the
ending chunk, so a runner still unwinding cannot append past its own
death. Python also gains the `status != RUNNING` guards in `_settle` that
TypeScript already had, so a runner completing in the window after `kill`
(cancel is deferred via call_soon_threadsafe) cannot relabel a killed job.

Regression test added in both languages with a runner that ignores the
abort; it times out on the pre-fix code and passes after.

* fix(shell): settle jobs at teardown, and stop close() stranding readers

Two of the four codex findings on the merge were real.

Teardown settled jobs on main, where kill was sync. This branch made
kill async and teardown dropped to a bare cancel, so a job stayed
RUNNING with no ending chunk and anyone parked on wait_finished waited
forever on a workspace that was already gone. Both close paths are
async, and kill no longer joins the runner, so they await kill_all
instead. It runs before any resource closes, so a job cannot keep
touching one that is already gone. The sync half keeps the bare cancel
as a last resort for a caller with no loop at all.

ConsoleStore.close promised to wake blocked readers, but follow() and
wait_finished() loop: woken, they re-read, find no CONTROL chunk, and
wait again on a console nobody will write to. Stores now carry closed
state, wait() returns immediately once set, and both loops end on it.

Regression tests in both languages, each verified to fail without its
fix. They use an abort-ignoring runner rather than sleep, which is the
one command that consumes the signal and would pass either way, and
they release that runner in a finally so a failure cannot turn into a
hang at loop teardown.

* refactor(io): exit-code reads delegate to the stream source

A streaming command's status can depend on its content (grep's
exit_on_empty settles the origin only at drain time), so merge() no
longer copies the number: the merged result links to its right-hand
original and reads through the link, always fresh. An explicit write
stores locally and severs the link, so aggregated or overridden
statuses still win (issue #43). Deletes sync_exit_code/syncExitCode
and every call site in both languages; the old one-shot sync also
detached on first use, so a sync before the drain froze a stale 0
forever, a hazard class the delegating read removes.

* refactor(console): split config into constants, types and utils

KILLED_OUTCOME moves to constants, Channel/ConsoleChunk/ReadResult to
types (ReadResult joins from store.py, mirroring where TS already kept
it), exit_outcome to utils, per the module-layout convention; config.py
held no configuration. Same split in TypeScript.

* fix(jobs): wait joins the console's ending chunk, not the status field

kill and settle both flip the status before their final appends, so a
status-based return let a waiter snapshot and reap a killed job before
the Killed marker or ending chunk was persisted: a waiter on another
loop today, any store that suspends tomorrow, and in TypeScript any
concurrent waiter, since every await yields a microtask. wait now joins
wait_finished for live jobs (restored jobs keep the no-task fast path),
and wait_all joins every job rather than only the running ones, since
bare wait snapshots killed jobs too. Gated-store regression tests in
both languages, verified failing against the old wait.

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:09:08 -07:00
bytecii 2de8d7962e Merge pull request #794 from bytecii/fix/notion-trello-prompt-trees
fix(prompt): mount trees that match readdir, and ntn's real grammar
2026-08-14 10:10:48 -07:00
bytecii b425a0ef58 fix(prompt): mount trees that match readdir, and ntn's real grammar
The LLM-facing Notion and Trello mount prompts described trees that no
backend serves, identically in both languages.

Notion omitted the data source level entirely. Since Notion-Version
2025-09-03 a database is a container plus one or more data sources, and
both readdirs have served three levels under `databases/` for a while:
`database.json` plus one dir per data source at depth 2, `data_source.json`
plus row pages at depth 3, row page dirs from depth 4. The prompt still
showed row pages hanging directly off the database.

The prose was wrong the same way, and in a way that would send an agent
looking for a key that is not there: it claimed `database.json` carries
"its typed property schema". It does not. `normalize_database` emits no
`properties` at all -- the schema lives on `normalize_data_source` -- and
`ls` on a database dir lists data sources, not rows.

Trello dropped two directories that readdir has always seeded on every
board: `members/` and `labels/`, each holding one `<name>__<id>.json` per
entry. Only `board.json` and `lists/` were shown.

Both prompts' Notion ntn guidance had also drifted from the CLI, which is
itself gated against real npm ntn@0.21.9 by integ/ntn_conformance.ts. It
advertised `ntn blocks append`, a verb that does not exist, told the agent
to pass ids as `--page`/`--block`/`--datasource`, which are not options,
and used `--json` as a request body when it is a boolean output flag. The
docs already said all of this (docs/*/cli/ntn.mdx: "Ids are positional,
not flags"); only the prompts were stale. They now teach the five real
verbs, positional ids, `pages create --parent/--content`, and blocks,
comments and search through `ntn api`.

The Notion prompts render byte-identical across the two languages; the
ids sentence was previously Python-only.

Docs needed no change -- they were already correct on every count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:16:39 -07:00
Zecheng Zhang 32ba306240 CLI doors, record tier, session write gate (#791)
* feat(cli): one door per state plane for CLI verbs

CLIVerbOpts becomes CLIDoors on inv.doors, carrying dispatch and
stat_path for the data plane, ns for the name plane and session_view
for session state. mount_root is gone: it was the same callable as
ns.mounts.root_of, wired identically at both call sites. Field names
now match CommandOpts, pinned by a meta-test in each language.

Growing the doors closed four gaps in git:

- git add stored a symlink as a regular file holding its target's
  bytes; it now writes mode 120000 with the target as the blob.
- a broken symlink was invisible, because the walk followed links
  through stat_path; it lstats through ns.links first.
- dispatch("unlink") could not remove a namespace link, so checkout
  to a branch without one left it behind.
- commit ignored GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL and EMAIL.

* refactor(workspace): move the record client out of the session package

Sessions, the namespace node table and workspace metadata are three
tables that persist the same way, so the keyed-record clients belong
under workspace/record/ rather than inside one of their consumers.
Before this the namespace and the state store imported upward into the
session package for a client that knows nothing about sessions.

A layering test in each language fails if the record tier grows an
import back into a tier that persists through it.

* fix(shell): route every session write through the policy gate

export and a plain assignment cleared pre_session; the expansion-time
writers did not, so ${X:=d}, $((X=5)), (( )), printf -v and
for ((X=0; ...)) wrote straight to the session env past every policy.
A deployment refusing AWS_* was one ${AWS_PROFILE:=x} away from being
advisory.

All five now write through SessionView.set. The view is threaded into
expansion explicitly and defaults to None, which is correct outside a
workspace. Reads stay sync.

* docs: CLIDoors, the record tier and the session write gate

CLAUDE.md still described CLIVerbOpts with mount_root on inv.ops.

* fix(shell): gate a subscripted printf target, and keep the array

`printf -v 'name[i]'` wrote the session's array table directly, so a
pre_session rule refusing the name never saw it. The door speaks in
whole variables and has since it was written, so the element write now
builds the array on a copy and hands that through view.set, which is
what set_var already asks writers with subscripts to do.

An expansion-time write to a name holding an array was storing a
scalar, which discarded every element after the first. bash writes
element 0 and keeps the tail: `a=(1 2 3)` then `$((a=5))` leaves
`5 2 3`, and `c=("" 9)` then `${c:=x}` leaves `x 9`. Both pinned
against bash 5.2.

* fix(git): replace a link and a file across a checkout

A path that is a symlink on one branch and a regular file on the other
was restored by writing the blob through the link. The dispatcher
follows it, so the content landed in the file the link pointed at,
which no branch touched, and the link stayed in the working tree while
HEAD and the index recorded a file. Linking over a regular file left
that file behind the link the same way.

git replaces the entry in both directions, pinned against git 2.47.
The kind that is there now is a namespace lookup, so a file restored
over a file still costs one write.
2026-08-14 08:42:06 -07:00
bytecii 75a519f0ab fix(glob): a match named like the glob word is not the nullglob literal (#790)
* fix(glob): a match named like the glob word is not the nullglob literal

Pathname expansion dropped a real match whose name equalled the word
that globbed for it. With `*a.txt` and `xa.txt` in a directory,
`echo /data/*a.txt` printed only `/data/xa.txt`; GNU bash 5.2.37
(debian:stable-slim) prints both, because the live `*` matches the
literal `*` in the first name.

The merge that unions a backend's matches with the namespace's (nested
mount roots and symlinks, which no backend can see) has to discard the
backend's nullglob-off fallback: a zero-match backend answers with the
literal word, and that is "no match", not an entry to merge against.
It identified the fallback by comparing the returned path to the word.
That comparison cannot work. After quote removal a zero-match glob and
a file genuinely named `*a.txt` are the same string, so the returned
value carries no evidence of which happened, and the real match was
thrown away. A `?` pattern was unaffected only because its word never
equals the name it matches.

resolve_glob_with already answers two different questions depending on
the spec it is handed: a word-shaped spec gets bash's own answer with
the literal reinstated, a directory-shaped one (PathSpec.dir) gets
matches alone. The expander now asks the dir-shaped question, so an
empty list means nothing matched, the comparison is gone, and
resolve_globs stays the only place that reinstates the literal -- which
it has to be, since only it can see whether the backend and the
namespace between them matched anything. That shape contract was an
incidental comment and is now stated in resolve_glob_with /
resolveGlobWith, because the fix rests on it.

TypeScript issued its backend glob call before branching, so a mid-path
word paid for a request whose result was then discarded; python's
if/elif/else never did. The call now lives in the branch that uses it.

_echo_resolve_glob in test_execute_node.py echoed back whatever spec it
was given, commented as matching real resolve_glob behavior. That was
only ever true on the word-shaped zero-match branch, so three tests
asserting the zero-match literal were passing through the unsound
filter. It now answers as a backend holding nothing the pattern
matches. The same latent echo in test_globs.py's default mock is fixed
too; no test exercises it today, but it would mislead the next one.

Pinned against GNU bash 5.2.37 on debian:stable-slim:

  echo /data/*a.txt  ->  /data/*a.txt /data/xa.txt
  echo /data/*d/f    ->  /data/*d/f /data/xd/f
  cd /data; echo *a.txt  ->  *a.txt xa.txt

integ `glob_matched_name_is_the_word` covers it on the backends that
allow `*` in a name; both hosts report 4999 passed, 0 failed on
ram+disk, and the case goes red without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(glob): a match is a child of the directory it was globbed in

Codex review on #790: `resolve_glob` is a public extension point, so the
spec shape the expander sends is not something it can assume a resource
honors. Every in-repo resource funnels through `resolve_glob_with`,
which reinstates a literal only for a word-shaped spec, but a resource
that implements nullglob-off on its own answers a no-match ask with the
spec it was handed -- now the directory. With the word comparison gone,
`/mount/sub/*.nope` could have expanded to `/mount/sub/` and handed a
command the directory.

The merge now keeps only specs strictly under the globbed directory,
which is the guard `_level_matches` already applies for the same reason
one level down. The distinction from the comparison it replaces is the
point: that one tested against the WORD, which a real match can be
spelled exactly like, and was unsound for it. This one tests against the
DIRECTORY, which no match can equal, because a match is strictly longer
than the directory holding it. So the public hook is defended without
reintroducing the ambiguity.

The directory it tests against is the unmarked one #783 already derives
for `_namespace_children`. A match is a real path a backend listed, so a
glob character quoted in the directory's own name is a character of it;
comparing against the marked spelling would reject every match under a
directory like `'/data/*d'/`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:08:42 -07:00
bytecii 66c614144a fix(errors): shell-quote a read-family operand the way GNU does (#789) 2026-08-13 20:15:47 -07:00
Zecheng Zhang 8b1e96e9cb Merge pull request #783 from bytecii/fix/quoted-glob-literal 2026-08-13 20:15:30 -07:00
bytecii 3baeb2bce1 fix(test): drop a dead optional chain on mountForPrefix
`mountForPrefix` throws on a miss and returns a non-nullable MountEntry;
`tryMountForPrefix` is the one that returns null (the #781 mount-lookup
contract). The `?.` here predates that split, so it is unreachable, and
`no-unnecessary-condition` started failing the lint gate when #788
landed -- on main as well as on this branch, so it is not this PR's
change. Unblocking CI here rather than leaving both red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:45:17 -07:00
bytecii f084ed61a9 fix(expand): track glob quoting per character, not per word
A word-level flag cannot say which metacharacter was quoted, and bash
decides that one occurrence at a time: `'/data/*'?.txt` still globs, on
the `?` alone, against a literal star. With files `*a.txt` and `xa.txt`,
GNU `rm '/data/*'?.txt` removes exactly `*a.txt`; reading the word as a
whole activated both metacharacters and removed both files.

Quoting now rides with the characters. `expand_words` returns the word
with every quoted glob character replaced by its own mark, and the
marks are produced by the expander itself, per node: a quoted node marks
everything it encloses (so `"$p"?.txt` differs from `$p?.txt`), a bare
word marks only what a backslash quoted, and a concatenation just joins
its children. At the matcher, `glob_pattern` hands a marked character
over as its own one-character class, which is what `escape_glob`
already does for quoted text in pattern contexts.

Three properties make this cheap rather than invasive. No mark is a
glob character, so `has_glob` already answers whether a word still
globs -- which retires the separate pattern-stripping pass and the `--`
index alignment it needed, and makes `set -f` just "mark every word".
A mark is one character wide, so every length relation a spec's
virtual/directory/resource_path/raw_path already depended on keeps
holding. And the marks come off at two places only: when resolution
finishes, and at the no-match fallback, where bash's own answer is the
word after quote removal.

Pinned against GNU bash 5.2.37 / coreutils 9.7 on debian:stable-slim,
including the `'*'*.txt` case previously documented as a deliberate
divergence -- bash matches only `*a.txt` there, and so does mirage now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:54:04 -07:00
bytecii 668bf9061f fix(expand): quoted glob characters never trigger pathname expansion
Bash applies pathname expansion only to unquoted metacharacters, but
quote removal happened before classification, so classify_word saw the
bare text of '/data/*.txt' and stamped a pattern: chmod 644 '/data/*.txt'
chmodded a.txt and exited 0 where GNU fails with 'cannot access', and
touch '/data/*.txt' touched a.txt instead of creating the literal name.
rm '/data/*.txt' could silently delete every match.

Expansion is the last layer that still sees quoting, so expand_parts
gains a metadata twin expand_words returning ExpandedWord(text,
globbable): a word is globbable only when it carries a glob character
from an unquoted region. A plain word is scanned raw (has_unescaped_glob
honors backslash quoting), a quoted node never contributes, an unquoted
expansion's words are live the way bash treats them, a concatenation is
live when any child is, and a brace template counts an atom only when
its child was an unquoted expansion holding glob characters
(template_globbable). expand_argv and expand_and_classify then reuse the
noglob shape: a non-globbable pattern PathSpec drops its pattern, so
every consumer (shell resolve, backend pushdown, per-builtin re-glob,
for/select word lists) reads the literal name.

Pinned against GNU bash 5.2.37 / coreutils 9.7 on debian:stable-slim:
'...'/"..."/backslash forms all literal; "/data/"*.txt still globs;
unquoted $p globs while "$p" does not; {'*',x} stays literal;
touch/rm address the literal name; for f in '...' iterates once.

Both languages, kept in lockstep: unit tests mirror across
test_parts.py / parts.test.ts, test_brace.py / brace.test.ts,
test_glob_walk.py / glob_walk.test.ts, quoting coverage suites, plus an
integ battery (integ/bash/glob/quoted.json) asserting both hosts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:52:54 -07:00
Zecheng Zhang 322a13749a fix(shell): ambient session for nested evals, whole-body cmdsub (#788)
* fix(shell): ambient session for nested evals, whole-body cmdsub

A re-entrant execute ($(), backticks, eval, source, xargs, timeout,
command, env, process substitution) now continues in the live ambient
session instead of re-resolving an id through the session manager, so
per-call cwd/env forks and restricted named sessions confine every
nested line. Background jobs rebind the ambient session around the
job task, so a nested eval inside '... &' runs in the job's fork and
can no longer read or move the parent session. Command substitution
executes its whole body: the old node filter ran only the first
command node, so $(a; b) ran only a, and declarations, assignments
and control flow ran nothing at all.

Both languages, red-first tests, plus six shared integ cases
(integ/bash/jobs/bg.json, integ/bash/cmdsub/body.json) pinned
against real bash.

* fix(shell): scope the ambient session to its workspace

Codex review on #788.

The ambient session is now owned by the workspace that published it,
so a callback fired mid-line that reaches a SECOND workspace resolves
that workspace's own session instead of adopting this one: a session
carries one workspace's cwd, env and mount grants, and an unrestricted
session must never stand in for a restricted one. A nested bind (a
background job's fork) inherits the owner, so it stays attributed to
the workspace running it.

The TypeScript policy router now decides with the session the line
actually runs, matching Python: it re-resolved the registered session
for cwd and env, so a policy could authorize a relative command
against one directory and have it execute in another. The router no
longer holds a session manager at all.

A background job binds its fork ambiently only where the async context
isolates tasks. The fallback storage a browser gets is one global slot
restored when the scope settles, which would show the job's fork to
the foreground for the job's whole life; there the job's inner evals
resolve by id as they did before.
2026-08-13 17:50:48 -07:00
Zecheng Zhang 5afe6b7bc7 fix(links): resolve an operand's link prefix, and honor a trailing slash (#782)
* fix(links): resolve an operand's link prefix, and honor a trailing slash

POSIX resolves a path one component at a time, and only the last one is
exempt for a command with lstat semantics. mirage resolved either all of
an operand or none of it, so a no-follow command never resolved the
prefix either: `stat /data/dlink/f2` answered "No such file or
directory" and `du` reported 0.

Always resolve the directory prefix; resolve the last component when the
command follows or the operand was typed with a trailing slash, which
POSIX reads as `dlink/.`. A slash then requires a directory, so
`cat reg/` is "Not a directory" rather than the file's contents.

tar is the one command a slash does not reach (GNU strips it and
archives the link); zip is the counter-example and honors it. rm, rmdir,
mv and unlink refuse instead of following, so a slash no longer deletes
the link it was protecting. mkdir now lstats the name it creates, so
`mkdir -p dangle` collides with the link instead of creating its missing
target.

Every expectation is probed against GNU coreutils 9.4 / tar 1.35 on
debian:stable-slim. Python and TypeScript answer identically on all 73
probed cases.

* fix(links): honor a trailing slash on keyed and synthetic backends

A listing never reaches the stat guard, and on a keyed store it cannot
tell "not a directory" from "no keys under this prefix" on its own, so
`ls flink/` answered with an empty listing and exit 0. Guard readdir
too, but only refuse on a stat that actually answers: on a prefix or
synthetic store a directory is the set of keys under it rather than an
object, so a miss is not evidence of a non-directory.

s3 and gridfs ship their own rm and mkdir, so the generic builders'
link handling never ran for them. The refusals move to one shared
helper both tiers call, rather than a second copy per backend.

Drop the anchored-repetition trims CodeQL flags as polynomial (ReDoS)
for the loop-based rstripSlash helper the repo already has.

hf has no rmdir op at all, so the command is not registered on that
mount and answers ENOTSUP before any operand rule applies; its two
rmdir cases are scoped away from it. A keyed store cannot hold an
empty directory either, so the battery no longer asserts one.

* fix(links): validate a removal line before it drops a link, and refuse a slashed mv source

The dispatcher removes a symlink operand from the namespace before the
command layer parses, so a line that layer rejects has to leave the link
alone: GNU answers `unlink a b` with "extra operand" and `rm --bogus x`
with "unrecognized option", both with everything still in place.

mv gets the same treatment for a link typed with a trailing slash.
rename(2) never follows, so POSIX's `dlink/` == `dlink/.` asks for a
directory the call will not resolve, and GNU refuses in four wordings
depending on what the link leads to and what the destination is.

Also pass the index through the slash guard's readdir probe: a synthetic
backend resolves a path through it and cannot stat without one, so chroma
answered "missing index" for `ls /knowledge/`.

All wordings pinned against GNU coreutils 9.7 on debian:stable-slim.
2026-08-13 17:49:03 -07:00
Zecheng Zhang 583b2fb545 feat(dsh): ship the package as a dsh bundle with declarative mounts (#787)
* feat(dsh): ship the package as a dsh bundle with declarative mounts

* fix(dsh): claim workspace-write only for a confined runtime world, recheck aborts after the ready wait

* fix(runtime): declare confined on the monty test double
2026-08-13 17:45:19 -07:00
bytecii 40c24734ce refactor: one mount-lookup contract — mountFor throws, tryMountFor returns null (py+ts) (#781)
* refactor: one mount-lookup contract — mount_for raises, try_mount_for returns None (py+ts)

Cleanup-plan item 23 (T1-K). Python's mount_for raised bare ValueError
while TypeScript's mountFor returned null, so every call site coped its
own way and the two languages drifted. Both registries now expose the
same explicit pair:

- mount_for / mountFor: the path must be mounted; a miss raises the
  typed NoMountError (py) / noMount-stamped Error (ts). Never caught —
  callers with a fallback use the try variant instead.
- try_mount_for / tryMountFor (+ _prefix twins): None/null on a miss.

All ~70 call sites swept with per-site intent: expected-miss sites
(classify, globs, storage keys, drift, provision, cross-mount scopes,
history, df, man, find actions, cache gates) take the try variant with
their existing fallbacks; post-routing invariant sites (metadata
builtins, expand_operands, spec_hints, watch) keep the raising lookup.
Blanket `except ValueError` catches around lookups are gone, so backend
ValueErrors can no longer be swallowed as "no mount".

Cross-language divergences fixed along the contract:
- TS groupByMount silently dropped unmounted paths; it now propagates
  like Python.
- Python mount_for_command could route a command to the /dev/ mount;
  it now skips DEV_PREFIX like TypeScript.
- TS applyStateDict resolved snapshot prefixes by longest-prefix match,
  so a stale nested-mount prefix would load resource state into the
  ancestor mount; it now uses the exact-prefix lookup like Python.
- TS specForCommand masked a broken-registry miss with ?? null; it now
  propagates like Python's documented behavior.
- TS Workspace.mount() returned null where Python raised; both raise.
- TS watch layer's private throwing wrapper is gone; the WatchRegistry
  interface carries the raising contract directly.
- Deleted the duplicated hasGlob(r) method probes (metadata.ts,
  expand/globs.ts) in favor of the plain resource.glob !== undefined
  narrowing mount.ts already used.

Verified no user-visible behavior change: an 18-case unmounted-path
probe (chmod/touch/chown/chgrp/cat/ls/stat/cd/rm/cp/du/mkdir/redirect)
is byte-identical before/after on both languages and matches GNU
(docker debian:stable-slim). New integ file integ/unix/meta/
unmounted.json pins the GNU-exact errors on 22 targets; ram battery
2438/2438 on both hosts.

Related #74 (the /dev/ skip removes one wrong-pick class; the
first-wins ambiguity remains) and #30 (contract groundwork; cross-mount
command alignment still open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: normalize the prefix argument in py try_mount_for_prefix (codex P2)

The new try variant inherited the old exact-string compare, so the
registration spelling ("/data", "data/") missed the stored "/data/"
entry only in Python; the TS twin normalizes. Normalize with the same
idiom mount/unmount use, delegate is_mount_root through it like the TS
registry does, and pin the spelling set in both languages' tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Zecheng Zhang <zechengzhang97@gmail.com>
2026-08-13 17:44:07 -07:00
Zecheng Zhang 62f4d0681d Resolve security alerts (#786) 2026-08-13 15:30:17 -07:00
Zecheng Zhang 84b1c5e9ea fix(shell): don't unescape an already-expanded word in classification (#785)
* fix(shell): don't unescape an already-expanded word in classification

classify_word ran a second POSIX quote-removal pass over words the
expansion layer had already unquoted, so a backslash that survived
expansion (a literal name character) was stripped again. printf's own
format arg is path-shaped, so `printf '/data/x\ty'` lost the tab before
printf ran, and `ln -s "$(...)"` stored a target with no tab.

Delete _unescape_path / unescapePath and both call sites in each
language. The relative-branch call site was unreachable: _RELATIVE_PATH
admits no backslash.

* test(integ): give the symv directory a real file so hf can remove it

hf's mkdir is a no-op, so a directory holding only symlinks (namespace
state, never backend keys) has no backend existence and rm -r reports
ENOENT. Write a file at setup the way sym/setup.json does; rm -r takes
it along with the directory.

* docs(hf): say why hf mkdir cannot write a directory marker

The comment claimed object stores have no real directories, but s3 and
gridfs are object stores and both write a zero-byte `key/` marker. The
real reason is the client: OpenDAL's hf service reports create_dir
unsupported and rejects a write to a slash-terminated path, both
client-side, in the python and node bindings alike.
2026-08-13 15:14:29 -07:00
Zecheng Zhang 8369f04533 feat(dsh): session binding for the shell executor (#784)
* feat(dsh): session binding for the shell executor

* docs(dsh): session binding
2026-08-13 14:49:44 -07:00
Zecheng Zhang ae1e8ff688 feat(session): per-session hidden paths and vars via SessionProfile (#778)
* feat(session): per-session hidden paths and vars via SessionProfile

* fix(session): close the hidden-var write doors, HOME channel, hydration, and native-find bypass

* fix(session): filter hidden arrays from expansion and gate the raw env writers
2026-08-13 13:54:51 -07:00
bytecii 0cfdd85199 Merge pull request #780 from bytecii/fix/find-bang-negation
fix(find): `!` is negation, not a start point
2026-08-13 13:50:50 -07:00
bytecii 4c421247ea fix(find): ! is negation, not a start point
GNU spells find's negation two ways and mirage only understood one.
`find /data -name '*.txt' ! -empty` exited 1 with `unknown predicate
'/!'`, because find's spec declares `rest=Operand(type="path")`, so
every word without a leading dash is classified as a start point and
`!` was cwd-prefixed into the path `/!`. `-not` worked, and so did
`\( ... \)`, because the parens were already exempted through
`ignore_tokens`; `!` was simply missing from that set. Nothing
downstream needed teaching -- `!` has always been in the expression
parser's operator table.

Fixing the classification made a second bug reachable: a line that
ends on an operator. `find /data !` used to print the whole tree and
then a bogus "No such file or directory" for `/!`; with the operand
gone it would have negated nothing in silence. Both parsers now refuse
it in GNU's words, and the check runs where the operator is consumed
rather than in the primary, because by then the token that needed a
right-hand side is no longer on the line:

    find: expected an expression after '!'
    find: expected an expression between '!' and ')'

Two py/ts divergences surfaced on the way and are closed here:

- The TypeScript parser skipped an ignore token silently, leaving its
  `wordKinds` slot null, which means "guess from the shape" -- and the
  shape of a grammar token says nothing about it. Python stated TEXT.
  TypeScript states it now too.
- `spec_hints.ts` carried a by-value override that re-nulled ignore
  tokens after the fact. It was redundant once the parser states the
  kind, and it was position-blind: it matched an option's value as
  readily as a grammar token, so `find /d -name '!'` lost the TEXT the
  parser had correctly given the pattern. Deleted.

The new cases also caught a live bug in the Nextcloud backend, fixed
here. Its find pushes the predicate tree down as a WebDAV SEARCH, and
Nextcloud inverts a <d:not> by swapping the comparison inside it for
its opposite, so a <d:not> around anything but a single comparison
raises server-side:

    Binary operators inside "not" is not supported     (HTTP 500)

Probed against nextcloud:30-apache (30.0.17), the image CI runs:
not(like) and not(is-collection) answer 207, while not(or(..)),
not(and(..)) and not(not(..)) all 500. The compiler now tracks whether
a compiled condition is a bare comparison and refuses to negate one
that is not, which falls that find back to the scan walk, which has no
such limit. `-not` reached this first and has been answering 500 all
along: `find /data -not -type f` compiles to not(not(is-collection)),
unnoticed because find_not_name was the battery's only negation case
and negates a bare -name. find_bang_type_f now pins it.

Pinned against GNU findutils 4.10.0 on debian:stable-slim as a
three-way byte-identical differential (GNU vs python vs typescript)
over 25 output cases and 15 error cases on one shared seed tree. One
known divergence is normalized and commented: GNU quotes as `x' where
mirage quotes 'x' repo-wide, which is pre-existing and already pinned
in integ/unix/find/error.json. Also left alone as pre-existing: GNU's
wording for a binary operator with nothing before it, the
paren-specific messages, and "paths must precede expression".

integ gains five output cases in find/not.json and three error cases
in find/error.json. The output cases carry seq 295, the same seq as
find_not_name whose expected output they derive from: the battery runs
one shared session ordered by seq, and every one of the eight fixtures
that would otherwise intrude (/data/fs at 302, /data/dr at 317,
/data/rgm at 459, /data/arch at 467, /data/xm2ctree at 755, /data/g4
at 500200, /data/f4 at 500300, /data/roots1 at 785) is created after
295 and never cleaned up. The one case already at 295,
find_empty_dir_holding_only_a_link, removes its own tree in the same
line, which is what leaves the slot usable.

chroma and github each gain the `!` twin of the `-not` case added in
#772, placed immediately after it in both file order and seq
(chroma_find_empty_composes_with_name -> 630056,
github_find_empty_composes_with_name -> 530014). Both verified against
a locally run fake on both hosts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:47:06 -07:00
Zecheng Zhang 6748e66c55 fix(stat): render the symlink arrow for %N (#779)
* fix(stat): render the symlink arrow for %N

stat -c '%N' on a symlink printed only the quoted name. GNU prints
'name' -> 'target'. The target was already on FileStat.extra under
LINK_TARGET_KEY, which is what ls -l and file read, so %N now reads
the same fact.

Pinned against GNU coreutils 9.7 on debian:stable-slim, which showed
two more rules: any format modifier drops the quoting (only a bare
%N quotes), and a width or precision applies to the name and the
target separately rather than to the joined line. That second rule
had to be implemented either way to render %20N on a link, and it
also fixes %30N on a plain file, which used to keep the quotes.

Tests in both languages plus six integ cases across all 22 targets.

* fix(stat): quote %N the way gnulib shell-escape does

The helper picked double quotes for any name holding an apostrophe, so
a'b$c rendered as "a'b$c" and replaying the advertised shell-safe output
expanded $c. It also passed control characters through raw.

Probed GNU 9.7 across ASCII 32-126 and the C0 range: single quotes are
the default with '\'' escaping, double quotes are an optimization for a
name whose only awkward character is an apostrophe, and a control
character becomes a $'..' segment spliced between single-quoted runs.
Now applies to a symlink target too, since %N quotes each field on its
own.
2026-08-13 11:13:54 -07:00
Zecheng Zhang 0666c16665 fix(glob): let glob expansion see nested mount roots and symlinks (#775)
* fix(glob): let glob expansion see nested mount roots and symlinks

Glob expansion enumerated candidates from a single backend's readdir.
A nested mount's keys live in another resource and no resource stores a
symlink, so neither was ever offered as a candidate, even though both
already show up in a plain listing via merge_readdir.

Both glob tiers now merge the same namespace_names union a listing uses,
session-filtered, and matches sort like bash. A glob whose directory
holds a child mount is expanded before routing so its matches route per
mount, and that happens before the follow policy so a matched symlink is
followed exactly as a typed one is.

Pinned against GNU coreutils 9.7 on debian:stable-slim with a tmpfs at
base/inner and the same symlink.

* fix(glob): expand boundary globs before admission, follow linked dirs

Codex review on #775.

Run the boundary expansion before pre_command, not just before the
follow policy: a word left unexpanded reaches MountRootPolicy as the
literal pattern, so `tar -cf out.tar /base/*` archived a mount root the
same operand typed by hand is refused for. cp -r and zip too.

Compare operands as words rather than by count, so a glob matching
exactly one name still installs. `du /base/i*` failed while
`du /base/inner` worked.

Resolve a glob's parent directory through the namespace link table
before a backend lists it, spelling matches back under the typed name.
Pinned against GNU bash on debian:stable-slim: `echo base/d*/f2` ->
`base/dlink/f2`, `echo base/*/f2` -> `base/dlink/f2 base/sub/f2`.

Drop a walk match that is the listed directory itself. A backend asked
to list a path that is really a file answers with that file, which
walked back out as a doubled segment (`/base/f*/f1` -> `/base/base/f1`)
where bash keeps the literal.

* test(integ): cover glob expansion across mount and link boundaries

Seven cases on ram-nested for the boundary glob rules, and one on
ram/disk for a glob whose parent is a symlinked directory. Five of the
eight fail against the previous commit, one per fix.

Expectations are pinned against GNU on debian:stable-slim with a tmpfs
at data/inner, except the mount-root refusal, which is mirage's own
documented divergence.
2026-08-13 10:07:27 -07:00
Zecheng Zhang f44c6e5c25 docs(dsh): drop the redundant workspace mode option (#777) 2026-08-13 09:16:06 -07:00
Zecheng Zhang 829a7e5c29 feat(dsh): DeepSeek Harness fs and shell providers over a mirage workspace (#774)
* feat(dsh): DeepSeek Harness fs and shell providers over a mirage workspace

* docs(dsh): monty python run and multi-source mounts in the dsh example and page

* docs(dsh): tidy the example into sectioned helpers

* docs(dsh): trim the page to install, compose, python, one world

* docs(dsh): use mirage as the example report owner

* docs(dsh): slack-hosted python script example

* docs(dsh): rename the slack-hosted script to example.py

* fix(dsh): match Slack upload names and newest-first date dirs in the example

* fix(dsh): address codex review on lock retention, pre-aborted signals, cyclic links, and byte limits

* docs(dsh): generalize the page, compose sample on redis + ram

* docs(dsh): keep slack in the compose sample

* docs(dsh): state the python3 capture forms

* docs(dsh): show the runtimes capture spec with an inline python sample

* docs(dsh): monty is the workspace python, capturing python and python3

* docs(dsh): one continuous example, no second workspace

* docs(dsh): show monty explicitly capturing python and python3

* docs(dsh): one example end to end — ram + redis + slack, monty capturing python

* docs(dsh): drop the one execution world section

* docs(dsh): state the in-memory property with measured capacity

* docs(dsh): drop the capacity numbers

* docs(dsh): drop the in-memory paragraph

* docs(dsh): add a harness comparison table

* docs(dsh): add concurrency column on a 2 vcpu / 8 gb server

* docs(dsh): correct the live python engine footprint to 27 MB

* docs(dsh): no em dashes, dsh row name, serving scenario before the table

* docs(dsh): tmp mount, redis-bound report, numeric concurrency table with startup

* docs(dsh): note the sandbox provisioning cost the cli sessions would add

* docs(dsh): sandbox startup cost in the cli startup cells
2026-08-13 08:59:04 -07:00
Zecheng Zhang e7f65bd089 fix(du): a namespace-only directory is not absent (#776)
`du /empty` reported `cannot access '/empty': No such file or directory`
and exited 1 for the implied parent of a nested mount, even though it
printed the right rows. `/empty` is a real directory, but no backend
holds an entry for it: the content lives in the descendant mount's own
resource.

du ran bound to one mount and decided existence from that mount's stat,
so it could not see either fact that makes such a path a directory: a
mount nested below it, or a symlink below it. Both are namespace state.
The dispatcher already answers correctly for them (namespace_stat behind
the backend's miss), and that answer is offered to every handler as
`stat_path`; `find` was the only consumer, which is why find was the one
sibling that got this right.

du_operands now asks that probe when the backend stat fails, ahead of the
has_content walk, which is both cheaper and authoritative. has_content
stays behind it for a backend with no directory entry for its own mount
root.

stat_path rather than registry.descendant_mounts on purpose: the registry
lookup is not session-filtered, so proving presence from the mount table
would answer `0 /empty` for a session that may not see /empty/hole and
confirm a walled-off mount's parent. It also misses the link-implied
case, which has no descendant mount at all. Both are pinned by tests.

Pinned against GNU coreutils 9.7 on debian:stable-slim with a tmpfs
mounted at /empty/hole: both rows, no diagnostic, exit 0.
2026-08-13 08:31:20 -07:00
Thomas Hart 20543fb031 feat(du): Honor -S/--separate-dirs (py + ts) (#722)
* feat(du): honor -S/--separate-dirs (py + ts)

GNU -S makes each directory total count only files that sit directly in
it, leaving subdirectory sizes out of the parent. Shared rollup and
summarize paths now match coreutils 9.7, with unit and integ coverage.

* fix(du): apply -S across nested mounts, keep the -c total recursive

The traversal fan-out re-derives du's tree centrally from the per-mount
blocks, so -S was silently dropped there: du -S over a mount printed the
recursive total. Teach the merge about it, and split the operand's own
row from what it contributes to -c, which GNU keeps recursive.

Add a guard that fails when a new du flag is neither applied centrally
nor classified as per-run, which is how -S went missing.

* chore(spec): regenerate du spec json for -S

---------

Co-authored-by: Zecheng Zhang <zechengzhang97@gmail.com>
2026-08-13 07:45:08 -07:00
Zecheng Zhang 0d5915457f feat(workspace): state doors and per-plane views (#771)
* feat(workspace): state doors and per-plane views

* fix(workspace): one general session door for every variable write

Codex round: shell readlink dispatches through the op door; array-shaped
assignments and subscripted unsets can no longer bypass pre_session.
SessionView.set is now general over variable shapes (str | ShellArray),
pre_session_gate is private to the door and takes a SessionContext that
carries session_id, staged declaration arrays store through the builtins,
for/select loop variables write through the view, and readonly pre-checks
are separated from policy-denial catches at every shell site. Also fixes
export ARR=(x) printing the export listing and CI formatter drift.

* test(integ): pin the state-door fixes; readonly declaration arrays abandon the line

* ci(integ): key the WinFsp install on the DLL, not choco's exit code
2026-08-13 06:59:17 -07:00
bytecii ef99849b94 refactor(seam): dispatcher-built CommandOpts, typed adapter ops, named ParsedCommand, one DispatchFn home (#609 items 26+23) (#772)
* wip(seam): py dispatcher builds CommandOpts; handlers take (accessor, paths, texts, opts); adapter op protocols; DispatchFn typing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(seam): dispatcher-built CommandOpts, typed adapter ops, named ParsedCommand, one DispatchFn home (#609 items 26+23)

Item 26 (T2-8 seam typing), all four pieces, plus task 23's static
FlagView query-name gate:

- The python dispatcher (Mount.execute_cmd) now constructs one
  CommandOpts per invocation and calls every handler as
  fn(accessor, paths, texts, opts) — the TS convention. Flags stop
  sharing a bag with injected context, accepts_kwarg opt-in dies, and
  all 72 builders, 75 bespoke wrappers and ~30 provision functions
  become pure wiring; the provision path builds the same bag with
  command/spec set. Generics that took trailing fact params (ls, find,
  tree, zip, tar, du, file, stat) now read opts.links/opts.mounts/...
  like their TS twins.
- adapter.py gains per-slot op protocols (ReaddirOp/StatOp/WriteOp/...)
  mirroring adapter.ts, so wiring readdir where stat belongs no longer
  type-checks; Builder.fn/provision and CommandIO fields are typed.
  Surfaced and fixed real drift: hf/databricks mkdir had index before
  parents (a positional parents call would land in index), wget -O
  dropped its PathSpec value under as_str.
- TS parseFlags returns a named ParsedCommand (twin of the py
  NamedTuple) instead of a 15-slot positional tuple; optionError takes
  (cmdName, parsed) like python's option_error.
- DispatchFn moves to runtime/types.ts (python's home for the same
  protocol); the crossmount re-export inversion and the CommandDispatch
  duplicate are gone; 38 python files stop spelling it
  Callable[..., Any]. The dead CommandOpts.resource field (zero readers)
  is deleted along with the CrossResourceStub that existed to fill it.
- Task 23: tests/commands/test_flag_query_names.py and
  commands/flag_query_names.test.ts fail on a FlagView query naming a
  dest no spec bound in the module declares, without needing the code
  path to run (both verified to fire on a planted typo).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(examples): redis example calls provisions with the 4-positional shape

file_read_provision/head_tail_provision/metadata_provision are
(accessor, paths, texts, opts) now; the direct demo calls still passed
the old command= kwarg, crashing the example before the persistence
section (CI examples gate).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(integ): pin the #772 fixes where the harness can reach them

- find -empty on chroma + github (the wrappers used to drop the flag,
  which would flood every path; -empty now rides find_generic), plus a
  -not -empty composition case on each so the pin has positive output
- mkdir -p gains databricks/databricks-prefix (zero prior mkdir
  coverage on that backend; the param-order bug itself is only
  reachable positionally, so MkdirOp+mypy is its real gate)
- wget -O / curl -o were already pinned by integ/resources/http

Both hosts green: py chroma+github 83 ok, ts all five targets 4070 ok.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 05:53:25 -07:00
Zecheng Zhang 320519d0fd refactor(ts): derive config types from their zod schemas (#773)
* refactor(ts): derive config types from their zod schemas

Every resource and account config declared its shape twice, once as an
interface and once as the zod schema that actually validates it, plus a
redacted twin that re-listed every field by hand. Two type helpers in
resource/secrets.ts replace both: ConfigOf reads the shape off the schema,
and RedactedConfig builds the twin from the config plus the names of its
secret fields.

ConfigOf is not a bare z.infer: that renders an optional field as
`k?: T | undefined`, which under exactOptionalPropertyTypes is wider than
the `k?: T` these configs were written with and stops an S3 alias's state
from matching its base's.

The twin is reached through an `as unknown as` cast, so nothing checked it
and it had drifted. gcs, oci, r2, supabase, wasabi, hf_buckets, node's s3
and gridfs all typed redacted credentials as plain strings, and box dropped
a field the schema carries.

Ten files keep a hand-written config type because their schema is not the
config's own shape, each saying so in place: s3 has a profile the schema
lacks, google/box/dropbox omit callbacks no snapshot can carry, and the
S3-alias family and hf_buckets describe the resolved config the redactor
fills in.

Browser trello and linear declared readonly arrays their schemas did not,
which is the only runtime change here.

* fix(ts): keep readonly arrays on core linear and node trello configs

The old interfaces declared teamIds/boardIds as readonly string[], and
deriving from the schema narrowed both to string[], so an as const value
a consumer used to pass no longer typechecks. Mirror the .readonly() the
browser twins already carry.

The whole sweep removed exactly four readonly array fields, in browser
linear/trello and these two; the browser pair was already fixed.
2026-08-13 05:39:33 -07:00
bytecii cbba2a55d9 Merge pull request #770 from bytecii/feat/generic-owns-operands-and-flags
refactor(commands): generics own operands and flags; builders become wiring (#609 items 21+22 / T1-E+T2-2)
2026-08-13 01:53:21 -07:00
bytecii 31e83de9d3 Merge remote-tracking branch 'upstream/main' into feat/generic-owns-operands-and-flags
# Conflicts:
#	spec/layout_exceptions.json
2026-08-12 21:18:51 -07:00
bytecii 6f49389aac ci(gates): lock in the operands.py layout win; hf keeps no empty dirs
Two CI-only gate failures the local pre-commit hooks never run:

- check_layout_parity --strict is a two-way ratchet, and pairing
  commands/builtin/utils/operands.py with its TS twin closed one
  divergence (305 -> 304); lower the committed baseline to lock the
  improvement in.
- The directory-list checksum pin assumed `mkdir -p` leaves a statable
  empty directory, which huggingface's keyed backend does not have (no
  marker object, nothing in the parent listing), so hf/hf-prefix
  reported ENOENT instead of GNU's "read error". Narrow the case's
  targets, matching the existing unlink_dir precedent; the
  missing-list pin stays on hf, where it passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:13:45 -07:00
Zecheng Zhang 41e43274db feat(gh): a GitHub CLI, and the write half of the integ GitHub fake (#768)
* feat(integ/github): repo, contents, issues and compare routes

The fake served the read path the `github` resource mounts -- repo
metadata, git/trees, git/blobs -- plus the Actions dataset `github_ci`
needs. A task that *acts* on a repository had nothing to call.

Adds the routes such a task uses: GET /user; POST /user/repos; DELETE
/repos/{owner}/{repo}; POST .../forks; GET .../branches and
.../branches/{branch}; GET .../commits; GET and PUT .../contents/{path};
GET and POST .../issues; GET .../compare/{basehead}.

Writes are visible to the next read, which is the point -- an issue
filed shows up in the listing, a committed file reads back with its new
bytes and a new blob sha, and the commit is recorded so `compare`
can answer which files moved. `FakeRepo` grows `issues` and `commits`
for that; a repository with no writes yet still reports one synthetic
root commit, so "the latest commit" is answerable before the agent has
done anything.

Three behaviours worth pinning rather than leaving to chance:

- A fork deep-copies. A task that forks an archive repo per run and then
  commits to the fork must not write through to the source.
- PUT /contents enforces GitHub's sha rule in both directions: replacing
  an existing file without the current blob sha is 409, and supplying
  one for a file that does not exist is 422. A task that reads before
  writing is doing so for this reason.
- A compare against a base the repository has never seen is 404, not an
  empty file list. Answering "nothing changed" to a question about an
  unrelated commit is the shape of wrongness that reads as success.

`_commit_list` is newest-first, so `compare` collects the commits
*before* it reaches the base; walking past the base instead would report
the commits the base already contains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(integ/github): git data API, rename, raw content and GHES paths

The write half a task that builds a repository needs, plus the two shapes
a real client insists on:

  - PATCH /repos/{owner}/{repo} renames, carrying the content with it, and
    POST /forks takes a name so a fork can be named in one step.
  - The git data API -- get commit, create tree, create commit, move ref --
    so a multi-file push lands as one commit. A staged tree is invisible
    until a ref points at it, which is what git does and what keeps an
    abandoned tree from showing up in a read.
  - GET /git/ref/{ref}, and trees by commit sha, because a client resolves
    a ref to a commit before reading anything.
  - /raw/{owner}/{repo}/{ref}/{path}, with a text content type for text, so
    a reader does not base64 the whole repository.
  - Every route is served under /api/v3 as well as at the root: a client
    pointed at a host that is not github.com talks Enterprise.
  - GET /search/repositories, and an unrouted fallback that names the
    method and path on stderr. Both exist for the same reason: a 404 is an
    answer to a real client, so an unimplemented endpoint reads as a
    negative result rather than as an error.
  - Seeding takes a per-repo default branch, since a template whose branch
    is master is graded at that ref by name.

* feat(gh): a GitHub CLI, and the write routes the fake needs behind it

The `github` mount is the read half -- a repository is a tree, so listing
and reading it is `ls` and `cat`. There was no write half at all:
GITHUB_IO carries readdir/read/stat where GDRIVE_IO carries the full set,
so nothing could commit a file, and forking and renaming are account
operations a filesystem has no shape for in any case.

`gh` is that half, spelled as cli.github.com spells it:

    gh repo view [OWNER/REPO]
    gh repo fork OWNER/REPO [--fork-name NAME]
    gh repo rename NEW-NAME -R OWNER/REPO
    gh api ENDPOINT [-X METHOD] [-f key=value] [-F key=value]

`rename` takes the new name as the operand and the repository as -R, which
is the reverse of what the shape of the line suggests and is upstream's.
`-f` sends a string and `-F` reads true/false/null/integers as their JSON
types, which is gh's own --raw-field / --field split; a call with no fields
is a GET, one with fields a POST unless -X says otherwise. Committing a
file is `gh api ... -X PUT -f content="$(base64 -w0 f)"`, which is what
real gh and real GitHub require of each other -- there is no file verb to
mimic.

The transport was GET-only, so it grows a `request`, and an empty body (204,
an empty 202) decodes to null rather than throwing on a call that worked.

Fake, in support: DELETE /contents with the same sha rule as the replace,
and GET /commits/{ref}, which is not /git/commits/{sha} -- it takes a branch
name and reports the file list. Both were found by the unrouted logger
during a run: an agent issued 35 deletes, every one 404'd, and it reported
the files as removed, because to a real client a 404 is an answer.

* feat(integ/github): repository metadata, star-sorted search and readme

A task that picks *between* repositories reads their descriptions and star
counts and never opens one, so seeding gains a second mode: --metadata takes
a JSON file keyed by owner/name and creates repositories that are metadata
only. The tree and contents endpoints answer 404 for them, which is what
GitHub says about a repository with no files.

search/repositories honours sort=stars and matches the description as well
as the name, since a repository is found by what it says it does at least as
often as by what it is called. Terms OR rather than AND, which is looser
than GitHub and errs towards showing a caller the row it wants.

GET /repos/{owner}/{repo}/readme, found by the unrouted logger during a run.

* feat(integ/github): branches

The fake kept exactly one branch, which rules out every task whose premise
is a difference between two of them. FakeRepo now holds a file map and a
commit list per branch:

  - `files` and `commits` stay bound to the default branch, so every route
    that does not name a ref reads what it always read.
  - `branch_for` resolves a ref -- a branch name, HEAD, or a commit sha
    belonging to one branch's history -- and `tree_of` returns that
    branch's files, so a bad ref is a 404 rather than a silent read of the
    default branch.
  - contents, tree, raw, readme, branches, branch, commits, commit and
    git/ref are ref-aware; PUT and DELETE /contents write to the branch
    their body names; PATCH /git/refs moves the branch it names.
  - Each branch has its own synthetic root commit, so two branches of a
    fixture do not share a head.
  - A fork copies every branch, not just the default one.
  - Seeding takes `into=<branch>`, so a two-branch fixture is two
    directories and two --repo flags naming one repository.

The default-branch binding is what keeps this small: 18 call sites read
`repo.files` and none of them had to change.

* feat(integ/github): GET /git/refs/{prefix}

The plural is a different endpoint from the singular: git/ref/<full-ref>
returns one object, git/refs/<prefix> a list of everything beneath it. A
caller picks whichever it expects, so serving only the singular made the
plural read as 'no such ref'. Found by the unrouted logger during a run.

* feat(integ/github): account repo listing, bare /contents, mirror-stable shas

Three things a task that reasons across several repositories needs.

GET /users/{login}/repos and GET /user/repos. A task that asks "is there a
repository for this on my GitHub" answers it here, and a 404 read as "the
account has none" -- a wrong answer rather than an error, which is the
failure mode the unrouted logger exists for.

GET /repos/{owner}/{repo}/contents, with no trailing slash. GitHub serves
both spellings of the root and a caller picks either; only one was routed.

A branch's root commit sha is now derived from its content rather than from
the repository's name. That is what `git clone --mirror` followed by a push
actually gives you -- the same shas -- and a grader that reads an initial
sha from an upstream repository and the latest from a local copy, then asks
what changed between them, depends on it. Two branches still differ exactly
when their trees differ.

* feat(integ/github): POST /git/refs, so a branch can be created

The last write a task can ask for that the fake had no answer to. A new
branch starts as a copy of whatever the base sha resolves to -- which is
what a branch is, another name for one commit and everything reachable from
it -- with its own empty history, so the two do not share future commits.

422 on a ref that is not under refs/heads/, on a name that already exists,
and on a base sha that resolves to nothing.

* feat(integ/github): GET /, the API root

github.com answers the root with a map of endpoint URL templates. Serving
only the endpoints beneath it meant a client probing the root got a 404,
which reads as the host not being there at all rather than as an unfamiliar
API. Found by the unrouted logger during a run.

* feat(integ/github): seed a branch's commit history, not just its tree

Seeding a directory filled a tree and nothing else, so every seeded
branch answered GET /commits with one synthetic root. That is enough for
a task that reads files and wrong for one that asks when something
arrived. `task-tracker` wants the tasks added by the *most recent*
commit on each of fifteen developer branches, and each branch has five
or six -- so a history of one does not make the task hard, it makes it
unanswerable while looking answerable.

--commits owner/name=<file> reads a manifest keyed by branch, each an
array oldest first, and is applied after every tree: a manifest names
branches, and a branch no directory was seeded into would otherwise be
created here as an empty tree with a history, which reads as a branch
whose files were all deleted.

Shas are derived from full_name, branch, date and message rather than
carried, for the same reason the root's is derived from its tree: a
fixture has to answer the same way twice.

Commits now carry commit.author/committer (name, email, date) and
author.login, which is what a client sorting or filtering by date reads,
and the synthetic root carries them too against a fixed epoch -- it
stands for everything before the fixture rather than for a moment, so
"now" would make it sort ahead of the commits it precedes.

GET /commits/{ref} now renders `files` as {filename, status} objects the
way GitHub does. The list endpoint keeps paths, which is also GitHub's
split: `files` is absent from a commit in a list and detailed when one
commit is asked for. _commit_files is shared with the compare route,
which was building the same objects inline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(github): back the transport with octokit

Wraps @octokit/core behind the unchanged GitHubTransport, so the github
mount and the gh CLI both gain retry and throttling. Handles three
octokit behaviours: {} in a url is a route template that silently eats
the segment, the "METHOD /path" form splits on whitespace, and 204
decodes to '' rather than null. The write throttle is github.com's own
secondary rate limit, so it is off for any other host.

* fix(gh): name the mount its writes invalidate, and parse the host segment

serves was empty, so the executor's post-write cache drop did nothing and
a committed file still read back as its pre-write bytes. parseRepo took
the first two segments of [HOST/]OWNER/REPO, so github.com/acme/tools
resolved to github.com/acme -- a different repository, reported as
success.

* fix(cache): invalidate the index instead of clearing it

A cleared index reads exactly like one that was never filled, so github --
whose index is the whole listing rather than a cache in front of one --
could not tell a cache drop from an empty repository, and ls reported the
mount root missing. IndexCacheStore grows invalidate(), which expires
entries in place, and github refetches once on an EXPIRED lookup. Also
fixes a latent bug with no CLI involved: after the 24h index TTL lapsed a
github mount answered ls with exit 0 and no output. Redis cannot express
stale (an expired key is gone), so it still clears; the comment says why.

* feat(gh): a python gh CLI, mirroring the typescript one

Same four verbs and the same grammar. The python client was GET-only, so
it grows github_request alongside github_get. Layout divergence 305 -> 303.

* test(integ): a gh CLI battery on both hosts

15 cases on a new cli-gh target, covering the write-then-read path through
the mount, the refusals, and the reversed rename grammar. The fake grows
POST /reset, because the cli facet runs both hosts against one process and
writes would otherwise leak between them; it also stops rejecting wrapped
base64, which real GitHub accepts. The unrouted-request logger is now a CI
failure rather than a log line.

* fix(github): probe the parent listing, so an update is not read back stale

The index tracks freshness per directory, never per entry, so get()
never answers EXPIRED and the refill it guarded was unreachable: after a
write invalidated the index the blob's row survived carrying the
pre-write sha, and the read served the old bytes. Create and delete went
through readdir and worked; an update did not.

Readdir on the TypeScript side refilled on any miss, which spent a full
recursive-tree call on every ENOENT and diverged from python. Both now
refill only on EXPIRED.

* test(cache): pin what invalidate keeps that clear discards

A cleared store reads exactly like one that was never filled, which is
what made a github mount report an empty repository. Assert the two
answer differently.

* fix(spec): render an operand's declared name outside the clap dialect

A named slot printed as <text>, so 'man gh api' said <text> where gh
says <endpoint>. argparse prints the dest too. gh is the only spec this
changes; ntn is clap and already rendered it.

* test(integ): cover the gh surface the battery missed

Update-then-read is the case that found the stale-read bug. Also the
help and discovery surface, a leading-slash endpoint, typed fields on a
GET, and a missing endpoint.

* docs(gh): a page per host for the gh CLI

Install, the mount-reads/CLI-acts split, the api rules, and a table of
what differs from gh 2.85.

* fix(github): keep an api field from steering the request

Octokit reads loose parameters off the same object that carries url,
method and headers, so `gh api X -f url=...` retargeted the call
instead of sending the field. The query is spelled into the url and the
body travels as `data`. The python client already separated them.

* fix(cli): let a handler say it did not write

A leaf declares `write` statically because for almost every verb it is
static, but `gh api` carries its method on the line, so a read like
`gh api /user` expired every github mount the install serves. A result
may now report what the spec cannot know.

* feat(gh): expand the owner, repo and branch placeholders

gh's own examples are written with them, so `gh api
repos/{owner}/{repo}/releases` asked for literal braces and 404'd. An
install's repo and branch stand in for the current checkout. Any other
brace pair still reaches the wire, which is gh's behavior too.

* feat(gh): render repo view the way gh renders it

A name line, a description line, then the README, with the separator
omitted when there is none. Probed against gh 2.85. The REST object is
still one `gh api repos/OWNER/REPO` away.

* test(integ): cover placeholders and the new repo view

Also carries a branch on the gh install, which is what {branch}
expands from.

* fix(gh): declare branch on the GhConfig type, and type the test trees

tsc runs in CI but not in pre-commit: GhConfig's zod schema grew
`branch` while its interface did not, and the new github tests built
tree items with a `mode` key the type has no room for.

* refactor(gh): derive GhConfig from its schema instead of declaring it twice

The zod schema is the one doing real work: it validates an install's
config and carries the secretStr marker redaction reads. A hand-written
interface beside it only adds a shape that can drift, which is how
branch reached the schema and not the type.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:03:53 -07:00
bytecii ef841eae02 fix(checksum): --check verifies every list operand (codex review)
GNU 9.7 pinned on debian:stable-slim: each operand is its own checksum
list, verified in order; an unreadable one reports its strerror and the
run continues with exit 1, a directory operand reports the literal
"read error" (fopen succeeds, the read fails), and --status keeps the
strerror lines while silencing OK/summaries. The defect predated this
PR in the TS generic (paths[0] since #731), so both sides are fixed
together: py hashsum loops with WALK_ERRORS translation, TS
checksumGeneric loops over checkFile with the new shared isEisdir
(twin of isEnoent), and the five py checksum builders bind
dir_aware_stream like their TS twins so implicit keyed-backend
directories classify as EISDIR on the check path. Mirrored unit tests
plus two unix/sha256sum integ pins (seq 960093/960094), verified on
both hosts over ram+disk (4823/4823 each).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:45:05 -07:00
bytecii ec110a92ad test(integ): complete the T1-E facet matrix
cat/head/tail/wc x postgres+mongodb mixed-operand pins, history tail
cross-mount continue, and a postgres directory-operand EISDIR pin that
exercises the implicit-dir parent-listing probe end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:46:37 -07:00
bytecii d5ebe25ede chore: restore uv.lock to upstream
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:13:51 -07:00
bytecii 146d995964 fix(operands): invoke the bound reader eagerly, inside the cache-manager scope
A lazy async-generator wrapper deferred the factory reader call to
drain time, past the command's cache-manager contextvar scope, so a
warm S3 LAZY cat re-read the backend instead of serving the cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:13:32 -07:00
bytecii ae36ac41ee test(integ): report-and-continue pins + builder wiring gate
postgres/mongodb mixed good+missing operand facets, history cross-mount
continue, test_builders_declare_only_dispatcher_params ratchet; history
ls/tree/stat/find and dify find/cat wrappers ride the generics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:02:28 -07:00
bytecii 836047e286 refactor(commands): builders become wiring; generics own the flags (T2-2)
26 builders forward **flags wholesale; each generic parses once into a
frozen struct via a spec-bound FlagView (ls/du/find/sed/tar/tree + 20
mechanical). rm/rmdir/ln/touch are builder-is-implementation and read
the bag through FlagView inline, mirroring the TS builders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:19:56 -07:00
bytecii f6920bdfa8 refactor(commands): read-family operand semantics move into the generics (T1-E)
operands.py + dir-aware stat/stream seam; cat/head/tail/wc/checksum5/
cut/expand/unexpand/fmt/fold/nl/rev/tac/strings/md5/zcat generics own
split_readable + flags; 16 builders and 12 bespoke wrappers become wiring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:57:58 -07:00
bytecii ae5760f4c9 refactor(runtime): name the default python engine (#609 item 19 / T1-N) (#769)
* refactor(runtime): name the default python engine (#609 item 19 / T1-N)

The default world was an opaque positional triple on both sides
(`("monty", "quickjs", vfs)` in Python, `['pyodide','quickjs','vfs']`
in TypeScript), so the single fact the two implementations disagree on
— which python engine a default world registers — was readable only by
diffing two literals in two languages.

Lift it to a named registration, `DEFAULT_PYTHON`, on each side, and
derive `DEFAULT_ENTRIES` from it. Python stays on monty, TypeScript
stays on pyodide: no behavior change, but the divergence is now one
greppable constant carrying twin comments that name the reason
(`@pydantic/monty` cannot answer builtin `open()` calls yet, while
`pydantic-monty` can) and point at each other.

Also drops the second hardcoded copy of the same fact in the TS
`Runtimes` constructor, where `options.python` was forwarded on a
literal `name === 'pyodide'` test.

Recorded as an executable pin rather than prose: the new
`integ/runtime/defaults.json` reads the split back out of a live
default world on each host — `python3 -m nosuchmodule` is refused by
name on Python (`-m is not supported by the 'monty' runtime`) and
answered by CPython's own "No module named" on TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(integ): pin that the -m refusal is byte-identical on both hosts

The defaults case shows Python's default world refusing `python3 -m`
and TypeScript's answering it, which reads like a message divergence.
It is not: it is purely which engine each default world registers.

Pin the actual parity claim ungated, so it is checked on both hosts:
run monty explicitly on each and assert the same bytes, including the
label normalization that makes the `python` alias report as `python3`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:00:30 -07:00
Zecheng Zhang e28d7e51d7 refactor(runtime): guest op surface parity and one Ops facade (#767)
* refactor(runtime): one verb casing on the TS bridge

* feat(runtime): create and truncate join the TS bridge, quickjs open dispatches the real op

* refactor(runtime): one fopen-mode parser, chmod parser renamed parse_chmod

* feat(runtime): python readdir answers resolved entries like the TS bridge

* style: formatter output and lint fixes

* refactor(ops): WorkspaceFS becomes Ops in ops/ops.ts with the ledger on it

* feat(ops): union the facade surface across languages

* style: prettier reformat in fuse core

* test(integ): guest op-mode battery + ledger pins; record create/truncate in direct cores, rebind the recorder across the hop

* fix(ops): exclusivity outranks truncate for wx, and only a confirmed absence establishes in the quickjs shim

* fix(integ): cli.sh seeds nested parents and skips facade steps as sdk-only
2026-08-12 16:02:05 -07:00
bytecii 001929a32c Merge pull request #766 from bytecii/feat/logical-cwd
feat(cd): give the session a logical cwd, so pwd -L/-P mean something
2026-08-12 15:04:03 -07:00
bytecii c174644aa6 Merge pull request #765 from bytecii/fix/cd-physical
fix(paths): absolute operands keep their typed spelling — unblocks cd -P and as-typed echo
2026-08-12 15:03:45 -07:00
bytecii 0478c56286 test(integ): build the CDPATH case on the seeded symlink, not a new tree
CI caught this on hf and hf-prefix, identically on both language hosts:

  expected "/data/cdp/lnk\n/data/cdp/t\n", got "/\n"
  stderr:  "cd: /lnk: No such file or directory"

The case created `/data/cdp/t` with `mkdir -p` and then looked inside it.
hf's `stat` short-circuits on a cached parent listing and raises ENOENT
without consulting the backend, and creating a nested directory does not
invalidate the ancestor that gained a child -- so the `$CDPATH` candidate
was rejected and `cd` fell through to the bare `/lnk`.

Reuses the symlink the setup case already makes, the way cdmode.json was
rebuilt onto `disptree/d` for the same reason. `CDPATH=/data` with the
operand `lkq` exercises the identical path -- the announcement is the
selected spelling, the cwd is the target -- without creating anything.

The hf caching bug itself is untouched and deserves its own issue; this
is the second fixture it has forced a rewrite of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 05:12:39 -07:00
bytecii ed3a73e300 test(integ): pin the -P CDPATH announcement and set -o ordering
Two behaviours from the `set -P` commit that its own integ cases missed.

A `$CDPATH` hit under `-P` announces the spelling it selected and lands
on the target, so the printed path and the resulting cwd disagree --
the same split as `cd -P -`, reached by a different route.

`set -o` applying everything named before the name it rejects is pinned
through pipefail's own effect rather than by reading the option back:
`set -o pipefail -o bogus` exits 2, and the pipeline after it still
reports its failing left-hand side.

Kept out of the `$PWD` commit so dropping that one leaves this in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 05:12:39 -07:00
bytecii 054be41167 feat(cd): make $PWD an ordinary variable rather than a resolved lookup
`$PWD` was answered by the expander before it reached the environment,
so the three things bash lets you do to it all silently did nothing:

    PWD=/clobber; echo "$PWD"   GNU /clobber   mirage the cwd
    unset PWD;    echo "$PWD"   GNU (empty)    mirage the cwd
    env | grep ^PWD             GNU PWD=...    mirage nothing

`$OLDPWD` was already a real variable written by `cd`, and `$HOME` reads
the env, which left `$PWD` the only one faked. It is now seeded at
session construction (bash exports it from startup) and re-stated by
every `cd`, and the special case in the expander is gone -- the fix is a
deletion, not a branch.

Two couplings are load-bearing and pinned:

  * `$OLDPWD` is a straight copy of `$PWD` as it stands, not of the
    shell's own record. After `PWD=/clobber; cd /data` a following
    `cd -` tries /clobber and fails; after `unset PWD; cd /data` it is
    empty. `cd` then re-states `$PWD`, so it always repairs itself.
  * The shell does NOT read `$PWD` back when deciding where to go.
    Clobbering it and running `cd ..` from /data/lk still lands on
    /data, so `logical_cwd` stays the shell's own record.

`fork` carries `$PWD` onto a caller-supplied cwd for the same reason it
drops the logical name there.

Consequences worth knowing: `export -p`, `env` and bare `set` now list
`PWD`, and a CLI subprocess inherits it -- both of which is what bash
does, and both of which reach past `cd`, so this is kept as its own
commit.

Pinned against bash 5.2 on debian:stable-slim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 05:12:39 -07:00
bytecii ce31c5938b fix(cd): honour set -P, announce the typed spelling, reject a bad set -o
Four fixes to the logical-cwd work, two from review and two from a sweep
for what the design did not reach.

`fork` now drops the logical name when a caller supplies `cwd`. An
`execute(cwd=...)` call forks with the cwd overridden but inherited the
persistent session's logical spelling, so `pwd`, `$PWD` and a logical
`cd ..` inside that call all described the directory the session used to
be in while operands resolved against the requested one. Deciding it
inside `fork` rather than at the call site is what keeps the next caller
from forgetting; TypeScript needed it spelled out because `??` cannot
choose `undefined`.

`cd -P` announces the path as selected, not the one it resolves to. GNU
prints the spelling and lands on the target, and the two deliberately
disagree:

    cd /tmp/lk; cd /tmp/other; cd -P -   ->  prints /tmp/lk, pwd /tmp/deep/real
    CDPATH=/opt/c cd -P lnk             ->  prints /opt/c/lnk, pwd /opt/c/t

Overwriting `logical` under -P had collapsed both into the target.

`set -P` / `set -o physical` now work. They are the session-wide version
of the flag this branch added, and GNU applies them to `cd` and `pwd`
alike -- but `P` named no option, so it was dropped as an unknown cluster
letter, and while `set -o physical` did store the option nothing read it.
Both were silent, exit 0. `pwd -L` under `set -P` reports the physical
path for free: no logical name is ever recorded, so the pair collapses.

`set -o` rejects a name bash does not have, with GNU's `set: NAME:
invalid option name` and exit 2, keeping the settings named before it and
dropping the rest of the line. Accepting anything is what let
`set -o physical` look supported while doing nothing.

Pinned against bash 5.2 on debian:stable-slim, including the full 27-name
`set -o` table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 05:12:39 -07:00
bytecii 42d120c817 feat(cd): give the session a logical cwd, so pwd -L/-P mean something
The shell kept one name for the working directory. bash keeps two, and
every surface that reports a cwd picks one of them:

  cd /data/lk          pwd = /data/lk        pwd -P = /data/deep/real
  cd /data/lk; cd ..   pwd = /data           (logical parent)
  cd /data/lk; ls ..   real                  (physical parent)

Not just a spelling difference: after following a symlink, `cd ..` and
`ls ..` land in different directories, and bash is internally
inconsistent on purpose -- its `cd` uses the name it remembers, its `ls`
asks the kernel. mirage was self-consistently physical, which is tidier
and not what bash does. It is also why `pwd -L`/`-P` were declared in the
spec and silently ignored: with one path there is no second answer.

`Session.cwd` keeps meaning physical -- it is what every operand resolves
against, so the ~58 readers outside the shell are untouched -- and gains
a `logical_cwd`, None whenever it would be equal. That collapsed
representation is the whole trick: a session that never walks a symlink
carries no new state, and `-P` collapses the pair rather than tracking a
second path forever.

One writer, four readers. `change_dir` maintains the pair (and stores the
logical name in $OLDPWD, which is what `cd -` returns to); `pwd`, `$PWD`
and `cd`'s own join read it. Under -L a relative operand joins the
logical name, under -P the physical one -- that is the `cd -P ..` row.

`set_cwd` is the seam for the four callers that move a session from
outside the shell (snapshot restore, session-store handoff, the
`workspace.cwd` setter). They have no typed spelling, so they drop the
logical name rather than leave it describing where the session used to
be, and they leave $OLDPWD alone because no `cd` ran.

pwd also grows the flag handling it always claimed: -L/-P last-wins
(`pwd -L -P` is physical) sharing cd's splitter, `pwd -x` exiting 2 with
GNU's usage line, and operands ignored. bash never re-validates the
logical name -- removing the link it was spelled through leaves `pwd`
still printing it -- so nothing here checks it either.

Every row pinned in GNU bash 5.2 (debian:stable-slim) and asserted in
both languages: 16 e2e rows each, plus 9 integ cases across 22 targets.

Not persisted across a session-store round trip: `to_dict`/`toJSON` is
the cross-language wire and a restored session collapses to physical,
which is the honest answer for a cwd nobody typed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 05:12:39 -07:00
bytecii 1215b612f4 fix(tar): announce a dropped prefix in operand order, even when unreadable
Two review findings, both confirmed against GNU tar 1.35 on
debian:stable-slim.

An operand that cannot be read still owes its notice. `scan.missing`
short-circuited before the prefix was collected, so
`tar -cf out.tar sub/../missing` reported only the stat failure. GNU
announces the prefix first and then fails:

    tar: Removing leading `sub/../' from member names
    tar: sub/../missing: Cannot stat: No such file or directory

The prefix now comes from the operand's own spelling, before the scan's
problems are reported, so it survives an operand with no entries.

And the notices belong in operand order, not at the front. Collecting
them and prepending put a later operand's notice ahead of an earlier
operand's error; GNU emits diagnostics as it walks:

    missing ../file  ->  Cannot stat missing   then  notice `../'
    ../file missing  ->  notice `../'          then  Cannot stat missing

`_announce_prefix` / `announcePrefix` now emits in place on first sight
of each distinct prefix, so per-name deduplication is kept without the
reordering.

The TypeScript side kept the old global prepend alongside the new
in-place emission for a moment, which doubled every notice -- caught by
the existing "warns once" assertion before it left the branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 05:11:37 -07:00
bytecii 4066bcf7a8 fix(tar): drop the traversal prefix from member names, as GNU does
Keeping an absolute operand's typed spelling (the commit before this)
reached further than cd: tar builds member names from `raw_path`, and
`member_name` only removed a leading slash, so `tar -cf out.tar
/data/sub/../file` began storing `data/sub/../file` -- a name that
extracts somewhere other than where it says.

GNU refuses to store any name that could climb out of the extraction
directory. It drops everything through the *last* `..` segment and falls
back to the leading slash only when there is no `..`:

    /data/sub/../file   ->  file        notice `/data/sub/../'
    x/../y/f3           ->  y/f3        notice `x/../'
    ../../file          ->  file        notice `../../'
    /data/file          ->  data/file   notice `/'
    ./file              ->  ./file      no notice

A `.` climbs nowhere, so it survives. Stripping is per name rather than
per operand, which is why `tar -cf a.tar ..` owes two notices: `..` for
the directory itself and `../` for everything under it. An operand that
is all traversal leaves nothing to name and GNU stores it as `./`.

The notice now names the prefix it removed instead of always saying `/',
and each distinct prefix is reported once.

Info-ZIP makes the opposite choice -- `zip sub/../g.txt` stores
`sub/../g.txt` verbatim -- so zip_cmd deliberately does not share this,
and an integ case pins that the two stay different.

All rows pinned against GNU tar 1.35 and Info-ZIP on debian:stable-slim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:09:13 -07:00
bytecii c9c3d116a9 test(integ): build the cd-mode fixture on a directory that already exists
`mkdir -p /data/lkd/real` created a directory whose only child was another
directory, and hf could not see it. Its stat short-circuits on a cached
parent listing:

    parent_listing = await index.list_dir(parent)
    if parent_listing.entries is not None:
        raise enoent(path)      # never consults the backend

/data's listing was already cached without `lkd`, and writing a file two
levels down did not invalidate the ancestor that gained a new child, so
`cd -P /data/lkp/..` got ENOENT on hf while `-L` passed by landing on the
mount root. Adding a file to the fixture did not help, for the same
reason -- the stale listing is consulted before any of it matters.

Point the link at `disptree/d` instead. It is already in the `files/v1`
fixture that all 22 targets mount at /data, so the case now creates
nothing but the symlink: no mkdir, no backend asked to make an empty
directory real, no cached listing asked to learn a new child.

The ancestor-invalidation gap is a real hf bug and is untouched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:41:59 -07:00
bytecii b879ccd8a9 test(integ): pin operands echoed as typed, not normalized
GNU echoes a path operand back exactly as the user spelled it. Pinned in
debian:stable-slim:

  du -b /k/                      ->  7  /k/
  find /k/guides/                ->  /k/guides/
  ls /k/nope/                    ->  ls: cannot access '/k/nope/': ...
  md5sum /k/guides/../guides/a   ->  <hash>  /k/guides/../guides/a
  tree /k/guides/                ->  /k/guides/
  tree -f /k/guides/             ->  /k/guides      (-f is the exception)

Nine cases captured the pre-fix spelling, where the classifier normalized
an absolute operand before any command saw it and the trailing slash was
gone by the time du/find/ls/tree echoed it. Every one of these commands
takes a trailing-slash operand, so all nine move to the GNU spelling.

Also give the cd-mode fixture a file. `mkdir -p /data/lkd/real` leaves an
empty directory, which backends with no empty-directory concept (hf) do
not materialize, so `cd -P /data/lkp/..` could not reach /data/lkd there
while `-L` passed by landing on /data. Writing f.txt makes both parents
real on every target, which keeps all 22 rather than dropping hf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:41:59 -07:00
bytecii 8e5cc40684 fix(integ): poll the OTLP port before pushing, not the query port
`seed` waited for the query API on 16686 and then pushed spans to the
OTLP receiver on 4318. Those are separate ports on the same container,
bound at different times, so the push could meet a docker-proxy socket
that accepts the connection and resets it -- exactly the failure `query`
already documents and guards against for its own port. It killed
integ-shared-py on PR #763's first run, during setup, before a single
case executed:

    ConnectionResetError: [Errno 104] Connection reset by peer
      jaeger_seed.py:186 in post_spans

Retry the push itself, on the same POLL_ATTEMPTS/POLL_DELAY cadence, so
the readiness check is the endpoint that has to be ready. Catch OSError
for the reason `query` gives -- the reset arrives raw, past urllib -- and
raise with the last error when the receiver never accepts, rather than
failing confusingly further along.

Verified against a real `jaegertracing/jaeger:latest` container from a
cold start, and against a dead port (fails loudly after the full window).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:41:59 -07:00
bytecii 91be752416 fix(cd): let -P resolve the link before applying the .. after it
`cd -P` was unreachable. `_resolve_target`/`resolveTarget` implement it
correctly -- given `/link/..` with `/link -> /deep/real` they return
`/deep` for -P and `/` for -L -- but nothing could reach the physical
branch, so the two modes returned the same answer for every input.

Two layers each normalized the `..` away before `cd` saw it:

- `_cd_candidates` built every candidate with `resolve_path`, which
  normpaths. Join without normalizing instead and let `_resolve_target`
  normalize, which it already does for both modes; when the workspace has
  no symlink table at all, `handle_cd` normalizes the candidate itself.
- the operand arrived already collapsed. `heuristic.py`'s absolute branch
  built its PathSpec with `virtual=normpath(word)` and no `raw_path`, so
  `raw_path` defaulted to the normalized form and the typed spelling was
  gone before any command ran. Pass `raw_path=word`, which is what the
  field is documented to hold and what `relative_spec` has always done for
  the relative case; `cd` reads it through `_typed_path`.

GNU bash 5.2, pinned in debian:stable-slim with /link -> /deep/real:

    cd -L /link/..      PWD=/         cd -P /link/..      PWD=/deep
    cd -L /link/sub/..  PWD=/link     cd -P /link/sub/..  PWD=/deep/real

All four now match, and `cd` lands in the directory bash lands in for
every pinned case. One divergence is left, and it is a separate design
question: bash's -L keeps the *logical* name in `$PWD` (`cd -L /link`
leaves PWD=/link), while mirage resolves and stores the physical path.
That needs a logical cwd on the session, which would change what every
command resolves against -- and is why `pwd -L`/`-P` are declared in the
spec and ignored today.

Verified: full battery on ram and disk, 4755 cases, 0 failures on each
side, and `parity.py` diffs 4755 rows with 0 divergences. New
`integ/unix/sym/cdmode.json` is what caught that the first cut of this
fix worked in a unit test and not through the real pipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:41:59 -07:00
Zecheng Zhang ba0856e1ea fix: one error vocabulary and one stat view per language (#764)
* fix(errors): one condition vocabulary, one classifier per language

A condition is named once (FsCondition) and every boundary keeps only
a table from that name to its own number: posix, wasi preview1, and
the monty guest view. CycleError gets its ELOOP seat (was EIO at every
kernel and guest boundary), the wasi wire's EXDEV is 75 per wasi-libc
(18 is EDOM there), and the cross-mount-rename-is-ENOENT decision
moves from an adapter arm into the wasi table row.

* fix(stat): one stat view per language, offset-less stamps read as UTC

mtime, dir-ness, content size and the mode bits live once per language
(utils/stat_view), delegating to the existing naive-stamp-is-UTC
parsers. Three of the four translators (node FUSE, the runtime bridge,
py wasm) read an offset-less backend stamp as local time, so python
FUSE and node FUSE disagreed by the host's UTC offset for the same
stamp; all four now answer the same epoch, pinned by tests.

* refactor(errors): move the wasi and cpython tables into their runtimes

* refactor(errors): classifier arm tables into constants modules

* fix(errors): address review on the ValueError arm and epoch-zero mtime
2026-08-12 01:41:13 -07:00
bytecii b6840439a1 Merge pull request #763 from bytecii/fix/test-layout-gate
test: put the python test tree on the source layout, and gate it (#609 item 13)
2026-08-12 01:40:44 -07:00
Zecheng Zhang d9ba2d2460 fix(himalaya): file the sender's own copy of a sent message (#761)
* fix(himalaya): file the sender's own copy of a sent message

`--send` opened one SMTP conversation and stopped, so mail was delivered
but the sender's Sent mailbox stayed empty. A Sent folder is not produced
by sending: SMTP keeps no record of itself, and the copy is a second,
separate IMAP APPEND that every mail client makes on its own. Four call
sites, not one: `message send` plus the route compose, reply and forward
share.

Upstream v2.0.0 spells that APPEND as `--save <MAILBOX>` on all four
verbs, having replaced 1.x's automatic `message.send.save-copy` config.
Both are here, because they answer different needs:

- `--save <MAILBOX>` is v2 parity. On its own, with no `--send`, it files
  the message without sending it, which is how a draft is written.
- `save_copy` is an account-level default, on, and is mirage's own
  divergence: an agent that never learned the flag still leaves the
  record a human sender would. `sent_folder` pins the mailbox.

The mailbox is asked for rather than guessed. A server implementing RFC
6154 tags one mailbox \Sent in its LIST reply, which is `[Gmail]/Sent
Mail` on Gmail and `Sent Items` on Exchange; failing that the configured
name, failing that `Sent`. This is ahead of upstream, whose v2 wizard
notes IMAP pins the reserved INBOX alone while it waits on LIST RETURN
(SPECIAL-USE) in io-imap.

Failure splits on whether a send already happened. A copy that fails
after a successful send is a warning on stderr with exit 0, because the
message is already gone and a non-zero exit invites a retry that sends it
twice. A `--save` that sends nothing fails loudly, because nothing
happened yet and retrying is safe. Upstream propagates in both cases.

Two bugs found on the way:

- aioimaplib joins command arguments with spaces verbatim and quotes
  nothing, so an unquoted mailbox holding a space arrives as two
  arguments and the server reads only the first word. That breaks on
  exactly the two providers above. `quote_mailbox` now covers the APPEND
  and the pre-existing `select_folder`, which had the same latent bug.
  imapflow needs none of it, building commands from typed attributes.
- The TypeScript test CONFIG was an untyped literal, so a new required
  field did not fail tsc and those tests passed vacuously through the
  `!saveCopy` early return. It is annotated now, and a global beforeEach
  stubs the accessor so no --send test reaches for a real connection.

GreenMail hands a new account nothing but an INBOX, where a real provider
ships a sent mailbox already made, so both integ seeders create one.

Verified against a live GreenMail on both hosts: the email and
cli-himalaya targets pass 86 and 74 cases including six new ones, and
compose, reply, forward and send each land in Sent with \Seen. Python
suite and TypeScript node suite green, pre-commit clean.

* fix(email): parse a LIST mailbox as an astring, not as a quoted string

Codex review on #761. A LIST mailbox is an astring, so a name needing
no quoting may legally arrive bare, and some servers emit it that way.
Splitting the line on quotes then reads the hierarchy delimiter as the
name: `(\HasNoChildren \Sent) "/" Sent` answered `/`.

That splitter predates this branch and already mis-listed every folder
on such a server, but this branch is what made it consequential: the
name now feeds an APPEND, so a sent copy would have been filed into a
mailbox called `/`.

The three tokens are walked in order instead. The atom form and the NIL
delimiter both parse, and a quoted name is now unescaped rather than cut
at the first inner quote, which the splitter also got wrong.

Verified against a live GreenMail on both hosts, whose own wire format
is the empty-attribute `() "." "INBOX"` shape.
2026-08-11 22:28:48 -07:00
Zecheng Zhang f8be7c5429 fix(integ/gws): quoted worksheet names, whole-column ranges, and updateCells (#762)
* fix(integ/gws): resolve quoted worksheet names and implement updateCells

Two fidelity gaps in the fake Google Workspace server, both found by
driving a real client at it.

`parseA1` stripped the quotes around a sheet name only on the `!` branch,
so `'Jun-Jul_2025'!A1:G9` resolved but a bare `'Jun-Jul_2025'` did not.
That is not a corner: gspread quotes unconditionally
(`utils.quote_sheet_name`) and sends the bare quoted name to read a whole
worksheet, so `Worksheet.get_all_records()` answered
`Unable to parse range` against any sheet at all. Quoting is now handled
before the `!` split, in `splitRange`, with doubled apostrophes unescaped
the way the real grammar spells an embedded quote -- and, because the
name is consumed first, a `!` inside a quoted name no longer confuses
`lastIndexOf`.

`updateCells` was unimplemented and answered `Unsupported request`. It is
how a caller shortens a sheet it previously wrote longer -- the real
request clears whatever `fields` names across the range that `rows` does
not cover -- and there is no other way to do that through batchUpdate.
Both forms are served: `range` (clear, then write what is supplied) and
`start` (write at a coordinate). Only `userEnteredValue` is kept, since
the fake stores strings and formatting has nowhere to go; a number
renders as its decimal, a bool as TRUE/FALSE, and a formula as its text,
which is consistent with the fake not evaluating formulas.

Three cases in integ/cli/gws.json cover them, each self-contained on a
spreadsheet it creates. All three fail on the unfixed server -- the read
with `Unable to parse range`, the two writes with
`Unsupported request: updateCells` -- and the target goes 65 to 68 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(integ/gws): read a whole-column range like A:Z as cells, not a tab name

`parseA1` decided an unquoted range with no `!` was a tab name unless it
matched `^[A-Z]+\d`, which wants a digit right after the column letters.
`A:Z` has none, so it resolved as a sheet called "A:Z", found nothing, and
came back `Unable to parse range: A:Z`.

That is the range gspread asks for when a caller wants every row of the
first worksheet -- `spreadsheets().values().get(range='A:Z')` is the
ordinary way to read a sheet whose length you do not know -- so a whole
family of readers could not read the fake at all.

The test is now a single pattern covering every A1 form the real API takes
with a half open side: `A1`, `A1:G9`, a whole column span `A:Z`, and a
whole row span `1:5`. A name a tab actually has still wins first, as
before, and the old `includes(' ')` special case is subsumed -- a name with
a space cannot match. `parseCell` already answered `{row: null}` for a
bare column and the range builder already read that as unbounded, so
nothing downstream changed.

One more case in integ/cli/gws.json; the target goes 68 to 69 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(integ/gws): honor the updateCells field mask before clearing values

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:14:28 -07:00
bytecii 5668ac0e4d style: break the yapf/isort deadlock the merged grep import created
Merging the two grep_helper suites produced a ten-name import, and the
two formatters disagree about how to wrap one: yapf wants a hanging
indent, isort wants the names aligned under the paren. Each undoes the
other on every run, so `pre-commit run --all-files` reported both hooks
as "files were modified" forever while the tree kept returning to the
same bytes.

Pin yapf's form and take the import out of isort's hands, which is what
the tree already does at `tests/commands/builtin/generic/test_split.py`
and eight other sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:57:36 -07:00
bytecii 4b5dfde9db test(executor): mirror the five executor modules that had no suite
`control.py` (537 lines) and `jobs.py` (233) had no test on the Python
side while `control.test.ts` and `jobs.test.ts` sit beside their sources;
the only suite references were two `ReturnSignal` imports. Port both,
then cover the three `builtins/` modules neither tree tested at all.

- `test_control.py` ports the TypeScript assertions for if/for/while/
  until/case, and adds what only Python has a twin for: the `for ((;;))`
  handler, a multi-level `break` decrementing on its way out, and the
  `_MAX_WHILE` cap emitting its stderr warning.
- `test_jobs.py` ports wait/kill/jobs/ps and adds `handle_fg`, which has
  no TypeScript twin: the no-job message, the unknown id echoed as typed,
  and the command-line header prepended to the adopted stdout.
- `test_history.py` pins `_parse_args`, whose bash-getopt corners were
  documented in a docstring and asserted nowhere: `-d3` attached, `-dc`
  reading "c" as the offset rather than another option, `history -1` as
  an invalid option, `--` and the first operand both ending option
  parsing, and a bare `-` as an operand.
- `test_dirs.py` covers `handle_cd`: CDPATH search order and the
  announce-the-destination rule, the mount-root fallback for a path the
  backend cannot stat, symlink following through a prefix and a chain,
  and ELOOP.
- `test_scope.py` covers the two PathSpec helpers.

Also move `test_find_action_dispatch.py` beside its source, which lives
at `executor/find_action_dispatch.py`, not `workspace/`.

Not covered on purpose: `cd -P`. `_resolve_target` implements it
correctly -- called directly, `/link/..` gives `/deep` under `-P` and `/`
under `-L` -- but nothing can reach that branch, because every candidate
comes from `_cd_candidates` -> `resolve_path`, which normpaths `..` away
first, so `-L` and `-P` return the same answer for every input. The
TypeScript twin is structurally identical (`resolvePath` then
`resolveTarget`), so this is a shared divergence from bash rather than a
parity gap. Fixing it is a behavior change to `cd`, out of scope here;
asserting today's answer would pin the bug.

Mirror baseline 211 -> 207.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:50:40 -07:00
bytecii 640bdba25e style: normalize the merged helper suites' import blocks
isort folds the two same-module imports the merge left behind into one
and drops the duplicated `compile_pattern`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:50:22 -07:00
bytecii bfc34c7130 test(layout): gate the test tree against the source layout (#609 item 13)
The previous commit's cleanup only holds if something refuses the next
drift, and the project already enforces comparable rules with
source-walking meta-tests (`test_no_raw_flag_reads.py`,
`test_no_dead_flag_params.py`). The absence of one here is why the
`__init__.py` count reached 87 and why a 24-file directory could sit
beside 19 packages it did not name.

Three hard checks and one ratchet:

- no `__init__.py` anywhere under `tests/`;
- every test directory mirrors a `mirage/` package, or is one of eight
  entries in `UNMIRRORED_DIRS` with a written reason -- harnesses and
  fixture trees (`commands/native`, `e2e`, `fixtures`, ...) that mirror
  nothing on purpose;
- no stale exemption: an entry whose directory is gone, or whose source
  twin now exists, fails instead of lingering;
- a ratchet on the number of source modules no test file is named for,
  moving in either direction only deliberately. It counts the weaker of
  the two readings -- `test_<name>.py` anywhere rather than beside its
  twin -- because CLAUDE.md asks for a mirror only "where reasonable"
  and several suites cover a family in one file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:35:02 -07:00
bytecii 1503cded27 test(layout): put the python test tree on the source layout
`tests/` is a namespace tree -- pyproject already sets
`--import-mode=importlib`, which resolves the same-basename collisions
(34 different `test_find.py`) an `__init__.py` would otherwise be needed
for -- so CLAUDE.md forbids them. Without a check the count reached 87,
all of them content-free: 81 carried only the license header, 6 were
empty. Delete them; `tests/commands/postgres/` held nothing else and
goes with them.

Then move the three directories that mirrored no source package, because
that is what hid two coverage gaps:

- `tests/resource/object_storage/` collected 24 suites for 19 sibling
  packages, so `mirage/resource/s3/s3.py` looked untested. Split it one
  directory per source package, the five s3 suites into `tests/resource/s3/`,
  and the cross-alias prefix suite up to `tests/resource/` beside
  `test_s3_aliases.py` -- renamed to name its subject, since `test_prefix.py`
  says nothing at that level.
- `tests/commands/builtin/helpers/` shadowed the flat `*_helper.py` sources.
  Its conftest was a byte-identical copy of its parent's, so it just goes.
  Three of its five suites collided with a same-named flat suite and were
  merged: they test disjoint halves of one helper (`number_flag_error` vs
  `parse_counts`; the `sort` command vs `parse_keydef`/`build_config`;
  `grep_lines`/`grep_recursive` vs `compile_pattern`/`search_query`). The
  sort merge renames a local `sort_lines` wrapper to `_run_sort`, which
  otherwise shadows the `sort_helper` import of the same name.
- `tests/commands/ssh/` sat beside `commands/builtin/ssh/`'s twin instead
  of under it.

Also flatten the ten single-file `{ram,redis}/{cat,head,ls,tail,wc}/`
directories onto their already-flat siblings, and move `test_man.py` under
`executor/builtins/` where `man.py` lives.

Collection is unchanged: 13863 tests before, 13863 after the moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:34:50 -07:00
bytecii 687407a09f Merge pull request #759 from bytecii/fix/tr-escapes-wc-width
fix: tr escapes, wc column geometry, code-point sort order + a layout parity gate (#609 T1-F remainder + item 30)
2026-08-11 20:23:52 -07:00
Zecheng Zhang 6467d84ee9 shared FileHandle/FileTable for the buffered handle tables (#760)
* shared FileHandle/FileTable for the buffered handle tables

* split runtime handles into one module per concern
2026-08-11 20:21:48 -07:00
bytecii 372e02970b fix(gate): count modules inside one-sided directories
A directory present in only one implementation was recorded as a single
finding and its module set discarded, so the 21 one-sided directories
counted 21 rather than the 74 modules they hold. That made the ratchet
blind exactly where drift is easiest: a new module under a directory
already in the baseline -- `agents/mcp`, `ops/postgres`,
`core/onedrive` -- left the count untouched and `--strict` passed.

A one-sided directory now contributes every module inside it. Adding a
module under `agents/mcp` moves the count 310 -> 311 where it used to
sit at 257 forever. Directories become presentational: they are printed
once with their module count and excluded from `total()`, so they are
not double-counted against the modules they now contribute.

An excused directory stays a deliberate blind spot, and keeps excusing
its whole subtree. The excuse is precisely that there is no counterpart
to mirror -- agno is python-only, opfs is browser-only -- so a new
module inside one is expected growth, not drift. Verified both ways: a
probe under `agents/mcp` (unexcused) moves the count, a probe under
`core/opfs` (excused) does not, and stale-entry detection still fires.

Baseline 257 -> 310. The number is larger because the unit is now
consistently one module; no divergence was added.

Reported by codex on #759.
2026-08-11 16:13:56 -07:00
bytecii df5164d91b fix(cli): declare the mirage-core dependency the sort sweep introduced
The #370 sweep added `compareCodePoints` to cli/src/config.ts and
cli/src/settings.ts, the first time anything under packages/cli/src
imported @struktoai/mirage-core. The package never declared that
dependency, and tsup externalizes only what package.json declares -- so
rather than leave it an import, tsup inlined the whole core barrel into
the CLI bundle, dragging isomorphic-git's CJS entry along with it.
esbuild rewrites a CJS `require()` it cannot resolve into a shim that
throws, so every `mirage` invocation died at module load with
`Dynamic require of "buffer" is not supported`.

Nothing local caught it: tsc resolves the import through the pnpm
workspace link and the build succeeds. Only the bundled binary breaks,
which is why it surfaced as 89 integ-runtime failures, the cli e2e
suite, and a cross-language snapshot job whose python client could not
reach a daemon that never started.

Declaring the dependency restores externalization: the CLI dist now
carries a plain `from "@struktoai/mirage-core"` and no isomorphic-git.
The unrelated follow-redirects hunk in the lockfile is pnpm's own
peer-dep dedup, emitted by the same install.
2026-08-11 16:13:44 -07:00
bytecii a6b59533c2 feat(gate): diff the python and typescript module layouts (#609 item 30)
CLAUDE.md asks for the two trees to keep a mirrored layout and nothing
checked it, so seven helper modules went missing and two module homes
swapped without anything noticing. scripts/check_layout_parity.py diffs
the module-name sets directory by directory: core/node/browser union onto
one python namespace because CLAUDE.md puts runtime-specific code in
node/browser, while cli, server and agents are separate npm packages
whose python twins are subpackages, so they map by prefix.

Two normalizations keep the report honest. __init__.py and index.ts are
skipped, since only python requires a package file per directory and
comparing them reports a gap across most of the tree that nobody intends
to close. Module and directory names fold camelCase, hyphens and a
leading underscore, so findEval.ts against find_eval.py reads as a
rename rather than two missing modules -- 15 renames separated out from
207 genuinely absent modules that way.

--strict does not demand zero. 257 divergences predate the gate and each
needs its own decision, so it fails when the count moves off the
committed baseline in *either* direction: new drift is blocked, and
closing a divergence has to be locked in by lowering the number rather
than being silently spent. A stale exception fails just as loudly, which
is the failure mode every hand-maintained allowlist in this repo has
already hit.

The report reproduces five findings the audit had filed separately:
findEval/findParse as the only camelCase modules, _provision.ts vs
provision.ts, workspace/snapshot missing keys and types on the python
side, no builtins/shared.ts, and node's cache/redis/file.ts sitting where
python has cache/file/redis.py. That is what it is for -- items 31 to 37
get scoped from its output instead of from taste.

Also gates scripts/gen_width_table.py against its committed output, the
same way the spec trees are gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 07:48:18 -07:00
bytecii b94a387368 fix(wc): measure -L in terminal columns and -w on glibc's space set
Two independent bugs, one shared table.

`-L` is the length of the longest line in *columns*, not characters, and
neither tree measured columns. Pinned against coreutils 9.7:
`printf 'a\tb' | wc -L` is 9, because a tab jumps to the next multiple of
8 -- both trees said 3. `printf 'a\rb' | wc -L` is 1, because carriage
return rewinds the cursor without ending the line: Python counted CR as a
column and said 3, TypeScript split on /\r?\n/ and reached 1 by treating
CR as a line break, which is a different answer that happens to match on
this input. A CJK character is 2 columns, a combining mark is 0, and a
control character is 0 (that is how wc accounts for wcwidth's -1).

`-w` splits on glibc's iswspace, which is Unicode White_Space *minus*
U+0085. Both trees approximated it with a builtin and both were wrong, in
opposite directions: Python's str.isspace() also reports U+001C-U+001F
and U+0085, so `printf 'a\x1cb' | wc -w` counted 2 words; JavaScript's
\s also matches U+FEFF, so `printf 'ab' | wc -w` counted 2. The
plan named Python correct on this row; it is not.

The width data now comes from scripts/gen_width_table.py, which walks
unicodedata and emits both trees plus integ/fixtures/wc/width.json. The
fixture is the contract, the same way integ/fixtures/filetype/tables.json
is: regenerating one tree without the other fails both suites. The
generated tables live under utils/generated/, exempted from yapf and
isort here and already covered by typescript/.prettierignore, because a
formatter that repacks 349 ranges fights the generator on every run.

Cf is not uniformly zero-width, which is the one place raw Unicode
categories would have been wrong: U+200B and U+FEFF measure 0 while
U+00AD, U+0600-U+0605 and U+110BD measure 1. Documented divergence: an
unassigned code point measures 1 here and 0 in glibc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 07:29:45 -07:00
bytecii 07238bf64f fix(ts): sort strings by code point, not UTF-16 code unit (#370)
JavaScript orders strings by UTF-16 code unit. An astral character
(U+10000 and up) is a surrogate pair in 0xD800-0xDFFF, so it sorts before
every BMP name from U+E000 up, while Python's `sorted` puts it after.
`ls` on a directory holding a U+E000 name and an emoji listed them in
opposite orders across the two trees. GNU agrees with Python here, in
both C and C.UTF-8: UTF-8 byte order *is* code-point order, so
TypeScript was the only one of the three disagreeing.

Adds utils/sort.ts (compareCodePoints, sortedByCodePoints) and routes
every string sort through it. Three classes of site, only the first of
which was obvious:

- 131 bare `.sort()` calls across 94 files.
- ~40 explicit `a < b ? -1 : a > b ? 1 : 0` comparators, which are the
  same code-unit comparison wearing a comparator's clothes.
  core/gdrive/readdir.ts even carried the comment "Codepoint compare, not
  localeCompare: python sorts by codepoint" directly above one.
- 3 localeCompare calls, whose Python twins are all plain code-point
  sorts. ICU collation reorders ASCII too, so those diverged on far more
  than astral names.

An integ pin on `ls` and `find` is what caught generic/ls.ts, which the
bare-`.sort()` sweep could not see because it already passed a
comparator. eslint now rejects a zero-argument `.sort()` outright (it is
also wrong for numbers and tuples, which it compares as strings) so the
default cannot come back; typecheck caught the four sites where the
sweep had reached numbers and bigints.

Two latent bugs fixed on the way: git/reset.ts sorted [path, letter]
tuples by their `toString()`, ordering `a+b` before `a` because ',' has
a higher code point than '+'; sort_helper.ts's compareLines is the
`sort` command itself, so the C-locale byte order it should have had is
now what it computes.

Closes #370

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 06:46:39 -07:00
Zecheng Zhang 26e83df2aa one door for VFS/FUSE ops in both languages (#753)
* feat(ops): one door for VFS/FUSE ops in both languages

Ops (python) and WorkspaceFS (typescript) now delegate every op to
Dispatcher.dispatch, so FUSE and programmatic access walk the same
pipeline as a shell command: link follow, session grants, admission
policies, cache read-through, namespace structure and write
invalidation each fire once. The facade keeps only the typed surface
and op recording.

- PolicyDenied.completed distinguishes a pre-deny from a post-deny, so
  a completed-then-denied op is still recorded before it propagates.
- A cross-mount rename is refused at the facade with EXDEV, so mv falls
  back to copy+unlink. TypeScript classifies FUSE errnos by code
  string, so EXDEV had to join the table or it degraded to EIO.
- TypeScript's MountCore was a split surface (reads via ws.fs, writes
  via ws.dispatch), so drainOps reported no writes at all. Every
  mutation now goes through the facade.
- Retired the forwardIndex knob so TypeScript forwards the index to
  every backend the way python always has.
- Deleted the workspace-less Ops pipeline: dispatch is now required.
  It was a second door with different behavior (no cache, no namespace
  structure, gates only when a caller passed policies), and only tests
  reached it.
- Ops.read gains raw, mirroring readFile(path, {raw: true}). FUSE's
  read-modify-write hands its merged buffer to write, which stores, so
  the read that feeds it must be the stored bytes. Raw reads also skip
  the file cache, which is keyed on the path alone and holds whatever a
  command rendered.

python 13185 passed, typescript core 7232 and node 2282.

* fix(ops): slice a warm cache for ranged reads, record denied read bytes

Two review findings from codex on the collapse.

A ranged read served from the file cache returned the whole object
instead of the window: the cache holds the full file, and the warm
path handed it back untouched. The dispatcher now slices it with the
same helper the cold path falls back to, so warm and cold agree. This
was already reachable on main through git, which reads pack indexes as
a few bytes at a known offset, and routing Ops.read(offset, size)
through the same door widened it.

A post_ops deny on a read recorded zero bytes, because the suppressed
result is the only place a read's byte count lived (a write's is still
in its own arguments). PolicyDenied now carries completed_bytes,
stamped where it is raised, so records and network_bytes stop
under-reporting traffic that actually happened.

* fix(ops): keep transfer bytes and cache origin through the op door

Two more review findings from codex, both cases where the deny and
limit paths lose a fact the success path has.

A post_ops Limit truncates what the caller receives, but the transfer
already happened, so recording the capped length under-reported
network_bytes by whatever the cap removed. IOResult gains op_bytes,
which the door sets only when a limit truncated a read, and the facade
prefers it over the delivered length.

A post_ops deny on a warm cache hit was recorded against the backend,
so a read served entirely from the file cache counted as network
traffic that never happened. PolicyDenied gains from_cache, stamped
where the warm branch raises, and the facade reads it exactly as the
success path reads a cache-served result.

* fix(ops): a namespace answer is not the parent backend's op

A directory that exists only because a mount or a link sits below it is
served from the namespace without contacting anything, but the facade
attributed it to the mount that lexically owns the path. On a remote
parent that invented a network record for every such lookup, which a
session granted only a nested mount hits on every walk down to its
grant.

The door now names the server explicitly through IOResult.op_source
rather than leaving the facade to infer it from a non-empty reads map:
"ram" for a warm cache hit and for a synthetic namespace answer, unset
when the owning mount served it. That replaces the inference, so the
two cases the door already knew about are now stated rather than
guessed.

The backend keeps the record when it was actually reached and answered
ENOENT, which is a real round trip on a remote mount.

* fix(ops): errors raised after an op ran carry its source and bytes

* style: match CI yapf on the door-error test

* fix(ops): stamp an OpReport at completion, drain drift at the door
2026-08-11 06:20:54 -07:00
bytecii 1233e2c4d8 fix(tr): read SET operands with tr's escape grammar, not echo's
Both trees resolved tr's SET operands with a reader built for `echo -e`.
Python's was a regex over eight named escapes, so `tr '\0' -` and every
octal spelling silently matched nothing. TypeScript's was echo's scanner
outright: it read `\x41` as `A`, stopped the whole scan at `\c`, and only
took octal behind a leading zero.

TypeScript did not drift there by accident. workspace/executor/escapes.test.ts
is a port of Python's *echo* test that imported tr's reader, so every
echo-only rule was pinned against the wrong command — the test was holding
the bug in place. It now imports interpretEchoEscapes, which text.ts
exports for it the way builtins/__init__.py already re-exports
_interpret_escapes.

tr's grammar, pinned against coreutils 9.7 (debian:stable-slim): octal is
\NNN with no leading zero required, greedy to three digits, so \0141 is
\014 then a literal 1; a three-digit value over 255 backs off to two
digits (GNU also warns, which a pure reader has no channel for); an
unknown escape drops its backslash, so `tr '\x41' -` deletes x, 4 and 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 06:17:14 -07:00
bytecii 83a26278c6 fix: GNU byte pins across tail, split, numfmt, sed, cp and find (#609 T1-F) (#758)
* fix: GNU byte pins across tail, split, numfmt, sed, cp and find (#609 T1-F)

Six of the eight T1-F rows, each re-pinned against debian:stable-slim
(coreutils 9.7, sed 4.9) before it was touched. Four of them were wrong
in both languages, not one, and two were larger than the audit recorded.

tail -c takes the same sign grammar as -n. TypeScript did
`raw.slice(-bytesMode)`, so `-c -3` dropped the FIRST three bytes;
Python's `abs(c)` turned `-c +3` into the last three. Both now route -c
through one shared parse_counts/parseCounts, which is what the three
hand-written call sites had drifted apart from.

split -t is one byte or the two-character spelling \0. TypeScript took
`ENC.encode(sep)[0]` and Python kept the whole byte string, so `-t XY`
split on 'X' in one tree and on 'XY' in the other where GNU refuses to
run. An empty value is now a report, not a silent newline.

numfmt: --to=none renders in fixed notation from an exact value, so
`--from=si 1Y` prints twenty-five digits instead of TypeScript's `1e+24`
-- and `--from=iec 1Q` at all, which the 28-digit default decimal
context could not hold. The suffix grammar is now GNU's: one unit
letter, lowercase only for kilo, with the trailing i required under
iec-i, optional under auto and refused elsewhere. `1KiB` used to read as
a kilobyte in both trees. Errors carry GNU's four message shapes and
exit 2 instead of raising decimal.InvalidOperation.

sed's missing-script and no-input paths had four spellings across the
two languages, and Python raised where TypeScript returned exit 1. Both
now share two constants, return an IOResult, and use GNU's `no input
files` with its exit 4. GNU's usage block is deliberately not
reproduced.

cp -rv ordered sibling directories by length alone, which is
PYTHONHASHSEED-dependent for equal-length names; both sides key on
(len, path) now.

find -mtime parsed timestamps inline on both sides instead of using the
helper each tree already had: Python stamped UTC over a real reported
offset and let a malformed value raise out of the walk, while
TypeScript's bare Date.parse produced a NaN that slipped past the null
guard and KEPT the entry Python dropped.

Deliberate divergences, documented in place: GNU's quotearg escaping in
the split message (matching the existing truncate choice), and the long
double GNU reads numfmt input into, which prints 1.10 as 1.11 while 1.20
and 1.30 round-trip.

Verified: 4695 integ cases pass on ram and disk in both hosts, py+ts
unit suites green, pnpm -r typecheck clean, spec parity 93/93.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tail): read a TailCounts field through ?? null, not !== null (codex)

Codex flagged the tailBytes signature change as a public API break.
Backward compatibility is explicitly out of scope here (CLAUDE.md), and
the described failure was wrong -- `tailBytes(data, 2)` does not fall
back to ten lines, it returns the whole input, because `(2).fromByte` is
undefined, `undefined !== null` is true, and `slice(NaN)` is `slice(0)`.

But that is a real hole and it is worth closing on its own: TailCounts
is a plain object, so any field a caller omits arrives as undefined and
a bare `!== null` guard waves it into the wrong branch. Python's twin is
a dataclass whose four fields all default to None and cannot do this.
`normalizeCounts` gives the TypeScript side the same floor, and both
`tailBytes` and the cache gate in the generic read through it.

Four tests pin it, including the exact shape codex described.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 05:59:15 -07:00
Zecheng Zhang 5a95be6ac1 fix(shell): run sh FILE / bash FILE as a script (#752)
* fix(shell): run `sh FILE` / `bash FILE` as a script, not as the program text

handle_bash treated the first non-flag operand as the program text, so
`sh /work/run.sh` executed the literal string `/work/run.sh` and reported
`/work/run.sh: command not found`.

- read the operand through the op dispatcher and run its contents
- positional parameters from the operands, and from `-c 'prog' name a b`
- `$0` is the script (new Session.script_name, read by `$0` and `${@:0}`)
- `-e -u -x -f` were silent no-ops; map them through SET_FLAG_TO_OPTION
  and restore afterwards
- GNU diagnostics: missing 127, unreadable 126, directory 126, prefixed
  with the head word so `sh` no longer says `bash:`

* refactor(shell): state the bash/sh startup rules once instead of case by case

The first commit fixed the reported case; this one replaces the case
lists it left behind with the rule each one was standing in for. Every
change below closes a divergence pinned against GNU bash 5.2 on
debian:stable-slim.

- The startup option grammar is `set`'s grammar, so both now read it from
  one place (`shell/options`). `-o name` was parsed only to be thrown
  away, which silently dropped `bash -o pipefail` even though pipefail is
  implemented; `+x` / `+o` were not option words at all, so `bash +x f`
  looked for a file called `+x`; and the options were a list of names to
  enable, which cannot express `bash -e +e` (last one wins). `set -eo
  pipefail` was broken by the same gap and is fixed by the same change.
- bash's long options carry arity now, not just names: `--rcfile FILE`
  consumes FILE, and an unknown `--long` is a usage error rather than a
  script path (`bash --version` used to report a missing file).
- The program comes from stdin whenever no operand names one. `-s` is one
  case of that rule, not the rule, so `echo prog | bash` ran nothing and
  `bash -s A B` ran the file `A`.
- One reader for a script file, shared by `source` and a nested shell,
  refining a keyed backend's ENOENT for a directory into EISDIR. `source`
  reported a directory as missing, spelled the path resolved rather than
  as typed, and had a third copy of the payload drain in TypeScript.
  Bare `source` now gives GNU's usage error instead of statting the cwd.
- The exit code comes from the errno: 127 only for ENOENT, 126 for
  everything else. It was `PermissionError ? 126 : 127` in Python and
  `EACCES` in TypeScript, so ENOTDIR was reported as 127 in both.
- One child-shell snapshot, shared by `( … )` and a nested `bash`/`sh`.
  The nested shell isolated three hand-picked fields, so a `cd`, an
  `export` or a function definition inside `bash -c` leaked into the
  caller; the subshell's own list had never been told about `script_name`
  and neither had Python's `Session.fork`, which is a hand-written
  literal whose docstring promised it could not happen. The field list is
  declared once and a test fails until every dataclass field is
  classified. `source` deliberately does not snapshot: a sourced `set -x`
  does leak in bash.
- `$0` reads one `Session.argv0` accessor; Python's `or` treated an empty
  `-c` name as absent where TypeScript's `??` did not.

* fix(integ): keep the cwd case self-contained, and pin symlinked script operands

Two follow-ups from review.

`ctl_sh_script_file_cwd_isolated` changed the session's working directory
and never changed it back. The battery shares one session per target, so
that re-rooted every relative operand in every later case. It runs in a
subshell now, which restores the directory and exercises the other half
of the same snapshot.

Review also asked whether a script operand that is a namespace symlink
reaches the backend unresolved, since the backend cannot see a link the
namespace owns. It does not: the operand resolves before dispatch, and
absolute, relative and cross-mount links all run their target while a
broken one reports the link at exit 127. Pinned in both languages and in
the battery, since nothing covered it.

* fix(shell): start a child shell outside every source its caller is inside

`session.source_depth` was transient, so the child-shell snapshot neither
reset nor restored it. Running `bash FILE` from inside a sourced script
therefore left the depth positive, and a top-level `return` in the child
was absorbed as if the child were itself sourced: GNU prints "can only
`return' from a function or sourced script", keeps going and reports 0,
where mirage ended the child early with the returned status. The field
joins CHILD_SHELL_FIELDS so the same snapshot that already restores it
carries it, and the nested shell sets it to 0 the way it sets `$0`.

`OptionWord` also moves to `shell/types.py` / `shell/types.ts`, next to
the SET_FLAG_TO_OPTION table it is the shape of, per the module layout.
2026-08-11 03:12:53 -07:00
Zecheng Zhang cd47bb87a4 fix(traversal): cross a nested mount correctly in du, find, ls -R and tree (#755)
* fix(traversal): cross a nested mount correctly in du, find, ls -R and tree

A mount nested inside another mount's tree is invisible to the parent
backend's readdir, so the parent's keys under that path are shadowed.
Six bugs followed from that, all fixed here in both languages.

- du counted the shadowed keys. The generic now opts into MountView and
  prunes leaves under a descendant mount after the link merge, then
  recomputes the total from the survivors.
- du -c printed one total row per mount. The bytes-level re-total is
  extracted as merge_du_totals so the executor fan-out reuses the same
  code, with -h humanizing once at the merge instead of per block.
- Fan-out sub-runs never received LinkView, so symlinks vanished from
  find/du/tree/grep -r exactly when a nested mount existed.
- A line whose operands span mounts never fanned out inside an operand,
  because the cross-mount runner executes one single-mount native run
  per operand. run_with_fanout wraps that seam; it forwards resolve_hint
  as well as stdin, which the STREAM strategy needs.
- ls -R was filtered a line at a time. Its format is PATH: plus bare
  names, so the path-first filter dropped the shadowed header and kept
  its entries, and ate the blank separator. It is filtered a group at a
  time now.
- tree is no longer fanned out at all: its output is one document, so a
  per-mount block printed a second root, drawing and summary. It crosses
  inside the generic through a new injected fact, readdir_path, a
  dispatcher-backed listing offered beside stat_path.

Pinned against GNU coreutils 9.7 and tree 2.2.1 on debian:stable-slim.

* fix(traversal): fold nested du into ancestors, and stop tree disclosing an ungranted mount

Three review findings, all reproduced before fixing.

tree crossed a boundary from the mount table alone: a crossing entry's
row is synthesized as a directory without asking any backend, so the
dispatcher never saw it and could not refuse it. `tree /base` drew and
counted an ungranted `/base/private` while ls, find and du all hid it.
The child-mount lookup is now session-filtered, the way ls filters the
same fact. du keeps the raw list on purpose: an ungranted mount still
shadows the parent's keys.

du concatenated per-mount blocks, so every ancestor row carried only the
parent backend's own bytes. Pinned on coreutils 9.7 over a real tmpfs
mount, GNU folds: `du base` prints `7 base/inner` then `17 base`, and
`du -s base` prints one row. The blocks are now merged at the leaf level
and the tree derived by the same rollup a single-mount run uses, so
folding, post-order, --max-depth and -a come from one implementation.
rollup grows a `dirs` hint so an empty mount keeps GNU's `0` row, and
the mount roots are stat'ed rather than assumed to be directories,
because /.bash_history is a mount serving a single file.

tree's cross-mount stat threw a bare Error, which isWalkError does not
recognize, so a vanished entry rejected the whole run instead of being
skipped. It throws a stamped enoent now, matching python's
FileNotFoundError.

The three nested integ truths encoded the pre-fix output: ls -R had no
blank line between groups (GNU 9.7 prints one) and tree showed two roots
and two summaries (real tree 2.2.1 over a mount prints one document).
2026-08-11 03:12:34 -07:00
Zecheng Zhang cfbe51b1fc feat(gws): calendar and forms API passthrough (#754)
* feat(gws): add calendar and forms API passthrough, and mock routes

Adds `gws calendar` (calendarList, calendars, events, freebusy) and
`gws forms` (forms, responses) in both languages, plus routeCalendar
and routeForms in the integ GWS mock.

* feat(gcal): mount Google Calendar as day directories

Adds the gcal resource: one directory per calendar, one per local day,
one JSON file per event, named <eventId>__<HHMM-HHMM>_<Title>.gcal.json.
rm deletes the event; everything else goes through `gws calendar`.

Day bucketing uses one mount-wide zone, defaulting to the primary
calendar's, with bounds computed as consecutive local midnights so a DST
day is 23 or 25 hours rather than a fixed 24.

TypeScript mirror still to come, so spec parity is red until it lands.

* test(integ): cover gws calendar and forms, fix the service count

Adds a calendar/v1 fixture seeded through events.insert on both hosts,
and 12 cases: overlap querying, all-day end-exclusivity, q search,
freebusy, delete, form create/batchUpdate, and that a form's id is
reachable through Drive (the Forms API has no list method, so that is
the only path an agent has to an existing form).

gws --help now lists 8 services, not 6.

* fix(gcal): address codex review

- percent-encode Discovery path params, so a holiday calendar id's "#"
  no longer truncates the URL at /calendars/en.usa (py + ts)
- resolve a zone-less dateTime through its declared timeZone instead of
  leaving it naive, which raised TypeError when bucketed (py + mock)
- register commands and ops at module scope; there is no cycle
- validate a day is a real date, not merely date-shaped, so 2026-02-30
  is ENOENT rather than a directory readdir then fails on
- centre the default window in the bucket zone, not the host's

* test(integ): cover calendar and forms mutations, fix the stale service count

* feat(gcal): mirror the calendar mount in typescript

* fix(gcal): drop the redundant close override and the polynomial regexes
2026-08-11 03:12:23 -07:00
bytecii 9f56af5c1c Merge pull request #751 from bytecii/fix/lifecycle-and-config-validation
fix(lifecycle,config): close the index, evict synchronously, forbid unknown config keys (#609 T1-M + T1-L)
2026-08-10 20:05:33 -07:00
dependabot[bot] 16f57fd37a chore(deps): bump pnpm/action-setup from 6.0.9 to 6.0.10 (#757)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 6.0.9 to 6.0.10.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.0.9...v6.0.10)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 20:00:53 -07:00
Zecheng Zhang 0f80101e34 fix(notion): add GET /v1/comments, version the database object, align ntn API errors (#756)
* fix(notion): add GET /v1/comments, version the database object, align ntn API errors

The fake had no GET /v1/comments, only POST, so nothing could read a
comment back. Comments were also seeded with one global position while
createComment numbers by the parent's count, so a new comment could sort
ahead of seeded ones; seeding is now per parent.

databaseJson was version-blind and always answered 2025-09-03. A caller
sending Notion-Version: 2022-06-28 now gets the column schema under
properties. The MCP arm keeps the default: a tool call carries no version
header and must render identically to the REST arm.

ntn reported every API failure through the executor's generic fallback
(ntn <verb>: <message>, exit 1), dropping the status, reason phrase and
Notion's code. failure.py / failure.ts render upstream's wording, probed
against ntn 0.21.9, and every leaf is registered wrapped so a new verb
cannot miss it. datasources query now reports the data source miss with
upstream's hint instead of the database fallback's own 404.

* fix(ntn): narrow the guarded result before destructuring it

CommandFnResult is a nullable union, so annotating the awaited value as a
pair failed tsc --noEmit. The test now narrows instead: guarded returning
null would mean it swallowed the failure, which is worth failing on.
2026-08-10 19:38:13 -07:00
bytecii 20051ef40d fix(config): key-check the s3 store group, and keep s3 to the plane it can host
Three review findings, all of them cases where TypeScript accepted a
config Python refuses — or built something other than what was asked.

**The s3 store group was exempt from key validation.** The exemption
generalized `mounts.*.config`, which Python types as a bare dict, to
every credential-carrying block. That does not hold: an s3 group is
`S3StoreBlock(S3Config)` with extra="forbid", a strict model, so
`endpoint_urll` is a load error in Python and silently ignored here.
It now has a key table like every other block; only the camelizing is
skipped, which is all the backend's own spellings ever needed.

**`store.namespace`/`store.observer` accepted `type: s3`.** Both
languages took the config and failed later, at plane construction, with
`S3WorkspaceStateStore` raising for the planes it does not host. Both
now refuse it at the loader, with the message the store used to raise.

**A built cache store passed as `options.cache` became a RAM cache.**
Every `CacheConfig` field is optional, so a store is structurally
assignable to it; the workspace then built a cache of its own and the
supplied store never saw a read, a write, or a close. Structural typing
cannot refuse this, so `buildFileCache` says so instead. The test that
covered this path only asserted construction succeeded, which is why it
kept passing — it now asserts the rejection.

The two loader rules are pinned in the shared fixture, so both languages
must agree; each was proven red before the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:45:48 -07:00
bytecii 2ed889bdb5 fix(workspace): own the state store the config builds; take the cache as config only
Ownership was spelled two different ways, and TypeScript was missing
the half Python has.

Python names ownership: `Workspace(store=..., owns_store=True)`, set by
the config loader and the daemon route beside the store they build.
TypeScript had no such knob — `owned = options.store === undefined` —
so a store built from a `store:` block, or the daemon's own disk
default, was always treated as borrowed and never closed. For redis or
s3 that is a client that is never quit, once per workspace the daemon
creates. Adds `ownsStore` and sets it at both build sites.

The cache had the mirror-image problem: `options.cache` accepted a
built store *or* a config and inferred ownership by duck-typing on
`.get`, which is neither Python's model nor a rule anything else here
follows. It now takes config only, as Python does, so the workspace
always builds the cache and always closes it — `resolveFileCache` and
`ownsCache` go away with it. A store core cannot build still reaches
`buildFileCache` through `registerFileCacheStore`, the seam node's
redis store already uses.

`WorkspaceArgs.options` was a hand-copied subset of `WorkspaceOptions`;
it is now the type itself. That list is what once dropped `clis` and
`guards` between the config and the daemon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:43:51 -07:00
bytecii 46548b4923 refactor(config): name the backend-config rule once, drop the duplicated cache block
Self-review pass over the PR, for special cases that should have been
general rules and for shapes that stopped earning their keep:

- The s3 exemption was spelled `block.type === 's3'` in two places
  (skip-camelize, skip-key-check) that encode one rule: a group whose
  type names a backend carries the backend's config, not ours. Named
  once as BACKEND_CONFIG_TYPES; a second such backend now joins a set
  instead of needing both sites found again.
- `buildCache` had decayed to an identity cast once the workspace took
  over building the store, and RamCacheBlock/RedisCacheBlock duplicated
  core's CacheConfig/RedisCacheConfig field for field. Both deleted;
  WorkspaceArgs.options.cache narrows to what it now actually carries.
- buildCliEntries kept its own copy of the cli key list this PR had just
  added as CLI_KEYS; the store group names were spelled three times.
- absolutizeScripts: isPlainObject guards for the casts it grew, and a
  comment about running before or after normalization that only one of
  the two is now true.

The key tables are copied by hand from Python's pydantic models, so a
field added there would be refused here until someone hit it. Pinned the
way rejected.json pins the other direction: integ/fixtures/config/
accepted.json exercises every key of every block and both suites read
it. Proved it bites by dropping `root` from STORE_KEYS — the store case
fails with `unknown store key \`root\``.

Docs showed TypeScript constructing a RedisFileCacheStore beside a
declarative `index:`, which is the divergence this PR removed; the
snippet is now the config form, matching its own index line and Python.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:15:49 -07:00
bytecii 01c9895d44 fix(lifecycle,config): close the cache the workspace built; normalize s3 store credentials (codex)
Two P1 review findings, both real, both introduced by this PR's own new
surfaces.

**Close declaratively constructed cache stores.** `closeWorkspace`
cleared the cache but never closed it — harmless while the only store it
could build was RAM, wrong the moment `cache: {type: redis}` became
expressible, since `clear()` itself connects and nothing then quits the
client. Python's `close_async` pairs them in a try/finally; TS now does
the same, behind an `ownsCache` flag so a store the caller built stays
theirs to close (the split `ownsStateStore` already draws).

That exposed a pre-existing leak one layer up: `configToWorkspaceArgs`
*built* the store and handed it over as `options.cache`, leaving the
daemon's redis client with no owner at shutdown. The loader now passes
the block through as a `CacheConfig` — which is what it already was —
so the workspace builds, owns and closes it.

**Normalize s3 store configuration.** An s3 store group IS an S3Config,
whose snake_case spellings do not camelize into the TS field names:
`aws_access_key_id` is `accessKeyId`, not `awsAccessKeyId`, and
`timeout` is seconds where `timeoutMs` is milliseconds. Casting the
camelized block silently dropped every credential and the endpoint, so a
configured store would have authenticated against the wrong service.
Fixed on both sides of the seam: `normalizeConfigKeys` now leaves an s3
group alone (as it already does for `mounts.*.config`, for exactly this
reason), and `buildStoreGroup` runs it through `normalizeS3Config` —
the same translation the s3 mount uses.

Both regressions are pinned by tests proven to fail without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:27:10 -07:00
Zecheng Zhang 8f4ac0721d fix: raw embedded-python literals, and splice a quoted "$@" into its word (#750)
* fix(runtime): make the embedded python literals raw so TS stops eating their escapes

A template literal processes backslash escapes before Python sees the
text, so a lone \n inside an embedded Python string became a real
newline and truncated the literal. The symptom was remote from the
cause: every pyodide run died with pyodide_fatal_error, print(42)
included.

String.raw makes the escapes mean what they say; the emitted programs
are byte-identical, which is how the conversion was checked. The new
test compiles each wrapper on the interpreter that runs it, so a future
break reports a named SyntaxError with a line number instead. Reading
the .ts and un-escaping it by hand is not a substitute; that models the
escaping wrong and reports a false pass.

* fix(shell): splice a quoted "$@" into its word instead of replacing it

A quoted string holding a bare "$@" was replaced wholesale by the
positional parameters, so every literal in the word was discarded:
`f() { echo "saw: $@"; }; f a b` printed `a b`. Both languages.

The braced `${a[@]}` splat already spliced correctly, so `$@` and
`${@}` now take that path rather than carrying rules of their own,
which also fixes `${@}` not word-splitting at all and `${@/x/y}` acting
on the joined value. Three consequences worth naming:

- an empty splat that is the whole word yields no word, where it used
  to yield an empty one (`x "${empty[@]}" y` is two words, not three)
- a slice numbers the parameters from 1, so `${@:0}` yields $0 first
- `${#@}` is untouched and still wrong (it measures the joined string);
  it is a different code path and stays for its own change

Pinned against bash 5.2.37 in debian:stable-slim, not the macOS bash
3.2, which drops $0 from `${@:0}` and would have pinned the wrong rule.

* fix(shell): keep a splat whose single element is empty

`fragments == [""]` decided "no word" from the rendered text, but zero
elements and one empty element render the same, so `set -- ""` passed no
argument where bash passes one empty one. `${@}` and `${arr[@]}` too.
Track whether a splat yielded elements instead of inferring it.

The word survives iff the joined text is non-empty OR some splat yielded
at least one element. An empty expansion beside it does not count: with
no parameters, "$u$@" is no word at all, even though "$u" alone is an
empty word. All 16 combinations pinned on bash 5.2.37 in
debian:stable-slim.
2026-08-10 14:22:34 -07:00
bytecii c6a8ac135a fix(cli): send the workspace config in the file's own spelling
The TypeScript CLI loaded the YAML, camelized it, and POSTed the
result; the daemon then ran `loadWorkspaceConfig` on that. With the
loader now refusing keys Python refuses, its own output no longer
round-tripped — `default_session_id` arrived as `defaultSessionId` and
was rejected.

Camelizing before sending was the actual defect: it made the wire
format differ from Python's, whose CLI sends `model_dump()` (snake_case)
and whose daemon validates the same spelling. Splits the loader into
`checkWorkspaceConfig{,File}` — interpolate, validate, resolve script
paths, and stop — from the camelizing `loadWorkspaceConfig{,File}`. The
CLI sends the checked mapping; the daemon does the single authoritative
load. Env interpolation stays client-side so a missing var still fails
before the round trip.

Caught by the cli-gate and cross-language interop jobs, which are the
only callers that cross that boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 06:10:53 -07:00
bytecii c44505ba4a fix(lifecycle,config): close the index, evict synchronously, forbid unknown config keys (#609 T1-M + T1-L)
Two Tier 1 items, both cases of a contract Python enforces and
TypeScript does not.

T1-M — core lifecycle
- `RedisIndexCacheStore.close()` had zero callers, so a mount
  configured `index: {type: redis}` kept its client open past
  `closeWorkspace` and the Node process never exited. `BaseResource`
  now owns a `close()` that releases the index behind a `#closed`
  guard, mirroring `resource/base.py`. The 29 hand-written
  `close() { return Promise.resolve() }` overrides are deleted (they
  existed only to satisfy the interface, and each one silently opted
  its backend out of the teardown); the seven with real work call
  `super.close()`. `noImplicitOverride` makes forgetting it a compile
  error.
- `installDriftState` fired `void cache.remove(path)` per entry from a
  sync function, so under `DriftPolicy.OFF` a read issued right after
  `fromState` could still be served the snapshot bytes OFF exists to
  bypass — and a rejected remove was an unhandled rejection. Adds the
  sync `FileCache.evictPaths` seam Python has had (`evict_paths`):
  implemented in the RAM store, a documented no-op in redis.
- `DiskObserverStore.walk` swallowed every readdir failure, so an
  EACCES mid-tree yielded a silently partial `/.bash_history` and a
  `clear()` that reported success while leaving files. Errors now
  propagate; only a missing root reads as "nothing recorded yet".
  Python's root probe used `os.path.isdir`, which has the same blind
  spot, so it moves to the same explicit check. Adds
  `ObserverStoreBase` (twin of Python's) so `readAll`/`close` stop
  being copy-pasted into three stores.

T1-L — TS workspace-config validation and cache-as-config
- Python sets `extra="forbid"` on 13 config blocks; TS validated
  unknown keys in two places and copied everything else through, so
  `mount_point:`, `consistancy:` and `limitt:` were hard errors in one
  language and silently ignored in the other. Adds per-block
  allowed-key tables for the top level, mounts, cache, index, store
  (+ groups), clis and guards, checked before normalization and
  against Python's canonical snake_case spellings — a camelCase alias
  TS would accept for free is just as unportable as a typo. Guards now
  fail at load, as in Python. The local `snakeToCamel` (which diverged
  from core's on uppercase-after-underscore and trailing underscore)
  is replaced by the exported one.
- `store: {type: disk}` silently built a RAM state store, so state a
  user believed was persisted was not; disk and s3 groups are now
  wired, matching `_build_state_store`.
- `WorkspaceOptions.cache` took a built store where `index` takes a
  config, so a programmatic TS consumer could not ask for a redis file
  cache declaratively. Adds `core/workspace/workspace/cache.ts`
  (`buildFileCache`, twin of `workspace/workspace/cache.py`) plus a
  `registerFileCacheStore` seam — core cannot import node, so the
  redis store registers itself the way runtimes already do — and
  widens the option to `FileCache | CacheConfig`.

Verified with a shared `integ/fixtures/config/rejected.json` both
suites assert against, so a config accepted by one loader and refused
by the other fails a test until they agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 05:31:09 -07:00
Zecheng Zhang e92842a8a3 feat(runtime): resolver protocol (R3), nested-mount integ, find fan-out fixes (#749) 2026-08-10 05:01:16 -07:00
bytecii 42c8423e83 fix(integ): route the python runner through the async resource builders (codex)
Two call sites outside python/ that the first pass missed, both required
CI jobs:

- `runners/python/adapters.py` built its github mount with
  `GitHubResource(GitHubConfig(...))`, which now raises TypeError against
  the tree-taking constructor before a single case runs — the whole python
  github battery was unusable. `GitHubService.resource` and `build_github`
  are async; `build_mounts` awaits whatever the builder table returns, so
  the other forty builders stay plain sync functions.
- `integ/root.py` indexed and splatted the coroutine from
  `to_workspace_kwargs`, failing the virtual-root job with
  `TypeError: 'coroutine' object is not subscriptable`.

Also corrects two comments (py adapters + the ts twin) that justified the
out-of-process github fake by "GitHubResource fetches the repo tree with a
blocking urlopen from its constructor". That is no longer true; it stays
external because both hosts share the one fake.

Verified locally against the CI fake (`--port 5098`, same --repo args):
github 20/20, github_ci 11/11, ram 2332/2332, disk 2297/2297,
ram-alias 8/8 (the alias_of branch), and `integ/root.py` all checks OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 04:33:03 -07:00
bytecii 90e564273d fix(github): build resources asynchronously, delete the sync HTTP path (#609 T1-J)
`GitHubResource.__init__` fetched the repo's default branch and its
recursive git tree before returning, over `urllib.request.urlopen` — the
only blocking HTTP call in the package. A constructor cannot await, which
is the entire reason that sync client existed.

It is reachable on a live event loop: `server/routers/workspaces.py`'s
`async def load_workspace` calls `build_resource` synchronously, so
mounting a github repo froze the daemon's loop for two GitHub round trips
— every other mount's in-flight I/O and the FUSE queue stalled with it.
Measured against a deliberately slow local GitHub, a 1.24s build let a
50ms heartbeat coroutine tick once; it now ticks 24 times over the same
1.24s.

TypeScript already had the answer, but not the one the audit recorded:
`GitHubResource.open()` there is a no-op, and the work lives in
`static async create` behind a private constructor, with the whole
`ResourceFactory` type declared `(config) => Promise<Resource>`. Mirror
that rather than the `open()` hook — a lazy hook would move the failure
from build time to first path resolution, which is a new divergence, not
a fix.

- `BaseResource.build`: async classmethod factory, default just calls the
  constructor. Named `build`, not TypeScript's `create`, because `create`
  is already an op name (make an empty file, what `touch` calls) and ops
  are served by `__getattr__` — a real `create` on the class shadows every
  backend's create op. Caught by the databricks_volume suite.
- `build_resource` is now async and awaits that factory through
  `_instantiate`, which falls back to the plain constructor: registered
  and entry-point resources need not subclass `BaseResource`.
- `WorkspaceConfig.to_workspace_kwargs` follows, matching TypeScript's
  already-async `configToWorkspaceArgs`. `Workspace(**kwargs)` stays sync.
- `GitHubResource.__init__` takes the fetched tree and touches no network;
  `GitHubResource.build` does the two fetches over the existing async
  `github_get`. `github_get_sync`, `fetch_tree_sync` and
  `fetch_default_branch_sync` are deleted.
- New `tests/resource/test_no_blocking_http.py` fails on any `urlopen`,
  `urlretrieve` or `requests` import under `mirage/`, including
  function-local ones.
- Docs updated on both sides; the TypeScript github pages showed
  `new GitHubResource({...})`, which has never compiled against its
  private constructor.

Snapshot load is untouched: github's state redacts its token, so
`requires_resource_override` always sends it down the live-override path
and never reaches `_construct_resource`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 04:33:03 -07:00
Zecheng Zhang 7fbe3aedb8 fix(notion): delete verb, one trash bit, row cells, and stale docs (#747)
DELETE /v1/blocks/{id} is the only delete verb the public API has and the
only one the MCP tool surface exposes, but the fake had no route for it
and `ntn api` had no DELETE method, so nothing could remove anything.

`archived` is upstream's deprecated alias for `in_trash` and always
returns the same value; the fake stored two columns, so a PATCH of
`archived` left the row in database queries while `ntn pages trash`
(in_trash) worked. Now one stored bit, both spellings on the wire.

A database row's cells now ride in its page.json under `properties`.

Also: trashing a child page moved only the page row, leaving the
child_page block in the parent listing; `datasources query` took its
TSV columns from the schema where upstream takes them from the returned
rows; and two MCP-parity cases pointed at pre-data-source paths, so they
compared two identical errors and asserted nothing.

Docs: both ntn.mdx files documented the pre-#740 grammar (--page flags,
ntn blocks/comments/search). notion.mdx, the TS setup doc and both
examples predated the data-source split; the examples were broken.
2026-08-10 02:42:29 -07:00
Zecheng Zhang 003753c21a feat(python3): parse CPython's option table, and seed sys.path on pyodide (#743)
* fix(ids): stamp uuid7 from the clock, not from the previous id

uuid6's uuid7 orders ids inside one millisecond by bumping the
timestamp past the previous one. The borrowed value becomes the next
call's floor, so the error compounds: a burst of 5000 ids stamps every
later one 4991 ms into the future, and it stays there until the clock
catches up. These are workspace and session ids, used as time-ordered
database keys.

Mint the id here instead, with RFC 9562's dedicated counter in rand_a:
48-bit timestamp, 12-bit intra-millisecond counter, 62 random bits. The
timestamp is only ever a real clock reading, and an exhausted
millisecond waits for the next rather than borrowing.

Drops the uuid6 dependency, which nothing else used. TypeScript already
had this right, via the uuid package's counter.

* feat(python3): parse CPython's option table, and seed sys.path on pyodide

python3's spec carried one option, -c, and a free-text rest, so every
other switch fell through to the operand slot: 'python3 -u s.py' ran
'/-u' as the script and failed, 'python3 -zz -c ...' exited 0 with the
flag swallowed, and -V and -h were read as paths.

Two spec knobs fix the class rather than the command. stop_at_operand
turns off the parser's dash-operand leniency and ends option parsing at
the first operand, which is CPython's own rule; ends_options marks the
options that carry a program (-c, -m), so the words after them are the
program's argv rather than python3's. Both mirror in TypeScript.

On top of that: -m runs through runpy, with a find_spec probe so a
missing module is CPython's one line instead of a traceback; the four
source doors each report the argv[0] CPython gives them; and the
interpreter-init switches ride in RunArgs.flags, honored where the
engine can and reported on stderr where it cannot.

argv[0] needed one more piece. The local and wasi tiers hand CPython
the program through -c, so it hardcoded argv[0] to '-c' and named every
frame '<string>'. bootstrap() re-compiles under the real name, which
fixes argv[0] and the traceback filename together.

Pyodide gains sysPath, which glob-expands and prepends mount paths once
the mounts are in place, so a vendored wheel imports without the
sys.path.append incantation. A glob that matches nothing is reported on
the run's stderr; the seed cannot report it itself, since it runs
before stderr is captured. packages, packageBaseUrl and lockFileURL
reach a prebuilt distribution, and the wrapper honors -O, -B, -W, -X,
-E and -I.

Verified against CPython 3.13.7: all 14 probed lines match on stdout,
stderr and exit code.

* refactor(spec): borrow argparse's REMAINDER instead of two bespoke parser knobs

python3's line needs opposite treatment of an unknown dash word on
either side of the script: `-zz` before it is python3's usage error,
`--foo` after it is data the script must receive. The first pass added
CommandSpec.stop_at_operand and Option.ends_options to say that, which
put two per-command dialect fields on dataclasses every command shares.

The first half is not ours to invent: it is argparse's
nargs=argparse.REMAINDER, and POSIX's own option order (GNU's permuting
default is the extension, which `POSIXLY_CORRECT=1 ls a -1` shows).
argparse spells it on the positional slot, so it goes on Operand under
argparse's name, and CommandSpec is untouched.

The second half argparse cannot express at all: `add_argument('-c')`
beside nargs=REMAINDER answers `unrecognized arguments: -u`, and CPython
parses its own command line in C for that reason. So it stays out of the
grammar. POSIX already spells the handoff `--`, and the parser already
consumes one, so a table in workspace/route inserts it after -c/-m's
value, beside the other command-name-keyed line rules. The marker is
added unconditionally: CPython passes a typed `--` through as data
(`python3 -c p -- -u` gives the program ['-c','--','-u']), so the parser
eats exactly the one added here.

Net: Option and CommandSpec return to what they were, Operand gains one
borrowed field. CLAUDE.md now states the rule as a literal test, since a
CLISpec is a CommandSpec and cannot be used to escape it.

* docs: require POSIX and argparse, not either, for a shared grammar field

POSIX alone specifies an option whose argument is a program (sh -c, and
python3's own synopsis), so an either-or rule would license the Option
field the section exists to refuse.

* fix(python3): address review on init switches, and declare the four missing ones

- pyodide restores warnings.filters after a run, so -W error no longer
  follows every later line on the warm interpreter
- -OOO saturates at compile()'s max of 2 rather than raising ValueError
- -E/-I stop deleting PYTHON* from os.environ: CPython's -E stops those
  variables configuring startup, it does not hide them from the program
- pyodide reports -E/-I/-s/-S through unhonoredNotice instead of faking
  them; unhonored() grew a honored subset, and now reports -OO too
- declare -b, -P, -x and --check-hash-based-pycs, which CPython accepts
  and mirage was refusing. -x is answered by the source resolver, since
  handing it to an engine that runs code via -c would honor it nowhere
- end_options_after_program walks a short cluster letter by letter, so
  -uc 'p' -v hands -v to the program, and steps over long value options

* fix(integ): argv[0] for a payload is -c on every engine, monty included

Monty's DEFAULT_PROG placeholder only applied because nothing ever told
it a name. The python3 command now supplies CPython's own answer, so
these four policy-routing steps read -c. They still distinguish monty
from local, since argv is a monty global and CPython would NameError.

Also picks up the formatting of the new flags test: it was untracked
when pre-commit ran, and --all-files goes through git ls-files.

* fix(python3): do not rewrite a shadowed name, and stop over-claiming -W and -X

- the `--` handoff is skipped when a shell function shadows python3.
  bash's rule gives the function the line, and it has no CPython option
  table to read a marker with. `command python3` masks the function for
  its inner run, so the rewrite applies there again. A CLI cannot reach
  this at all: register_cli refuses a shell builtin's name.
- an invalid -W filter is reported as CPython reports it and the program
  still runs, instead of _OptionError escaping the wrapper and killing a
  line every other runtime completes.
- a known -X name is reported as unhonored, since populating
  sys._xoptions is all a warm interpreter can do for one. An arbitrary
  name stays silent, which is all CPython does with it either.
- -O's reach is written down: the payload is compiled at the requested
  level, a module imported from sys.path is not, and sys.flags stays 0.
2026-08-10 02:07:12 -07:00
bytecii 9a86bf72ff Merge pull request #746 from bytecii/feat/box-dropbox-io-slots
fix(box,dropbox): wire the du slot in python, drop the fake find slot in ts (#609 T1-I)
2026-08-10 00:39:31 -07:00
bytecii 460ae8ac37 Merge pull request #745 from bytecii/fix/tee-operands-and-append
fix(tee): write every operand, and use the append slot (#609 T1-I)
2026-08-10 00:38:56 -07:00
bytecii 0e0029d3aa fix(tee): diagnose any backend write failure, not just OSError (codex)
`write_output` caught `OSError`, so a write that failed with anything
else aborted the whole command and the remaining operands were never
attempted — even in the default warn mode, where GNU keeps going. That
is every remote backend: `core/s3/write.py` forwards botocore's
`ClientError` out of `put_object` and `core/gridfs/write.py` pymongo's
`PyMongoError` out of `upload_from_stream`, and neither is an `OSError`.
The TypeScript twin catches whatever is thrown and classifies it, which
is why only python had the hole.

`error_line` already told the two apart (shared strerror for a
recognized filesystem refusal, the exception's own message otherwise),
so only the catch had to widen. `Exception`, not `BaseException`, so
cancellation still propagates. Not a swallow: every caught error is
named on stderr and the command exits 1.

The test sink now raises a non-OSError, mirroring the TypeScript sink's
plain `Error` — that alone turns four existing cases red on the old
catch — plus a case pinning the multi-operand diagnostic for an SDK
error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:01:59 -07:00
bytecii 1d8fb1cb53 fix(box,dropbox): wire the du slot in python, drop the fake find slot in ts (#609 T1-I)
Finishes T1-I, the `CommandIO` slots that are wired on one side only.

du (python gains it): `core/box/du/` was written and unreachable —
imported by its own `__init__` and its tests and nothing else — and
dropbox had no python du at all, so both took `DU_BUILDER`'s walk bounded
by `max_du_entries`. Past 10 000 entries that prints a partial total, a
stderr notice and exit 1. Ports dropbox's du beside box's and wires both,
matching the typescript tables.

find (typescript loses it): `core/box/find.ts` and `core/dropbox/find.ts`
were not pushdowns. Neither API has a search call to push to, so both
were the *same* readdir/stat walk the find and cp builders already run —
minus three things the builders pass and an op cannot:

  - the mount's own index (they allocated a scratch `RAMIndexCacheStore`,
    so every walk started cold and warmed nothing for the next command),
  - the namespace stat overlay, because the native branch only applies it
    when `ops.local === true` and both backends are remote,
  - the symlink table `-empty` counts entries from.

The overlay half is a live divergence: `touch -d 2020-01-01 f` then
`find -mtime +5 f` finds it in python and does not in typescript. Pinned
by extending find/mtime.json's four mtprobe cases to box and dropbox;
they fail on pre-fix typescript on both targets and pass after.

The stale comment that justified the ops ("the cp builder may call find
without threading an index, unlike python") is refuted by cp.ts:47, which
reads `opts.index` and passes it to its own fallback walk.

Leaves box/dropbox with one command_io exception each (python range-reads,
typescript's core read takes no window) instead of three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:26:15 -07:00
bytecii 74eba0145a test(ops): record ssh's append op in the inventory
Wiring `append` into python's ssh CommandIO makes the ops factory emit
an `append` op for ssh, which is what typescript has emitted all along
(`node/.../ssh/io.ts:55`). The expected table is a hand-kept literal, so
the new row has to be added by hand; the other three backends that wire
append (disk, ram, redis) already carry theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:21:35 -07:00
bytecii 80f096a9f4 fix(tee): write every operand, and use the append slot (#609 T1-I)
`tee`'s spec declares `rest: {type: "path"}`, so the parser accepts N file
operands — and both generics wrote only `paths[0]` and silently dropped the
rest. `tee a b` put nothing in `b`. Identical in both languages, which is
exactly why no parity gate saw it: a shared bug is not a divergence.

GNU 9.7, pinned in docker: `printf x | tee a b c` writes all three; a failing
operand is diagnosed and skipped while the others still get the data (exit 1);
only `--output-error=exit` stops at the first failure. That last mode had never
been implemented because with one sink it was unobservable — the old comment
said as much — so it was parsed and ignored.

Deliberate divergence, documented at the call site: GNU opens every operand up
front, so under `exit` an *open* failure aborts before anything is written. A
mount has no open/write split, so operands before the failure are already
written. The two agree whenever the failure is at write time, which is what a
remote backend reports.

The `append` CommandIO slot is now wired into the generic, per the decision to
wire rather than delete it. It was declared on both adapters, wired by five
backends, and read by no builder, so `tee -a` was read-modify-write everywhere
— on ssh that meant downloading and re-uploading the whole file plus a
lost-update window. A backend with the slot now appends; the rest keep the
fallback. Where the native path runs, the resulting content is not in hand, so
the path is reported in `writes` but not in `cache`: that is how apply_io is
told to drop the stale entry instead of caching a wrong one. The slot was typed
`(...args: any[]) => unknown`, which is what let five backends wire something no
builder could consume; it is typed like `write` now.

Found while wiring it: python's `core/ssh/append.py` existed with no importer
outside the resource, so python ssh took the read-modify-write path its
typescript twin did not. Wired.

Two more real bugs on the way through:

- TS `tee -a missing-file` threw instead of creating the file. The not-found
  test was `/not found/i.test(err.message)`, but `enoent()` puts the *path* in
  the message, so it never matched — verified by running it. Python's
  `except FileNotFoundError` was always correct. That broken test is why s3 and
  gridfs had bespoke tee wrappers carrying an `exists()` pre-check (with a
  `catch {}` that swallowed everything) to route around the generic. All four
  wrappers — s3 and gridfs in both languages — are now wiring only, which fixes
  their multi-operand bug in the same edit.
- `isEnoent` existed in three copies (`core/generic/find.ts`,
  `workspace/reconcile.ts`, `executor/builtins/metadata.ts`) and in none of them
  was it next to `isFsError`. One copy now, in `utils/errors.ts`.

Verified: three integ cases across 17 targets, each proven red on pre-fix code
and green after, on both hosts — the multi-operand pair fails as `DUP|` (first
operand only) and `tee -a` on a missing file fails TS-only. `xm_tee_multi`
passed all along because it is cross-mount: one operand per mount, so each
backend tee wrote its single `paths[0]`. Full core/node/browser vitest and the
python suite green; specs regenerate with no drift and the parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:43:56 -07:00
bytecii e3b1394d87 Merge pull request #742 from bytecii/feat/registry-and-flag-values
feat(spec): gate resource capabilities and CommandIO slots (#609 item 4)
2026-08-09 21:08:13 -07:00
Zecheng Zhang 2630fb43ab fix(notion): normalize written properties against the data source schema (#744)
* fix(notion): normalize written properties against the data source schema

The fake echoed the client's property object back, so a PATCH that omitted
`type` (which the API allows) stored an untyped value that its own reader
rendered blank. Fill id and type from the column and resolve a select name to
the whole option.

* fix(notion): persist a minted select option into the schema

The answer's option id was only usable once. A later write naming that id
alone missed the schema lookup and came back with an empty name, which is the
blank cell this PR is about. Add the option to its column when it is minted.

* fix(notion): queue a workspace's writes at the door

Two writes to one workspace interleave at any await, so both could read the
same schema and the later write would drop the other's minted option while the
page that minted it kept the id. Serialize every mutating request per
workspace; GET stays concurrent.
2026-08-09 20:59:34 -07:00
bytecii cc91155480 fix(s3): browser ranged reads, EOF windows, and a node/browser gate
Resolves both codex findings on #742, plus the regression they framed and a
divergence found alongside it.

The presigned-fetch shim (core/s3/_client_browser.ts) reads only `Key` off a
GetObject input and drops `Range`, so #742's `readRange: rangeOf(s3Read)`
returned the *whole object* as the requested window — on all fifteen browser
S3-family resources (s3, r2, gcs, minio, ceph, backblaze, wasabi,
digitalocean, scaleway, supabase, aliyun, tencent, oci, qingstor, seaweedfs).
Before #742 the ops factory took the slice fallback and answered correctly.

`core/s3/stream.ts` now owns a `readRange` that branches: with the AWS SDK it
asks for one ranged GET as before, and on the presigned path it streams and
stops at `offset + size`. Adding a real Range header there is not the fix —
the header makes the request non-simple and trips a CORS preflight presigned
deployments generally do not allow — so the shim now *refuses* a Range it
cannot serve instead of answering wrongly. Stopping early only saves the bytes
if the transfer stops too: `bodyFromResponse` released the reader's lock
without cancelling, so the rest of the object arrived anyway.

Codex C2, unsatisfiable ranges: a window at or past EOF is an empty read under
POSIX and a 416 over HTTP, so wiring a native range changed the answer for the
same call. Normalized once per language in the ops factory rather than in each
reader, keyed on a shared `isUnsatisfiableRange` / `is_unsatisfiable_range`
that reads the botocore, aiohttp, httpx and aws-sdk-js error shapes. The node
S3 mock sliced silently instead of refusing, which would have let the fix look
correct without being tested; it now answers 416 like the real thing.

Codex C1, node vs browser: `merge_variants` resolves the two runtimes by
preferring node, which turned a divergence into a silently discarded value.
`check_variant_facts` now diffs the two variants for both resource-fact tables
*before* that merge, with its own stale-checked `variant_resource_facts`
exemption table. It found thirty real divergences: the browser S3 resource
declared neither `sizesAlwaysKnown` nor `storageId` where its node twin
declared both, so a browser `mv` between two mounts of one bucket saw two
separate storages. Fixed rather than exempted — the browser class gains both,
and the identity now comes from a shared `s3StorageId` so the runtimes cannot
compute different answers for one bucket. Zero exemptions needed.

Found alongside: TS `rangeRead`'s fourth argument is end-exclusive on ssh, hf,
nextcloud and databricks_volume, and in every python twin, but s3 and gridfs
read it as a length — `range_read(p, 10, 20)` returned twenty bytes from
offset ten where python returned ten. Both corrected. s3's inline Range
builder also now goes through the shared `rangeHeader`.

Verified: core/node/browser vitest and the full python suite green, both spec
trees regenerate with no drift, and the parity gate proven red on a planted
node/browser divergence and on a stale variant exemption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:43:17 -07:00
Zecheng Zhang 798c7d62b7 feat(workspace): merge mount structure into the door's readdir and stat (R1) (#739)
* feat(workspace): merge mount structure into the door's readdir and stat (R1)

* fix(structure): gate synthetic door answers, tighten bridge LIST errors, drop the flagged regexes

* test(integ): root ls truths carry the trailing newline the door renderer emits

* fix(structure): synthesize link ancestors like mount prefixes, feed one union to the door and the ls fact

* fix(structure): filter link names by owning-mount grant, serve granted structure under an ungranted parent

* fix(structure): ls lists structure-only dirs, nested-mount preload fails loud, fs.ts fallback gates with the synthetic prefix

* fix: fan-out gates on raw descendants, nested-mount rename raises EXDEV in pyodide

* refactor: rename ops/structure to ops/namespace_view, promote norm_dir into path utils

* fix: ls serves namespace-only directories under -R and -d, fan-out threads the child-mounts fact
2026-08-09 19:54:24 -07:00
Zecheng Zhang 415e26d6c6 feat(notion): land the fake's writes, migrate to data sources, gate ntn against the real CLI (#740)
* feat(notion): rebuild the fake on Prisma, migrate to data sources, align ntn to upstream

The fake Notion server is rebuilt on Prisma and its writes now land, so a
create is readable back through the mount and through the CLI. Slack's
writes never landed either; fixed the same way.

The client moves to Notion-Version 2025-09-03, the generation that split a
database into a container plus one or more data sources. The database object
no longer carries `properties`, so the mount nests to match: the schema and
the rows live under `databases/<Title>__<id>/<Name>__<ds-id>/`.

`ntn` is realigned to the real CLI verb for verb. `blocks`, `comments` and
`search` were mirage inventions and are gone; they are `ntn api v1/...`,
which subsumes the whole REST surface offline. Added `api`, `whoami`,
`auth token` and `datasources resolve`.

Every case in integ/cli/ntn.json is now asserted twice: once inside a mirage
workspace and once as the same shell line run by the real npm ntn binary
pointed at the same fake. A golden both agree on is by construction what the
official CLI prints. It caught five wrong goldens and one real fake bug
before they could be committed as correct.

* chore(spec): regenerate the spec snapshots for the four new fields

Operand gained `name` and `required`, Option gained `metavar` and `env`, and
all four serialize into every command's spec JSON. The TypeScript generator
had to learn to emit them too, or python and typescript specs diverge on all
93 commands.

* fix(ntn): address the codex review, and correct the fix it suggested

Four P2s, all real.

- TypeScript declared --filter-file and never read it, so a query with one
  silently returned unfiltered rows. It now reads the path through the op
  dispatcher, the way Python does and the way himalaya reads --attach.
- Both languages recognised `Header:Value` inputs and then discarded them.
  Probed on the wire against the real binary: it sends them. Threaded through
  as per-request headers in both.
- Malformed --data. Codex asked Python to converge on TypeScript's exit 2
  usage error; probing the real binary shows neither was right, so both now
  answer exit 1 with `error: Invalid JSON from --data`, and a conformance
  case pins it.
- Env-backed options were filled after validation, so an option declaring
  both `env` and `required` could not be satisfied by a populated variable.
  The fill moved ahead of validation in both languages.

ntn api had an empty test file, which is why the first two survived. It now
covers classification precedence, headers, the body sources and the output
serializer.

* fix(ntn): satisfy CI's flake8 and the test-inclusive typecheck

E129 on a wrapped return annotation, and a ByteSource|null decode in the new
api test. Both are CI-only: the repo's flake8 is stricter than the local hook,
and `pnpm -r typecheck` covers test files that a bare -p tsconfig run skips.

* fix(ntn): match the real CLI on api body sources and JSON parse errors

`ntn api` diverged from the upstream binary in ways the conformance
harness could not see, because no case covered them.

- inline `path:=json` failures now carry serde_json's own message, from
  a new scanner that also decides validity (python's json accepts NaN
  and serde does not). Columns count bytes, newlines reset them, and
  the recursion limit is 128.
- no object check on a body: `--data '[]'` is sent, not refused, and
  any body source makes the call a POST, so `--data '{}'` no longer
  falls back to GET on falsiness.
- stdin is a body source, and the three sources are validated in
  upstream's order before the conflict is reported.
- refusals exit 1 for a malformed body and 5 for a line the CLI will
  not interpret, both with upstream's hint lines.
- `name==value` survives a non-GET instead of being dropped.
- typescript gains PUT, which python already had.

Every byte is probed against ntn 0.21.9 and gated by 7 new conformance
cases, which the real binary is run through too.

* test(ntn): pin that the scanner never hands the engine parser a failure

`api` calls json.loads / JSON.parse only on input the serde scanner
accepted, so a document the scanner passes has to parse. A gap there
would put a raw parse error back on the command's error path, which is
what the scanner was introduced to remove. Checked over 8,145 generated
documents; the list here is the readable subset.

* fix(cli): answer group refusals in the CLI's own dialect, and let the environment through the parse

Three findings from the codex review.

- A group-level option refusal always exited 129 with git's wording,
  even for a CLAP program whose own leaves exit 2. The style now picks
  both: clap answers `error: unexpected argument '--x' found`, one usage
  line and a footer, at every level of the tree. Probed against ntn
  0.21.9 and pinned by three conformance cases.
- An option's declared environment variable was filled after the parse,
  so an env-backed int went unvalidated, an env-backed choice untested
  and an env-backed path stayed a raw string that FlagView.as_paths()
  could not see. It now lands inside the parse, exactly where a default
  does and just ahead of one, so it gets the same coercion and the same
  required credit. apply_env is gone.
- `ntn pages edit --json` threw the PATCH response away and printed the
  page id, on the one flag that asks for the response.

The clap renderer moved to the spec layer: the walk needs it and cannot
import a module that reaches the workspace.
2026-08-09 17:27:59 -07:00
bytecii 92a9196ac9 feat(spec): gate resource capabilities and CommandIO slots (#609 item 4)
Registry membership only ever said a backend could be *built*. What it
does once mounted — how stale a listing may be, whether reads are cached,
whether a `du` is pushed down to the API — was a second hand-maintained
surface with no gate at all, which is how python served up-to-ten-minute
stale listings of a live postgres schema while typescript pinned 0.

`spec/*/resources.json` now carries two more tables, diffed by
`check_spec_parity.py` with the same one-fact-per-exemption rule the
command checks use:

- `capabilities`: per registry name, `index_ttl` / `caches_reads` /
  `supports_snapshot` / `sizes_always_known`, plus whether the class
  overrides `storage_id` and `statfs`.
- `command_io`: per backend command package, the wired `CommandIO` slots
  plus `local` / `max_glob_matches` / `max_du_entries`.

Python reads both off its classes and dataclasses. TypeScript reads them
from the source declarations (`scripts/resource_facts.ts`), because the
twins are instance fields and observing them at runtime would mean
constructing the resource — and construction is not inert:
`buildResource('github', {})` issues an HTTP request and `postgres` opens
a connection. A value the extractor cannot read as a literal is dumped as
`<expr:…>` rather than guessed.

The gate found 15 live divergences on its first run. Fixed here:

- `readRange` was wired on `disk` alone in typescript while python pushed
  the window down on twelve backends, so `head -c 100` on a large object
  downloaded the whole thing and sliced. Wired on the five whose read
  already takes an `{offset, size}` window (s3, gridfs, nextcloud, hf,
  databricks_volume) via a new `rangeOf` adapter, and `gen-specs` now
  refuses to emit when a reader takes a window with no slot to hand it —
  the twin of python's `test_read_range_optin.py`, which is why python
  was fully wired and typescript was not. The check keys on `offset` *and*
  `size`: postgres pairs `offset` with `limit` to mean SQL rows.
- python github ignored `SCOPE_ERROR` and refused globs at 10001 matches
  instead of 5001; the constant had no importers.

The remaining ten are documented in `parity_exceptions.json`, each naming
one resource and one key: box/dropbox `du`+`find` and the seven readers
with no window argument (T1-I), github `supports_snapshot` (snapshot
redesign), lancedb `caches_reads` (computed per URI), notion/hf `find`
(slot on one side, bespoke command on the other), ssh `append` (wired in
typescript, read by no builder on either side).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:14:25 -07:00
Zecheng Zhang 8bd1d97069 fix(tar): drop the GPL bzip2 dependency, and parse tar with modern-tar (#741)
* fix(tar): drop the GPL bzip2 dependency, and parse tar with modern-tar

compressjs is GPLv2-or-later and sat in dependencies of the Apache-2.0
mirage-node package. Replace it with seek-bzip (MIT), which only decodes,
so a CompressionCodec may now be decompress-only and tar -cj exits 1 with
"tar: bzip2 not supported" the way browser core already answers for an
unregistered codec. Python keeps both directions through stdlib bz2.

Replace the hand-rolled ustar reader and writer with modern-tar (MIT,
zero dependency, web streams) behind the existing tar_helper seam. The
hand-rolled version truncated any name past 100 bytes instead of using
the ustar prefix field or a PAX header, so a deep member extracted to the
wrong path, and it read a PAX extension block as a member, so tar -t on
any archive GNU or Python wrote listed a phantom ././@PaxHeader row.
writeTar and readTar are async as a result.

* test(tar): cover -h, and round-trip bodies through the xz codec

-h had no test at all: the name a link member carries is the same whether
tar stored the link or followed it, so the existing symlink test could not
tell them apart. Read the archive back instead and assert the member kind,
in both directions, plus the unreachable-target case.

The xz test created and listed, which only proves headers survived. Extract
too, so the decompressed bodies are checked byte for byte.
2026-08-09 10:32:17 -07:00
bytecii 0851e54bc5 Merge pull request #737 from bytecii/chore/integ-floor-and-dead-code
refactor(integ,provisions): make the integ runner fail on zero work, and single-source the tables that existed twice (#292)
2026-08-09 10:31:16 -07:00
Zecheng Zhang 07bca1b503 fix(archive): tar and zip archive a directory, and -C re-bases the operands after it (#738)
* fix(archive): tar and zip archive a directory, and -C re-bases the operands after it

tar -cf d.tar d exited 1 with "tar: <hostpath>/d: Is a directory": every
operand went straight to read_bytes, with no isdir check and no
recursion, so the most common tar invocation there is could not work.
zip -r <dir> had the identical defect. Both now walk.

Create is a two-phase pass in each: decide every member, then write.
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 (find's walk_find, so an entry is classified
through stat and never by name), the namespace's symlinks, and the
mount table. It reports paths, never names, because naming is where the
two formats part company; the two things they disagree about in the
traversal itself are parameters, so tar passes recurse=True and
dereference=-h while zip passes recurse=-r and dereference=not -y.

A directory is its own member, so an empty one survives a round trip,
and both extractors now mkdir for one. A symlink is a symlink member
under tar (SYMTYPE, target in linkname) and under zip -y (mode 0120777).
An unreadable operand is reported in virtual path space with the
archiver's own wording, because the raw IsADirectoryError was leaking
the host path behind a disk mount.

tar -C was ignored: `tar -czf /work/out.tgz -C /work/check my_paper`
failed as "paths span multiple mounts", the same defect class as unzip
-p in #725, because the operand resolved against the session cwd and
the router then saw a phantom mount span. -C is not a flag the command
reads once, it is a chdir for the operands typed after it, so it is now
declared in the spec (CommandSpec.operand_base) and resolved by the one
component that walks the line positionally: the parser reports a base
per word and the classifier resolves each operand against it.

MountView is how a command sees mount boundaries, offered the way
LinkView is (name a `mounts` parameter, nothing else). A traversal that
renders lines gets this free from the executor's fan-out; one that
emits a single binary object cannot, which is why the archivers read
the table themselves. A descendant mount is not crossed: the mountpoint
stays an entry and its contents are dropped with GNU's
--one-file-system wording, since descending would archive by accident
what MountRootPolicy now refuses on purpose for tar, zip and cp in a
source slot.

Semantics pinned against GNU tar 1.35 and Info-ZIP 3.0 on
debian:stable-slim, including Info-ZIP's inverted defaults, its
anchored -x, its silent leading-slash strip, and "Nothing to do!"
exiting 12 with no archive written.

* chore(spec): the dumps state what a command declares, not what it defaulted to

Both generators emitted every field of every dataclass, so `truncate`,
which declares one thing (`-s`/`--size` takes a string), spent 27 lines
of spec body restating 21 defaults that read identically in all 93
files. `zip` was 198 lines for five flags, 11 of the 13 keys on each
option being defaults.

Anything equal to its default is now dropped on both sides. `type`
survives even at its default, because what a token is is the first
thing a reader looks for, and `"rest": {}` says less than
`"rest": {"type": "path"}`.

The defaults come from the dataclass fields in python and from a
default-constructed instance in typescript, rather than from a table
either side could let drift. The two must drop exactly the same keys,
and the parity gate reports every command if they do not.

This narrows that gate's blast radius rather than widening it. When
`operand_base` was missing from gen-specs.ts, python emitted the key in
all 93 files and typescript in none, so parity reported ~95 divergences
to sift. The same bug now reports one, on tar, which is the only
command that sets it.

check_spec_parity's option-diff renderer keyed options by `o["long"] or
o["short"]`, which raises once an option carries only the spelling it
declared.

279 files, 22113 lines of restated defaults gone.

* fix(archive): strip the mount prefix by scan, not by a backtracking regex

childSpec measured the backend key with `.replace(/^\/+|\/+$/g, '')`.
The trailing alternative backtracks on a run of slashes, which CodeQL
flags as js/polynomial-redos, and the input is a resource path a mount
supplies.

utils/slash.ts already has stripSlash, which walks the two ends by
charCode and cannot backtrack. Use it. slash.test.ts pins it against
the regex it replaces.

* fix(archive): the five symlink and mode bugs the codex review found

All five reproduced against a live workspace first, then pinned against
GNU tar 1.35 and Info-ZIP 3.0 on debian:stable-slim.

A symlink operand never reached the planner as a link. tar and zip were
absent from NO_FOLLOW_COMMANDS, so the router rewrote the operand
through the link table and `tar -cf o.tar link` stored a regular file
holding the target's bytes where GNU stores a symlink member of size 0.
It also skipped the planner's cross-mount refusal, since by then there
was no link left to refuse. Both archivers now lstat, and carry no
DEREFERENCE_FLAGS entry on purpose: -h and -y are the planner's to read.

Only the last -C was checked. `tar -cf m.tar -C missing x -C good y`
reported `x: Cannot stat` and still wrote an archive holding y, where
GNU chdirs at each -C and dies at the first it cannot enter. The option
accumulates now and the planner walks the list, so the first bad one is
fatal and no members are written.

Two links to one target were called a loop, and a real loop was not
caught at all. Both were the same mistake: an operand-wide `seen` set
doing detection the namespace already does properly under a hop limit.
Deleting it archives both names, as GNU and Info-ZIP do, and catching
the CycleError that resolve raises turns a genuine cycle into one fatal
problem per member with GNU's "Too many levels of symbolic links",
keeping the directory entry and exiting 2 instead of throwing out of
the planner for a bare exit 1.

The mount-root refusal denied member selectors. Under -t and -x an
operand names something inside the archive, so `tar -tf a.tar data`
was refused as busy when `data` happened to spell a mount. Gated on
create mode now, dashless first word included.

A test asserted the loop bug rather than catching it; it is replaced.
Five integ cases cover the lot end to end in both languages.

* test(archive): build the test LinkView's stat through FileStat, not a cast
2026-08-09 07:58:38 -07:00
bytecii ac1af6a8cd fix(integ): run the gate self-test where both hosts actually exist
integ-shared-py passes build-packages: false — the action's own note says
the python host needs node only for the tsx fake servers, which import no
mirage package. The typescript *runner* does import them, so the self-test
died on ERR_MODULE_NOT_FOUND there. It moves to integ-shared-ts, the job
that builds the packages; the setup action installs the python venv
unconditionally, so that job has both halves.

Reproducing it locally exposed a false pass: `strict (ts)` asserts a
non-zero exit, and a crash-on-import is also non-zero, so it went green
for the wrong reason. A smoke check now proves the runner starts (an
unknown facet exits 2 without running a case) before any exit code is read
as a verdict, and run_typescript keeps the first stderr line mentioning an
error so a failure names its cause instead of printing a bare exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:35:56 -07:00
bytecii 7f688e93a8 fix(integ): close the two gaps codex found in the strict gate
P1 — the database battery ran without --strict. My sweep that added the
flag matched single-line invocations, and integ-database's two commands
use a folded YAML scalar, so they were missed: losing mongodb, postgres,
chroma or qdrant to a renamed variable would still leave both jobs green.
Both now pass --strict; a re-scan that handles multi-line invocations
confirms every runner call in the workflow now carries it.

P2 — the gate self-test silently skipped its typescript half. The `integ`
job installs python but neither node nor integ/node_modules, so the
typescript assertions never ran while the step reported success — exactly
the report-green-having-tested-nothing failure this file exists to catch.
Adds --require-ts, which turns an absent tsx into a failure, and moves the
step to integ-shared-py, the job that installs both hosts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:08:31 -07:00
bytecii fda274d78e chore(spec): regenerate the spec trees for the three commands that gained a provision
dify/find, mongodb/rg and postgres/rg had no provision while their generic
siblings on the same backend did; routing the hand-written command tails
through the family catalog gives them one, so `has_provision` flips to true
for those three in all three spec trees.

Spec parity: 93 commands match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 05:14:47 -07:00
bytecii c22f7e9ca2 fix(integ): let a facet split across CI jobs declare its expected skips
CI caught two things the local run could not.

pre-commit: `pre-commit run --all-files` only checks TRACKED files, so
gate_selftest.py was never linted locally while it was untracked. autoflake
drops an unused import and yapf reformats it.

--strict: the core facet is deliberately split across jobs (postgres and
mongodb run in integ-database, chroma/qdrant/lancedb in integ-data, notion
and nextcloud in their own steps), so integ-shared-{py,ts} skip six
services by design and --strict had no way to hear that. Adds --allow-skip,
which tolerates a declared skip, still fails an undeclared one, and rejects
a service name that does not exist so the list cannot rot into silently
widening what --strict accepts. The two --facet core invocations name the
six; every other --strict invocation needs none. gate_selftest covers both
directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 05:03:37 -07:00
bytecii 4254be734e refactor(integ,provisions): make the integ runner fail on zero work, and single-source the tables that existed twice (#292)
Items 11, 12 and 14 of the parity plan, plus the backend half of item 11.

Item 14: both runners' floor guard is facet-only and all-or-nothing, so a
facet that loses SOME targets still exits 0 — measured at 161 -> 71 passing
with TRELLO_ENDPOINT unset, still green. Adds a `services` table to
targets.json replacing both hand-rolled service->env if-chains (13 py / 21
ts, already drifted), `--strict` on all 16 CI invocations, load-time gates
for duplicate case ids and unknown target refs, a zero-pairs floor in
parity.py, and gate_selftest.py asserting all of it on both hosts in CI.
`check` becomes additive (expect.check) so a case can pin stdout AND the
post-condition; the 15 existing cases are migrated.

The duplicate-id gate found a live bug: unix/lookup/type.json and
bash/type/* shared six ids, and parity.py keys rows by (target, id), so one
of each pair was silently dropped from the py/ts diff.

Item 11: TS Limit.aggr gets a mapped-type field table (a fifth bound is now
a compile error); with_default_provisions lands in python and is applied to
history and then to every backend. Of 39 hand-named provisions per
language, 21 were pure catalog duplication and now come from the catalog
(verified 21/21 land on the identical estimator); 14 are genuinely bespoke
and stay. mongodb/rg, postgres/rg and dify/find had no provision at all
while their generic siblings did — now pinned by new integ cases.

Item 12: cut's legacy_flags shim, cp's find_type knob, grep's unreachable
files_only arm, OpRecord.rel_path + mount_prefix, the duplicate
chainStreams, the unreachable root vitest.config.ts (which also carried
passWithNoTests: true), and the dead mongodb/postgres _provision modules in
both languages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 04:08:48 -07:00
bytecii 86cf0d515d refactor(flags): spec-bound FlagView across every command wrapper, and the three Python bugs it surfaced (#292) (#735)
* refactor(flags): every command reads the bag through a spec-bound FlagView (#292)

The 152 raw `opts.flags.x` reads under `commands/builtin/` now go through
`new FlagView(opts.flags, spec)`, which throws on a name the spec does not
declare instead of reading it as false. The eslint rule that already
enforced this for `generic/*.ts` now covers `commands/builtin/**`, so the
next one fails CI rather than shipping.

Three things the guard caught on the way in:

- `bc -l`, `date -I`, `wget -O` and `email/rg -l` each read a key the
  parser can never emit -- those shorts are disambiguated to `args_*`
  (`AMBIGUOUS_NAMES`) -- and three test files pinned the same dead shape.
- `generic_bind/provision.ts` carried the TypeScript twin of the Python
  `touch -r` bug fixed in #733: the shared write-metadata estimator read
  `r`/`R` blindly, so `touch -r REF` (a reference file, not recursion)
  reported UNKNOWN precision. Provisions now receive the invoked command's
  spec (`CommandOpts.spec`, mirroring Python's `spec=` keyword) and resolve
  by spelling, so head's `bytes` and tail's `c` need no hardcoded guess.
- `seq -w`'s string branch was unreachable: the spec declares it boolean.

cp/mv stop declaring their own flag-bag type. `parseCpFlags`/`parseMvFlags`
and the four helpers take a `FlagView` the way `parse_cp_flags` does in
Python, the local `export type Flags` is gone, and the builders and the
crossmount relays build the view with `specOf('cp'|'mv')`. The bag's value
type is the already-exported `FlagValue` everywhere -- 70 inline copies of
`string | boolean | number | string[]` across 43 files, plus the private
alias in `cross_mount.ts`, now name it.

Deliberately not widened to include PathSpec: Python's executor promotes
PATH-typed flag values, TypeScript's `command/flags.ts:91` keeps the
resolved virtual-path string, and no site in the tree writes a PathSpec
into the bag. cp's PathSpec-tolerant read stays, with a comment pointing
at the Python promotion it is reserved for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(flags): three Python bugs the raw bag reads were hiding (#292)

Converting the TypeScript wrappers to a spec-bound FlagView surfaced the
same rule violations on the Python side, and each one hid a live defect.

- **`--desc_file`/`--text_file` never worked on trello.** A PATH-typed
  flag reaches a Python command as a PathSpec (the executor promotes it),
  so `fl.as_str("desc_file")` read it as absent while the guard beside it
  used the raw `_extra.get` and saw the value -- the branch was entered
  and the file was never read. The eight trello wrappers now build the
  view and read path flags through a shared `file_operand` helper.
- **`gzip -1` compressed at zlib's default.** `extract_level` looked the
  bag up by the bare digit, but `-1` is the one digit the parser
  disambiguates to `args_1`. `gzip -2`..`-9` worked, `-1` silently did
  not. The native test passed because it only asserted `len(-9) <= len(-1)`.
- **`cp --backup --suffix=` used an empty suffix**, naming the original as
  its own backup, where GNU 9.7 falls back to `~` (pinned with docker).
  TypeScript already had this right, so the fix goes to Python and both
  sides now pin it.

`test_no_raw_flag_reads.py` only matched `flags.get(` -- the `kwargs` and
`_extra` names a wrapper collects the same bag into slipped past, which is
how all of the above survived. It now matches all three (and exempts
`config.py`, which answers --help/--version off the bag as spec layer).
qdrant/lancedb stop fishing `cwd` out of `_extra`, curl's `-o` and unzip's
`-d` stop claiming to be `str`, and CLAUDE.md records the PathSpec rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(integ): pin gzip -1, cp/mv empty --suffix=, touch -r provision (#292)

The three flag fixes earlier in this branch were covered by unit tests
only, so nothing proved them across the 20 backends the battery sweeps.

- `unix/gzip/1.json` mirrors `9.json` for the one digit the parser
  disambiguates to `args_1`, which is the one that was broken.
- `cp`/`mv` grow an empty `--suffix=` case pinning the GNU 9.7 fallback
  to `~` (docker-pinned), next to the existing `.bak` cases.
- `unix/provision/basic.json` grows `prov_touch_r`, which sits between
  `prov_rm_r` (unknown) and `prov_mkdir` (exact): before the fix the
  reference file read as recursion and the estimate degraded to
  UNKNOWN. Its op count is 2 -- the provision layer scopes every
  PathSpec in the command line, reference file included -- and both
  hosts agree on that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(integ): serve trello from a shared Prisma fake, the way slack is (#292)

Trello was the last write-capable service whose fake ran per-host: the
Python runner started `server/trello_server.py` in-process while CI
started a second copy of the same file for the TypeScript runner. Two
processes seeded from one fixture agree only as long as nobody edits
one of them, and neither host could observe what the other wrote.

`server/trello.ts` replaces it, built the way `server/slack.ts` is:
Prisma + SQLite, `db push` into a fresh file per instance, seeded from
`fixtures/trello/v1.json`, one shared process both hosts call over
TRELLO_ENDPOINT with POST /reset rolling back to the fixture. Writes now
persist in a store rather than a dict, so a card created by one host is
visible to the next command instead of dying with the process.

Every collection the API returns has a caller-visible order, so each
table carries an explicit `seq` and is read back with `orderBy: seq`;
membership (card members, card labels) is rows rather than JSON arrays
so an idempotent add is a `findFirst`, matching the endpoints. Writes
draw ids from one counter and stamp one date, exactly as the fake they
replace did, so `crd_new_1`/`cmt_new_2` still number the same way.

Verified by diffing both servers endpoint for endpoint -- 27 requests
covering every route, the 404 bodies, a create/update/assign/label/
comment/unlabel write sequence, an idempotent re-add, a unicode card
name, and the reads after those writes -- all byte-identical.

The datasource env var is now INTEG_DB_URL (it backs two services, not
one) and `slack:setup` is `prisma:setup`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(trello): --desc_file/--text_file address the mount, not the URL (#292)

`resources/trello/cmd.json` only ever ran reads, so no trello write was
covered on either host. Adding that coverage found the other half of the
`--desc_file` bug this branch already fixed once: reading the flag now
works, but the path handed to the backend was still the virtual one.

`core/trello/read` keys off the mount-relative path and quotes the
virtual one in an ENOENT. Both hosts were passing the virtual path as
the key, so `/board/workspaces/...` looked for a `board` collection that
does not exist and every read missed -- an in-mount operand died with
ENOENT, an off-mount one with "Operation not supported". Python now
carries the whole PathSpec through `file_operand` and reads
`resource_path`; TypeScript, whose bag holds only a string, strips the
prefix with `mountKey(filePath, opts.mountPrefix)`. The shared
`resolve_text_input` is generic over the path shape so the reader can
take whichever one its backend is addressed by.

The same new cases caught a divergence in the API error text: TypeScript
named the failing endpoint, Python did not. Python now names it too --
an agent reading a 404 needs to know which call made it.

`resources/trello/write.json` covers create (inline desc), create into a
list, update by `--desc_file`, update by inline desc, comment by
`--text_file`, comment by stdin, comment append, assign/label/move,
unlabel, the off-mount refusal, and the 404. They run after every read
pin (seq 940010+) because they mutate the board the size pins measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 01:37:12 -07:00
Zecheng Zhang 52d307da8d chore(runtime): report a root mount in prefixes, inline the runtime names, split the monty tests (#736) 2026-08-09 00:44:35 -07:00
Zecheng Zhang fdfb8a075b fix(tar): accept GNU's old option style (#734)
* fix(tar): accept GNU's old option style

`tar xzf a.tgz` failed with "must specify -c, -x, or -t" because a first
word with no leading dash was read as an operand. It is a cluster of
option letters whose arguments follow as separate words, expanded now by
expand_old_style / expandOldStyle before anything else reads the line.

Per letter, not one -xzf token: a cluster may hold several
argument-taking letters (tar xfC a.tgz out) and may continue past one
(tar cfz a.tgz f gzips), neither of which getopt clustering can express.

* chore(spec): regenerate spec dumps for old_option_style
2026-08-08 23:33:20 -07:00
Zecheng Zhang 3b18a436df refactor(runtime): one mount op vocabulary for the typescript sandbox runtimes, and serve pyodide from an Emscripten filesystem (#732)
* refactor(pyodide): serve mounts from an Emscripten filesystem, not a python shim

Replace the 425-line python source string the runtime injected with a
filesystem mounted through pyodide.FS.mount, so interception moves below
the interpreter instead of rebinding names inside it.

This fixes two bugs the shim shipped. os.open/os.write/os.fdopen and a
bare os.truncate recorded nothing, because the shim rebound 13 names and
those were not among them, so a low-level write applied to guest memory
and was dropped at exit 0. And a cross-mount rename raised errno 18,
which is EDOM under pyodide's musl numbering, so a guest comparing
against errno.EXDEV (75) never matched; the old test asserted the
message string python rendered from that same literal, so it could not
fail.

Every prefix is re-seeded on every run. The conformance rows covering
post-boot seeding only passed before because the shim's lazy backfill
used run_sync, and vitest enables JSPI while production does not.

A file the mount lists but will not serve now becomes a node that
refuses to open with EIO, rather than a hole an append would fill by
replacing the file.

* refactor(pyodide): name the filesystem package vfs, not fs

Mirrors the python side, where the module holding the guest filesystem
is vfs.py beside runtime/vfs.py. Leaving the package as fs/ while its
entry module became vfs.ts would have kept the mixed spelling the
rename is removing.

* refactor(pyodide): name the sentinel and the whence values

Number.MAX_SAFE_INTEGER appeared five times as the has-not-written-yet
sentinel, and llseek compared whence against bare 1 and 2. Both are now
in constants.ts beside the mode bits, matching the python side where
READONLY_HINT moved to wasm/constants.py.

* refactor(runtime): one mount op vocabulary for the typescript sandbox runtimes

New runtime/vfs.ts holds RuntimeVFS, planFlush and the mount routing that
pyodide, quickjs and monty each had their own copy of, mirroring the python
core. BridgeDispatchFn gains APPEND, so a handle that only extended a file
ships its tail: eight 3-byte appends now cost 24 bytes rather than 536, and
the two amplification rows in the conformance suite flip from it.fails to
it. runtime/config.ts takes coerceRuntimeConfig out of base.ts so a typed
config has the same home in both languages.

monty.ts becomes a monty/ package (binding, constants, errors, osaccess,
runtime, vfs) matching the python split, and MontyVFS gains the negative
cache python already had: monty asks whether a path exists on nearly every
guest expression, and each miss was costing a fresh listing.

mirage_bridge.ts splits into vfs/journal.ts and vfs/preload.ts, and
js/mirage_fs.ts becomes js/vfs.ts, so every module holding a guest
filesystem is named vfs. Dead MontyVFS.mountOf dropped in both languages.

* fix(pyodide): refuse a root mount, mount nested prefixes parent-first

Three review findings.

A `/` prefix stripped to the empty string, which Emscripten takes as a
detached pseudo-mount no path reaches: the guest kept reading and writing
MEMFS, and a write reported success the resource never saw. `/` is already
MEMFS's own mount root holding the stdlib, so there is nowhere to put it;
the prefix is now skipped with one warning rather than silently dropped.

prefixes() is longest-first, which is what routing wants and the reverse of
what the mount table wants: with /data/ and /data/inner/ the child mounted
first and the parent then mounted over it, orphaning the child. Unmount
deepest-first, mount shallowest-first.

MontyVFS lives as long as the runtime while python rebuilds its
MirageOSAccess per run, so the negative cache added here outlived the
command it belonged to and hid a file another writer created between two
monty runs. Reset it at the top of run and eval.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-08-08 20:20:18 -07:00
bytecii d82c6e9478 refactor(flags): delete the dead short-flag params, name the bag's type, spec-bind the TS write-side builders (#292) (#733) 2026-08-08 17:55:48 -07:00
Zecheng Zhang 1b6f5ed0a3 refactor(runtime): one mount op vocabulary for the python sandbox runtimes (#730) 2026-08-08 17:54:31 -07:00
bytecii a03f17dafc test(redis): make the env gate effective — conftest pytestmark is a no-op
pytest only honors pytestmark in test modules, so the redis suite
crashed on RedisStore's URL parse instead of skipping on machines
without REDIS_URL. The gate moves into the shared store fixture, and
the registry raw-kwargs test gets the same skipif plus the env URL
instead of a hardcoded localhost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:00:13 -07:00
bytecii 834ec80b7e fix(checksum): GNU-exact -c terminal fatals (codex round)
Two edge cases pinned against coreutils 9.7 and fixed in both
languages: 'no properly formatted checksum lines found' keys on the
parsed-line count (a malformed line next to an --ignore-missing skip
misread as it) and still prints under --status; 'no file was verified'
fires whenever --ignore-missing leaves zero OK lines — mismatches
included — follows the WARNING block, and --status silences the text
while its exit 1 stands. Also hoists the dev workspace-test helper to
module scope per the no-nested-functions rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:00:13 -07:00
bytecii fedd06146a style: pre-commit formatting + mypy/eslint fixes
dict.pop override gains the supertype's overloads (mypy), the dify
zulu formatter narrows to its number/string domain instead of
String(object) (eslint no-base-to-string), and the awk propagation
test throws from the stream factory instead of a yield-less generator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:00:13 -07:00
bytecii 09f260c7a9 fix(parity): dify detail-fetching stat in TS + real /dev/null removal (T1-O)
TS dify had ported only python's stat_light: no document-detail fetch,
so document_id, refreshed tokens/indexing_status, detail-derived
source_size, and the updated_at mtime were missing. The heavy stat now
mirrors python field-for-field (second-precision zulu mtime included),
with the same cost containment: a new opsOverrides on the generic
command factory (the TS twin of python's ops_overrides) pins ls to the
light stat, and find upgrades to the heavy stat only under -mtime.
df_prov_ls's net=0 pin stays green, and a new df_stat_mtime case pins
'stat -c %y' on both hosts.

rm /dev/null raised an uncaught KeyError('/null') in python and
silently no-opped in TS while rm -v printed a false removed claim.
Both stores are now a synthetic null/zero overlay with tombstones over
a real backing map — root-on-linux semantics, pinned in docker: the
removal succeeds and the path is genuinely gone, a later redirect
recreates it as a regular file, and writes to a live /dev/null still
vanish. /dev stays excluded from snapshot capture on both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:00:13 -07:00
bytecii bb6e715712 fix(errors): honest error classes in the read/walk command family (T1-G)
TS ls/tree/rg/checksum/awk caught every error class where python
narrows to (OSError, ValueError)/FileNotFoundError, so auth failures
and transport errors on those paths vanished from listings or were
laundered into GNU-shaped 'cannot access' lines. A new isWalkError
(isFsError + the noMount stamp, python's ValueError twin) narrows all
seven ls sites, tree's walk, rg's dir probe, and checksum -c; awk -f
mirrors python's absence-only catch. Python's own loose sites tighten
the other way: grep/rg per-entry warnings catch WALK_ERRORS instead of
Exception, and the existence probes in builders/common catch
MISS_ERRORS.

checksum -c also becomes GNU-complete (coreutils 9.7 pins): recorded
names resolve against the cwd as PathSpecs — a relative name used to
reach backend readers as a bare str and die on .virtual — and stderr
now carries the per-file strerror lines (kept under --status), the
prefixed WARNING block in GNU's order, per-line --warn diagnostics,
and the no-properly-formatted-lines / no-file-was-verified fatals.
awk -f resolves relative program files against the cwd too instead of
the mount root. The two tier2 integ pins move to the GNU behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:00:13 -07:00
bytecii 00d8a40ab5 fix(find): walk every start point on the native-op path (T1-A)
Python's generic find read paths[0] and dropped the rest, so
`find /a /b -name x` returned only /a's matches on all 12 backends
that wire a native find op (the readdir walk already looped). The
generic now owns the per-operand loop — GNU semantics pinned on
findutils 4.10.0: operand order, duplicates walk twice, a missing
operand gets its diagnostic and exit 1 while every other operand's
rows still print (the old path discarded them with the stdout).
The per-root -path tree is stamped into a local, so the shared
FindArgs stays unprefixed, and bespoke wrappers (github, chroma,
dify, history) inherit the loop through generic_find.

TypeScript looped already but re-sorted across roots at the end,
interleaving operands; the cross-root sort is gone.

integ/unix/find/roots.json pins operand order, duplicate roots, and
partial output past a missing middle operand on both hosts (22
targets; the order case provisions its own subtree since shared
directories accumulate per-target state).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:00:13 -07:00
bytecii d87201f0d8 fix(filetype): one shared extension table for stat across both languages (T1-H)
Five private TS guessType copies (box/gdrive/dropbox/gmail/email) drift
from the shared table py already uses: 8 typed extensions read as BINARY
(jsonl/tsv/py/js/ts/yaml/yml/toml), so the same file stats differently
per language. Py gmail/email guess through stdlib mimetypes, whose
platform tables make .ts video/mp2t and .gz unknown. All seven sites now
import the shared table; log->TEXT and gzip->GZIP (the private copies'
two extra keys) join it on both sides. The qdrant/lancedb 4-entry image
maps fold into a shared image_type_for_extension, and FILE_MIME_MAP
drops the parquet/orc/feather/hdf5 keys unreachable since #651.

integ/fixtures/filetype/tables.json now pins every table; both unit
suites assert equality so one side cannot drift alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:00:13 -07:00
Zecheng Zhang bec5734e6b fix(pyodide): resolve mutation paths against the guest cwd, stop the replay at the first failure
Codex review on #728.

A relative operand after os.chdir into a mount missed every prefix check,
so the mutation (and, already on main, the write) stayed in MEMFS and the
run exited 0. Paths now resolve through abspath before the mount test.

An append whose base read failed for any reason treated the base as empty,
so a file that exists but is momentarily unreadable would be replaced by
the tail alone. Only a confirmed absence starts empty now, via the
repo's own isMissingPath.

The replay continued past a failed mutation, which could apply a later
entry against a prerequisite that never landed (a rename moving a stale
temp file onto its destination). It stops at the first failure and reports
how many entries it dropped.
2026-08-08 13:45:23 -07:00
Zecheng Zhang 42d99b55c6 fix(pyodide): route guest mutations to the mount and keep appends additive
The shim patched only open, listdir, stat and scandir, so every mutation
spelling mutated MEMFS and never reached the mount, and an append-mode
open on a file MEMFS had not seen flushed its whole buffer over content
the run never read.

Patch the os primitives instead of a spelling list (makedirs, pathlib and
shutil all reach the mutation through os), record an append as its tail
rather than the whole file, and backfill on non-truncating writable opens.
Guest mutations now go into an ordered journal that the runtime replays
after the script, so a write-then-rename lands the way it ran. None of it
consults the bridge inline, so mutations need no JSPI.
2026-08-08 13:45:23 -07:00
Zecheng Zhang ae061eaba4 fix(email): bucket on the calendar date as written, not the UTC date
RFC 3501 compares SENTON/SENTBEFORE/SENTSINCE (and ON/BEFORE/SINCE)
"disregarding time and timezone", so a message written 05 Jan 23:30 -0500
answers a search for the 5th. Converting to UTC filed it under the 6th, and
`himalaya envelope search date 2026-01-05` then selected mail its own
directory did not contain.

Python drops the UTC conversion, restoring what it did before this branch.
TypeScript was always UTC and is the side that was wrong: `Date` keeps only
the instant, so the stated offset is parsed back out of the string (numeric
plus RFC 5322's obsolete zone names) and added before reading the fields. A
header with no zone at all reads its local fields, which hands back the wall
clock exactly as written.

Verified identical output between the two languages across nine timestamps
under TZ=UTC, America/New_York and Asia/Tokyo.
2026-08-08 04:25:06 -07:00
Zecheng Zhang 8f0067bef3 fix(email): bucket messages by IMAP INTERNALDATE when Date: is missing
A message with no parseable Date: header fell straight to 1970-01-01, so
every undated message collapsed into one directory and /mail/<FOLDER>/<date>/
carried no information. The header still wins (it is what himalaya's SENT*
date conditions search on); INTERNALDATE fills the hole, epoch only if
neither parses.

Metadata is now requested ahead of the body in the FETCH item list: a server
may answer items in the requested order, and anything after BODY[] lands on
the line behind the literal where the response parsers never look.

Python normalizes offsets to UTC, matching TypeScript and both gmail
backends; it previously kept the sender's offset, so 23:30 -0500 bucketed a
day apart between hosts.

INTERNALDATE is transport metadata, not part of the message, so it is
dropped in the single renderer. The five other places that serialized a
fetched message by hand (himalaya read/list/search, email rg, searchAndFormat)
now route through that renderer too, so `himalaya message read` cannot drift
from `cat` of the same file.
2026-08-08 04:25:06 -07:00
bytecii f328bc0314 fix(parity): truncate/split data-loss flag values, od radix, registry membership, and the gate that missed them (#609 Block A) (#706) 2026-08-08 02:28:52 -07:00
Zecheng Zhang 0e5473eaab test(runtime): conformance suite for runtime mount access (#727)
* refactor(runtime): declare the default captures once per language tier

* test(runtime): conformance suite for runtime mount access

* chore(deps): bump pypdf to 6.15.0 for two DoS advisories

uv audit rejects pypdf 6.14.2 over GHSA-fp3f-mc75-235c (large
/ToUnicode streams) and GHSA-fwg2-594c-jp42 (large CID font width
ranges), both fixed in 6.15.0. pypdf is transitive only —
openhands-tools -> browser-use -> pypdf, nothing in the tree imports
it — so this is a lock-only bump that leaves the rest of the
resolution untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: wrap the monty rmdir command under the column limit

flake8 caught the one-liner at 80 columns; yapf cannot split a string
literal, so it passed the formatter and failed Check PEP8. Split it at
the same point as the neighbouring rename rows, which leaves the
concatenated command byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(runtime): assert the verification command itself succeeded

An absence want was read off the stdout of a check that was never
required to succeed, so a mutation that damaged the mount could certify
itself: `ls /nope` answers exit 2 and empty stdout, and "gone.txt is
absent" holds for every x in an empty string. Verified against the
suite before the fix — a row whose verifier cannot succeed passed.

Both helpers now require exit 0 from each check before inspecting its
output. Positive wants were already safe, since empty stdout fails the
substring, but they get the clearer message too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:52:09 -07:00
Zecheng Zhang e9d69f319b fix(unzip): select archive members, exit 11 on unmatched filespecs (#725)
* fix(unzip): select archive members, exit 11 on unmatched filespecs

* fix(unzip): match filespecs bytewise, read each selected ZipInfo

Pinned against Info-ZIP 6.00 on debian:stable-slim:

- `?` stands for one byte, not one code point, so `?.txt` misses
  `é.txt` and `??.txt` hits it. Python matched code points and
  TypeScript matched UTF-16 units; both now match encoded bytes.
- A name lookup resolves every duplicate member to the last one, so
  `unzip -p a.zip dup.txt` served the same payload twice. Read the
  selected ZipInfo instead.

Test callbacks move to module scope.
2026-08-07 13:49:30 -07:00
Zecheng Zhang aa0172a87b fix(google): thread apiBase through TS resources, accept snake_case CLI config (#724)
* fix(google): thread apiBase through TS resources, accept snake_case CLI config

* fix(cli): reject dual config spellings, finish the whole-config and snake_case sweep
2026-08-07 12:30:23 -07:00
bytecii be72d87b95 feat: $'...' quoting, git log/show formats, himalaya --attach + byte-exact MIME parity (py+ts) (#723)
* feat: $'...' quoting, git log/show formats, himalaya --attach + byte-exact MIME parity (py+ts)

Shell:
- $'...' ANSI-C strings decode per bash 5.2 (docker-pinned): full escape
  table, octal/hex/unicode/control forms, segment-only NUL truncation;
  $"..." keeps plain double-quote semantics. New shell/escapes decoder,
  expansion branches, allowlist sites, dollar-quote-aware backtick scan.
- Quoted case patterns match like bash: patterns stay nodes and a new
  expand_case_pattern renders quoting into the fnmatch dialect (quoted
  segments literal, unquoted globs live, backslash escapes, expansion
  results live unless double-quoted, no word-splitting).
- export/local/declare/readonly accept quoted assignment operands; a
  bare $ word survives as a literal argument (echo $).

Git:
- log gains --all (multi-root walk with annotated-tag peeling) and
  --format/--pretty: oneline/short/medium/full/fuller presets,
  format:/tformat: templates, ~20 placeholders incl %d decorations in
  git's exact order; unsupported presets refuse honestly.
- show gains --stat (full diff.c show_stats geometry), -s/--no-patch,
  --name-only, --no-ext-diff, --format; oracle-verified byte-identical
  to real git in both languages.
- commit report counts binary files as files with zero lines (shares
  diffstat's NUL sniff); fixes a TS-only miss of mode-only changes.

Himalaya:
- --attach wires up: repeatable path-typed flag (first leaf-level
  type="path" option; python reads via new FlagView.as_paths), reads
  through inv.ops.dispatch, multipart/mixed with a content-addressed
  boundary so both builders emit identical bytes.
- TS serialization is now byte-identical to python's
  EmailMessage.as_bytes(policy=SMTP): new mime.ts ports RFC 2047
  encoded words (q/b chooser), unstructured/address header folding,
  RFC 2231 filename params, and the 7bit/8bit/qp/base64 body
  selection; fixes threading-header order, empty-subject rendering,
  and multi-line header refusal. 31 shared pins
  (integ/fixtures/himalaya/mime_parity.json) asserted by both suites,
  plus differential fuzzing against the python oracle.

Docs list the new git capabilities and remaining deliberate limits;
51 new integ cases across bash/git/himalaya targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: codex round + generalize pattern quoting, multi-line strings, MIME to core

Codex review fixes (all pinned against real git 2.37/2.54, bash 5.2
docker, and CPython 3.12):
- git format: empty entries keep their separators under format: and
  their terminators under tformat: (only an empty template is silent);
  %xHH emits a raw byte through the shell's byte-escape convention;
  bare --format gets git's own fatal while bare --pretty stays medium.
  git show's format: header drops its trailing newline the same way.
- ANSI-C \u/\U: values now ride bash's u32toutf8 (UTF-8 locale) -
  surrogate halves and values past Unicode become raw UTF-8-shaped
  bytes, 0x80000000 and past produce nothing. The previous "verbatim"
  rule (and the crash codex flagged) was a C-locale pin artifact.
- himalaya: an ASCII attachment filename holding any line break is
  refused with EmailMessage's exact error; the RFC 2231 path
  percent-encodes the same characters (new shared parity pin).

Review round (generality follow-ups to the case-pattern fix):
- expand_case_pattern is now expand_pattern and serves all three
  pattern-word constructs: case patterns, the [[ == ]] right side
  (replacing the whole-node right_literal boolean that broke mixed
  quoting), and parameter-expansion operands. Quoted operand nodes
  match literally; opaque regex tokens get a lexical scanner honoring
  single/double/ANSI-C quotes, backslash binds, and live $-refs.
  escape_glob moved beside GLOB_CHARS in utils/glob_walk (both
  languages), killing a duplicated constant.
- Multi-line double-quoted strings: the newline bytes belong to no
  tree-sitter token. TypeScript dropped them entirely and python
  collapsed blank lines; both now re-emit per row step anchored on the
  quote tokens (leading, trailing and blank lines included).
- MIME machinery out of himalaya: mime.ts is now the runtime-agnostic
  @struktoai/mirage-core utils/mime (TextEncoder + shared base64, no
  Buffer), exporting the header guard as assertHeaderValue; the fixed
  extension table lives in utils/filetype as MIME_BY_EXTENSION /
  mime_type_for beside the FileType maps in both languages. Fixed a
  silent TS-only bug found in the same file: jpg guessed IMAGE_PNG.
  python's local drain() replaced by the existing io materialize.

Coverage: 40+ new docker/real-git/CPython-pinned unit rows across both
languages, 20 new integ cases (git format band, param/test/quoted bash
categories, surrogate byte-count), one new shared MIME parity pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:29:50 -07:00
Zecheng Zhang 26c35f1d35 refactor(runtime): tiered taxonomy and TS runtime package layout (#726)
* refactor(runtime): tiered taxonomy and TS runtime package layout

* style: format runtime language import
2026-08-07 02:41:26 -07:00
Zecheng Zhang 35a4319303 fix(monty): port to pydantic-monty 0.0.19 and stop rewriting files on append (#717)
* fix(monty): port to pydantic-monty 0.0.19 and stop rewriting files on append

0.0.19 moved execution into subprocess workers: Monty became a pool,
MontyRepl is gone, and run_async moved onto a checked-out session. run()
and eval() now drive the pool, and eval() is what the policy layer runs
config-borne scripts on, so the pin is exact while the API keeps moving.

Cancelling a feed no longer stops a worker, so both paths reclaim the
worker pid on CancelledError. Without it a safeguard timeout wedged the
line forever and pool teardown blocked uninterruptibly.

Also fixes four bridge gaps: mkdir, rename and rmdir never reached the
workspace and failed with a bogus ENOENT on paths that existed; piped
stdin was discarded, and now binds as a global beside argv; and appends
re-sent the whole file every write, which made a write loop quadratic
(200 appends shipped 164 KB to build a 1.7 KB file). Monty hands the
append hook the new text alone, so it routes to the mount's append op.

Documents what a command_safeguard does and does not stop inside a
sandbox, where mirage holds only a client and the process keeps running.

* fix(monty): bump the ts binding to 0.0.19 and route the write ops

Path.mkdir, rmdir, unlink and rename declined in the monty callback, so
they applied inside the sandbox's own tree and never reached the mount.
The bridge already carried the ops; this wires them up, matching the
python runtime.

* fix(monty): address review on append fallback, pool race, and cross-mount rename

- appends fall back to the whole-file flush on mounts without the
  optional append op (S3 registers write but not append), remembering
  the mount so it costs one failed dispatch rather than one per append
- the worker pool is cached as a task, so two cold runs cannot each
  build one and leak the loser's workers past close()
- a cancelled eval session hands its checkout back instead of dropping
  a lease the pool can no longer reach
- mkdir forwards parents and answers exist_ok itself
- a rename across two mounts raises EXDEV in both languages: the
  dispatcher picks the mount from the source alone, so crossing would
  drop the source and write the target into the wrong backend

* docs(monty): correct the EXDEV rationale, monty ships no shutil

* fix(monty): reject mkdir over a file, and type the mutation errors
2026-08-06 19:16:40 -07:00
Zecheng Zhang 7fa16fe460 fix(pyodide): flush mount writes without JSPI (#720)
* fix(pyodide): flush mount writes without JSPI

* chore: retrigger checks

* fix(pyodide): address codex review
2026-08-06 19:06:20 -07:00
Zecheng Zhang db807f304b fix(find): classify walked entries through stat, drop the is_dir_name heuristics (#719)
* fix(find): classify walked entries through stat, drop the is_dir_name heuristics

* fix(test): satisfy noUncheckedIndexedAccess in the gmail find fixture

* fix(dropbox): walk the native find over a scratch index

* test(integ): pin find -type f over attachments and uploads

* test(integ): find -type f matches a pdf upload too

* ci: retrigger after the Actions outage

* fix(email): route the TS find through the generic walk so every flag applies

* chore(deps): bump h2 to 4.4.1 for GHSA-6hr6-w5qg-qmwg
2026-08-06 18:27:42 -07:00
Zecheng Zhang 97356763de feat(gws): place creates in the folder scope, and report Google's errors (#715)
* feat(gws): place creates in the folder scope, and report Google's errors

Three fixes and a mock-server sweep, all on the Google surface.

The gws CLI ignored folder_id, so a create landed in My Drive's root
even when the CLI and a gdrive mount shared one config. The editors'
create methods carry no parents field at all, so those are moved with a
follow-up Drive update; drive files create and copy just default their
parents. An explicit parents still wins.

Google reports why a call failed in the response body, but the python
client raised before reading it, so an agent only ever saw "Bad
Request". Typescript already did this correctly. The API and OAuth
error shapes are both handled.

An account CLI mutates its service by id, which no vfs path can be
derived from, so a newly created file had no cache entry to expire and
the agent's next ls could not see its own work. Write verbs now drop
the listings of the mounts their service backs, declared per spec so a
Slack or S3 mount alongside keeps its cache. This corrected two integ
goldens that had been asserting the stale listing.

The fake Google server grew the Sheets endpoints it never had
(values:batchUpdate, batchGet, batchClear, clear, sheets:copyTo, the
dimension requests), plus the matching gaps in docs, slides and drive.
gws integ coverage is now 35 of 35 leaves.

* fix(gws): address codex review on the folder-scope PR

Three findings, all real.

Cached file bodies survived a CLI write. drop_service_listings cleared
the resource index but not the file cache, and all five Google resources
cache reads, so a cat after a Docs or Sheets edit kept serving pre-write
content without reaching Google. A stale listing hides a create, a stale
body hides an edit, and only the listing was being dropped. Adds a
prefix-scoped evict_prefix to the file cache contract, implemented on the
RAM and Redis stores in both languages, exposed as CacheManager.drop_prefix
and called beside the index clear. The function is drop_service_caches now,
since listings no longer describes what it does. Redis escapes the prefix
before using it as a SCAN MATCH pattern, because a mount path may hold
glob metacharacters.

Scoped placement never sent supportsAllDrives. Every other Drive helper
in the repo sends it, and a folder scope may name a Shared Drive folder,
so both the injected parent and the relocation patch would fail there,
stranding an already created editor file in My Drive. Both paths send it
now, only when mirage is the one injecting.

An explicitly empty parents array was silently replaced. The check was a
truthiness test, so parents: [] read as absent while parents: ["root"]
was honored, which contradicts the documented rule that an explicit
array wins. Presence of the key is the test now.

Adds unit coverage in both languages for each finding, plus two integ
cases pinning the stale body end to end. The integ pair has to span two
command lines: apply_io runs once per line, so a cache warmed earlier on
the same line is never live for a later read, and a single-line case
passes with or without the fix. Documents folder scope and its two
divergences from the upstream passthrough in gws.mdx.

* test(cache): pin drop_prefix on a root mount

A root mount strips to the empty prefix, so the eviction argument is
"/" and matches every key. Correct, but non-obvious enough that a
reviewer reasoned their way to the wrong answer and back, so both
languages now assert it instead of leaving it to be re-derived.
2026-08-05 21:55:48 -07:00
Zecheng Zhang 9c2053bec7 feat(cli): author a CLI in code, by pointer, or as a script (#712) 2026-08-05 18:52:27 -07:00
Zecheng Zhang 67945abf63 feat(git): a git CLI over any mount, in both languages (#710)
* feat(git): a git CLI over any mount, in both languages

Adds `git` as a builtin CLI (issue #705 item 1): status, log, show,
diff, branch, add, reset, commit, checkout. It takes no config, and the
repository is read entirely through the mount ops, so a repo on RAM, on
disk or on an object store reads the same way. Python goes through
dulwich, TypeScript through isomorphic-git backed by a PromiseFsClient
over the dispatcher.

Status is pinned against the real git binary across 22 repository states
and 6 spellings, the mutation verbs are read back by real git and pass
`git fsck`, and the rename-similarity score matches dulwich digit for
digit. Both run in integ on `git-ram` and `git-disk` in both languages.

Also fixes the grep family this leaned on:

- grep reads a basic regular expression by default, as POSIX says, with
  -G for the default and -E for extended. `grep -l` and `grep -rl`
  compiled their own pattern and still read extended.
- zgrep does the same. Its -E flag was read and discarded.
- an operand grep could not search exits 2, as GNU does, instead of
  being flattened to 1.
- `grep -l` on a directory reports it rather than walking it. The
  shared fallback called the recursive walk whenever a failed read
  turned out to be a directory, which made -l behave like -rl.

Supporting pieces: ranged reads (`read_range`) with a read-and-slice
fallback and a seeking implementation for disk, PATH-typed group options
resolved against the working directory, and git's three unknown-option
dialects.

* fix(rg): report an unreadable operand as exit 2, like ripgrep

CI caught four goldens the grep change had missed, all outside the JSON
harness targets I had been running, plus a divergence the change itself
introduced.

The divergence: TypeScript routes rg through grepGeneric, so rg moved to
exit 2 there while python's rg, which has its own generic, stayed at 1.
Real ripgrep exits 2 for an operand it could not read, so TypeScript was
right and python is brought up to it rather than the other way round.
Its single-operand path also let the error escape to the shared handler,
which flattens every OSError to exit 1; rg now reports it itself, the
same fix grep needed.

The rule is now stated once in grep_helper (`exit_code_for` /
`exitCodeFor`) and imported by both generics in both languages, instead
of living in the grep generic where rg could not reach it.

Goldens updated: integ/cross_commands.{py,ts}, the shared observability
contract, langfuse and dify. Each pinned exit 1 for a missing operand
and carried a comment calling it a deliberate divergence.

* fix(git): refuse the three mutations that could lose work

Three review findings, each measured against git 2.50.1 before fixing.

checkout only compared tracked paths, so a branch holding a file the
working tree has untracked wrote its blob straight over it. The file is
in no index and no tree, so nothing could see it and nothing could get
it back. The untracked set is now part of the conflict check, which
needs UNTRACKED_ALL rather than the mode status uses: "normal" collapses
a wholly untracked directory to one row, and git names the file inside
it. An ignored file stays overwritable, which is git's own split. When
both kinds of conflict apply git prints both paragraphs and aborts once,
so CheckoutConflictError carries both lists.

branch -d deleted a branch HEAD does not contain, dropping the only name
pointing at those commits. It now refuses, and -D is added, because -d
alone would be a delete with no way to say no. dulwich's can_fast_forward
answers exactly this and cannot be used: it asks the repository for its
grafts and shallow boundary and a bare BaseRepo raises. Walker is what
log already walks with and needs only the object store.

add -u ignored its pathspecs and restaged every tracked file, which is
how an unrelated edit reaches the next commit. git tells two misses
apart and so does this now: a pathspec naming nothing is a fatal about
the pathspec, one naming an untracked file is a fatal about git not
knowing it, both exit 128.

Also two CI failures. The spec dumps were stale for the -G flag added
with the zgrep BRE fix. And the git fixture exported its identity as
environment variables, which reach only its own commits, so a test that
committed into the built repository failed with "Author identity
unknown" on any runner with no global identity. It now records the same
identity in the repository, leaving the object ids unchanged.

Covered by unit tests in both languages and six integ cases across
git-ram and git-disk on both hosts.

* fix(test): the redis missing-file suite still pinned grep at exit 1

Sibling of the disk suite, which was updated with the exit-2 change. This
one needs a live server, so it skips silently without REDIS_URL and the
local run never reached it. Only the grep case moves: cat, head, tail and
wc all still exit 1 for an operand they could not read, which is the
split the comment now records here as it does next door.

* fix(grep): read an operand's type from stat, not from how the read failed

Four integ jobs caught this across seven backends, and it is one mistake
with three faces. Classifying a directory operand *after* a failed read
makes the answer depend on what each backend does about reading one, and
they disagree: s3, gridfs, hf and nextcloud read a directory path without
complaint and hand back nothing, and ssh raises an asyncssh SFTP error
that is not an OSError at all, so it escaped the catch entirely and
exited 1 with an unattributed "grep: Is a directory". So the operand is
now stat-ed before it is read, which is what GNU does and what the -r
branch of the same function already did.

operand_is_directory had its own version of the same error: a readdir
that did not raise counted as a directory, and a prefix store answers
readdir for any path at all, returning nothing for one that is not
there. Every missing file on those backends therefore read as a
directory. The listing must now be non-empty to count, which costs a
genuinely empty directory being invisible there, the same divergence du
already documents and the safer way round.

The catch also widens from three exception types to WALK_ERRORS, the
tuple every other operand-tolerant walk in the repo uses, so an errno
split cannot make grep abort where tree and grep -r keep going.

One test fake resolved stat with `undefined as never` and only worked
while nothing read the value. It resolves with a real FileStat now,
which is what the postgres backend answers there; the assertion the test
exists for is untouched.

Verified on gridfs and gridfs-prefix, which reproduce the prefix-store
half locally, plus ram, disk and redis for regressions: all five targets
on both hosts, 0 failed.

* fix(git): three review findings on -C, checkout -b and reset

`-C` accepted any path that was not missing, so naming a file inside a
repository walked up and ran in the parent instead of failing the way
git's chdir does. For a write verb that means mutating a repository the
caller never named. It now refuses a non-directory with git's own second
wording, "Not a directory".

`checkout -b <new> <start>` forced the new branch to HEAD and dropped the
start point without a word, so every commit after it landed on the wrong
history. The operand is honored, and a start point that is not a commit
gets git's sentence naming both it and the branch rather than the
generic "ambiguous argument" the same lookup failure produces elsewhere.

`reset <operand>` that selected no path unstaged nothing and exited 0,
which a script reads as "the index was reset". Two different mistakes
reach that point and they now get different fatals. A typo is git's
"ambiguous argument". A revision is not: real git resets the index to any
commit named there, measured on 2.50.1, so the review's premise that git
refuses one is wrong. This build resets from HEAD only, and says which
feature is missing instead of claiming a revision it can resolve is
unknown. Recorded as a divergence in both git.mdx files.

Six integ cases across git-ram and git-disk on both hosts, plus unit
tests in both languages.

* chore: merge main and regenerate the grep and zgrep specs

#713 landed a `pair` field on the option spec while this branch was
open. CI builds the PR merged with main, so it regenerated specs
carrying the field and compared them against grep.json and zgrep.json,
the two files this branch had already rewritten for `-G`, which
therefore predate it. Every other spec file came across from main
already carrying it.

The regeneration also needs a rebuilt dist: gen-specs.ts reads the built
package, and against a stale one it dies on an Operand field it does not
know, which silently leaves the typescript half unregenerated and shows
up as a python/typescript parity divergence rather than as a build
error.
2026-08-05 13:38:03 -07:00
Zecheng Zhang 8ad5ec2a4a fix: printf byte escapes and Google Sheets cell values (#714)
* fix(shell): write a hex or octal escape as the byte it names

printf '\xc3\xa9' wrote four bytes where bash writes two: \xHH read as a
code point and was then UTF-8 encoded, so \xff came out as c3 bf rather
than the single byte it names. Octal escapes and echo -e had it too.

A byte above ASCII now rides as its surrogate escape and every place the
shell turns its own text into bytes decodes it back, which covers printf,
echo, %q, heredocs and here strings. \u and \U still name code points.

* fix(gsheets): ask for the cell values the mount documents

.gsheet.json promised cell values at .sheets[0].data[0].rowData[] and
carried none: spreadsheets.get returns no grid data unless the request
asks for it, so the file was tab metadata against real Google too, not
only against the fake server.

The fake server implements the endpoint now, with the shapes taken from
the live API: no startRow or startColumn at zero, {} for an empty cell,
numbers parsed under USER_ENTERED, row and column metadata, tableRange on
append, and the whole SheetProperties in an addSheet reply.

* fix(shell): mask a byte escape and keep a non-BMP character whole

Three octal digits reach past one byte, and bash writes the low byte of
those. The sentinel mapped the whole value instead, so 256..511 landed on
U+DD00..U+DDFF, outside the range surrogateescape can encode: printf
'\400' raised UnicodeEncodeError rather than printing a NUL. Mask to one
byte before building the sentinel.

In TypeScript the sentinel range also swallowed the low half of an
ordinary surrogate pair, so a non-BMP character with a low surrogate in
U+DC80..U+DCFF (U+10080 is D800 DC80) came out as U+FFFD plus a raw 0x80
even with no escape in sight. Only a lone low surrogate is a byte now.

The non-BMP integ case named U+1F600, whose surrogates are D83D DE00 and
never were in the sentinel range, so it passed against the broken
encoder. It names U+10080 instead, with an echo case and a case that
mixes the character with a byte escape.

Pinned against bash 5.2 in a UTF-8 locale.

* test(gws): match live Sheets on grid size and cell typing

The mock reported a fixed 1000x26 grid, so a tab holding more rows than
that claimed to hold 1000 while carrying more, and handed out one
rowMetadata entry for only the first 1000. Live grows the grid to what
was written, so gridProperties, rowMetadata and columnMetadata all read
from one extent now: 1313 written rows report 1313.

Cell typing went through Number(), which is looser than USER_ENTERED: a
whitespace-only cell became numeric zero and 0x10 became 16, where live
keeps both as strings. A plain decimal test replaces it. TRUE is a
boolean rather than a string, and formattedValue is the rendered value
rather than the raw input, so 007 reports "7" and 4.50 reports "4.5". A
number typed with an exponent keeps a scientific format, so 1e3 reports
"1.00E+03".

Verified against the live API: 18 typing cases and 8 exponent cases
match exactly. Currency, percent, thousands-separated and date-shaped
cells stay strings and say so in a comment, since they need locale-aware
number formats.

The fixture seeds a boolean, a hex-looking string and an exponent so the
typing is asserted through the mount.
2026-08-05 13:37:31 -07:00
Zecheng Zhang 745044f5d2 feat(jq): support the rest of jq's flag surface (#713)
* feat(jq): support the rest of jq's flag surface

Only -r, -c and -s were accepted, with no long forms at all. Adds
-n/-R/-j/-a/-S/-e/-M/-f/-h, every long spelling, --raw-output0, --tab,
--indent, --unbuffered, --arg, --argjson, --rawfile, --slurpfile,
--args, --jsonargs, --stream and --seq, in Python and TypeScript.

Two spec-parser additions carry it: Option(pair=True) for two-token
options like --arg name value, and Operand(text_when=...) so --args
turns later operands into positional strings instead of input files.

Also fixes -s to slurp across every operand rather than per file, and
an empty input to print nothing instead of erroring.

* fix(jq): read inputs as a call, not as the word

The detector matched `.inputs`, `{inputs}`, `$inputs`, a module member
and any string or comment, and a match switches the run into drain mode,
so `jq -c '.inputs'` over a two-document stream printed one line where
jq prints two. Both languages now blank string bodies, comments and
object-shorthand keys before searching for the token, keeping
interpolations as code. $ARGS is read the same way.

The native fixture also tolerates EPIPE on the child's stdin: `jq -n`
exits without reading, which raced the fixture's write and failed the
whole node package on unhandled errors with every test passing.

Adds integ coverage for the rest of the flag surface, including the long
spellings, the refused options and the usage errors.
2026-08-05 11:45:15 -07:00
Zecheng Zhang 619be25d32 feat(himalaya): align the CLI with the upstream pimalaya grammar (#709)
* feat(himalaya): align the CLI with the upstream pimalaya grammar

- mailbox is -m/--mailbox, message id is a positional operand
- envelope search with upstream's query DSL, replacing list filter flags
- message compose writes RFC 5322 to stdout unless --send; message send
  takes raw MIME, so the two chain through a pipe
- upstream aliases: ls, sr, write, new, fwd

core/email/send.* is deleted: the email mount is read-only, so the
composer and the SMTP transport moved into the himalaya package.

Fixes three node-side bugs found while testing:
- sendMail({raw}) needs an explicit envelope; Bcc is stripped by hand
- parseSearchCriteria silently dropped unknown keys, so NOT SEEN matched
  every message
- parseImapDate rolled a bad month or day into a wrong date

* fix(himalaya): satisfy the CI eslint rules and stop the yapf/isort import flip

* fix(himalaya): search the Date header and bound envelope pages

Date conditions emit SENTON/SENTBEFORE/SENTSINCE rather than
ON/BEFORE/SINCE, which match the mailbox internal date, and the missing
upstream `before` condition is now parsed.

Envelope listing asked IMAP for every matching uid and fetched headers
for all of them before slicing one page. It now fetches the newest
page * page_size, capped by the account's max_messages.

* test(himalaya): cover every flag in integ, and fix what it found

Grows the himalaya integ suite from 38 to 64 cases: every search
condition, sorter, flag and alias, the composer flags, both quoting
options, stdin bodies, the send operand form, unknown flags and a
missing mailbox.

Three bugs it turned up, all in the email client:

- Five IMAP selects ignored their response, so a missing mailbox left
  the session in AUTH and the next command complained about that
  instead ('command SEARCH illegal in state AUTH' in python, imapflow's
  bare 'Command failed' in typescript). Both now say no such mailbox.
- A refused SEARCH returned an empty uid list, so criteria the server
  cannot answer read as 'matched nothing'. It now raises.
- Found because a mutation emitting a malformed date stayed green.
2026-08-04 22:41:23 -07:00
Zecheng Zhang 7730eb3c37 feat(shell): discover installed CLIs through man, type and which (#707)
* feat(shell): discover installed CLIs through man, type and which

man renders an installed CLI from its own spec, type reports it as a cli, and which is new. Precedence now lives in one lazy layers() generator that serves both route (the winner) and route_all (every layer, which type -a prints).

* refactor(shell): share one option scanner across the bash builtins

command, type and which each hand-rolled the same non-permuting letter
scan. They now share scan_options/last_of, which pins bash's grammar in
one place: a long spelling refuses on its second dash, and a mutually
exclusive group resolves to the last letter typed.

type -f is a filter over the layer list instead of a pop-and-restore on
the session function table, and man takes the install it already looked
up rather than fetching it again.

* fix(shell): keep the layers under a reserved word visible

type -a stopped at the keyword, so a function sharing a reserved word's
name never showed. bash prints both lines (function time { :; }; type -a
time), and mirage's parser lets any reserved word be a function name, so
the shadow was reachable and hidden.

time and coproc leave the keyword table with it: mirage implements
neither, so type called them keywords while running one reported command
not found. which now drops the keyword layer before picking a winner
rather than after, so it reports the function underneath instead of
nothing.

* refactor(shell): split lookup into a package the way route and condition are

types.py holds NameKind, constants.py the consumer and description
tables, classify.py the layer walk, handle.py the two builtins. The
keyword pool moves to the name-pool leaf beside SHELL_NAMES, where the
CLI registry can read it: installing a CLI under a reserved word was
allowed and would never have been reachable, and is now refused.

command.py and lookup build their result triples with the shared
helpers instead of by hand, and man renders its missing-description
placeholder in one place.

* fix(shell): keep command -V's diagnostics when another name resolved

The shared-helper refactor routed the any-found case through ok(),
which drops stderr, so command -V ls nope printed the found line and
swallowed 'command: nope: not found'. bash prints both and exits 0
(pinned), and the TypeScript sibling always did.

All three handlers now build one result with a computed exit code, so
the diagnostics can never ride on the status again, and the mixed case
is a test in both languages.
2026-08-04 20:34:14 -07:00
bytecii ba0ee03c00 test(native): skip the tac comparison tests when the host has no tac (#708)
`tac` is GNU coreutils; macOS ships `tail -r` instead. The native fixtures
capture only the child's stdout, so on a host without `tac` the native side
silently yields "" and the comparison fails with a confusing diff rather than
reporting the missing binary.

Guard both sides on the binary's presence rather than the platform, so the
tests still run wherever `tac` exists (Linux CI, or macOS with coreutils
installed) and skip cleanly where it does not:

- typescript/packages/node/src/commands/native_tac.test.ts: `it.skipIf`
  keyed off `command -v tac`, mirroring native_fmt.test.ts's skipIf shape.
- python/tests/commands/native/test_tac.py: `pytest.mark.skipif` on
  `shutil.which("tac") is None`, mirroring test_fmt.py's named-mark shape.

Verified both directions on macOS: 4 TS / 6 py tests skip with no `tac` on
PATH, and all 10 pass with a GNU-equivalent `tac` shim on PATH.

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:47:20 -07:00
Zecheng Zhang fc524e1d90 feat(cli): slack, discord, ntn and linear as builtin CLI packages (#703)
* feat(cli): slack, discord, ntn and linear as builtin CLI packages

Migrate the remaining service bundles to installed CLIs with the settled
vocabularies (OpenClaw slack/discord actions, the official Notion CLI
grammar as ntn, linear's noun/verb tree), including the new API surface
they need (slack pins/emoji/reactions/history, discord edit/delete/
threads/polls, notion page updates). Configs move resource -> core in
both languages and the mounts go filesystem-only. chroma-query stays a
mount command on purpose: it is path-scoped.

Fixes two CLI dispatcher bugs found by the new integ cases in both
languages: leaf exceptions now become the command's IOResult (so
redirects and ; sequencing work, with the GNU prog: prefix), and argv
words re-enter the walk as typed so quoted glob-looking flag values
survive.

Adds cli-facet integ targets and cases for all four CLIs (108 cases
both runners), extends the fake slack/discord/notion servers, and moves
CLI usage out of the resource docs into new CLI sections on both tabs.

* integ: cover the remaining linear write verbs in the cli facet

* feat(cli): linear add-label and set-project resolve names via the issue's team

* fix(cli): codex review, standalone thread type and bounded notion search pagination
2026-08-04 02:20:50 -07:00
Zecheng Zhang 8427729440 fix(crossmount): honor wc --total and stop du -h rounding twice (#702)
* fix(crossmount): honor wc --total and stop du -h rounding twice

The FANOUT combiners re-parse each native run's rendered text, so a flag
that changes output shape has to be re-implemented in the combiner or it
diverges from the single-mount path. Two did.

wc ignored --total entirely. Worse, it guessed that a run with more than
one row ended in a per-run total and dropped that row, so a glob operand
silently lost a file. Native runs are now forced to --total=never and
combine_wc delegates to the generic's own format_count_rows, which is
what applies --total for one mount.

du -c -h fed each run's already humanized total back through parse_size,
so two 1500 byte operands read as 1536 each and printed 3.0K where one
mount prints 2.9K. Native runs now report exact bytes and du_total
humanizes once.

That removed the last caller of utils/formatting.parse_size in both
languages, so it and the orphaned SIZE_UNITS constant are gone.

* fix(crossmount): diagnose an invalid wc --total before the override

Forcing --total=never on the native runs also hid a bad value from
them, so `wc --total=bogus` across mounts ran clean. Python then raised
out of the combiner and an upstream handler happened to render it,
while TypeScript returned null and exited 0 with no diagnostic at all.

Both now parse the user's flags in run_fanout before overriding, and
return the GNU message with exit 1, which is what one mount does.
2026-08-04 01:24:37 -07:00
Zecheng Zhang 4a1baff52c fix(find): report an empty directory start point, and stop collapsing -type in typescript (#701) 2026-08-04 01:24:18 -07:00
dependabot[bot] 72f75343b3 chore(deps): bump pnpm/action-setup from 6 to 6.0.9 (#700)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 6 to 6.0.9.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6...v6.0.9)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 21:50:19 -07:00
Zecheng Zhang a39755b3b1 feat(policy): absorb output safeguards into the policy layer as Limit (#694)
* feat(policy): absorb output safeguards into the policy layer as Limit

* fix(policy): loop-based trailing-slash strip in limitOverride (CodeQL polynomial-redos)

* fix(integ): surface policy EACCES through the TS fuse read/write callbacks, skip code-policy cases in the CLI harness

* chore(deps): bump cryptography 50.0.0 and aiohttp 3.14.3 for audit advisories

* fix(policy): stamp builtin producers at the dispatch chokepoint, purge safeguard vocabulary from docs
2026-08-03 18:32:34 -07:00
Zecheng Zhang d13ec69ba4 feat(cli): himalaya and gws as builtin CLI packages plus integ cli facet (#699)
* feat(cli): himalaya and gws as builtin CLI packages plus integ cli facet

* fix(cli): flake8 re-exports, gws refreshFn config, docs register_cli, EmailConfig to core
2026-08-03 18:03:12 -07:00
Zecheng Zhang 166cf59005 Surface Shared Drives in the g* mounts, and make the Graph service root configurable (#695)
* fix(google): surface Shared Drives in the g* mounts, and make the Graph service root configurable

The g* mounts sent no corpora flags, so gdocs/gsheets/gslides silently
missed every Shared Drive file while gdrive showed them. They now search
every corpus, and report Drive's incompleteSearch flag so a short listing
is never cached as the directory. gdrive's root had the same bug already:
a failed Shared Drive enumeration was cached as a My-Drive-only root.

GRAPH_API stops being a module constant. The service root now comes from
the mount config, so a mount can address a national cloud, and the integ
fake is reached by configuring a mount instead of rebinding a global in
every module that spells a URL. OneDrive also gains /groups/{id}/drive
and /users/{id}/drive addressing, with a validator rejecting more than
one drive target.

Closes #305, #308, #309.

* fix(msgraph): escape Graph identifiers, and drop the national cloud table

Two Codex findings on #695, plus a scope cut.

Escaping: drive_base interpolated identifiers raw, so a guest user's UPN
(guest_contoso.com#EXT#@fabrikam.com) had its `#` read as a URL fragment
and Graph received a truncated /users path. id_segment now escapes each
identifier as one path segment, applied in drive_base, SharePoint's
item_url and its site drive listing. TypeScript already did this.

The safe set is "!*'()" rather than an empty one so quote matches
encodeURIComponent exactly. Drive ids are b!<base64url>, and the ref
paths built off them go into a JSON body Graph reads literally, so
escaping the `!` on one side only would have traded a guest-user bug for
a copy and rename bug on every drive-id mount.

Docs: the OneDrive example used GraphCloud without importing it, and it
was not re-exported from the documented module. That example is gone
with the enum below, so the broken import goes with it.

National clouds: the GraphCloud enum, GRAPH_CLOUD_HOSTS and the `cloud`
field are removed from both languages. mirage cannot verify those four
hostnames, and a table of unverifiable constants reads as a guarantee
the library cannot make. graph_base_url stays and is now the single way
to name a non-worldwide root, which is what #305 actually asked for: the
service root is read from config rather than a module constant, so two
mounts in one process can address different deployments and the integ
fake is reached by configuring a mount. The docs now point at Microsoft's
own national cloud documentation for the root.
2026-08-03 18:00:28 -07:00
Zecheng Zhang b88ed40119 fix(find): classify the start point once, above every backend (#696)
* fix(find): classify the start point once, above every backend

`find` asked the backend to walk a start point that is not a directory,
and each answered differently: a Graph 404, a silently empty listing, or
ENOTDIR. The reported symptom was `find -L <cross-mount link>`, but the
operand rewrite was correct all along: `find /data/a.txt`, with no
symlink anywhere, failed identically.

Every native find op listed `<start>/children` unconditionally and
hardcoded the start as `kind="d"`. Extends the shape #688 built for
symlink start points so the start point is classified once and every
backend inherits the answer: a symlink goes to link_results, a
non-directory is reported and walked no further, a directory walks.

The stat rides the op dispatcher as a new `stat_path` fact rather than a
threaded callable, for the reason #688 recorded when the same threading
left `hf` behind, and because the router resolves `find -L` into another
mount before the command runs. Only a positive non-directory answer
short-circuits: a stat that sees nothing is not proof of absence when a
backend's directories are implicit.

Also fixed, found on the way:

- `find /d \( -name x \)` classified `(` and `)` as PATH operands, giving
  find two phantom start points, hidden because the native path reads
  paths[0] and drops the rest
- `tree <file>` diverged three ways by backend; GNU prints the marker,
  counts the file and exits 0
- Python's missing-operand message printed the path twice instead of
  `No such file or directory`

Fixes #684, which this change needed: gdrive was the one native-find
backend that could not be tested at all, so `find` there reached the
real Drive API. Widens the fake's patch targets to resolve/tree/rename,
teaches it the `name=` filter, and makes an unresolvable target a hard
error. The e2e s3 fake needed `list_objects_v2` for the same reason.

Pinned against GNU findutils 4.10.0 and tree 2.2.1 on debian:stable-slim
before changing behavior. Mirrored in Python and TypeScript.

* fix(find): decide absence above the backend, not per backend

`find <missing>` exited 1 only on the three backends that wire a stat
into find, and `tree <missing>` rendered GNU's marker on twelve of
eighteen targets. Both were the same gap: a backend could not tell
"absent" from "a directory that exists only as its children", so
neither command could either.

The rule already existed in the repo. `test -d` has always asked both
channels a backend can answer on, and only both coming back empty means
nothing is there. `resolve_path_stat` / `resolvePathStat` is now that one
implementation, shared by the start-point probe and `path_kind`, so a
traversal and `test -d` cannot disagree about whether a path exists.

Measured on 18 targets before changing anything: an implicit directory
answers, a missing path does not, and an empty `mkdir`'d directory
answers everywhere except hf, whose `mkdir` creates nothing and which
`root_create_mkdir` already excludes for that reason.

Two pre-existing fixture bugs the uniform rule exposed, both of which a
silent `find` had been hiding:

- TypeScript's integration fixture seeded `store.files` without the
  ancestor directories, a store no write can produce. RAM keeps
  directories explicitly, so `ls /data/logs` printed nothing and
  `test -d /data/logs` was false there already. The python mirror
  fixture has always `mkdir`'d each ancestor; now both do.
- The nextcloud exclusion was missing from `tree_start_missing`'s
  target list because it needs a real server, which is what turned
  `integ-data` and `integ-ts` red.

Verified against a local `nextcloud:30-apache`, the image CI uses.
2026-08-03 18:00:17 -07:00
Zecheng Zhang 369aa78194 fix(deps): bump cryptography to 50.0.0 for GHSA-g6cj-pr64-35w5 (#698) 2026-08-03 16:40:13 -07:00
Zecheng Zhang b29e4bdb6f fix(deps): bump aiohttp to 3.14.3 for GHSA-mq44-7p77-q5h7 (#697)
aiohttp <= 3.14.1 accepts compressed WebSocket frames without negotiated
permessage-deflate (CVE-2026-59881, moderate). Fixed in 3.14.2; the lock
moves to 3.14.3, and the pyproject floor is raised so the fix cannot be
resolved away.

Clears the red audit job.
2026-08-03 15:01:00 -07:00
Zecheng Zhang a16684823d fix(symlinks): report links across commands, and add -R to chmod/chown/chgrp (#688)
* fix(symlinks): report and follow links the way GNU does

Symlinks live in the namespace, so no backend readdir or stat can see
them and every command was blind to them unless wired by hand. Six GNU
divergences, each pinned against debian:stable-slim first, mirrored in
python and typescript.

- ls -l on a dangling link failed the whole listing with exit 2; GNU
  prints the link row and exits 0. ls -l/-d on a live link silently
  followed it, and ls -R and -F omitted links entirely.
- readlink -e printed a dangling target and exited 0; GNU requires the
  whole resolved path to exist. -f now requires only the parent.
- file followed the link and relabelled the operand as its target; GNU
  prints "symbolic link to <target verbatim>", or "broken symbolic link
  to ..." when the target is absent.
- du omitted links; GNU lists one line per link under -a and does not
  follow a link operand without -L.
- find followed a symlink start point; GNU's default is -P, which
  reports the link and stops. -P/-H/-L are leading options, not
  predicates, so the expression splitter consumes them first.
- -L has to apply below the operand too, not just to it: ls -L stats
  each link child and reports the target under the link's own name, and
  du -L stops counting links as entries of their own.

Generalize the seam while it is being touched:

- A command opts in by naming a `links: LinkView` parameter. execute_cmd
  offers the fact to every handler and accepts_kwarg reads the signature
  to decide delivery, so there is no allowlist to keep in step. This also
  removes the hardcoded stat_overlay command list. TypeScript needs no
  equivalent: a command that does not read `links` off its context
  ignores it.
- LinkView bundles all five link facts, so a new need is a field read
  rather than a keyword threaded through four layers.
- Link merging lives in the generic above the native-op/walk fork, so a
  mount's symlink behavior cannot depend on whether its backend ships a
  native find or du op.
- ls merges links as stat rows instead of appending to its own stdout,
  which is what makes -R, -F, -l and sorting work. Deletes inject_links
  and its latent dedup bug, where splitting a GNU long row on tabs
  returned the whole line.

Also moves data shapes and constant tables to the modules the layout
calls for (ops/types, commands/builtin/constants, utils/constants) in
both languages, and adds 33 integ cases that run in both runners.

du sizes a link at its target length, which is not a divergence: mirage
counts bytes, GNU's --apparent-size mode, and there GNU reports
len(target) too. Two known gaps, both documented rather than papered over: du -L
undercounts a link pointing outside the operand's own subtree, and ls
-RL does not descend a directory link. A version of the latter that only
worked on RAM was removed rather than shipped, because descending needs
the child read re-keyed onto the resolved target and that only resolves
on backends with real directory entries. Symlink behavior that depends
on which backend is mounted is the exact failure this branch adds a rule
against.

A link operand is caught on both readdir shapes. Backends without real
directories (s3, nextcloud) answer readdir on a link path with an empty
list rather than raising, so the link row has to be resolved there too
or the operand renders as an empty directory. Pinned by unit tests
against the generic in both languages, so the case is covered without
needing a remote backend to reproduce it.

Fixes #414

* fix(integ): drop the empty-directory dependency from the symlink fixtures

* fix(symlinks): give bespoke stat wrappers the link table, and make find's link options last-wins

s3 and gridfs ship their own stat command, and neither declared the
links parameter, so signature-based injection silently withheld the
namespace link table and stat on a symlink reported ENOENT on exactly
those two backends. TypeScript is unaffected: its wrappers forward the
whole opts object, so the generic reads opts.links regardless.

find states its link policy as a leading option and GNU takes the last
one, so find -L -P x must not follow while find -P -L x must. The
scanner returned true on seeing any -L or -H anywhere.

* feat(metadata): walk the subtree for chmod, chown and chgrp -R

All three refused -R with exit 2. GNU splits three ways here: chmod -R
skips a traversed link but follows a command-line link to a directory,
while chown -R and chgrp -R change the link node itself because POSIX
gives -R an implicit -P.

Two bugs surfaced while pinning it. chown -h wrote uid onto the node but
link_stat dropped ownership when rendering, so nothing ever showed it;
only mode is meaningless on a link. And TypeScript rebuilt stat rows
field by field where Python uses model_copy, dropping uid/gid on the
link row, so FileStat gains a with() method and the four rename sites
use it.

* fix(find): classify a link by its target under -L

find -L must test a link as what it points at, not as a link: a link to
a file is -type f, a link to a directory is -type d, and only a dangling
link stays -type l. link_results classified every namespace link as l
regardless, so find -L d -type f missed the links and find -L d -type l
wrongly named the live ones.

Both find paths reach link_results, so the classification lands above
the native-op/walk fork rather than in one branch.

* fix(symlinks): stop backends losing links and -L one wrapper at a time

`find -L` classifies a link by statting its target, and that stat was
reached through the callable wired for the -mtime post-filter, which is
gated on ops.local. Post-filtering costs one stat per result, so gating
it is right; classification costs one stat per link, so gating it was
not. Off the local backends nothing was statted and every link stayed
typed `l`: -type f and -type d missed them, -type l still listed them.

The fact belongs on LinkView, not on a threaded argument, so it is now
`target_stat` / `targetStat`. It resolves through the op dispatcher
rather than a backend key, which also fixes a link pointing into another
mount: the old code built the key from the search root's mount prefix
and could never resolve one. Pinned in integ/crossmount/find/deref.json.

The same omission then turned up in ten bespoke commands that shadow a
link-aware generic without declaring `links`, and in the same ten for
`-L`, which lands in the wrapper's opaque flag bag when unnamed. Both
failures run fine and exit 0, so nothing notices until someone makes a
link on that backend. tests/commands/test_links_optin.py asserts it
instead, deriving from the builders which link parameters each family
requires; it caught history/ls on the first run.

TypeScript needs no guard because wrappers forward the whole opts
object. Its one exception, email/find, walks its own tree and never
reaches a generic, so it now calls linkResults directly.

* fix(find): trim slashes without a regex, and derive the link guard from the router

Exporting linkResults made its input library-reachable, which surfaced
the polynomial-ReDoS trim inside it (CodeQL js/polynomial-redos, high).
Both `/^\/+|\/+$/g` trims now use the loop-based stripSlash helper.

The guard's list of required link options was hardcoded. It now reads
DEREFERENCE_FLAGS and LAST_WINS_LINK_OPTIONS, so a new link option
taught to the router starts being required of bespoke wrappers too.

* fix(symlinks): let real backend failures surface while statting a link target

link_target_stat caught OSError around _stat_or_none, which already maps
the missing-target case to None. The extra catch could therefore only
swallow a genuine failure: a permission, connection or I/O error came
back as a dangling link, printed as -type l with exit 0.

Only the two legitimate no-target cases stay None now, a loop from
namespace.follow and a missing target from _stat_or_none. Everything
else propagates. Same in TypeScript, where the bare catch did the same.
2026-08-03 05:01:05 -07:00
Zecheng Zhang a1555baa96 feat(policy): rename CommandFacts to ParsedCommand, add the cli fact and the cli-registry snapshot (#693)
* refactor(policy): rename CommandFacts to ParsedCommand

* feat(cli): ParsedCommand.cli policy fact and cli-registry snapshot

* fix(policy): recurse nested config secrets, carry live specs through copy, honor zod extra-key policy
2026-08-03 04:13:09 -07:00
Zecheng Zhang 9d0142b9dd feat(policy): pre_ops/post_ops hooks at the op doors (#692)
* feat(policy): pre_ops/post_ops hooks at the op doors

* fix(policy): classify setattr as a write and run bookkeeping before the post gate
2026-08-03 01:08:56 -07:00
Zecheng Zhang 56d3a7ba7c feat(cli): install registry, dispatch by name, and the YAML clis: section (#691)
* feat(cli): install registry, dispatch by name, and the YAML clis: section

* fix(cli): codex review round: CLI head wins command-prefix match, leaf safeguard applies, module-scope help helper
2026-08-03 00:54:57 -07:00
Zecheng Zhang 0bb90ee0e0 feat(policy): policy package with the pre_command hook, absorbing the command guards (#690)
* feat(policy): policy package with the pre_command hook, absorbing the command guards

* refactor(policy): GuardSpec is a data shape, move it into types

* docs(policy): trim the seam narration from the Policy base docstring

* fix(policy): fire the hook at the dispatch chokepoint, cover flag paths, harden TS spec handling

* fix(policy): ln refusal wording follows the link kind

* chore(policy): keep hasSymlinkFlag module-private
2026-08-02 23:07:25 -07:00
Zecheng Zhang edd20a6544 feat(spec): one ValueType axis: merge value_kind and type (#689)
* feat(spec): merge value_kind and type into one ValueType axis (python)

* feat(spec): merge value_kind and type into one ValueType axis (typescript + dumps)

* test(spec): float coverage, lint fixpoint

* fix(spec): linear-time FLOAT_VALUE regex (CodeQL polynomial-redos)

* fix(spec): numeric refusal before choices in option_error, add FlagView.as_float
2026-08-02 21:11:07 -07:00
Zecheng Zhang a039a174b8 feat(spec): mirror argparse: aliases, long-option abbreviation, typed int values (#687)
* feat(spec): mirror argparse: subcommand aliases, long-option abbreviation, typed int values

* fix(spec): coalesce synonym longs by signature, report scan errors in encounter order
2026-08-02 20:00:13 -07:00
Zecheng Zhang a0ea973aa7 refactor(executor): split command.py and command.ts into a command package (#686) 2026-08-02 14:02:19 -07:00
Zecheng Zhang 16743b7bcc CLI tree walk with git-pinned diagnostics (#685)
* feat(cli): tree walk with git-pinned diagnostics

* fix(cli): narrow instead of assert, CI formatter alignment

* refactor(cli): one help renderer, --help as a registered option

* fix(cli): walk option precedence per flat parser, module layout
2026-08-02 13:55:02 -07:00
Zecheng Zhang fed7e364ba Guard cp/mv against a same-storage operand, and refuse a non-empty destination directory (#683)
* fix(mv): refuse a non-empty destination directory, not only under -T

GNU mv refuses to replace a non-empty directory whether the target was
named outright with -T or mapped under an existing destination
directory. The refusal existed but was gated on no_target_dir, so
`mv src dest/` merged the trees at exit 0 instead (#272).

Dropping the gate also required moving the check after the clobber
gate: GNU lets -n and --update=none skip such a target silently at
exit 0, which the old ordering turned into an error. Pinned against
coreutils 9.7 in debian:stable-slim, all seven arms:

  mv src dest/          -> cannot overwrite 'dest/src': Directory not empty
  mv -T src dest/src    -> same
  mv -f src dest/       -> same (-f does not bypass)
  mv -n src dest/       -> exit 0, skipped
  mv -n -T src dest/src -> exit 0, skipped
  mv src dest/          -> exit 0 when dest/src is empty
  cp -r src dest/       -> exit 0, still merges

The -b arms keep working through backup_displaces, which renames the
target aside before the refusal can apply.

* fix(crossmount): compare storage identity, not path spelling, in cp/mv

Two mounts can address one store: two disk mounts on a shared root, one
bucket mounted twice, or the same resource object mounted at two
prefixes. Cross-mount routing keys off the mount prefix alone, so such a
pair looked like a genuine cross-mount move and mv did read -> write ->
unlink, writing the object over itself and then deleting it (#154):

  $ mv /m1/x.txt /m2/x.txt     exit 0
  $ cat /m2/x.txt              No such file or directory
  $ find /m1                   /m1

The generic cp/mv already had a same-file guard and a backend_key hook
for it, but nothing injected a key and the default is the mount-relative
path, which carries no mount identity. Resources now answer
storage_id(); make_storage_key pairs it with the mount-relative path and
the executor threads it through route -> relay -> cp/mv.

The default is per-instance identity, so a backend that says nothing
keeps today's behavior. That is the safe direction: a false "different"
only fails to catch an alias, while a false "same" would refuse a real
move. disk (resolved root), s3 (endpoint, bucket, key prefix) and redis
(url, key prefix) override it, so two separately built instances over
one target still compare equal.

Verified: aliased mounts refuse with the coreutils "are the same file"
line and the bytes survive; distinct stores sharing a basename, distinct
paths within one store, and two ram mounts all still move normally.

* fix(crossmount): mirror the storage-identity guard in typescript

Same shape as the python side: Resource gains an optional storageId,
BaseResource defaults it to a per-instance serial (JS has no identity
primitive), and disk, s3 and redis override it with the config that
actually pins their storage. makeStorageKey pairs it with the
mount-relative path and command.ts threads it through
handleCrossMount -> runRelay -> runCp/runMv.

storageId is optional on the interface because some resources implement
Resource without extending BaseResource; a resource that omits it is
keyed by its mount prefix, which reproduces the pre-existing behavior
rather than risking a false "same file".

* fix(s3): guard a same-key copy and rename in the core, py+ts

core.rename was copy_object + an unconditional delete_object, and
core.copy a plain copy_object, neither checking whether the resolved
keys were equal. Safety came from the remote refusing the request
rather than from our code (#150).

Both now follow POSIX rename(2) for the equal-key case: succeed and
perform no other action, with a missing source still raising ENOENT.

What the probing actually showed, which corrects the issue's premise:

  - MinIO REJECTS a plain self-copy, versioned or not, exactly like AWS
    (InvalidRequest). It is not one of the lenient stores the issue
    names, so the data loss is not reachable there today.
  - But a self-copy IS accepted once the request carries
    MetadataDirective=REPLACE, and the delete then removes the object.
    Confirmed against MinIO: on a versioned bucket the old version
    survives behind a delete marker, on a plain bucket it is gone. So
    the loss is one plausible code change away.

Coverage: the s3 mock's copy_object is deliberately lenient, which is
what makes the guard observable; a real store's rejection would mask
it either way. The mock now counts copy_object/delete_object so the
test asserts no write was issued at all, not merely that the object
survived. Confirmed discriminating: disabling the guard fails four of
the seven, one of them literally on an emptied store.

* test(integ): cover the non-empty-dir refusal and aliased mounts

Two overwrite cases for #272, on the seed that already builds the
shape: /data/ow/t holds two files and /data/ow/s/t is the source, so
`mv /data/ow/s/t /data/ow` maps onto a non-empty target. One asserts
the refusal, one asserts -n skips it at exit 0. Both pinned against
coreutils 9.7 replaying the same state in debian:stable-slim.

For #154 the harness could not express the shape at all: every builder
allocates fresh storage, so no two mounts could address one store. A
mount may now carry alias_of naming an already-built mount, reused in
both runners (spelled snake_case, matching seed_root). The new
ram-alias target mounts one RAMResource at /data and /alias, and
alias_both_prefixes_see_one_store proves the aliasing is real before
the guard cases assert anything.

ram, disk, ram-alias, ram-root: 4074 passed on the python host, 4069
on typescript-node, 0 failed on both.

* test(ts): unit-cover the storage-identity key

Mirrors tests/workspace/mount/test_storage.py: aliased prefixes compare
equal, distinct stores do not, a config-pinned resource matches across
instances, the ancestor prefix boundary survives, and a resource that
declares no storageId falls back to per-mount identity rather than
risking a false same-file refusal.

* chore: apply yapf formatting and satisfy restrict-template-expressions

yapf reflowed the three crossmount relay signatures and the new s3
guard test. The eslint fix is real, not cosmetic: storageId
interpolated a number straight into a template literal, which
@typescript-eslint/restrict-template-expressions rejects.

* fix(crossmount): trim slashes with rstripSlash, not a backtracking regex

CodeQL flagged all three `/\/+$/` trims in makeStorageKey as
js/polynomial-redos (high): the pattern backtracks on a path with many
repeated slashes, and the input is a caller-supplied virtual path.

utils/slash.ts already exports the loop-based rstripSlash for exactly
this reason. Same result, linear time, and it matches the trim idiom
used elsewhere in the codebase.

The python side was never affected: it uses str.rstrip('/').

* fix(crossmount): canonicalize the storage key, keep identity for bare resources

Three review findings, two of them the same data loss the guard was
meant to close.

**Nested backings collapsed onto one key.** The key joined the storage
id and the mount-relative path with a delimiter, so overlapping roots
stayed apart:

  /a -> /srv/data, /b -> /srv/data/sub
  /a/sub/x.txt  ->  disk:/srv/data:/sub/x.txt
  /b/x.txt      ->  disk:/srv/data/sub:/x.txt

Same file, different keys, so mv wrote it onto itself and unlinked it.
Reproduced: exit 0 and the file gone from disk. The two parts are now
concatenated into one path-like string, so both render as
disk:/srv/data/sub/x.txt. s3 and redis had the same flaw with nested
key prefixes and now join theirs path-like too. A sibling that merely
shares a name prefix (/srv/data vs /srv/dataX) still stays distinct,
since the relative part always starts with a slash.

**Bare resources lost object identity.** The typescript fallback keyed
a resource with no storageId by its mount prefix, which handed one
object two identities. Browser resources (browser s3, opfs) implement
Resource directly rather than extending BaseResource, so they took
that path and a self-move still relayed a write then an unlink. A
WeakMap now assigns a serial per object, so the fix is not Node-only.
Python gained the matching getattr fallback for a resource that does
not inherit BaseResource.

**Nested function.** storage_key was a closure; the repo prohibits
nested functions. It is module level now, bound with functools.partial.
2026-08-02 11:43:54 -07:00
Zecheng Zhang 998c7c25a7 Recursive CLISpec atop CommandSpec (#682)
* feat(cli): recursive CLISpec atop CommandSpec

* fix(cli): CI formatter and lint fixes for the new tests

* fix(cli): whitespace-aware name check, validation into compile module

* style: yapf formatting per CI env
2026-08-02 10:57:01 -07:00
Zecheng Zhang 871a2b90ef Split the Workspace orchestration into a workspace package in both languages (#681)
* refactor(workspace): split the Workspace orchestration into a workspace package in both languages

* review: type the workspace dispatch callback as a DispatchFn protocol
2026-08-02 02:36:03 -07:00
Zecheng Zhang 255fa3ef84 fix(shell): honor line continuations, reject unterminated backticks, finish the size sweep (#680)
* fix(shell): honor line continuations and reject unterminated backticks

Two parser-level divergences from GNU bash, both pre-existing.

A trailing backslash is a line continuation. With nothing to continue
onto, bash drops it and runs the command, while mirage raised a syntax
error and exited 2, so `echo a\` produced nothing. Parity decides: only
an odd-length trailing run ends in a live continuation, since each
earlier pair is an escaped backslash. This also settles the last backtick
divergence, `echo `echo a\\`` now prints `a`.

An unterminated backtick was accepted and run. tree-sitter parses
`echo `echo a` as a complete command, so the region is scanned directly.
Quoting follows the shell reader: single quotes protect a backtick,
double quotes do not, and once inside a substitution only a backslash
escapes, which is why `"`echo '`'`"` is an error in bash rather than a
quoted backtick.

Also completes the stat/read size sweep across the last backends:
lancedb, chroma, qdrant, jaeger and langfuse .jsonl all hold the
invariant. notion page.json and langfuse trace/prompt .json are
size-unknown by design, since sizing them at listing time would cost an
extra API call per entry; both are now pinned as cold/read/warm cases and
carry a comment saying why, which is what was missing when I mistook the
same shape on trello for a bug.

* test(jaeger): scope the size invariant to the seeded services

CI caught one mismatch the local run never hit. Jaeger self-instruments,
so /j/services/jaeger/* is live telemetry that keeps growing: a trace
listed at time T can gain spans before it is read, and stat then
disagrees with the read through no fault of the sizing.

Walk only the four seeded services, named explicitly so self-telemetry
cannot drift back in, and assert an exact 8/8 so an empty glob fails
instead of passing vacuously. 101 of the 109 files in that tree were
self-telemetry.
2026-08-02 01:44:36 -07:00
Zecheng Zhang 03a38a0493 Declarative count, multiple, choices, required and default option fields (#679)
* feat(spec): declarative count, multiple, choices, required and default option fields

* fix(types): widen fanout depth-flag helper to the int-carrying flag union

* fix(spec): a default on a multiple option lands as a one-element list
2026-08-02 01:12:20 -07:00
Zecheng Zhang 338cc8324a Pair the du ops, move opfs to the command factory, cover find predicates in integ (#678)
* fix(du,opfs,find): pair the du ops, move opfs to the factory, cover find predicates

Three issues that all sat on the generic command layer.

optional fields, but they are not independent. A backend setting only
the cheaper du_size silently degraded du to one operand line with no
directory rows and an inert -a. They are now one value (DuOps in
python, DuOps<A> in typescript) with both halves required, so half a
native du cannot be constructed. That makes compute_entries required
through run_du/runDu, so the degraded branch is deleted rather than
documented.

hand-registering 69 command families across 70 files. It now declares
a CommandIO like every other backend. opfs gains numfmt, od and
truncate, which it never had.

but only the malformed-value half was covered in integ. Adds nine
cases across trello, linear and discord: one precise positive filter
each, plus two unsatisfiable filters that must return empty. Those two
are the regression guard, since an ignored predicate returns the whole
tree.

* chore(spec): regenerate browser specs for the commands opfs gained

The factory gives opfs numfmt, od and truncate, which it did not
register by hand. Regenerated with scripts/gen_specs.py and
typescript/scripts/gen-specs.ts.
2026-08-02 01:11:54 -07:00
Zecheng Zhang 858a4324ea fix(shell): keep whitespace between expansions, split adjacent backticks, and extend the size sweep (#677)
* test(sizes): assert the size invariant across linear, github_ci and trello

Extends the stat/read size sweep to the three remaining fake-server
backends. linear walks the whole mount, github_ci uses per-kind globs
because recursive find across runs is deliberately refused, and trello
covers its sized kinds.

trello's comments.jsonl is left size-unknown on purpose (it needs a
per-card actions call), so a cold stat reports 0 while a read returns
the real bytes. That is pinned as its own case rather than treated as a
failure, so the next reader does not chase it.

* fix(shell): keep whitespace between expansions and split adjacent backticks

Two tree-sitter tokenisation quirks made the expander drop or merge text.

Whitespace between two expansions inside double quotes was dropped, so
"$(a) $(b)" printed "ab". tree-sitter does not emit a whitespace-only run
as string content; it folds it into the following node, so the `$(` token
text is literally " $(". `${a}` and `$a` already compensated with their
own prefix handling, `$(...)`, `$((...))`, `$?`/`$#` and "${arr[@]}" did
not. One `_folded_whitespace` helper now covers every expansion branch.
Unquoted words never fold, so word splitting is unchanged.

Adjacent backtick substitutions were merged into one node when the gap
between them was empty or whitespace-only, so `a` `b` expanded to
"a` `echo b", quoted or not. The backtick region is now re-lexed from the
node's own text instead of trusting the grammar. Rewriting backticks to
$(...) before parsing would shift byte offsets, which history recording
and error snippets depend on.

Every expected value is pinned against GNU bash.

* fix(shell): respect backslash escapes when splitting a backtick region

Codex review on #677: a command substitution ending in `\\` before its
closing backtick has an escaped backslash, not an escaped backtick, so
the delimiter was being swallowed and the command text ran past it.

Consume escape pairs whole instead of counting slashes, gated to the
inside of a command and to the three characters POSIX escapes there
(`$`, backtick, backslash). Parity falls out: `\\` is consumed as one
unit, so a backtick straight after it still closes the region.

This also unescapes backtick bodies, which mirage never did, fixing a
divergence that predates the re-lex: `echo 'a\\'` in backticks now
prints a single backslash like GNU bash.
2026-08-02 00:15:01 -07:00
Zecheng Zhang 8942c091a1 Compile command specs once and unify short/long flags onto one dest (#676)
* Add a typed, spec-validated FlagView to TypeScript

Python reads command flags through FlagView, which takes the command's
spec and throws when asked for a name the spec does not declare.
TypeScript had no equivalent: commands reached into the raw dispatcher
bag with `flags.a === true`, so a name that no longer exists reads as
false rather than failing.

That failure mode is silent. Renaming tee's --append to --add in the
spec and running the suite shows it: with the raw reads tee's own tests
pass 7/7 and exit 0 while the flag has quietly become a no-op; with
FlagView the same rename fails 39 tests immediately.

Adds flagKwargName to constants (the dash-stripping rule the parser had
inlined, now shared rather than duplicated), specFlagNames and FlagView
to types, and migrates tee as a first consumer. Placement mirrors
Python: flag_kwarg_name in spec/constants.py, FlagView and
spec_flag_names in spec/types.py.

Deliberately not a sweep. The remaining commands are better migrated
alongside the parser's dest unification, which removes the both-name
reads a migration would otherwise write and then delete.

* Address Codex review: export FlagView, make asInt strict

FlagView was reachable only by deep import, so a node or browser command
could not use the sanctioned accessor. It now ships from both the spec
barrel and the package root alongside FlagValue, specFlagNames and
flagKwargName.

asInt used parseInt, which takes the numeric prefix of '5x' and returns
NaN for 'abc', and NaN still satisfies number, so a malformed value flowed
onward. It now validates the whole string and throws, matching Python's
int(): verified identical on '5x', 'abc', '', '1.5', ' 42 ', '-7', '+7'
and '1_0'.

* Migrate the first generic commands onto FlagView

13 commands now read flags only through the spec-validated view, and 9
more are partly across. Done in verified batches rather than one sweep:
each batch is typechecked and run against the full core suite, which
caught three bad rewrites (an insertion landing mid-expression in
unexpand and expand, one inside an object literal in tree, and reads
rewritten in basename with no declaration in scope because its command
is an arrow function).

Those all failed at the compiler, which is the argument for batching: a
single 53-file commit would have buried them.

* Migrate the bulk of the generic commands onto FlagView

35 of 45 flag-reading commands now go only through the view; 10 have
30 sites left that need hand edits.

Two real defects the batches caught, neither by tests alone:

split -d and -x, and mktemp -p, take an optional value, so they arrive
as boolean true when passed bare. Rewriting them to asStr dropped that
form and silently disabled numeric suffixes; split's own test caught it.
An audit of every asStr call against its option's valueKind found the
other three sites of the same class, now on raw().

stat's chained ternary was mangled by a partial regex match into
'(fl.asStr(c) ?? typeof) opts.flags.f', which only failed because it
would not parse. It is now a plain ?? chain.

Also removed an unreachable instanceof PathSpec branch in shuf: the
parser only ever stores strings, booleans and arrays in the flag bag.

* Finish the FlagView migration and enforce it with a lint rule

Every generic command now reads flags only through the spec-validated
view: zero direct opts.flags reads remain. The last sites were the list
shapes in awk and sed (Array.isArray(x) ? x : typeof x === 'string' ?
[x] : [] is exactly asList), comm's and join's numeric-keyed selectors,
and xxd's local toInt over the raw union.

The lint rule is the point of going to 100%. Partial adoption protects
only the migrated files; banning opts.flags outright makes drift
impossible for commands nobody has written yet. Its selector is scoped
to opts.flags rather than any .flags member, because a first attempt
flagged RegExp.prototype.flags in zgrep. Mutation-checked: putting one
raw read back in tee fails lint.

* Guard the Python side against raw flag reads too

Python needed no migration: its wrappers already bind flags to typed
parameters and use FlagView for the remaining spellings, so the generic
commands never see a bag at all. Auditing the whole command tree found
zero raw reads.

What it lacked was the enforcement TypeScript just gained, so this adds
the mirror of the lint rule as a test. Reads only: crossmount fanout
legitimately assigns into a bag it builds for the sub-commands it
dispatches, which a first cut flagged. Mutation-checked by adding one
flags.get back to tac.

* Apply yapf formatting to the flag-read guard

The hook rewraps the assert message. My earlier local run reported clean
before this file's last edit, so the reformat only surfaced in CI.

* refactor(spec): compile specs once and unify flag dests onto the long spelling

parse_command rebuilt its lookup tables from the spec on every
invocation; they now compile once per spec into a cached CompiledSpec
(spec/compile.py), with attached-value candidates ordered longest-first
so -name can never lose a match to -n by set iteration order.

Both spellings of an option now land on one canonical dest (the long
name when both exist), so cp --update=all -u is last-wins without the
per-spelling mirror, sort -k1 --key=2 -k3 accumulates in true command
line order, and the both-name or-chains in 12 generics collapse to one
read. spec_flag_names returns canonical names only, so a stale short
read raises through FlagView instead of silently reading False; the
sites that raised are fixed here (11 or-chain generics, the wc
formatter helpers, fanout's head/tail header forcing, and the s3 mkdir
wrapper that read flags through signature params).

* refactor(spec): mirror CompiledSpec and dest unification to TypeScript

Same shape as the python change: compile.ts lowers a CommandSpec once
(WeakMap-cached) into the tables parseCommand walks, both spellings of
an option land on the canonical long dest, and specFlagNames returns
canonical names only so stale short reads throw through FlagView.

Sweeps the TS readers the canonical dests surface: the or-chain
generics, sort.ts's per-spelling alias helpers (whose short-then-long
list concatenation lost key order), the shared checksum and base64
helpers, fanout's head/tail header forcing, and the fallback-less
wrappers that read flags through signature params or bare short keys
(s3/gridfs/opfs mkdir, postgres head pushdown, mongodb tail -f,
curl -f).

Also fixes the same wrapper class on the python side, found by codex
review and the integ reds on the previous commit: curl -f exit 22,
gridfs mkdir -p/-v, mongodb tail -f, and head -c provisioning (the
parity mismatch: provision read 'c' while head now delivers 'bytes',
degrading the exact estimate to a range).

* fix(provision): head byte caps arrive as the canonical 'bytes' dest in TS too

Same fix as the python side: the head/tail provision read only 'c',
but head now delivers the canonical 'bytes', so head -c priced as a
0..size range instead of exact (the prov_head_c reds on integ-ts,
integ-shared-ts and integ-shared-parity). tail's -c stays short-only
and still arrives as 'c'.
2026-08-01 17:51:20 -07:00
Zecheng Zhang 36235b4a30 Add a typed, spec-validated FlagView to TypeScript (#674)
* Add a typed, spec-validated FlagView to TypeScript

Python reads command flags through FlagView, which takes the command's
spec and throws when asked for a name the spec does not declare.
TypeScript had no equivalent: commands reached into the raw dispatcher
bag with `flags.a === true`, so a name that no longer exists reads as
false rather than failing.

That failure mode is silent. Renaming tee's --append to --add in the
spec and running the suite shows it: with the raw reads tee's own tests
pass 7/7 and exit 0 while the flag has quietly become a no-op; with
FlagView the same rename fails 39 tests immediately.

Adds flagKwargName to constants (the dash-stripping rule the parser had
inlined, now shared rather than duplicated), specFlagNames and FlagView
to types, and migrates tee as a first consumer. Placement mirrors
Python: flag_kwarg_name in spec/constants.py, FlagView and
spec_flag_names in spec/types.py.

Deliberately not a sweep. The remaining commands are better migrated
alongside the parser's dest unification, which removes the both-name
reads a migration would otherwise write and then delete.

* Address Codex review: export FlagView, make asInt strict

FlagView was reachable only by deep import, so a node or browser command
could not use the sanctioned accessor. It now ships from both the spec
barrel and the package root alongside FlagValue, specFlagNames and
flagKwargName.

asInt used parseInt, which takes the numeric prefix of '5x' and returns
NaN for 'abc', and NaN still satisfies number, so a malformed value flowed
onward. It now validates the whole string and throws, matching Python's
int(): verified identical on '5x', 'abc', '', '1.5', ' 42 ', '-7', '+7'
and '1_0'.

* Migrate the first generic commands onto FlagView

13 commands now read flags only through the spec-validated view, and 9
more are partly across. Done in verified batches rather than one sweep:
each batch is typechecked and run against the full core suite, which
caught three bad rewrites (an insertion landing mid-expression in
unexpand and expand, one inside an object literal in tree, and reads
rewritten in basename with no declaration in scope because its command
is an arrow function).

Those all failed at the compiler, which is the argument for batching: a
single 53-file commit would have buried them.

* Migrate the bulk of the generic commands onto FlagView

35 of 45 flag-reading commands now go only through the view; 10 have
30 sites left that need hand edits.

Two real defects the batches caught, neither by tests alone:

split -d and -x, and mktemp -p, take an optional value, so they arrive
as boolean true when passed bare. Rewriting them to asStr dropped that
form and silently disabled numeric suffixes; split's own test caught it.
An audit of every asStr call against its option's valueKind found the
other three sites of the same class, now on raw().

stat's chained ternary was mangled by a partial regex match into
'(fl.asStr(c) ?? typeof) opts.flags.f', which only failed because it
would not parse. It is now a plain ?? chain.

Also removed an unreachable instanceof PathSpec branch in shuf: the
parser only ever stores strings, booleans and arrays in the flag bag.

* Finish the FlagView migration and enforce it with a lint rule

Every generic command now reads flags only through the spec-validated
view: zero direct opts.flags reads remain. The last sites were the list
shapes in awk and sed (Array.isArray(x) ? x : typeof x === 'string' ?
[x] : [] is exactly asList), comm's and join's numeric-keyed selectors,
and xxd's local toInt over the raw union.

The lint rule is the point of going to 100%. Partial adoption protects
only the migrated files; banning opts.flags outright makes drift
impossible for commands nobody has written yet. Its selector is scoped
to opts.flags rather than any .flags member, because a first attempt
flagged RegExp.prototype.flags in zgrep. Mutation-checked: putting one
raw read back in tee fails lint.

* Guard the Python side against raw flag reads too

Python needed no migration: its wrappers already bind flags to typed
parameters and use FlagView for the remaining spellings, so the generic
commands never see a bag at all. Auditing the whole command tree found
zero raw reads.

What it lacked was the enforcement TypeScript just gained, so this adds
the mirror of the lint rule as a test. Reads only: crossmount fanout
legitimately assigns into a bag it builds for the sub-commands it
dispatches, which a first cut flagged. Mutation-checked by adding one
flags.get back to tac.

* Apply yapf formatting to the flag-read guard

The hook rewraps the assert message. My earlier local run reported clean
before this file's last edit, so the reformat only surfaced in CI.
2026-08-01 06:51:12 -07:00
Zecheng Zhang 0dea1a3e90 test(slack): assert the size invariant over every user and channel day (#673)
* test(slack): assert the size invariant over every user and channel day

The users/*.json size comes from the users.list member, but read renders
the users.info response, so the advertised size is exact only while those
two endpoints encode identically. Verified live on 2026-08-01: every real
user in a live workspace matched byte for byte, key order included.

Pinning that verification in integ where it can be: two cases compare
stat -c %s against cat | wc -c across all 11 users and all 90 channel
days rather than pinning one constant for one file, and count the matches
so an unexpanded glob fails instead of passing silently.

Both comments now record what integ cannot cover: the fake builds
users.list and users.info from one row through one helper, so they agree
by construction and a Slack-side divergence would stay green.

* test(discord): assert the size invariant over every member, day and file

Same treatment as slack, one commit earlier. Three cases compare
stat -c %s against cat | wc -c across all 3 members, all 60 channel days
and every attachment, counting matches so an unexpanded glob fails
instead of passing silently.

The attachment case is the one that carries weight. members and
chat.jsonl are sized from the same endpoint read fetches, so they can
only drift by staleness, but an attachment's size comes from Discord's
reported attachment.size while read returns the downloaded bytes, which
is the same class of assumption github_ci artifacts needed checking for.

It also covers what absolute pins cannot: the rendered chat.jsonl for a
day holding an attachment embeds the CDN url, which carries the server's
port, so its byte count differs between an in-process ephemeral port and
CI's fixed one. Comparing stat to wc -c is port-independent.

* test(mail): assert the size invariant over every gmail and email message

Same treatment as slack and discord. Four cases compare stat -c %s
against cat | wc -c across all 11 gmail messages and 6 attachments, and
all 5 email messages and 2 attachments, counting matches so an
unexpanded glob fails instead of passing silently.

email is the one with a real divergence risk, and unlike the slack and
discord fakes the integ server can actually expose it. readdir sizes a
message from fetch_headers, which despite the name pulls BODY.PEEK[] and
parses it with _parse_multi_fetch; read pulls the same bytes through
fetch_message and parses them with parse_rfc822. Two parsers, one
renderer, so a dict that differs by one field or one key makes the
advertised size wrong. GreenMail is a real IMAP server, so these cases
exercise both parsers against real RFC822 bytes rather than a fixture
that agrees by construction.

gmail is lower risk: readdir and read both call messages.get with
format=full, so only staleness can drift them.
2026-08-01 05:39:47 -07:00
Zecheng Zhang 4493648eaf Split the TypeScript builtin command specs by family (#672)
* Split the TypeScript builtin command specs by family

builtins.ts held all 93 command specs in one 1189 line table while
Python splits the same specs across nine family modules. The TypeScript
side now mirrors that layout: builtin_specs/ holds archive, fs_mutate,
hashing, listing, net, runtime, search, text_proc and viewing, with an
index that aggregates them and raises on a duplicate name the way
Python's __init__ does. builtins.ts keeps specOf and BUILTIN_SPECS, so
no caller changes.

The command to family assignment was taken from Python's modules rather
than reassigned by hand, and the two key sets already agreed exactly at
93 names.

This is a pure move. Regenerating the spec dump from the split code
produces byte identical JSON for all 93 commands in both the node and
browser outputs, which is the gate added in #666 doing the job it was
built for. The gate is not vacuous: dropping a single spec fails
generation with "no builtin spec: wget".

The null prototype freeze on BUILTIN_SPECS is preserved, so a name like
toString still misses rather than resolving an Object.prototype member.

* fix(spec): index the split spec modules without a type assertion
2026-08-01 05:39:38 -07:00
Zecheng Zhang 604bf47f3b Consolidate S3 provider configs and make credentials optional (#671)
* Consolidate S3 provider configs and make credentials optional

Two cleanup items.

The 14 S3-compatible provider configs were each a hand-written copy of
the same credentials, endpoint rule and S3Config conversion. They now
share a base (s3_alias.py, s3_alias.ts) and declare only their endpoint
rule, which removes about 680 lines. Python had no test coverage for any
of them, so a characterization test went in first and the refactor
happened under it.

Credentials are now optional on every provider in both languages.
S3Config already left them optional, so omitting them falls through to
the usual AWS resolution order: profile, environment, shared credentials
file, instance role. Only r2 exposed that before, and even r2 required
keys alongside the profile it accepted, so the profile could never be
used on its own. session_token and aws_profile moved onto the shared
base, which also deleted the bespoke to_s3_config overrides on r2 and
supabase. Required is now bucket plus whatever the endpoint needs.

Also from the stale-debt pass: a resolved issue doc is deleted, the
TypeScript forwardIndex knob keeps its comment corrected to the real
reason it exists (WorkspaceFS reaches OpsRegistry directly and has no
equivalent of Python's Ops on_write hook), with index-invalidation tests
added in both languages for the dispatch and fs paths, and gen_specs.py
now fails loudly when a command module will not import instead of
silently dumping a spec set missing every optional backend.

* Ignore docs/plan/ so plan docs cannot be committed again

The gitignore covered docs/plans/ but not the singular docs/plan/, which
is how the one tracked plan doc got committed. That doc is deleted in
this branch; this closes the hole behind it.

* Fix eslint errors the local run missed

Two typed-lint errors that only surface once the TypeScript dists are
built: a forbidden non-null assertion in the index invalidation test and
a type parameter used once in the alias optional helper.

* Honor a named profile in the TypeScript S3 client

The alias configs forwarded aws_profile into S3Config but createS3Client
never read it, so a named profile was silently dropped and the default
credential chain signed with whatever it found, which can reach the
wrong account. The SDK takes profile as a per-client AWS_PROFILE, so
forwarding it is enough; explicit keys still win, matching the
precedence Python gets from aioboto3.Session(profile_name=...).

Found by Codex review on this PR. Pre-existing for r2, which already
forwarded the field, and made reachable everywhere by this branch.
2026-08-01 04:12:37 -07:00
Zecheng Zhang b40cb41f11 fix(safeguard): a null override must not shadow the serving mount's table (#670)
* fix(safeguard): a null override must not shadow the serving mount's table

* test(mount): hoist the hanging command stub to module scope
2026-08-01 04:12:23 -07:00
Zecheng Zhang a90e4360ef Runtime and policy sweep 4: interruptible pyodide, killable monty, prototype-safe shell records (#669)
* fix(runtime): interrupt busy pyodide runs, kill monty workers, null-proto shell records, docker stdin EPIPE guard

* fix(runtime): store the watchdog trip reason before the interrupt, eslint arrow-body fix
2026-07-31 21:21:45 -07:00
Zecheng Zhang 24fba5d132 Finish the fskit size push-down: github_ci, trello, notion, jaeger, qdrant, lancedb, discord, email, langfuse, chroma (#667)
* feat(sizes): render-at-readdir sizes for github_ci, trello and notion

Batch 2 of the fskit size push-down (plan: ~/Desktop/fskit.md). Each
listing already carries the payload read renders, so readdir seeds a
sized IndexEntry and stat serves it. Zero added API calls.

github_ci workflow/run/job JSON and trello workspace/board/list/card
JSON use the shared node-dir shape: the parent readdir set_dirs each
child with a sized node.json, and the child readdir consults list_dir
before bootstrapping the parent. Notion differs because a database dir
listing is dynamic, so database.json's size travels in
extra[database_json_size].

Artifacts stay class T: size_in_bytes is the zip's exact byte length,
confirmed against two live samples. Flags stay False for all three:
job logs, annotations, comments.jsonl and page.json are class N.

integ gains a github_ci target backed by a fixed Actions dataset in the
fake server, with 24 stat==read pins.

* feat(sizes): render-at-readdir sizes for jaeger, qdrant and lancedb

Batch 3 of the fskit size push-down. qdrant and lancedb flip
SIZES_ALWAYS_KNOWN; jaeger stays False because operations.json is class
C and traces are class P.

jaeger traces are genuinely P: verified live that the /api/traces search
document renders byte-identically to the by-id fetch for all four seeded
traces, including the one listed under two services. operations.json is
sized by one /api/operations call at the service dir readdir.

lancedb needed table_columns/tableColumns to widen the select beyond
ids; it excludes vector and blob columns in schema order so projected
rows render byte-identically. Blob entries stay unsized on purpose,
since sizing a blob at listing time would let one undecodable value fail
the whole directory listing rather than just that file's read.

A previously pinned integ expectation was wrong: jg_read_28 asserted du
-sh printed 0B, which was an artifact of unknown sizes, and now prints
the real total.

* feat(sizes): sizes for discord, email, langfuse and chroma

Batch 4, the last of the fskit size push-down. discord, email and chroma
flip SIZES_ALWAYS_KNOWN; langfuse stays False because traces and prompts
are class N.

chroma's producer-supplied size was measurably wrong for every file in
our own fixture: 180 declared against 166 rendered, 90 against 52, and
150 against 159, which truncated reads. It describes the source document
rather than the chunk join mirage serves, so it can never be the byte
length. It moved to extra.source_size and the real size is measured by
one batched chunk scan per directory, run lazily from stat and cached.
This also corrected provenance byte accounting and du totals.

langfuse items.jsonl follows the class C shape: the dataset dir readdir
makes the one call and seeds a sized entry.

Carries the discord fake-API arc. The new integ/server/discord_server.py
exposed four real backend bugs: message pagination used the oldest id as
the after cursor when discord answers newest-first, attachments were
classified as directories, unknown paths returned an empty listing
instead of ENOENT/ENOTDIR, and a cold ls of a guild dir ENOENTed in
python only.

* fix(sizes): propagate backend failures from the stat parent-listing fallback

Review follow-up on the stat fallback these three backends use to
populate a cold index: the TypeScript side caught every error from the
parent readdir and fell through to ENOENT, so an auth failure, a rate
limit or a transport error read back as "file not found". Python already
caught only FileNotFoundError. Now only isMissingPath errors fall
through, which is the rule that helper's own contract states.

Reachable through the new size lookups, since workspace.json and the
other sized files resolve through this path on a cold index.

Also pins that discord's base_url config key reaches DiscordResource as
baseUrl through the registry: normalizeFields snake-to-camels by default
so no explicit rename is needed, and a silent miss there would send
configured mounts to the public API.
2026-07-31 21:21:35 -07:00
Zecheng Zhang 640f4200ab Cross-language command spec parity check (#666)
* feat(spec): cross-language command spec parity check

Add scripts/check_spec_parity.py and wire it into CI next to the existing
spec drift gate. It diffs the generated python and typescript spec trees
command by command: every option (help text, value kind, repeatability,
shorthand), every operand, the _meta flags, and the resource set each
command registers under. Structural divergences live in
spec/parity_exceptions.json with a reason, and a stale entry fails the
check so an exception cannot outlive what it documents.

gen-specs.ts could only see command groups the package index re-exports,
so HISTORY_COMMANDS and GRIDFS_COMMANDS were silently missing from the
dump. It now asserts every builtin *_COMMANDS is reachable, and both
backends are exported.

The check found four real bugs:

shuf required a write op it only needs for -o. TypeScript marked the
builder write:true and threw before doing anything, so shuf did not
register on 21 read-only backends. Python registered it but eagerly
called ops.require(WRITE), so every invocation raised. Both now resolve
the write op lazily, matching sed -i / sort -o / uniq -o.

The TypeScript factory only gated on the generic write op, so truncate
registered on databricks_volume, dropbox and the hf backends and rmdir on
the hf backends, none of which have those ops. Ported Python's
requirements mechanism; the tables are now identical in both languages.

TypeScript search carried no cost provisioning on qdrant, lancedb or
mem0, where Python does.

js and node had no help text in TypeScript, and curl, history and seq
differed. Aligned per option.

* fix(spec): compare spec metadata per resource, not as unions

Addresses two review findings on the parity gate.

gen-specs.ts treated any index.ts read failure as an absent file, so a
permission or IO error silently dropped every command group in that
directory, defeating the reachability assertion exactly when the source
scan was incomplete. Only ENOENT continues now.

The _meta union flags cannot say which resource carries a provision, an
aggregate, the write flag or a filetype, so dropping one backend's
provision while another kept it left every union unchanged and the gate
accepted the regression. Both generators now emit _meta.by_resource and
the checker compares keyed by resource, falling back to the unions only
once the per-resource entries agree.

That found six more divergences the unions had masked:

Python's history cat, grep, head, tail and wc carried no aggregate where
TypeScript did, and the same for github and email grep. Added them.

TypeScript had no github du provision where Python does. Added it.

TypeScript still has no github grep provision. Python's is a bespoke cost
model that walks the index and reaches IndexCacheStore internals, so it
is recorded in parity_exceptions.json for its own change rather than
ported here.

Exemptions are now granular: by_resource names one resource and one key,
so exempting github grep's provision cannot also hide the aggregate
divergences on the same command. An exemption counts as used only when it
suppresses a live divergence, so the stale check no longer misreports an
exemption that fully covers its diff.
2026-07-31 17:17:48 -07:00
Thomas Hart 2e0a2f1b5a feat(shell): export/readonly -p print mode (py + ts) (#661)
* feat(shell): export/readonly -p print mode (py + ts, #608)

Bare export/readonly and the -p form print declare -x/-r lines, matching
bash. Invalid option clusters fail with status 2 and the GNU usage line.
Array readonly names print as declare -ar. Unit tests and integ cases
cover both runtimes.

* fix(shell): match GNU on -- , control chars and type flags in -p print

Three defects in the export/readonly print path, all pinned against
bash 5.2 in debian:stable-slim:

- `export -p --` / `readonly -p --` failed with exit 2. The declaration
  word collector folded any `-`-leading word into a char set, so `--`
  contributed a literal `-` that the option validator then rejected.
  Option words are now kept verbatim and in order, and `--` ends option
  parsing. Keeping the order also makes the invalid-option message name
  the *first* bad letter, as bash does; the old set was hash-ordered, so
  `export -zq` reported `-z` or `-q` at random between processes.

- Only newline and carriage return selected `$'...'` quoting, and even
  there only \n \r \t were escaped, so a tab, escape or DEL byte was
  emitted raw inside double quotes. Any control character now selects
  ANSI-C quoting, with bash's named escapes (\a \b \t \n \v \f \r, \E)
  and three-digit octal for the rest. Printable non-ASCII stays literal,
  which is what bash emits in a UTF-8 locale.

- Type-selecting flags were parsed and discarded, so `readonly -f`
  listed variables and `readonly -a` listed scalars. `-a` now narrows to
  indexed arrays; `-f` and `-A` list nothing, mirage carrying no readonly
  or export attribute for functions or associative arrays.

Python and TypeScript stay mirrored. Adds 8 pytest cases, 6 vitest
cases, and 5 integ cases whose expectations were verified byte-for-byte
against GNU bash.

---------

Co-authored-by: Zecheng Zhang <zechengzhang97@gmail.com>
2026-07-31 17:17:35 -07:00
Zecheng Zhang 16a871639a fix(jq): preserve output arity instead of guessing it from the program (#668)
jq_eval collapsed a program's output stream into a single value: one
output bare, many as a list, zero as a JQ_EMPTY sentinel. A list of
outputs then reads the same as one output that is an array, so the
printer recovered the difference from the program text by scanning for
a `[]` at bracket depth 0 (has_top_level_spread).

Text cannot answer that question. Any multi-output program with no
literal `[]` printed a JSON array instead of one line per value, so
`jq -r '.a, .b'` printed `[1,2]`, and so did `range(3)` and `..`.

jq_eval now returns every output in order, format_jq_output prints one
per line, and the heuristic plus both JQ_EMPTY sentinels are gone: an
empty list means no output. Behavior is pinned against GNU jq 1.7 in
debian:stable-slim.
2026-07-31 16:57:33 -07:00
Zecheng Zhang 2566e50457 Runtime and policy sweep 3: bounded policy, interruptible quickjs, real job kill, guest os surface (#665)
* fix(runtime,policy): bound policy evaluation, interruptible quickjs, working job kill, guest os surface

* fix(quickjs): reject cross-mount renames, full C printf conversions
2026-07-31 16:02:27 -07:00
Zecheng Zhang dc80f4d742 Drop the eight provision helpers left with no caller (#664)
* chore(filetype): drop the eight provision helpers left with no caller

s3, ssh, redis, gridfs, nextcloud, onedrive, sharepoint and
databricks_volume each carried a _provision.py whose only consumer was
the provision= argument of the filetype command factory removed in
#651. Backends that also reach file_read_provision from their own grep
path keep theirs.

* fix(integ): keep the redis provisions and cover them with probe cases

The redis _provision.py is not an orphan of the filetype factory: it is
the public surface the redis example imports, mirroring the publicly
exported redis/provision.ts on the TypeScript side. Deleting it broke
examples/python/redis_resource/example_redis.py, which the Python
examples CI step runs.

It had no integ coverage, which is why nothing caught that. Adds
integ/resources/redis/probe.json with five provision cases running on
both hosts. Values are measured, not guessed: head -n 2 reports
net=0-24 precision=range, which is the hand-written head_tail_provision
bounding the read rather than claiming the whole file.

* test(integ): pin provision behavior for gdrive, google apps and trello

These four backends hand-write provision modules in one language or the
other, and none of them had provision coverage, so a divergence between
the hosts was invisible. Every value here is measured, not guessed.

The numbers differ per backend in ways worth pinning: redis reads over
the wire (net=24), gdrive serves the same file from its index cache
(cache=24 hits=1), a rendered .gdoc.json cannot predict its byte count
(precision=unknown), and head bounds the read as a range rather than
claiming the whole file.

Cases run on both the python and typescript-node hosts, so the layout
difference between the two languages is now gated on behavior.

* fix(provision): charge ops for remote listings, add search provision to TS

exactZeroProvision documents itself as being for backends whose listing
is already in memory, so metadata commands cost no backend I/O. gdocs,
gsheets, gslides and trello are remote API backends whose readdir hits
the wire, and TypeScript wired all four to it: ls reported ops=0 while
Python reported ops=1 for the same listing. Dropping the override lets
them fall through to the op-charging default, which is both the correct
estimate and the layout Python already had.

TypeScript's lancedb and qdrant search carried no provision at all where
Python's did; both now use exactZeroProvision, which is right there
because the search result is materialized in memory. The regenerated
search spec flipping to has_provision: true is that fix showing up.

The gdrive _provision.py had no consumer but its own test, and TS wires
nothing for gdrive, so both are gone.

Every change is pinned by a probe case measured on both hosts.
2026-07-31 06:27:56 -07:00
Zecheng Zhang 03b7608ea7 fix(shell): fully general $? semantics, C-style for, and observed mtimes for find (#654)
* fix(shell): fully general $? semantics, C-style for, and observed mtimes for find

* fix(ci): camel bg launch via $! and eslint require-await in statement test

* feat(ssh): native setattr via SFTP, stat surfaces mode and atime like disk

* fix(lint): narrow ssh2 nullable callback errors in ssh set_attrs

* fix(shell): reject readonly assignments in C-style for slots

* integ: pin the C-style for readonly abort
2026-07-31 05:25:55 -07:00
Zecheng Zhang 3d1dc0dfe9 Runtime and policy sweep: parity fixes, quickjs evaluator, node local runtime (#663)
* fix(policy): fail loud on entry-script verdict shapes, stop fanout swallowing

* fix(monty): raise typed fs exceptions in the ts guest

* feat(quickjs): evaluator capability in both languages, js policy scripts

* feat(local): host python runtime for the node package

* test: align the script loader wording

* style: satisfy the ci linters

* fix(runtime): reclaim the local subprocess on safeguard timeout, tolerate stdin EPIPE

* fix(quickjs): bridge the workspace fs into eval, match the real engine's output surface
2026-07-31 04:10:53 -07:00
Zecheng Zhang 86e9e20da7 feat(sizes): render-at-readdir sizes for slack and gmail; flip mem0 and opfs size-known (#662)
* feat(sizes): render-at-readdir sizes for slack and gmail; flip mem0 and opfs size-known

* test(integ): pin slack file, dm and empty-day chat, and gmail attachment sizes

* test(fuse): use notion as the size-unknown specimen for the fskit guard

* fix(slack): skip tombstoned and access-restricted file payloads in listings
2026-07-30 22:47:58 -07:00
Zecheng Zhang 5ebb0b61d2 Policy layer: rename route to policy, add a deny verdict (#659)
* refactor: rename the runtime route to policy

* feat(policy): extensible verdict with a deny arm

* refactor(policy): move the safeguard resolver into the policy package

* fix(policy): rename the route key in the ts server config, fix the integ import

* fix(policy): record denied lines in ts history, type the verdict docstring

* fix(policy): attribute deny and admission errors to the command

* feat(policy): typed verdict arms RouteResult and DenyResult

* docs: type the policy example return value

* integ: move the safeguard scenarios to the JSON runtime harness

* fix(policy): gate syntax errors before the policy in python

* fix(shell): report the full syntax error span like bash echoes the line
2026-07-30 20:25:43 -07:00
Zecheng Zhang 9194c75d23 fix stat sizes across github, hf, databricks_volume and sharepoint; mark them size-known (#660)
* fix stat sizes across github, hf, databricks_volume and sharepoint; mark them size-known

* test(hf): move the metadata-stripping list doubles to module scope

* test(integ): pin the size backfills and the truncated-tree fallback
2026-07-30 17:20:31 -07:00
Zecheng Zhang 61c2a74aa4 fix stat sizes across onedrive, box, dropbox, ssh and nextcloud; mark them size-known (#658)
* feat(fuse): size push-down T flips for onedrive, box, dropbox, ssh and nextcloud

* style: prettier format for the nextcloud readdir test

* fix(box,ssh): follow ssh symlinks in TS stat, hide weblinks from direct box stat

* test(integ): pin ssh symlink sizes and box weblink hiding in the shared battery

* test(ssh): add lstat to the mock SFTP client
2026-07-30 06:24:15 -07:00
Zecheng Zhang 45dd79d292 refactor(runtime): evaluator capability, route engine on runtimes, drop PythonRuntime (#657)
* refactor(runtime): evaluator capability, route engine on runtimes, drop PythonRuntime

* refactor(runtime): split base into types/errors/mixin, eval transport belongs to the runtime

* docs: module layout convention (types/errors/config/mixin/base)

* docs: trim module layout section

* review: brand the evaluator, bind session inputs and bytes on pyodide

* fix(runtime): refuse unbound interpreters with GNU command-not-found wording

* fix(integ): survive connection resets while jaeger boots

* docs: evaluator page under learn, language icons for the runtime pages

* docs: reframe the evaluator page as the policy engine

* docs: both entries capture python3 in the policy engine example

* docs: real routing case with the ctx payload and verdict semantics

* docs: drop the engine-only yaml block, one parenthetical instead

* docs: live-captured ctx payload, verified config, function route signature

* docs: trim the policy engine section
2026-07-30 05:53:05 -07:00
Zecheng Zhang 3a5eb5c260 fskit size guard warns, Linear gets size push-down (#656)
* fix(fskit): downgrade the size guard from a refusal to a warning

* feat(linear): push file sizes down to readdir so stat matches read

Every linear file is now sized at its parent directory's readdir from the
payload the listing already fetched: team.json from the teams listing,
issue.json from the issues listing, member/project/cycle/document JSON from
their own listings. comments.jsonl costs one bounded comments call, paid
only when the issue directory is entered. Listing a tree never fetches file
content, and stat reports the exact rendered byte length, which is what
fskit needs at lookup time to serve reads. SIZES_ALWAYS_KNOWN flips on.

Invariant pinned in both languages (stat size equals read length for every
file in the tree) and in integ with paired stat/wc cases against the fake
Linear server.

* fix(fuse): drain buffered writes on release, warn on writable fskit mounts

The macFUSE FSKit shim issues WRITE then RELEASE with no FLUSH in between
(the kext always flushes on close), so MountCore dropped every buffered
kernel write at release. Release now drains the handle's write buffer in
both languages.

Testing the CLI over a live fskit mount also measured a shim bug mirage
cannot fix: pages for regions a file did not already have (a new file, an
empty file, a truncate-then-write) flush as NUL bytes of the right length,
and appended regions arrive intact or zeroed depending on cache state,
with no error surfaced to the writer. A new check_writes guard warns at
mount time for writable fskit mounts (metadata ops are reliable; /dev is
excluded), and the behavior is pinned in integ/truth_fskit.json plus a new
CLI battery, integ/cli_fskit.sh, which drives both daemon-backed CLIs
through a real fskit mount and skips cleanly on hosts that cannot engage
the FSKit module. cli_fuse.sh now also asserts that a backend fskit
workspace is rejected cleanly off macOS.

* refactor(integ): move the fuse truth from txt lines to JSON

* refactor(integ): gather the fuse and fskit harness under integ/fuse/

* docs(fuse): document the fskit write-path limits and the size warning

* docs(fuse): tighten the fskit warnings
2026-07-30 04:23:17 -07:00
Zecheng Zhang 377c59a538 refactor(runtime): uniform runtime interface, one integ suite for all runtimes (#655)
* refactor(runtime): one constructor shape for every runtime

* integ(runtime): one JSON suite for all runtimes across SDK and CLI

* integ(runtime): drop the e2b suite, docker stays the sandbox coverage

* integ(runtime): cover the --runtime error paths

* fix(integ): keep battery case discovery out of integ/runtime, satisfy ci yapf

* fix(grep): -r with no path operand searches the cwd like GNU

* fix(rg): bare rg searches the cwd like ripgrep, rename rebase_* to respell_*

* fix(cwd): bare find, tree, du and ls default to the cwd like GNU

* integ: cover routing gaps, fix redis du golden and rg battery stderr

* integ: materialise folder-backed mount roots at seed time

* fix(interpreter): refuse uncaptured python/js lines instead of a hidden fallback

* integ: seed the onedrive /data2 root too
2026-07-30 01:45:36 -07:00
Zecheng Zhang 0c3c827ac9 feat(fuse): fskit mount backend and a MountCore/adapter split (#648)
* refactor(fuse): split the mount layer into MountCore plus a libfuse adapter

MirageFS was one class doing two jobs: filesystem semantics and talking to
libfuse. The FUSE-specific part was smeared across every method rather than
sitting at a boundary, which is why it could not be reused by another kernel
interface.

MountCore (fuse/core.py, fuse/core.ts) now owns all semantics and imports
nothing from mfusepy or fuse-native. MirageFS keeps only the callback
signatures and error translation. The attr dicts stay as they were: st_mode
and friends are POSIX stat field names, not FUSE ones.

The load-bearing change is error handling. Python raised FuseOSError inline
in ten methods with hand-rolled except chains while TypeScript already had a
single classifyError. Both languages now share one classification table
(fuse/errors.py, fuse/errors.ts), so the same backend failure reports the
same errno on both sides.

That fixes a real bug. Python's rmdir caught OSError before
FileNotFoundError, and FileNotFoundError is an OSError subclass, so rmdir on
a missing path returned ENOTEMPTY instead of ENOENT. No test covered it;
test_errors.py now pins it.

Behavior is otherwise unchanged: the pre-existing suites (test_fs.py 689
lines, fs.test.ts 43 tests) pass untouched apart from renaming private
attribute reads onto .core.

* feat(fuse): fskit mount backend, kext-free mounts on macOS

Adds backend selection to a FUSE mount: Mount(..., fuse_backend="fskit")
routes through macFUSE 5.x's FSKit shim, so the mount runs with no kernel
extension loaded. Closes #82.

Three rules are enforced at mount time rather than discovered at run time:

- macOS only, raising elsewhere instead of silently dropping the option.
- The mountpoint must live under /Volumes. FSKit refuses anything else,
  which is what made the reporter's /tmp attempt in #82 fail. The rule is
  owned by FuseManager, so no call site has to know it.
- Every mounted resource must be able to size its files. FSKit has no
  direct_io, so reads are driven entirely by the reported size: a resource
  that stats as 0 before open would serve an empty file with exit code 0.
  Mirage refuses such a mount and names the offending prefixes.

That last rule needs a new capability, SIZES_ALWAYS_KNOWN / sizesAlwaysKnown,
default false. Opted in on the byte stores (ram, disk, redis, s3, gridfs)
plus dev and history. History matters more than it looks: it is mounted into
every workspace at /.bash_history, so leaving it false would block every
root-scoped fskit mount.

There is deliberately no "auto" value. Auto-selecting fskit would silently
break every API-backed mount, and an option whose safe value is always the
default is a trap.

TypeScript cannot reach FSKit at all: @zkochan/fuse-native bundles a
pre-macFUSE-5 dylib, so the option never arrives at a driver that
understands it. checkPlatform throws rather than quietly mounting through
the kext. The capability and guards are still mirrored so the semantics
match. Documented as a known gap.

Examples cover both sides of the limit: ram, redis and s3 mount and read
under fskit; slack and gmail show the guard refusing and the supported
workaround of scoping the mount to a byte-store subtree.

* refactor(mount): one backend field, vfs by default

Replaces the fuse boolean and the fuse_backend string with a single
MountBackend StrEnum: vfs, fuse, fskit. VFS is the default and finally has a
name, instead of being spelled fuse=False.

    Mount(resource, backend=MountBackend.FUSE)
    Mount(resource, backend=MountBackend.FSKIT, mountpoint="/Volumes/x")

Three things this fixes.

The default was previously defined by negation. Most mounts are not
kernel-mounted at all, and that case is what mirage is mostly used for, so it
deserves a name rather than a falsy value.

The overloaded bool | str union is gone. fuse=True and fuse="/path" packed
two decisions into one field; backend and mountpoint separate them.

The enum no longer claims a name it does not own. fuse_backend="fskit" read
as "use FSKit", but which delivery mechanism reaches FSKit (macFUSE 5's shim
today, a native Swift module later) is mirage's business, not the caller's.
One value covers both.

MountBackend lives in mirage.types next to MountMode rather than in
mirage.fuse, so the mount spec does not have to depend on the fuse package
for a value that also covers the non-fuse case. resolve_backend rejects vfs:
reaching it means a kernel mount was requested, and vfs registers nothing.

Config, server routers, and the TypeScript mirror follow, including the YAML
schema (backend: fuse plus mountpoint: <path>) and fuseMounts becoming
kernelMounts.

* fix(mount): migrate the CLI config path and integ fixtures to backend

The backend rename missed three call-site shapes that a literal search for
fuse=True / fuse: true does not catch:

- YAML mountpoints written from shell variables (integ/cli_fuse.sh wrote
  `fuse: $dmnt`, five of them), which is the CLI's own end-to-end path.
- Mount options passed a variable rather than a literal (integ/fuse.py and
  integ/fuse.ts both used `fuse=pinned`).
- The TypeScript server config schema, which still declared
  `fuse?: boolean | string` and exported fuseMounts. It now mirrors Python:
  backend plus mountpoint in, kernelMounts out.

The CLI itself has no fuse flag, so its whole surface is the workspace YAML.
Both config loaders were checked against the exact document cli_fuse.sh
writes and agree: /data and /logs resolve to (fuse, <path>), a backend with
no mountpoint resolves to (fuse, None), and a mount with no backend key stays
on vfs and is absent from the kernel mounts.

CLAUDE.md documents the single backend field and the YAML keys.

* refactor(mount): missing is vfs everywhere, and one guarded entry point

Two related cleanups.

Missing meant two different things. Mount.backend defaulted to VFS and an
absent YAML key resolved to VFS, but resolve_backend(None) returned FUSE,
reinterpreting an absent value as a request for a kernel mount. Now every
absent value lands on VFS, and the mount entry points spell their own
default: backend: str | MountBackend = MountBackend.FUSE. The intent is in
the signature instead of hidden in the resolver.

resolve_backend is pure coercion again; require_kernel_backend is the
separate step that rejects VFS. The unknown-name error lists all three
values rather than just the mountable two.

The fskit guards were three separate calls at each mount path, so a new path
could pick up fskit support and silently skip the macOS assert, the /Volumes
rule, or the size check. prepare_backend now runs all of them, and every
mount path routes through it: mount_background, mount, and FuseManager.setup.
Tests pin that a linux platform raises through prepare_backend, not just
through check_platform directly.

Same shape in TypeScript: resolveBackend, requireKernelBackend, prepareBackend.

* fix(fuse): drop the polynomial regex from unsizedMounts

CodeQL js/polynomial-redos, two high-severity alerts on the same helper.
`prefix.replace(/\/+$/, '')` backtracks on a string of many repeated
slashes, and the input is a mount prefix, so it is library input.

Uses core's loop-based rstripSlash instead, which is the existing sanctioned
trim for exactly this. Python was never affected: str.rstrip is a C loop,
not a regex.

* fix(fskit): match the mount recipe and volume ownership verified in #82

Three corrections to the fskit path, all found by re-reading issue #82
against what we actually shipped.

Mount options. #82's only reported working mount passes backend=fskit AND
volname, and omits direct_io. We passed direct_io unconditionally and no
volname, on my assumption that direct_io is simply inert on this path. That
assumption contradicted the one person who has run it, and direct_io is a
libfuse concept, so the shim needing it removed is not surprising. Now
matched exactly, with a comment telling the next person not to restore it.

Mountpoint ownership, which would have broken every fskit mount. /Volumes is
drwxr-xr-x root:wheel, so tempfile.mkdtemp(dir="/Volumes") raises
PermissionError for any non-root user. The reporter mounted at
/Volumes/mirage-* as a normal user, so macFUSE creates the entry: an FSKit
mount is a volume, not a directory we make. The fskit path now names its
mountpoint and never creates it, skips makedirs for a pinned one, and never
rmdirs a /Volumes entry on unmount.

Readiness. The /Volumes entry does not exist until the volume is live, so
bare existence is a real ready signal there, exactly as for WinFsp, and it
does not depend on os.path.ismount recognizing the mount.

These are the same three deviations WinFsp already needed, for the same
underlying reason: the filesystem driver owns the mountpoint. Tests pin the
option set and all three ownership rules, because nothing in CI can reach
this path (macOS 15.4+, macFUSE 5.x, a GUI-enabled FSKit module).

* fix(tests): pin the platform in the fskit manager tests

These three passed on macOS and failed on the Linux runner: FuseManager
routes through prepare_backend, whose check_platform rejects fskit off
darwin. Patch sys.platform explicitly so the assertion under test is the
mountpoint behavior, not the host, matching what test_backend.py already
does in both directions.

* ci: run the fskit backend for real on a macOS runner

Until now fskit shipped on unit tests plus one user report, because no
job could mount it. integ/fuse.py cannot cover it: its sizeless probe is
refused by the fskit size guard by design, and its two-mount scenario
needs something macOS forbids. So this adds integ/fskit.py, one RAM mount
under /Volumes that reads, writes and stats through the kernel.

Advisory like integ-fuse-windows, since whether a hosted runner enables
the FSKit module without a GUI toggle is what the job measures.

The truth file is JSON checked by value, not a txt file checked by
substring: the old harness passes a result of 166 against a truth of 16.
check_json.py is written to serve the remaining txt probes too. Only what
mirage controls is asserted; the mountpoint, the raw mount row and
whether a kext happens to be loaded are reported and left alone.

* ci(fskit): report the mount state before reading it

The first macOS run failed with a bare FileNotFoundError and told us
nothing: every diagnostic in the probe was printed after the read that
crashed. The volume entry appeared (readiness passed, the mount thread
stayed alive) but served no tree, and there was no evidence of why.

Print the mountpoint, its stat flags, the mount row and a listdir before
touching a file, and add an always-run step dumping the mount table,
/Volumes, the registered FSKit modules and the recent macfuse log.

* fix(fskit): a stray /Volumes directory is not a live mount

The macOS integ job reported exists=True isdir=True ismount=False with no
row in the mount table: macFUSE creates the /Volumes entry while mounting
and leaves the empty directory behind when the FSKit handoff fails. Bare
existence as the ready signal accepted that, so a mount that never came up
was reported live and failed with ENOENT on the first read.

Require os.path.ismount for every POSIX backend and keep the existence
shortcut for WinFsp only, where _prepare_mountpoint removes the directory
first so its reappearance really does mean the filesystem is live. The
backend argument is unused now, so it goes. _await_ready had no test at
all; it has one now.

Also register both macFUSE appexes on the runner: the installer registers
only the -local module there, while a developer Mac has both.

* ci(fskit): capture why the macFUSE mount never comes up

Both modules register and enable now, and the mount still times out with
no libfuse error, while the only module the system launches is msdos. So
the mount request is not reaching macFUSE at all. Capture the kext device
nodes, the system extension list and a macfuse-scoped log to tell an
environment limit from a mirage bug.

* docs(fskit): record where the /Volumes rule comes from

The guard read as an arbitrary house rule. It is measured: issue #82 ran
an fskit mount under /tmp, got 'mount_macfuse: the file system is not
available (1)', and the same mount worked once it moved to /Volumes.

* fskit: pin the real write surface, measured on a Mac

The mount works: /Volumes/mirage-*, tagged fskit, tree served, reads exact
(stat size == bytes read, so the size guard holds). ismount is true on a
live FSKit volume, which is what the readiness fix now depends on.

Writes are a different story. In-place writes and unlink work; create,
mkdir and rename return ENOSYS. A failed create still applies: the syscall
reports ENOSYS and the file exists in the resource and through the mount
anyway. Tracing shows mirage's create succeeds and returns a handle, then
the shim fails the syscall, so we cannot report this more accurately.

Pin the whole matrix in the probe and say plainly in the docs that
anything creating files will fail on an FSKit mount.

* examples: one fskit example per language

Five scattered files (ram, redis, s3, slack, gmail) collapse into
examples/python/fuse/fskit.py, which shows the size guard refusing, a live
mount with reads matching their stat size, and every write op with the
errno it returns. Verified end to end on macFUSE 5.3.3.

examples/typescript/fuse/fskit.ts cannot mount, since fuse-native bundles
a pre-macFUSE-5 dylib, so it shows the refusal and both alternatives:
backend fuse there, or the Python package for a kext-free mount. It
typechecks but is not run here, because its fuse leg is a live kext
mount.

* feat(fskit): TypeScript serves fskit after all

The 'fuse-native bundles a pre-macFUSE-5 dylib' claim was wrong: the
libosxfuse.2.dylib it ships is a stub whose install name is
/usr/local/lib/libfuse.2.dylib, and fuse.node links that absolute path,
so Node loads the same macFUSE 5.x libfuse Python does. Verified with a
live mount: /Volumes tagged fskit, cat/wc exact, and the same write
surface as Python (in-place ok, create/mkdir/rename ENOSYS).

Drop the unconditional throw from checkPlatform (macOS-only stays),
generalize appendDirectIO into appendMountOptions, and give mount() the
fskit branch: /Volumes named-not-created, backend=fskit + volname,
no direct_io, ownsMountpoint false so nothing ever rmdirs a /Volumes
entry. The example now mounts for real; docs flip from known-gap to
supported-with-caveats.

The caveat is earned: one of three example runs wedged on an append, and
a dead FSKit volume blocks mount-table enumeration system-wide until the
macFUSE appex is killed. Documented as read-mostly and experimental.

* docs: example READMEs and a concise fskit story

Both examples/ READMEs explain how to run, the naming convention, and why
fskit exists: Apple has deprecated third-party kexts (reduced-security
boot + approval on Apple Silicon already), FSKit is the supported
userspace replacement, and macFUSE 5.x serves the same libfuse API
through it. One mermaid flow shows the two paths differing only in the
kernel-to-userspace hop. The two fuse.mdx warnings shrink to their
load-bearing facts and the python page gains the same why + diagram.

* ci(fskit): report the known hosted-runner limit as a skip

Everything installs and enables headlessly, but the mount request never
reaches macFUSE's FSKit module on a hosted runner, so every run ends in
the same readiness TimeoutError and a permanently red advisory job is
noise. Treat exactly that signature as a skip and drop continue-on-error:
green now means environment-limited or verified, red means something new
broke (a guard regression, a crash, or the runner starts mounting and
diverges from integ/truth_fskit.json).

* feat(fskit): full write surface via macFUSE's Darwin-only callbacks

The read-mostly limitation was never FSKit's: the shim finalizes every
created item through setattr_x and routes rename through renamex, both
Darwin-only fuse_operations fields that mfusepy leaves as reserved NULL
slots. libfuse answered those requests itself with ENOSYS, after our
CREATE/MKDIR had already applied (wire trace: CREATE success, then
SETATTR -78), which also explains why the failures were dirty and why
rename never reached userspace.

fuse/darwin.py replaces the 13 reserved slots with macFUSE's real Apple
tail (same size, asserted) and marshals setattr_x, fsetattr_x and
renamex; MirageFS decomposes setattr_x (size routes to truncate, other
attributes follow the chmod/chown accept-if-exists semantics) and maps
renamex flags (EXCL honored, SWAP refused as ENOTSUP). Installed once
per process from _run_fuse, no-op off macOS, layout-guarded so an
mfusepy upgrade degrades to the old behavior instead of corrupting the
struct.

touch, mkdir, mv, rm and a new-file write roundtrip now all pass on a
real fskit mount; integ/truth_fskit.json pins the full matrix. Docs flip
Python fskit from read-mostly to full-write; TS keeps the old surface
because fuse-native's compiled op table cannot gain new C callbacks from
JS. Remaining upstream caveats stay referenced in code comments:
macfuse#1181 (exec until first read) and macfuse#1165 (root readdir
cache invalidation).

* test(fskit): skip the struct-extension test off macOS, settle yapf's import wrap

mfusepy builds fuse_operations per platform, so the Darwin reserved tail
the Apple fields replace does not exist in the Linux layout and
install_macfuse_extensions correctly bails there; monkeypatching
sys.platform cannot conjure the struct. yapf also disagreed with the
committed import wrapping; both yapf and isort accept the new form.
2026-07-29 23:37:22 -07:00
Zecheng Zhang d5d08eb541 feat(runtime): sandbox runtimes with FUSE-mounted workspaces (#590)
* feat(resource): generic remote_mount_spec so cloud backends are sandbox-mountable

Lift the S3-only remote_mount_spec to the base class: any backend that
opts in with remotely_mountable and holds a pydantic config serializes
{resource, config} (credentials unwrapped) so a remote mirage, e.g.
inside a sandbox, can reconstruct and mount it. RAM and disk keep the
None default. Enables s3, gdrive and slack; adds base coverage.

* feat(runtime): sandbox runtimes with FUSE-mounted workspaces

Add the RemoteSandbox base and the Daytona, e2b and Docker runtimes:
whole-line execution against a remote or local container, lazy
provisioning on the first line, reattach by id, and sandbox ownership so
teardown only touches what we created.

The workspace becomes visible by running mirage inside the sandbox and
FUSE-mounting each remotable mount live: the host serializes the mounts
into the public workspace config, writes .mirage-workspace.json, and runs
one command, `mirage workspace create`. The CLI auto-spawns the in-sandbox
daemon and mounts synchronously, so the exit code is the ready signal and
stderr carries the error. Reads and writes flow both ways with no sync;
writes reach the backend on file close. Needs an image with mirage baked
in (e.g. mirage-python-fuse).

* feat(sandbox): translate virtual mount paths onto provider mountpoints

The agent speaks virtual paths (/data/a.py); mirage is the control
plane that rewrites them onto each provider's physical mountpoint
(/home/daytona/workspace/data/a.py on Daytona, /workspace/data/a.py on
Docker). Before, only cwd was rebased, so absolute virtual paths broke.

Rewrite is longest-prefix-first and only touches tokens that start
exactly at a mount (/s3 or /s3/...); siblings (/s3.txt), system paths
(/usr/bin), and relative paths are left alone. Each mount is also
exported as a MIRAGE_<PREFIX> env var for paths built at runtime. A
bare / world mount is skipped (it would capture the sandbox's own /usr).

Verified end to end on a real Daytona sandbox with an absolute
/data/in.txt round trip.

* docs(examples): README for the Daytona FUSE runtime example

Documents the working flow: bake the mirage-fuse snapshot once, then
drive an S3-backed workspace whose python3 lines run in a Daytona
sandbox that FUSE-mounts the bucket live. Explains the control-plane
path translation (virtual /data/x rewritten to the sandbox mountpoint).

Fixes the workspace yaml: drops the removed mount: fuse/copy option
(fuse is now the only sandbox mode) and points the active runtime entry
at the mirage-fuse snapshot so the example actually mounts.

* fix(sandbox): typecheck fallback for env tuple; remove RAM runtime example

The env-var test's ?? fallback widened env to {}, failing tsc
(TS2339 on MIRAGE_DATA). Type the fallback like the sibling cwd test.

Remove sandbox_runtime.py: it mounted RAM, which is not
remotely_mountable, so under the fuse-only sandbox path its first
python3 line is rejected. The Daytona example (S3-backed) is the
supported demo.

* feat(docker): bake all backends into the sandbox image, not just s3

The sandbox image install was pinned to mirage-ai[s3,fuse], so a
workspace mounting any other backend failed in-sandbox for lack of its
deps. Install mirage-ai[all,fuse] via a MIRAGE_EXTRAS build arg
(default all) so one image mounts any backend; narrow the arg or extend
FROM the image for a lean build. Verified: [all,fuse] resolves under
pip and postgres/mongodb/gcs import in the built image (1.74 GB).

* feat(docker): sandbox image installs mountable backends only, no agent deps

Add a curated 'sandbox' extra (every mountable backend + fuse) and
default the Dockerfile to it, instead of 'all'. A sandbox is a
filesystem host: it never builds agents or launches other sandboxes,
so the agent frameworks (anthropic/openai/deepagents/openhands/agno/
claude-agent-sdk/pydantic-ai) and provider SDKs (daytona/e2b) that
'all' pulls do not belong. mem0 is excluded too: mem0ai is the lone
backend that hard-requires the openai client.

Verified in the built image: no openai/anthropic/daytona/e2b/mem0,
backends (s3/postgres/mongodb/chroma) and fuse still import. Image
1.74 GB (all) -> 947 MB. Narrow further with --build-arg MIRAGE_EXTRAS
or extend FROM the image.

* docs: document sandbox runtimes (docker/daytona/e2b)

Add a Sandbox page to the Runtimes section for both the Python and
TypeScript docs, wired into docs.json nav. Covers capture-based
routing, the live FUSE-mounted workspace, control-plane virtual-path
translation, the mirage-python-fuse image + MIRAGE_EXTRAS, per-provider
setup, reattach/lifecycle, and resource limits. The existing runtime
pages document the in-process interpreters (monty/wasi/pyodide/local);
this covers the remote whole-line runtimes.

* refactor(resource): unify remote flag, rename remotely_mountable -> remote

One boolean now answers 'does this backend live remotely', replacing
both the verbose remotely_mountable and qdrant's dead is_remote/isRemote
one-off. remote=True unlocks reconstructing the resource elsewhere (FUSE
mount inside a sandbox) via the generic remote_mount_spec. Set on s3,
gdrive, slack, qdrant.

* docs: add cache-invalidation example to invalidate_all_after_remote

Concrete cat -> sandbox-write -> cat example showing why a sandbox line
forces a full local cache reset.

* feat(sandbox): reconcile mounts imperatively, drop the workspace config file

mirage is the control plane: the host workspace is the desired state,
the sandbox is the actual state, and every captured line reconciles the
two through the provider's own exec API. A new or changed mount runs
'mirage mount add <prefix> --fuse <path>' inside the sandbox with the
spec in the exec environment (never a file, never argv), a dropped
mount runs 'mirage mount remove <prefix>', unchanged mounts cost
nothing. Mounts added or removed after the sandbox booted converge on
the next line.

New in-sandbox CLI 'mirage mount add/remove/list': each prefix becomes
its own single-mount daemon workspace (deterministic id), so mounts
attach and detach independently through the existing create/delete
endpoints. The uploaded .mirage-workspace.json and one-shot
mount_workspace are gone; TS mirrors the reconciler (syncMounts,
serialized per line).

Real docker e2e green: reconciled mount add, absolute-path read, FUSE
write-through to S3, host readback.

* test(qdrant): follow the is_remote -> remote rename

* ci(integ): survive chocolatey outages in the WinFsp install

Retry choco three times, fall back to the official WinFsp GitHub
release MSI, and verify winfsp-x64.dll landed so a bad install fails
at the install step instead of as 'Unable to find libfuse' mid-test.
The advisory integ-fuse-windows job went red on a chocolatey.org 503.

* refactor(sandbox): mount once, run lines verbatim, drop path magic

Remove the line translation, the MIRAGE_<prefix> env injection, and
the per-line reconcile state. The contract is now plain: the sandbox
mounts the workspace's backends once at boot (mirage mount add per
mount, spec in the exec env), mounts appear at
<workspace_root>/<prefix>, the session cwd is rebased, and the line
runs verbatim. Path consistency beyond the rebased cwd is the
caller's job; static rewriting could never be complete (quoted code,
runtime-built paths) and half-working magic is worse than none.

Docs and the daytona example teach the relative-path contract. Real
docker e2e green: relative read and write through the mounted bucket
with rebased cwd.

* refactor(sandbox): one general SandboxConfig, shared constants, provider packages

* refactor(sandbox): provider-owned configs, spec-derived mounts, remote sweep

* refactor(sandbox): connect-only runtimes, one in-sandbox workspace

* refactor(sandbox): drop the lazy provider re-exports

* refactor(sandbox): user-provisioned sandboxes, connect and exec only

* fix(sandbox): safeguard whole lines, per-invocation stdin paths, trim sandbox extra

* refactor(safeguard): one resolve_safeguard entry point, shared guard_output boundary
2026-07-29 15:25:22 -07:00
Thomas Hart bf076359ed fix(shell): update $? between commands inside subshells and brace groups (#652)
CLI exit codes / changes (push) Has been cancelled
Test (Install) / changes (push) Has been cancelled
Pre-commit / pre-commit (push) Has been cancelled
Integ / changes (push) Has been cancelled
Test (Python) / changes (push) Has been cancelled
Test (TypeScript) / changes (push) Has been cancelled
Integ / integ-facets (mem0) (push) Has been cancelled
Integ / integ-facets (project) (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Python) / runtime (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / python-install (push) Has been cancelled
Test (Install) / ts-install (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-shared-py (push) Has been cancelled
Integ / integ-shared-ts (push) Has been cancelled
Integ / integ-shared-parity (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Integ / integ-fuse-windows (push) Has been cancelled
Integ / runtime-py (push) Has been cancelled
Integ / runtime-ts (push) Has been cancelled
Integ / integ-observability (push) Has been cancelled
Integ / integ-facets (chat) (push) Has been cancelled
Integ / integ-facets (dify) (push) Has been cancelled
Integ / integ-facets (email) (push) Has been cancelled
* fix(shell): seed $? between commands in subshell and brace-group bodies

Inside ( ) and { }, last_exit_code was never updated between children,
so $? always reflected the pre-block value. Match the program-loop
behavior and seed after each child in both Python and TypeScript.

Fixes #476

* fix(shell): finalize lazy exit codes before seeding $? in subshell and brace-group bodies

* fix(shell): seed $? with a barrier in if/loop/case/function bodies too

---------

Co-authored-by: Zecheng Zhang <zechengzhang97@gmail.com>
2026-07-28 16:58:09 -07:00
Zecheng Zhang fa18d0b091 fix(observe): keep the mount prefix on op records in typescript (#653)
* fix(observe): keep the mount prefix on op records in typescript

Op events named the mount-relative path, so two mounts holding the same
filename produced identical records and the audit trail could not say
which backend was touched. Python already had the machinery for this;
typescript was missing three pieces of it.

- pushMountPrefix returns the previous prefix so callers restore it
  instead of clearing to '', which a command dispatching into another
  mount needs (mirrors push_mount_prefix).
- withMountPrefix re-pushes the prefix around each pull from a stream,
  so a command that defers its backend read to the first chunk still
  records the right path. Mount.execute wraps the stream plus
  IOResult.reads/writes through it (mirrors with_mount_prefix and
  _wrap_cmd_streams), with CachableAsyncIterator.wrapSource standing in
  for replace_source.
- Dispatcher.dispatch and Mount.executeOp push the prefix around the
  backend op, which is how eager writes get theirs (mirrors Ops._call).

The two s3 sites that passed the virtual path to work around this keep
doing so; record() leaves an already-prefixed path alone. Their comments
no longer describe a bug.

* test(observe): pin op record paths per mount in both languages

Two mounts each holding report.json, driven through an eager write, a
lazy read and a cp that crosses mounts. Both languages now emit the
same six records. The typescript test previously carried a comment
explaining why the path was not asserted; that is gone.

* fix(observe): scope the mount prefix per async branch

pushMountPrefix mutated the one RecordingState every branch shares, so
two mounts consumed concurrently (cat /s3/a & cat /db/b) could see each
other's prefix and their restores could overwrite each other, recording
paths like /db/a. Python avoids this by re-setting a frozen Recorder per
task and sharing only the sink.

runWithMountPrefix replaces it: it derives a state for the branch,
shares the records array, and hands it to the async context, so nothing
is mutated in place. The push/restore pairs in Mount.execute,
Mount.executeOp and Dispatcher.dispatch become plain scopes and their
try/finally blocks go away.

Both new context tests fail against the mutating version.

* fix(observe): require a path boundary before treating a record as prefixed

record() decides whether a backend already named the virtual path by
testing startswith(prefix), which also matches a filename that merely
shares the prefix's leading text. A mount at /s3 holding s3-report.txt
recorded /s3-report.txt, dropping the mount. Compare against prefix + "/"
(or the mount root itself) in both languages.
2026-07-28 15:22:52 -07:00
Zecheng Zhang 25772c6b0b integ: move history and find arg-errors to the JSON harness, add curl/wget (#649)
* integ: move history and find arg-errors to the JSON harness, add curl/wget

The JSON harness has grown to cover almost everything the old script-plus-
truth-file suites did, so this retires the ones that are now redundant or
cheap to migrate, and closes the last command with no integ coverage.

Deleted as dead code:
- cases.py / cases.ts (~1.7k lines each). Only three meta helpers were still
  imported, by metadata.py / metadata.ts.
- metadata.py / metadata.ts / truth_metadata.txt. Every case they ran already
  exists in integ/unix/meta and integ/unix/meta_overlay under the same ids,
  on 22 backends instead of 4.
- s3_probe.ts and runners/tools/migrate_legacy.py, referenced by nothing.
- Seven integ/package.json scripts pointing at files that do not exist.

Migrated to the harness:
- history.py + truth_history.txt become integ/bash/history/*.json under a new
  ram-history target. It needs its own target because the shared battery's
  ~1900 commands on ram would otherwise land in the recording and shift the
  history line numbers. The observer-introspection tail is dropped: those are
  Python API assertions the shell harness cannot express, and
  python/tests/observe covers all five. TypeScript gains history coverage it
  never had.
- find_arg_errors.py / .ts + truth_find_arg_errors.txt become four
  {mount}-bound cases across sixteen dummy-credential argerr-* targets, wired
  through one new arg_error builder per language. This is the first integ
  coverage for discord and github_ci.

New coverage:
- curl and wget were the only spec commands with zero integ cases. Their unit
  tests mock the HTTP layer, so the untested part was the write landing in the
  VFS. integ/server/http_server.py is the fixture; the adapter owns its
  lifecycle and exports the endpoint as the {http} token, so --facet http
  needs no CI setup.

Four curl/wget cases are deliberately absent because python and typescript
disagree: the 404 path (python leaks httpx's message including an MDN link and
exits 1, typescript exits 22), the unreachable-host path (python reports a
platform-specific errno), and the read-only-mount message. Those are backend
bugs to fix separately, not test scaffolding.

Root truth files drop from seven to four: safeguard, runtime, runtime_py and
fuse remain, each blocked on a harness feature.

* integ: restore the metadata snapshot/GC scenarios, pin the observer event shape

Codex review on #649 caught two places where the previous commit removed
coverage rather than moving it.

metadata.py / metadata.ts held three scenarios beyond the case lists that were
migrated to integ/unix/meta and integ/unix/meta_overlay, and truth_metadata.txt
asserted all three: overlay attributes surviving a snapshot reloaded onto a
fresh S3 resource, ConsistencyPolicy.ALWAYS garbage-collecting an attribute
overlay orphaned by an out-of-band unlink, and the same snapshot roundtrip on
RAM. Verifying that the case ids matched was not enough, main() did more than
call those three functions. Both scripts come back trimmed to just those
scenarios, with the meta_stat_line helper inlined now that cases.py is gone.
Both reproduce the original truth output byte for byte. Retiring them for real
needs snapshot and namespace support in the harness, which the file now says.

The history observer assertions are a narrower case. recorder-not-mounted and
the post-clear session projection are genuinely covered by
python/tests/observe. The event shape is not: exit_code, cwd and the op source
were only ever asserted on entries the tests build by hand, never on the real
execute path, so a regression in the wiring from a command to its event would
pass while the rendered views stayed correct. Three tests per language now pin
that on ws.execute(), which also gives TypeScript coverage it did not have.

Doing so surfaced a divergence: TypeScript records the op path mount-relative
('/test.txt') where Python records the virtual path ('/data/test.txt'), so the
prefix Mount.execute sets is not reaching record(). Python's test pins the
correct form; the TypeScript test asserts a read op exists without freezing the
wrong path in place, and says why.

* curl,wget: align exit codes and streams with the real tools (py+ts)

Pinned against curl 8.14.1 and GNU Wget 1.25.0 in debian:stable-slim before
changing anything, per the repo rule for command semantics. The probe found
both languages wrong, in different ways, on almost every failure path.

The root cause was one design mistake shared by both: the transport layer
called raise_for_status() (python) or threw on !resp.ok (typescript), so an
HTTP error status became an exception before either command could see it. That
is wrong for curl, which treats a 404 as a successful transfer, and it meant
the HTTP library's own text reached stderr, including a documentation URL from
httpx and a bare "fetch failed" from undici. The layer now reports status,
reason and body, and each command owns its own CLI semantics.

curl, matching curl 8.14.1:
- an HTTP error status is no longer a failure. The body goes to stdout (or to
  -o) and the exit code stays 0.
- new -f/--fail, the only way to make a status an error: exit 22, and nothing
  is written.
- a refused connection is exit 7 with a host/port message, not a
  platform-specific errno (61 on macOS, 111 on Linux, so the old message could
  not even be asserted in CI).
- a failed -o write is exit 23.
- no URL is exit 2 with curl's two-line usage text.
- -o prints nothing on stdout; it used to print "saved to <path>".
- -s now only suppresses the message and -S restores it; neither changes the
  exit code.
- -L is honoured. Python passed follow_redirects=True unconditionally, so -L
  was a no-op and redirects were always followed.

wget, matching GNU Wget 1.25.0:
- any 4xx/5xx is exit 8 with "ERROR <code>: <reason>.", and the -O target is
  still created empty, the way wget truncates it before reading the status.
- a refused connection is exit 4.
- --spider reports on stderr ("Remote file exists." / "Remote file does not
  exist -- broken link!!!") rather than stdout, and inherits the same exit 8.
- the progress report moved to stderr; stdout is empty.
- no URL is exit 1 with the usage block.

Two more bugs found on the way: both commands caught only three exception
types, so a missing parent directory escaped uncaught, now fixed by using the
shared WALK_ERRORS set; and python rendered OSError with str(), leaking
"[Errno 13]" and a python repr onto stderr, now using strerror.

Coverage: 35 integ cases under --facet http, byte-identical across both hosts,
plus 31 new python unit tests (test_curl.py, test_wget.py) and 10 for the
transport layer, and the typescript tests updated to the pinned behavior.

Known remaining divergence, deliberately not asserted: a read-only -o target
produces different wording per host because python rejects the write at the
mount layer and typescript at the dispatcher layer. Those cases pin the exit
code with -s/-q so neither host's wording is frozen as correct.

* fix(ci): yapf formatting, a long line, and the tsc-only stderr type

Three CI failures, all mine from the previous commit:

- yapf reformatted the two new test files (multi-line signatures) after the
  commit that added them, so the committed tree was not formatter-stable. The
  local pre-commit run that reported success had applied those edits without me
  re-staging them.
- flake8 E501 on a 82-char assertion in test_curl.py.
- tsc --noEmit rejected DEC.decode(ioResult.stderr ?? ...) because stderr is
  ByteSource, which may be an AsyncIterable. vitest does not typecheck, so the
  test run was green while the typecheck job was not. Both harnesses now use
  IOResult.stderrStr(), which materializes the stream the way the class already
  does internally.

The typescript gate failure was only downstream of the typecheck job.

* fix(ci): regenerate the committed specs for curl -f

Adding -f/--fail to the curl spec makes the checked-in spec/ snapshots stale.
The "Spec drift" check is a separate step in the pre-commit workflow rather than
a pre-commit hook, so a local `pre-commit run --all-files` never covers it:

  python scripts/gen_specs.py
  node --experimental-strip-types typescript/scripts/gen-specs.ts
  git diff --exit-code spec/

Regenerated all three snapshots (python, typescript node, typescript browser);
the generators are idempotent on the result.

* ci: one database job driving both hosts against one service fleet

integ-database and integ-database-ts started an identical fleet (postgres,
chroma and qdrant as services, plus a hand-initiated mongodb replica set) and
then ran the same four targets, differing only in which runner they invoked. So
every database PR paid for four container startups, two chroma health waits, two
qdrant health waits and two replica-set initiations twice over.

They are now one job. This is the shape integ-observability and integ-facets
already use: start the fleet once, run the python battery, then the typescript
one. Database was the only family still split by language.

Two details worth keeping:
- the typescript step carries `if: ${{ !cancelled() }}` so a python-only
  regression still reports whether typescript has it too. Splitting by job used
  to give that for free.
- the per-backend env (CHROMA_HOST, QDRANT_PORT, ...) moved to the job level so
  one step per language can cover all four targets, matching how
  integ-shared-py runs 34 targets in a single step.

Trades a little wall clock (the two batteries serialize where they used to run
in parallel) for roughly half the runner minutes and half the containers.
79 fewer lines of workflow.
2026-07-28 03:23:50 -07:00
Zecheng Zhang e8e2f5f14a fix(jq,gws): evaluate multi-document input per value, paginate gws GETs (#650)
* fix(jq,gws): evaluate multi-document input per value, paginate gws GETs

jq computed output arity with a naive "[]" substring test while jq_eval used a
depth-and-string-aware predicate, so any program with a nested [] inside a
collector had its single array exploded one element per line. Reuse the careful
predicate in both places.

jq also read .json files with strict orjson, so a file holding several JSON
values failed, and the stdin path collapsed a multi-document stream into one
list. Real jq reads a stream of values from any input and evaluates the program
per document. Both paths now do that; -s still slurps.

gws list methods made exactly one HTTP call and dropped nextPageToken, so
'gws drive files list' silently returned the first 100 of N at exit 0.
Pagination is now the default (a truncated listing is indistinguishable from a
complete one) with --page-limit to opt out. Single-response GETs keep their
exact bytes; only real multi-page streams are newline-delimited NDJSON.

Adds 'gws --help' and 'gws <service> --help' matching googleworkspace/cli, and
teaches the integ mock server to paginate. Two existing goldens encoded the jq
bug and are corrected.

* fix(gws): port pagination and help to TypeScript, regenerate specs

The epilog field added to CommandSpec was missing from the committed spec
dumps, which is what the pre-commit spec-drift step caught. Regenerated
both trees and taught the TypeScript side about the field, so the two
dumps keep the same shape.

The PR description claimed TypeScript had no gws passthroughs. It has all
25, so the unpaginated-GET bug was live there too. Ported pagination,
--page-limit, the per-method --help descriptions, and the
gws --help / gws <service> --help surface. render_services() and
render_service_methods() output is byte-identical between the two.

Two defects surfaced during the port:

- withHelpSupport rebuilds the spec from a hand-listed init object to
  inject --help, so it dropped epilog on every command. Python is immune
  because it uses dataclasses.replace. Typecheck and unit tests both
  passed; only the integ case caught it.
- gws --help was registered for gdrive and gsheets only, so a gdocs-,
  gslides- or gmail-only mount had the passthroughs but no help. Now
  registered for all five in both languages.

Structural alignment between the two implementations:

- drop the orphaned GWS_API_SPEC, replaced by gws_method_spec
- add _parse_page_limit mirroring parsePageLimit, so a bad --page-limit
  reports the same message instead of leaking int()'s ValueError text
- re-export the help commands from the gws package so backends stop
  reaching into gws.help, matching gws/index.ts
- make the four help description constants module-private on both sides
- drop the now-dead non-GET guard before invalidate_mount_listing
- give each page its own params dict instead of mutating one across calls

gws integ coverage is no longer blocked: g_help_lists_services,
g_help_lists_service_methods, g_files_list_paginates_by_default and
g_files_list_page_limit_truncates run on both hosts.

Docs: a Pagination section on the Drive page covering the default-on
behavior, the NDJSON output, --page-limit and the deliberate divergence
from googleworkspace/cli; the same note on the Gmail passthrough section;
and a JSON with jq section in the bash docs for the stream-of-values
semantics, verified against the jq binary.

* fix(spec): trim the help epilog without a polynomial regex

CodeQL flagged js/polynomial-redos (high) on the `/\n+$/` I used to strip
the epilog's trailing newlines: it backtracks on a long run of '\n'. The
trim now walks backwards, which is what Python's rstrip('\n') already did,
so that side was never affected. Verified identical output on 'Services:',
a single trailing newline, three trailing newlines, and an all-newline
epilog; a 100k-newline epilog now renders instantly.

Also align --page-limit validation across the two. Python used bare
isdigit(), which accepts non-ASCII digits that TypeScript's /^\d+$/
rejects: '١٢' parsed as 12 on one side and errored on the other, and '²'
passed the check then crashed int(). Python now requires isascii() too,
and both suites reject the same five inputs.

* fix(gws): register help commands per resource, drop the nested handler

Both findings from the codex review on #650.

P2, resource filtering. MountEntry.register keys commands by
(name, filetype) and ignores RegisteredCommand.resource, so registering
the whole GWS_SERVICE_HELP_COMMANDS list on every backend made a
gdocs-only mount answer `gws gmail --help` and `gws drive --help` with a
method listing it cannot execute. Reproduced on a gdocs-only workspace
before the fix. gws_help_commands(resource) / gwsHelpCommands(resource)
now build one registration per reachable service, bound to the single
resource asked for, which is what the TypeScript backends were doing with
an explicit filter. A drive mount still reaches docs, sheets and slides.

P1, nested function. make_service_help_command defined its handler inline
and closed over the body, against the repo's no-nested-functions rule.
The handler is now module-scope run_help, bound to its listing with
functools.partial, the same way run_gws_method is bound to its method.

Both languages end up with the same shape, so the per-backend filter
duplication in TypeScript is gone too. ROOT_DESCRIPTION is module-private
on both sides.
2026-07-28 02:16:14 -07:00
Zecheng Zhang b117125495 refactor(filetype): remove the bundled format renderers, keep the extension point (#651)
* refactor(filetype): remove the bundled format renderers, keep the extension point

mirage shipped renderers for parquet, ORC, feather/arrow/ipc and hdf5/h5, plus
a PDF module that turned out to be entirely dead code: '.pdf' was never in the
factory registry and nothing imported mirage.core.filetype.pdf, so its test
exercised it directly and kept it looking alive.

Both core/filetype/ trees are removed. The dispatch machinery stays, in both
languages and in both places it lives (commands/builtin/filetype_factory and
ops/generic/factory), with empty registries and a comment marking them as the
extension point. A file with an unregistered extension now reads as raw bytes.

Registering a filetype-scoped command still works and is covered:
tests/commands/custom/test_filetype_fns.py registers a '.parquet' handler with
a fake function and asserts dispatch, and test_unregister_removes_all_filetypes
now registers a '.demo' renderer itself rather than leaning on the bundled ones.

Also removed: the parquet/hdf5/pdf extras (and their references from 'all' and
'deepagents'), the hyparquet, hyparquet-writer, apache-arrow and h5wasm
dependencies, integ/resources/columnar, the columnar FileType enum members, the
per-backend filetypeRead declarations that existed only on the TypeScript side,
and the sentence advertising 'cat on .parquet/.orc/.feather returns a formatted
table' from 33 backend and agent prompts, which would otherwise have been
lying to agents.

grep_helper's skip-list and file's MIME map keep their columnar entries: those
are still correct, since the formats remain binary whether or not mirage can
render them.

core/src/ops/generic/factory.ts was reaching NodeJS.ErrnoException through the
columnar packages' type dependencies. core has to work in both runtimes, so it
now uses a structural { code?: string } instead.

Python suite passes, TypeScript core 5458 pass, pre-commit clean.

* refactor(examples): drop the columnar demos alongside the renderers

Deleted, because their whole subject was the removed rendering:
s3_data.py (181 lines built around four columnar constants),
ram_filetypes.ts, box_parquet.ts, dropbox_parquet.ts, ram_parquet.ts.

Also deleted fuse_hooks.py, which was already broken before this change: it
imports mirage.fuse.filetype.data.local.parquet, a module that does not exist
anywhere in the tree.

Edited rather than deleted, since columnar was one section of a broader demo:
gdrive_complex.py, disk.ts, ram_fuse.py, and the openhands README (which
advertised format-aware reads and piped a .parquet through jq).

Everything still referencing parquet only names it in find/ls patterns, which
keeps working: mirage can still list and locate these files, it just no longer
renders them.

* fix(ci): drop the last filetype wiring the removal missed

The nextcloud ops table still declared filetypeRead for feather/hdf5/
parquet, so makeGenericOps threw at module load and every job that
imported @struktoai/mirage-node died before running anything.

Also: regenerate the specs (the crash hid 89 files of drift), drop the
removed pdf/parquet/hdf5 extras from the install matrix, take the
columnar members out of the TS FileType enum and the box/gdrive/dropbox
type guesses so both languages agree again, and stop passing an inert
filetype_read=True from ten Python backends.

* fix(spec): regenerate with every optional backend importable

The previous regeneration ran under the worktree venv, which has no
optional extras installed, so backends that failed to import were
dropped from each spec's resources list. Regenerated with the full
environment: the only remaining change is the emptied filetypes list.

* fix(agents): stop telling models mirage renders columnar formats

The langchain backend mapped parquet/h5/hdf5/feather to text/plain, so
with no renderer registered it handed the model raw binary decoded as
UTF-8 instead of the binary redirect. Those extensions now fall through
to application/octet-stream.

The system prompt, the execute tool description in both languages, and
the README extensibility example all still advertised columnar rendering
as a shipped feature; they now describe it as the extension point it is.
Also drops commands/optional.py, which existed only to soft-import the
removed format helpers and has no callers left.

* refactor(filetype): drop the filetype command factory, keep mount registration

The factory built nine commands per registered extension, but with no
renderers shipping it produced nothing on every one of the ~19 backends
that called it, and its handlers had no test in either language while
the module contract they expected was written down nowhere.

Removes commands/builtin/filetype_factory/ in both languages along with
the filetype_read / filetypeRead op knobs and the reads and provisions
that existed only to feed them. Registration on a mount survives and is
the whole extension point: a command or op carrying a filetype resolves
as (name, filetype) before (name, resource).

examples/{python,typescript}/filetype/ register a .tally renderer end to
end and are gated in CI against integ/truth/*/filetype.txt, so the path
is now exercised rather than asserted. Both emit byte-identical output.
2026-07-27 15:21:01 -07:00
Zecheng Zhang a2bd1018b4 fix(mkdir): GNU conformance for mkdir and mkdir -p (py+ts) (#647)
* fix(mkdir): GNU conformance for mkdir and mkdir -p (py+ts)

mkdir was the one write op #641 left short. Four divergences, pinned
against GNU coreutils in docker before changing any semantics.

- mkdir -p across a plain file turned that file into a directory on the
  store backends. The -p loop added every component blind, so the new
  directory key shadowed the file key: reading it started reporting
  EISDIR while the bytes stayed orphaned in the store. Now "cannot
  create directory 'a.txt': Not a directory", and the file is untouched.
- mkdir -p onto a plain file target reported nothing at all. GNU calls
  that EEXIST, not ENOTDIR: the walk reaches the target itself rather
  than crossing it.
- mkdir without -p silently succeeded on an existing path, on every
  backend. GNU refuses with "File exists"; only -p is idempotent. Every
  internal caller that mirrors a tree through the op (cp -r) already
  probes with is_directory first, so none of them relied on the no-op.
- mkdir -p quoted the whole operand where GNU quotes the component it
  tripped on ("cannot create directory 'a.txt'", not 'a.txt/sub').

Shared pieces: check_mkdir_target joins check_dest_parents in each
store backend's dest module, so the two destination questions a write
can ask live side by side. The real-filesystem backends keep the
kernel's errnos and only walk the chain to attribute a failure it
already reported, which is what disk/dest and opfs's local
checkMkdirTarget do. error_path/errorVirtualPath is factored out of
format_fs_error so the command layer can quote whatever path an error
names rather than assuming the operand, and mounted_path moves from
generic/cp to utils/key_prefix now that backends address ancestors too.

Deliberate divergence: operand spelling stays normalized, so a relative
argument is reported absolute (as #641 already established).

* test(crossmount): mkdir -p the redis parent chain in the matrix fixture

The fixture built the chain with repeated plain mkdir calls, which only
worked while an existing directory was a silent no-op. Use -p, like the
disk branch beside it already does.

* fix(mkdir): address Codex review on #647

- kindAt swallowed every OPFS resolution failure, so a SecurityError or
  InvalidStateError read as an absent component and mkdir carried on.
  Only NotFoundError/TypeMismatchError mean "nothing here"; the rest
  re-raise.
- disk's component walk used isfile, which misses a FIFO, socket or
  device ancestor: makedirs still refused with ENOTDIR but the walk
  found nothing to blame and the whole operand was named. It now stats
  once and treats any non-directory as the failing component, matching
  the TypeScript side.
- mkdir reported an absolute path for a relative operand, where GNU and
  mirage's own read-family commands quote it as typed. operand_spelling
  rebases whatever path an error names (the operand, an ancestor of it,
  or something under it) onto raw_path, so `cd /data && mkdir -p
  f.txt/sub` now says 'f.txt' like GNU.

drop_trailing_segments joins rebase_one in utils/path as the ancestor
half of the same idea, with unit tests in both languages, and two integ
cases cover the relative spellings.
2026-07-27 00:08:30 -07:00
Zecheng Zhang 087ed195c0 feat(db): semantic virtualization + JSON-harness integ for all DB backends (#644)
* feat(db): semantic virtualization + JSON-harness integ for all DB backends

Postgres/Mongo DB virtualization work plus a full integ-harness migration.

Core:
- Postgres semantic.json (dimensions/time_dimensions/facts/relationships),
  byte-identical py+ts.
- Fix SQL identifier injection via quote_ident/qualified (py; ts already safe).
- Fix grep/rg push-down: honor a literal+no-shaping-flags gate so -v/-c/-l/-n
  fall through to the generic scan; rg now mirrors grep per backend in py+ts.
- Fix a hidden py/ts divergence: whole-valued double renders as `5` (Postgres
  canonical + node driver) not `5.0`; shared canonicalize_row in
  utils/json_canonical, applied to postgres rows and mongodb docs/schema.
- Mongodb schema type inference classifies whole-valued doubles as int
  (matches the JS driver) and samples deterministically (sorted _id).

Integ:
- Migrate postgres, mongodb, chroma, qdrant, lancedb, notion off the bespoke
  .txt truth-file harness onto the shared JSON case harness (targets.json +
  resources/<backend>/*.json), byte-identical py==ts; delete the bespoke
  *.py/*.ts scripts and truth_*.txt; CI runs `main.py/ts --target <backend>`.
- notion's TS-only MCP/REST transport parity kept as a standalone
  self-asserting notion_mcp_parity.ts (no golden file).

* fix(search): faithful grep/rg push-down (Codex review)

- Postgres push-down was case-INsensitive (ILIKE) and left % / _ unescaped,
  so `grep Ada` matched `ada` and `rg -F user_id` matched `userXid`. Now
  case-sensitive LIKE by default, ILIKE only under -i, and LIKE wildcards are
  escaped. Threads case sensitivity through the row + metadata search chain
  (py + ts); metadata scan is case-sensitive unless -i.
- search_pushdown_ok rejects newline-joined patterns (-F with multiple -e are
  independent alternatives LIKE cannot express) so they take the generic path.
- has_search_shaping_flags now recognizes rg's -I (no filename) and the
  file-filtering --glob/--type, forcing those onto the generic scan.
2026-07-26 22:12:28 -07:00
Zecheng Zhang e6767aa0d2 du: one backend contract and GNU-aligned output (#642)
* feat(du): one backend contract and GNU-aligned output

du had two backend shapes (du_total/du_all plus a flat du_multi list) and
printed only the operand line. Both are replaced.

Backends now expose one pair, core/<backend>/du/{size,entries}, wired as
du_size / du_entries. entries returns (entries, total) with leaf files
only, mount-relative, no summary row. The generic lifts them onto virtual
paths and re-spells them as the operand was typed, which fixes du -a
rendering identical lines for two mounts holding the same filename.

Output now follows GNU:

- a line per directory with its recursive total, post-order, derived from
  the leaf list rather than a second walk
- --max-depth prunes directory levels, so it finally does something
- -d N as a spelling of --max-depth, with C strtoul base 0 parsing
- -s with -a, -s with --max-depth, and a bad depth are usage errors (exit 1)
- an unreadable operand is named, the rest still print, exit 1
- no operand measures the working directory instead of erroring

A failed stat is not treated as proof of absence: backends that never
materialise a mount root entry (redis) still report their subtree.

Fallback walks are bounded by CommandIO.max_du_entries; when the cap trips
du prints what it accounted for, warns on stderr and exits 1. Slack sets a
low cap because it exposes a directory per conversation per day.

Verified against debian:stable-slim: paths, exit codes and stderr match GNU
on 23 cases, and python and typescript are byte-identical on all of them.
Deliberate divergences (sibling order, empty directories, byte sizes) are
documented in CLAUDE.md.

* refactor(du): drop the redundant typescript du wrappers

s3, gridfs and hf each shipped a bespoke du command that did what the
generic_bind builder already does. s3 and gridfs even wired duSize and
duEntries on their CommandIO and then overrode du anyway; hf only lacked
the wiring. Python deleted its equivalents when the contract landed, so
this is the typescript half of that cleanup.

Removing them is provision-neutral: default_provision / defaultProvision
map du into the metadata family, which returns the same metadata_provision
the wrappers named explicitly.

opfs keeps its wrapper because opfs registers every command by hand and has
no CommandIO at all, and github keeps one in both languages because it
answers du from the index.

* refactor(du): one entry point so backend wrappers cannot drift

The three remaining du commands (github in both languages, opfs) each
repeated the same preamble: parse the flags, split the operands, render.
Wiring rather than semantics, but three copies of an ordering that has to
stay identical, including the GNU rule that flag validation happens before
any I/O.

run_du / runDu now owns those three steps, so a wrapper supplies only its
backend callables. du_operands and du_has_content lose their exports and
become internal to it.

* fix(du): refresh legacy truth files and decode sftp filenames

The four .txt-truth integ backends (chroma, lancedb, notion, qdrant)
predate directory rows, so they diffed against the new du output. All
four regenerated from live runs and verified byte-identical in Python
and TypeScript.

asyncssh types SFTPName.filename as bytes | str, so the annotated walk
tripped mypy's str-bytes-safe check. Decode it the way find.py does.

* fix(du): restore du on nextcloud, onedrive and sharepoint

Deleting the three bespoke du wrappers left "du" in their generic
command override sets, so the factory skipped it and nothing supplied
it. du was unregistered on all three backends.

The overrides existed because those backends returned a flat list where
the generic wanted a (list, total) tuple. The shared du_entries contract
settles that, so the sets are now empty and the factory registers du
with their native ops.

Regenerate the checked-in specs, which is what caught this. du also
loses hf_datasets, hf_models and hf_spaces: the deleted wrapper
registered for all four HF resource names, but no other command does,
so du now matches the rest.

* fix(du): directory markers and failed content probes

Three failures the shared battery caught on backends with no local
harness.

Keyed backends (S3, GridFS) store a zero-byte marker object for a
directory. It reaches the rollup as a leaf, and under -a it overwrote
that directory's computed total, so 'du -a' printed 0 for a directory
whose file it had just listed at 5. Directory sums now win on a clash.

The content probe that tells an implicit directory from an absent path
runs only after stat has already failed, and it calls the backend
again. On gdrive, onedrive, sharepoint and ssh a genuinely missing
operand made that second call raise a driver error, which escaped and
replaced GNU's "cannot access" line. A failing probe is a negative
probe.

The slack walk-budget case expected exit 0. GNU exits 1 on a partial
traversal (pinned against debian:stable-slim: unreadable subdirectory
prints the partial total, warns on stderr, exits 1), so the budget
warning is the same class of answer.

* fix(du): codex review, cwd mount prefix and -s --max-depth=0

The implicit cwd operand for bare 'du' built its backend key by
stripping slashes off the virtual path, so from a non-root mount
(cwd=/ram) the backend was asked for a 'ram' entry inside itself
instead of its own root. TypeScript reported the cwd missing and
exited 1 where Python printed the totals. The key now comes from
opts.mountPrefix via mountKey, matching checksum.ts.

GNU treats -s and --max-depth=0 as the same request: it warns
'summarizing is the same as using --max-depth=0', prints the total and
exits 0, and only a nonzero depth is a real conflict (pinned against
debian:stable-slim). Both were rejected as usage errors. DuFlags now
carries the non-fatal warning through to stderr.
2026-07-26 21:39:39 -07:00
Zecheng Zhang 28af80cb8b fix(write): destination-parent conformance for cp, mv and the whole write family (py+ts) (#641)
* fix(cp,mv): destination-parent conformance with GNU (py+ts)

cp and mv never create the destination's parent, and the store-backed
backends never grow a key under a directory they did not record. Pinned
against GNU coreutils in docker before changing any semantics.

Six divergences, all reproduced on both ram and disk (they were generic
command bugs, not store bugs):

- cp into a missing parent exited 0 and created the parent plus an
  orphan. disk/copy did makedirs(parent) and ram/redis copy wrote the
  key blind. Now "cannot create regular file X: No such file or
  directory", exit 1.
- cp under a plain file wrote an unreachable key (ram) or reported
  "File exists" against the real host path (disk). Now "cannot stat X:
  Not a directory", at any depth.
- mv had the same orphan gap in rename: the phantom directory made both
  itself and its real parent unlistable.
- cp/mv with several sources and a missing target said "Not a
  directory". GNU says "No such file or directory" and keeps "Not a
  directory" for a target that exists but is not one.
- cp -rv printed no directory lines. GNU reports directories too,
  including the source root.
- cp -r into a bad parent reported one error per subdirectory and
  copied the tree anyway. Now one error and nothing copied.

The disk backends also leaked the real host path into user-facing
stderr (mkdir, touch, redirect, stat, copy). disk_errors/diskError
restamp the errno against PathSpec.virtual, so only the mount path is
ever reported.

Shared pieces: ancestors() in utils/path, a per-backend dest module
used by both rename and copy, and dest_parent_error in the generic
layer so every backend gets GNU's wording through the existing stat
seam. entry_kind now absorbs ENOTDIR as "does not exist"; isMissingPath
stays ENOENT-only so read-family commands keep reporting "Not a
directory" verbatim.

Prefix stores (s3, gridfs, databricks) have implicit directories and
are deliberately exempt. Accepted divergences: operand spelling is
normalized, cp -rv sibling order is sorted rather than readdir, and
cp -r dir dir refuses without leaving GNU's partial copy behind.

* fix(write): destination-parent conformance for the whole write family (py+ts)

A write is not `mkdir -p`. GNU reports ENOENT on a missing parent
component and ENOTDIR when one is a plain file, at any depth; mirage
either created the chain silently or grew an orphan key under a
directory it never recorded. That orphan makes both the phantom
directory and its real parent unlistable.

Every op that places a key at a caller-supplied path now shares one
destination probe per backend (rename, copy, create, write, append,
mkdir). Previously only rename and copy had it.

Backends:
- ram, redis: create had no check at all, write/append checked only the
  immediate parent and always reported ENOENT
- disk: write, create and append silently ran `mkdir -p`; append also
  leaked the host path into stderr
- opfs: resolveFileHandle passed `create: true` for every path segment,
  so write, create, append, copy and rename all built the chain on
  demand. Contrary to the note in CLAUDE.md, opfs does not inherit this
  from a real filesystem: the OPFS API creates per segment.

Commands:
- touch and mkdir report the GNU line (`cannot touch 'X'`,
  `cannot create directory 'X'`) and keep going past a failed operand,
  exiting 1, instead of aborting the command
- tee and curl -o render the shared strerror rather than the backend's
  own exception text, while keeping the raw message for refusals whose
  wording is load-bearing (read-only mount, unsupported op) or that
  carry the only description of the cause (transport errors)
- opfs exists returns false for a plain-file component instead of
  leaking a DOMException

The redis probe tests one component at a time (SISMEMBER) rather than
pulling the whole directory set: it now runs on every write, so a
membership test per component beats transferring every directory in the
mount.

Fixtures that encoded the old behaviour are updated, not worked around:
the ram readdir orphan test and integ/runtime.{py,ts} built their
orphans by calling rename, which now refuses. readdir stays defensive
about orphans because a restored snapshot or another client can still
seed one.

Pinned against real GNU in docker (debian:stable-slim, bash not dash;
the redirect diagnostics and exit codes differ between the two).
Deliberate divergences kept: mirage names a redirect target without a
`bash: line N:` prefix (#635), and quoting follows GNU's POSIX locale
(`'X'`, not the UTF-8 locale's fancy quotes).

* test(redis): assert errno, not the old parent-does-not-exist wording

These two are gated on REDIS_URL, so a local run without redis skips them
and reports green. Run the node suite with REDIS_URL set to match CI.

* fix(disk): create the mount root at construction, not via the first write

DiskResource pointed at a path that does not exist used to work only
because write_bytes ran `mkdir -p`, which created the mount root as a
side effect. Removing that auto-mkdir broke it.

The mount root is infrastructure, not a path component a caller asked
for, so create it up front and keep writes reporting ENOENT for a
missing parent. This is what TypeScript already does in
DiskResource.open(); Python has no open() hook, so the constructor is
the equivalent seam.

* fix(cp,mv): keep ENOTDIR on source operands, blame the right copy operand

Two review findings.

A source that traverses a plain file is `cannot stat 'X': Not a
directory` in GNU, but both loops hard-coded "No such file or
directory". The backends cannot supply the distinction: stat answers
ENOENT for a path under a plain file just as it does for an absent one,
so only readdir splits the two. source_kind therefore walks the parent
chain the way dest_parent_error walks a destination's, and only on the
failure path. entry_kind keeps absorbing ENOTDIR, which is correct for
destinations, where the wording is re-derived.

copyFile and shutil.copy2 both answer ENOENT for a missing source and
for a missing destination parent, so restamping unconditionally against
the source named the wrong operand. Probe the source on failure to tell
them apart. Python was not restamping at all here, so it also leaked the
host path; it now shares a disk_error() helper with disk_errors,
mirroring the TypeScript diskError.

* style: let yapf own the signature wrap in the new disk copy test
2026-07-26 19:59:12 -07:00
Zecheng Zhang 37d734b67e feat(integ): jaeger backend, real-server observability integ, cross-backend contract (#643)
* feat(integ): jaeger backend, real-server observability integ, cross-backend contract

Add a jaeger backend and put both observability backends under a real server
in integ, then extend the shared read-only contract across nine backends.

Jaeger backend (python + typescript):
- service-scoped tree: /services/<name>/{operations.json,traces/<id>.json}.
  Jaeger's search API requires a service, so there is no listable /traces.
- always sends an explicit microsecond window: `lookback` is ignored by the
  query API, so without start/end the search silently returns nothing.
- an unknown service answers 200 with an empty list, so existence is checked
  against the service list rather than inferred from an empty listing.

Langfuse fixes found by running against a real self-hosted instance:
- prompt versions were unlistable: the list endpoint returns a `versions`
  array, and reading a scalar `version` collapsed each prompt to one 0.json.
- dataset runs rendered as an indented document under a .jsonl name.
- a 404 leaked the raw SDK error instead of ENOENT; a 500 still propagates.
- stat accepted any plausible path without checking it exists.
- typescript requested dataset runs under /v2/, a hard 404 on a real server.
- typescript applied a hidden 7-day window that hid traces cat could serve.
- an unrecognized path resolved to scope "root", so the grep/rg push-down
  answered a missing file with every trace in the mount and exit 0.

GNU alignment:
- du reported a missing operand as size 0 with exit 0; now reports it and
  exits 1 while keeping output for the operands that exist.
- tree wrote a malformed line to stderr where GNU writes nothing.

ENOENT alignment across backends:
- trello, linear, slack and email readdir returned [] for an unrecognized
  path, so ls and tree reported a bogus path as real but empty.
- email selected an unvalidated IMAP folder, leaking "command SEARCH illegal
  in state AUTH" to the caller.
- email and slack hand-rolled an ENOENT whose message carried an "ENOENT: "
  prefix, and slack's dropped the mount prefix.
- trello typescript readBytes lacked the virtual path python passes, so the
  message reported the mount-relative path.

Integ:
- resources/observability/ holds langfuse (135 cases), jaeger (99) and the
  36-case shared contract, which runs on nine read-only backends via a new
  {mount} token so one case can cover backends with different mount paths.
- targets gain a facet, and the runners gain --facet, so CI runs one backend
  family per job: observability, project, email, chat, dify, mem0, core.
- langfuse runs against a six-container compose stack, jaeger against a
  single container seeded over OTLP.

Two contract cases stay scoped away from email and gmail: their grep/rg push
down to a server-side search that reports "no matches" for a path that does
not exist. Six typescript-only ENOENT prefix sites remain in box, discord,
gdocs, gdrive, gsheets and gslides.

Also includes the pre-existing session-mode integ migration that was already
in the tree: session_modes scripts replaced by session/modes.json.

* fix(jaeger,langfuse): CodeQL ReDoS, eslint findings, formatter pass

- stat used a /\/+$/ trailing-slash regex in jaeger and langfuse, which
  CodeQL flags as js/polynomial-redos on a path from library input. Both now
  use the existing loop-based rstripSlash. Python already used str.rstrip.
- errorMessage stringified an unknown errors[0].msg, which could render as
  [object Object]; now requires a non-empty string, mirroring python's
  truthiness check.
- readdir tested pattern against undefined, which its type excludes.
- drop an unused fixture constant in read.test.ts.

* fix(integ): shell-attributed redirect golden, regenerate specs for jaeger

The ro_write_refused contract case expected "echo: <path>: Operation not
supported", but #635 (which landed in main after this branch was cut)
attributes a failed redirect target to the shell rather than the command, so
the correct stderr has no command prefix. It is the only contract case using a
redirect; mkdir, rm and tee take file operands and keep their prefixes.

Regenerate spec/ for the new jaeger resource (154 one-line additions across
both generators). CI runs gen_specs.py and gen-specs.ts as a separate drift
step, not through pre-commit, so a local pre-commit run does not catch this.

* fix(ci): build mirage-browser in the observability and facet integ jobs

The typescript runner imports mirage-browser statically for the opfs target,
so its dist must exist even in a job that runs no browser target. Both new
jobs hand-roll their setup and built only core and node, so every typescript
host step died with ERR_MODULE_NOT_FOUND. The pre-existing jobs get the build
from the integ-battery-setup action, which is why only these two were hit.

This was masked in the first run: the typescript step is skipped when the
python step fails, so the stale golden hid it.

* fix(jaeger,du): codex review, service-scoped reads and a real request deadline

read() served a trace fetched by id through any service directory, so
/services/<other>/traces/<id>.json returned content that stat and ls both
report absent. It now asserts the service exists and that the trace's own
process table names it. Membership comes from the trace document, not the
service listing, because that listing is windowed and limited and would hide a
trace that really does belong.

The typescript jaeger transport ignored requestTimeout: the config field, the
snake-case mapping and the transport option all existed, but nothing read them
and fetch ran with no deadline, so a stalled endpoint hung the command forever.
Threaded through as seconds, matching python's httpx timeout and dify's
existing requestTimeout convention.

du's operand stat caught every exception and reported it as
"No such file or directory", so an auth failure or a backend bug printed a
wrong reason and a partial total. Narrowed to isMissingPath, matching python's
(FileNotFoundError, ValueError). The test mock rejected with a bare
Error('ENOENT') rather than a stamped FsError, which is what let this pass.

Moved the jaeger resource's command and op imports to module scope, per the
repo's import rule. Verified no cycle: the registry and workspace still import
cleanly. This also matches the typescript resource, which already imports
JAEGER_COMMANDS at module scope.

Integ gains a cat and a stat through a foreign service, the pair that proves
the two agree.
2026-07-26 19:51:08 -07:00
Zecheng Zhang 755f4ca0a5 Add fake GitHub API server for integ, fix unsound grep/rg search push-down (#640)
Pre-commit / pre-commit (push) Has been cancelled
CLI exit codes / changes (push) Has been cancelled
Test (Install) / changes (push) Has been cancelled
Integ / changes (push) Has been cancelled
Test (Python) / changes (push) Has been cancelled
Test (TypeScript) / changes (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / python-install (push) Has been cancelled
Test (Install) / ts-install (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-shared-py (push) Has been cancelled
Integ / integ-shared-ts (push) Has been cancelled
Integ / integ-shared-parity (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Integ / integ-fuse-windows (push) Has been cancelled
Integ / runtime-py (push) Has been cancelled
Integ / runtime-ts (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Python) / runtime (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
* test(integ): add a fake GitHub API server and a github target

Fakes the four api.github.com routes the github backend calls (repo, git
trees, git blobs, code search) and wires a `github` target for both hosts.
Code search is token-indexed like the real API rather than substring, so
the battery sees the same push-down semantics production does.

Adds `GitHubConfig.base_url` to match the existing TypeScript `baseUrl`
seam; Python had a hardcoded API_BASE and could not be pointed anywhere.

* fix(grep): require -w and a literal pattern for search push-down

grep/rg narrow their scope with the provider's search API, then scan only
the narrowed files. Provider search matches whole words while grep matches
substrings, so for a bare literal the narrowed set is a strict subset of
the real matches and files holding the literal only inside a longer word
were silently dropped. Over the new fake, `grep -rl quokka /repo` returned
3 files while the same pattern under the size threshold returned 4.

Push-down now needs -w, where both sides agree and any tokenizer
disagreement can only over-fetch, which the local scan filters. It also
needs a fully literal pattern: a regex narrows on an extracted literal, so
the searched term is only part of the match (`foo[0-9]` matches `foo1`
under -w, but searching `foo` never returns a file whose only token is
`foo1`). Applies to github, box, dropbox and slack in both languages.

Drops files_only_shortcircuit: it bails on -w, which push-down now
requires, so it was unreachable, and it cannot be re-enabled safely
because over-fetch is fatal to a path that skips the scan.

* fix(grep): extend the -w push-down gate to gmail, discord and mongodb

Same defect as github/box/dropbox/slack: the provider's search matches
whole words while grep matches substrings, so a bare literal under-reports.
gmail and discord return search results verbatim as the grep output, so
their native path now needs -w too.

mongodb needed a different fix. Its $text branch matches whole words and
stems them, so it both missed `foo` inside `foobar` and matched stems the
pattern never had, with no local re-scan to filter either. $regex was
already the fallback and takes the pattern as written, so the $text branch
is gone.

Integ coverage for the narrow-then-scan backends: the box and dropbox fake
servers now match content by whole word like the real providers, since a
substring fake agrees with a full scan and proves nothing. A /search mount
over a 3-file trap fixture pins both paths; removing the gate makes the
non--w cases fail with the exact under-report.

Also updates the slack and gmail integ cases that asserted native-search
output, and adds gmail push-down tests, which did not exist.

* fix(integ): move the fake GitHub server off trello's port

The new fake GitHub server was started on 5095, which trello_server.py
already uses. Trello started second, failed to bind, and its requests
reached the GitHub fake instead, so every trello case failed with
"Trello API error (/members/me/organizations): HTTP 404".
2026-07-26 12:43:16 -07:00
Zecheng Zhang 6e1b800c2a Fix dependency and CodeQL security alerts (#639)
* Fix dependency and CodeQL security alerts

* fix(stat): port linear-time format parser to Python (CodeQL #247)

The TypeScript fix left the identical backtracking regex live in the
Python mirror, so stat -c FORMAT could still hang on a crafted format.
Replace _FORMAT_RE with the same cursor-based directive scanner.
2026-07-25 23:21:54 -07:00
bytecii 099aa4b35e fix(readdir): ENOENT vs ENOTDIR for a missing path (py+ts, 6 backends) (#637)
* fix(readdir): ENOENT vs ENOTDIR for a missing path (py+ts, 6 backends)

`ls` on a missing path reported the wrong errno string on several
backends, and Python/TypeScript disagreed on the same backend.

GNU pin (`docker run --rm debian:stable-slim`):

    ls /nope             -> No such file or directory
    ls /data/nope        -> No such file or directory
    ls /data/nope/deeper -> No such file or directory   (however deep)
    ls /data/a.txt/x     -> Not a directory             (component is a file)
    ls /data/a.txt/      -> Not a directory

The rule: ENOTDIR only when a path component exists and is not a
directory; a component that does not exist is ENOENT, at any depth.

Every affected site was wrong, in both directions:

| backend  | before                                     |
|----------|--------------------------------------------|
| py disk  | always ENOTDIR (+ leaked the real fs path) |
| py ram   | always ENOENT (missed genuine ENOTDIR)     |
| py redis | always ENOENT                              |
| ts ram   | always ENOTDIR                             |
| ts disk  | mapped ENOENT -> enotdir explicitly        |
| ts opfs  | mapped NotFoundError -> enotdir            |

Store-backed backends (ram, redis) have no kernel to draw the line, so
they now ask a new shared helper — `readdir_error` / `readdirError` —
which walks the ancestors and returns ENOTDIR at the first existing
non-directory component, else ENOENT. Kernel/API-backed backends already
had the right signal and just needed to stop squashing it: disk lets
listdir raise and restamps onto the PathSpec (which also fixes the
real-fs-path leak in the Python error), opfs keeps NotFoundError->ENOENT
distinct from TypeMismatchError->ENOTDIR.

Blast radius is stderr bytes only: no caller distinguishes the two codes
after a readdir (glob_walk, condition operators, chroma/dify walk,
find_action_dispatch, wasm/fs, monty all catch them together). Three
tests pinned the old behavior and were corrected.

Coverage: 6 cross-backend cases in integ/unix/nf/ls.json (ram, disk,
redis, opfs — `nf/` had cat/grep/head/stat/tail/wc but no ls, which is
the gap this lived in), 5 in conformance/cases/ls.json, plus helper and
per-backend unit tests on both sides. The new cases pin exit 1, matching
this tree's `ls` exit and the existing convention in
integ/resources/slack/ls.json; they will need the same bump as the other
ls cases when the 0/1/2 exit-code split lands.

Verified: pre-commit clean; python + TS integ 1886/0 (ram) and 1874/0
(disk) on both hosts; full TS suites and targeted Python suites pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(readdir): stop the errno walk at the first missing component

readdir_error / readdirError scanned every ancestor for a file and
reported ENOTDIR if it found one at any depth. A flat store can hold a
key whose parent is not a directory (RAM/Redis rename writes the
destination key without adding its ancestors), so `ls /missing/a.txt/x`
found the orphan `/missing/a.txt` and said "Not a directory" while
`ls /missing` said "No such file or directory" for the same tree.

Both helpers now take an is_dir / isDir probe and stop where the kernel
stops resolving: the first component that is neither a directory nor a
file is ENOENT. A file component is still ENOTDIR.

* fix(tree/grep/rg): keep walking when readdir reports ENOTDIR

Splitting the readdir errno into ENOENT/ENOTDIR left the python tree
and grep/rg walkers behind: they caught only
`(FileNotFoundError, ValueError)`, and NotADirectoryError is a sibling
of FileNotFoundError under OSError, not a subclass. So on RAM/Redis:

- `tree /a.txt/x` escaped the walk and returned exit 1 with no body,
  where `tree /nope` still printed GNU's `[error opening dir]` + exit 2
- `grep -rl foo /a.txt/x /real` aborted and dropped /real's results,
  where the same line with `/nope` warned and kept them
- `rg -l foo /a.txt/x /real` did the same

GNU pins (debian:stable-slim): `tree /d/a.txt/x` prints the same body
and exits 2 as `tree /d/nope`; `grep -rl foo /d/a.txt/x /d/real` warns
and still prints `/d/real/b.txt`.

Both cases now route through one named `WALK_ERRORS` tuple (FS_ERRORS
plus the ValueError store backends raise for "not a directory"), so a
future errno split cannot desync these catch sites again. TypeScript
already used a broad catch and was unaffected; python now matches it.

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Zecheng Zhang <zechengzhang97@gmail.com>
2026-07-25 21:38:23 -07:00
bytecii 4807b23630 fix(ls): GNU per-directory headers for multi-operand listings (py+ts) (#638)
`ls a b` flattened every operand's entries into one unseparated list
instead of printing GNU's `a:` / `b:` blocks. Pinned the real rule with
docker (debian:stable-slim, coreutils 9.7): headers are keyed on the
*operand count*, not on how many directories were listed —

  ls a          -> bare listing, no header
  ls a b        -> `a:` block, blank line, `b:` block
  ls /nope a    -> `a:` (2 operands, one of them failed)
  ls -R a       -> `a:` (recursive always heads)

and non-directory operands print first as one unheaded block, with all
operands sorted together before that split.

Both the non-recursive and `-R` paths now share one `probe_operand` /
`probeOperand` that lists an operand and reports whether it turned out
to be a directory, so unifying them fixed three more divergences found
alongside the reported one:

- operands were listed in command-line order, interleaved: `ls b zfile
  a mfile` printed `g.txt /zfile f.txt sub /mfile`
- `-d` did not sort its operands (`ls -d b a` -> `b a`)
- `ls -R zfile a` emitted a bogus `zfile:` header with `zfile` as its
  own entry

`ls -R` on a directory whose readdir fails no longer prints an empty
header, and the TS path now type-checks a failed-readdir operand before
treating it as a file row (Python already did).

The exit-code rule is unchanged in meaning but no longer keyed on
stdout being empty: it asks whether any operand listed, so a header
line cannot flip the code. That keeps this independent of the in-flight
ls exit 0/1/2 work.

Verified: 41 Python + 16 TS unit cases with byte-exact stdout, and both
integ hosts at 1882 passed / 0 failed on `--target ram` (all 7 pinned ls
surfaces unchanged, including the 24-operand `ls /data/*.txt` glob).

Cross-mount `ls` (operands on different mounts) still prints flat — the
FANOUT strategy runs one native ls per operand, so each run correctly
sees a single operand. Closing that needs operand count threaded
through the executor and still cannot produce GNU's global operand
sort, so it is left out here.

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Zecheng Zhang <zechengzhang97@gmail.com>
2026-07-25 18:59:51 -07:00
bytecii 8b66dad1a0 fix(shell): shell-attribute unopenable redirect targets (py+ts) (#635)
* fix(shell): shell-attribute unopenable redirect targets (py+ts)

An unopenable redirect target diverged three ways. GNU bash 5.2.37
answers both `cat < missing` and `echo x > /nosuchdir/f` with
`bash: line 1: <target>: No such file or directory` and exit 1 —
attributed to the shell, since the command never runs. mirage said:

  py  `cat: /data/missing: No such file or directory`
  ts  `/data/missing`                (bare path, no reason)

Both hosts now render `<target>: <strerror>` on the `<` read and the
`>` write side alike, through one helper per host.

Deliberate divergence: GNU's `bash: line N:` prefix is dropped, matching
the house style already set by the other shell-attributed error,
`nosuchcmd: command not found` (bash prints `bash: line 1: nosuchcmd:
command not found`). `bash:` is bash's `$0` and mirage is not bash, and
`line N` has no meaning for a one-line `Workspace.execute` call. Both
docstrings record the choice.

The wording bug hid three behavior bugs, all fixed by returning an
IOResult instead of raising:

- The whole line died. `cat < missing; echo next` never ran `echo next`;
  GNU prints the error and continues (rc becomes echo's 0). `&&` now
  short-circuits, `||` runs, and pipeline peers still run. The `>` side
  had no handler at all on python, so it died the same way.
- Unwinding reached the workspace-level OSError handler, which stamps
  `command.split()[0]` — the first word of the *whole line* — so
  `cd /data && cat < missing` reported `cd:`.
- Backend prose reached the user as the path: `echo x > /nodir/f` said
  `echo: parent directory does not exist: /nodir: No such file or
  directory`. The label is now the target's own spelling (`raw_path`),
  so a relative target reports as typed like GNU.

The ram/redis `writeBytes` guards threw an untyped Error on TS while
python raised FileNotFoundError, so the TS write side could not reach
the GNU line at all. Both now throw a stamped ENOENT that keeps the
human message (new `enoentWithMessage`, mirroring `eaccesReadOnly`) —
the FUSE bridge and executor builtins still sniff that prose off the
exception.

Non-filesystem errors keep propagating on both paths and both hosts.

Coverage: 20 new unit tests (10 py + 10 ts) and 3 integ cases under
integ/bash/redirect/, all GNU-pinned via docker debian:stable-slim.
Read-only refusal pins move to the shell-attributed spelling on both
hosts (GNU: `bash: line 1: /ro/y.txt: Permission denied`), as does the
mem0 write-rejected integ case.

Known divergence left in place, documented in both docstrings: bash
processes redirects strictly left to right, so `> out < missing` empties
`out` before failing, while mirage creates output files in a second pass
and leaves it untouched. `< missing > out` leaves `out` uncreated on
both, matching bash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(integ): scope the write-failure redirect case to backends that enforce parents

CI showed `redirect_write_target_unwritable` failing on 11 of its 17
targets, all for the same reason: a missing parent directory is not a
write error there. disk, opfs, nextcloud and the object stores (s3,
s3-prefix, dropbox, dropbox-root, onedrive, sharepoint,
sharepoint-prefix) create the key regardless, so stderr was empty and
the redirect simply succeeded. ssh fails a third way — it raises an
error carrying no path, which still unwinds the line (identically on
both hosts, so parity holds; that shape is pre-existing).

Only ram and redis enforce parent existence, and their `writeBytes`
guard is exactly what this PR retypes, so the case now runs there. The
read-side case still pins the shell-attributed rendering on all 17
targets, and the write path keeps its 10 unit tests per host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(shell): stop the redirect write pass at the first failure (py+ts)

Two review findings on #635.

The write pass continued after a failed open, so `echo x > /nodir/f >
/data/out` reported the first error but still created /data/out. GNU
5.2.37 (pinned, debian:stable-slim) stops processing redirects there:

  $ echo x > /nodir/f > /data/out
  bash: line 1: /nodir/f: No such file or directory   # rc=1
  $ ls /data/out -> No such file or directory

The mirror case keeps the earlier target, which bash had already opened
and truncated, so `break` is right for both:

  $ echo y > /data/out2 > /nodir/g
  bash: line 1: /nodir/g: No such file or directory   # rc=1
  $ ls -l /data/out2 -> 0 bytes

The python append pre-read only caught FileNotFoundError, so a
PermissionError from the mount guard (`echo x >> /b/f` for a session not
granted /b) escaped handle_redirect entirely and unwound to the
workspace-level OSError handler — reintroducing, for `>>` only, the bug
this PR fixes for `>`: the rest of the line died and the message was
stamped with the whole line's first word. It now catches FS_ERRORS and
logs at debug. TypeScript's readExisting swallowed everything instead,
including backend bugs, so it now rethrows non-filesystem errors; both
hosts land on the same rule.

Coverage: 3 new py unit tests, 3 new ts unit tests, and 2 integ cases on
ram + redis (the targets that enforce parents). py + ts integ green on
both targets, pre-commit and `pnpm -r typecheck` clean.

* fix(shell): shell-attribute an ungranted mount on the TS redirect path

The py/ts parity claim in the previous commit only held for mounts the
session had a READ grant on. A mount with no grant at all goes through
`assertMountAllowed`, and TypeScript's `MountNotAllowedError` carried no
POSIX code, so `isFsError` said false and the redirect write pass
rethrew it. Verified before fixing:

  echo leaked > /b/leaked.txt; echo next
  py  exit 0, "next\n", "/b/leaked.txt: Permission denied\n"
  ts  exit 1, no "next", "session 'agent' not allowed to access mount '/b'"

That is the exact bug this PR set out to fix, still live on TS for both
`>` and `>>`. Python has no equivalent hole because its guard raises
PermissionError, already a member of FS_ERRORS.

MountNotAllowedError is now stamped EACCES + operand, mirroring python's
PermissionError, so the redirect pass renders the same line both hosts.
The `instanceof MountNotAllowedError` checks in command.ts and the FUSE
bridge run earlier and are untouched, so command-level messages
("cat: session '...' not allowed to access mount '/side'") do not move:
truth_session_modes.txt still matches byte-for-byte on both hosts.

Full TS core (5070) and node (1773) suites pass.

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Zecheng Zhang <zechengzhang97@gmail.com>
2026-07-25 16:52:28 -07:00
bytecii 7d1c518184 fix(ls): GNU exit-code seriousness split (0/1/2) in py+ts (#636)
Pre-commit / pre-commit (push) Has been cancelled
CLI exit codes / changes (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / changes (push) Has been cancelled
Test (Install) / python-install (push) Has been cancelled
Test (Install) / ts-install (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / changes (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-shared-py (push) Has been cancelled
Integ / integ-shared-ts (push) Has been cancelled
Integ / integ-shared-parity (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Integ / integ-fuse-windows (push) Has been cancelled
Integ / runtime-py (push) Has been cancelled
Integ / runtime-ts (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / changes (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Python) / runtime (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / changes (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
* fix(ls): GNU exit-code seriousness split (0/1/2) in py+ts

`ls` returned 1 for an inaccessible command-line operand where GNU
coreutils returns 2, and — worse — masked the failure entirely when any
other operand succeeded: `ls /data/nope /data` exited 0.

The rule was `1 if warnings and not results else 0`, i.e. nonzero only
when *every* operand failed. GNU instead ratchets a status upward
(`set_exit_status`): 0 ok, 1 minor problem (trouble met below an
operand), 2 serious trouble (a command-line operand could not be
accessed); serious always wins, minor only upgrades a clean run.

Pinned against GNU coreutils 9.7 (debian:stable-slim), including the
non-root cases that reach the minor-problem path:

  ls /nope                          -> 2
  ls /nope /ok       (either order) -> 2, still lists /ok
  unreadable dir as operand         -> 2
  ls -R, unreadable subdirectory    -> 1, keeps parent output
  ls -l, unstattable entries        -> 1, lists the siblings
  minor + serious together          -> 2

Warnings now carry a `serious` flag and are collapsed by
`exit_status_for` / `exitStatusFor`. Seriousness comes from a
`command_line_arg` / `commandLineArg` parameter threaded through
`walk`/`walk_grouped` (`walkGrouped`), false on recursion; a per-entry
stat failure inside a listed directory is always minor.

Three fixes were load-bearing for the split:

* Both executors gated `ls` child-mount and symlink injection on exit
  code == 0. A minor problem still lists the directory, so exit 1 would
  have silently dropped the injected rows; the gate is now
  `!= LS_FAILURE`. (Injection already bails for multi-operand and for
  -R/-d, and usage errors return early via UsageError, so the affected
  surface is a single-operand listing with a minor warning.)
* Python's minor path was unreachable: `walk` caught only
  FileNotFoundError/ValueError/NotADirectoryError, so a PermissionError
  escaped the command and discarded stdout. Now `(OSError, ValueError)`,
  matching TypeScript.
* TypeScript's `listDir` used `Promise.all`, so one failing child stat
  killed the whole listing and surfaced as an operand failure — which
  would have exited 2 where Python exits 1. Now `Promise.allSettled`
  with per-entry warnings, matching Python's tolerance.

Also fixes a Python-only bug where `walk_grouped` appended a group
unconditionally, so `ls -R /nope` printed a bogus `/nope:` header on
stdout (TypeScript was already correct).

Pinned surfaces: integ/resources/slack/ls.json (slack_ls_archived_missing)
1 -> 2, and five hard-pinned unit tests. conformance/cases/ls.json had no
error coverage, so two cases were added; they assert the status via
`2>/dev/null; echo rc=$?` because the stderr text itself diverges for an
unrelated pre-existing reason (Python+disk and TypeScript+RAM report
"Not a directory" where GNU and Python+RAM say "No such file or
directory") — tracked separately.

Verified: 26 Python + 14 TypeScript unit tests; integ 1611 passed / 0
failed on both hosts; conformance green on both; both hosts byte-identical
across all 12 pinned scenarios.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ls): mem0 golden, -R separator, review nits

- integ/resources/mem0/basic.json: mem0_readdir_of_file_enotdir asserted
  exit 1 for an inaccessible command-line operand; GNU returns 2. This
  was the second nonzero-ls golden in the repo and the only CI failure.
- The -R blank-line separator was keyed on the operand index, so a first
  operand that failed (rendering no group) still made the next one emit a
  leading blank. It now keys on what was actually emitted, matching GNU
  and the TypeScript path.
- Test denial stubs moved to module scope (no nested functions) and the
  new docstrings carry Args types.

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Zecheng Zhang <zechengzhang97@gmail.com>
2026-07-25 16:07:32 -07:00
Zecheng Zhang 9ab799bbd3 feat(shell): case ;&/;;& fallthrough, printf -v, source args, unset -f/-v (#608 Tier 2) (#630)
* feat(shell): case ;&/;;& fallthrough, printf -v, source args, unset -f/-v (#608 Tier 2)

Fixes the four Tier 2 items from #608 that silently produced wrong output
rather than a missing feature. All are pure language/session semantics
with no job-table involvement.

- case `;&` runs the next arm's body unconditionally (fallthrough); `;;&`
  keeps testing later patterns. The terminator is now captured per arm in
  get_case_items/getCaseItems and honored by handle_case/handleCase.
- printf `-v NAME` assigns the formatted text to a variable or array
  element `NAME[idx]` instead of writing to stdout, keeping GNU's
  exit-1-still-assigns behavior on a bad number.
- source/. positional args set $1..$# for the sourced script and restore
  the parent's afterwards; the no-arg form keeps the parent's params.
- unset gains -v/-f/-n: -f unsets a function only, -v a variable only, a
  bare name a variable if one exists else a function; plus whole-array
  and arr[i] element unset. -n is a documented no-op (no nameref
  attribute exists, matching bash on a non-nameref name).

Divergence: a trailing `;&`/`;;&` immediately before `esac` does not
tree-sitter-parse (same on main); it is a no-op spelling and the loud
rejection is acceptable.

GNU bash 5.2 pinned for every case. Python and TS unit tests added (each
verified to fail on the unfixed code), 11 integ cases under
bash/{case,printf,source,builtin} run 1834 passed / 0 failed on both the
Python and TS hosts.

* fix(shell): close the #630 review findings on printf -v, unset, and source

Every codex/claude review finding plus CodeQL alert 259, in both
languages, each behavior pinned against GNU bash 5.2 first.

- unset of an array element keeps the later positions: an interior
  element leaves a hole, a trailing one shortens the array. The old
  del/splice shifted every later index, so ${a[1]} returned what used to
  be ${a[2]}.
- printf -v and unset refuse a readonly target, resolving an arr[i]
  operand to its base name (which is what readonly records, and what
  bash names in the error).
- printf -v on a bare name whose variable already holds an array writes
  element 0 and keeps the tail instead of deleting the array.
- printf -v mutates nothing until the assignment succeeds, so an
  out-of-range subscript no longer destroys the existing scalar. It
  reports `bash: TARGET: bad array subscript` with status 1.
- printf -v validates the name before the format runs: status 2 with
  "not a valid identifier", and no conversion errors.
- unset 'scalar[0]' unsets the scalar; a non-zero subscript on a scalar
  reports "not an array variable" with status 1. Both were no-ops.
- TS source/. positional args use wordText, so $1 is the operand as
  typed, not the resolved mount path (Python and bash already agreed).
- python seeded name[idx] from an empty scalar as an empty array, so
  Z=""; printf -v 'Z[-1]' hi errored instead of assigning element 0.

The readonly guards were unreachable from the shell until execute_node
also learned that a bare `readonly NAME` operand parses as a
variable_name node, and that an array assignment still has to mark its
name readonly. That also makes local/export register bare names.

CodeQL 259 was a real prototype pollution: session.arrays['__proto__']
returns Object.prototype on a plain object, so printf -v '__proto__[0]'
wrote through to it. sessionEntry/setSessionEntry in session.ts guard
every read and write of a session record.

Known divergence, documented in both docstrings: arrays are stored
densely, so an interior hole is an empty string and still counts toward
${#a[@]} and ${a[@]}. That predates this change (a[9]=v pads the same
way); a sparse array model is its own PR.

8 new/updated integ cases under bash/{builtin,printf,source} pass
identically on the Python and TypeScript hosts.

* fix(shell): make arrays sparse so unset arr[i] and arr[9]=v stop lying

Arrays were stored as a dense list of values, so an index with no
element had to be spelled as an empty string. That made two things lie:
`unset arr[i]` left a countable empty element (or, before the last
commit, shifted every later index), and `arr[9]=v` reported
${#arr[@]} as 10.

Arrays are now `list[str | None]` / `(string | null)[]`, where null is a
hole: addressable, but not an element. One shared primitive owns the
rules (mirage/shell/array.py, shell/array.ts) and every consumer goes
through it:

- ${arr[@]} / ${arr[*]} and the slice, strip, replace, and case forms
  expand only the assigned values.
- ${#arr[@]} counts assigned elements; ${!arr[@]} lists their indices.
- ${arr[i]} on a hole is empty and counts as unset for ${arr[i]:-x}.
- a negative subscript resolves against the extent (one past the
  highest assigned index), so ${arr[-1]} is the last real element.
- arr+=(x) starts at the extent, refilling the slot a trailing unset
  freed and skipping interior holes.
- unset arr[i] drops trailing holes, keeping the extent honest.
- declare -a NAME with no value now declares an empty array rather than
  an empty scalar, so ${#NAME[@]} is 0 and NAME[3]=x leaves index 0
  unassigned.
- an existing scalar seeds element 0 even when empty, matching bash on
  Z=""; Z[3]=x.

An empty string stays a real element, so a=("" y) still has two.

14 shell-level cases pinned against GNU bash 5.2 cover holes,
extents, negative subscripts, slicing, case/replace ops, iteration,
append, reassignment, and declare -a; they run in both tests/shell and
shell_arrays.test.ts, plus 10 unit tests per language on the primitive
itself and two integ cases on both hosts.

* fix(shell): count an empty scalar as one element in ${#x[@]}

Expansion seeded the implicit one-element array from a scalar only when
the scalar was truthy, so `Z=""` looked like an empty array: ${#Z[@]}
reported 0 and ${!Z[@]} was empty, where bash reports 1 and 0. An
actually-unset name is the case that has no elements, so the seed now
keys off presence in env rather than emptiness.

Found while auditing the sparse-array consumers; pinned against GNU
bash 5.2 and covered in tests/shell/test_arrays.py and
shell_arrays.test.ts.

* fix(shell): write array elements through splice (CodeQL 260)

arraySet/arrayUnset assigned through a script-derived subscript
(`arr[idx] = value`), which is the prototype-polluting-assignment shape
even though the index is a validated non-negative number and the target
is always a real array. splice cannot name a property at all, and both
call sites already guarantee idx < arr.length at that point, so the
behavior is unchanged.

* fix(test): drop a dead ?? on ExecuteResult.stderr

ExecuteResult.stderr is a Uint8Array, never null, so the fallback in the
readonly test tripped @typescript-eslint/no-unnecessary-condition. Local
`pre-commit run --files <changed>` missed it because the type-aware rule
only reported it against a fresh build; `--all-files` reproduces CI.

* fix(shell): close the six codex findings on the sparse array model

All pinned against GNU bash 5.2 first, both languages.

- ${a[@]:o:l} slices by SUBSCRIPT, not by position among the assigned
  values. For a=([1]=b [3]=d [9]=j), ${a[@]:2} is `d j` because it keeps
  every index >= 2; length then caps how many are taken. Compacting with
  array_values first turned the offset into an ordinal. A negative offset
  resolves against the extent and yields nothing when it stays negative,
  where the old code clamped to 0 and returned everything.
- `declare -a` / `local -a` inside a function are local again: the
  caller's array is recorded in session._local_arrays / localArrays and
  restored on return, and the declaration shadows with a fresh empty
  array. `f(){ declare -a leak; leak[2]=x; }; f` no longer leaks, and
  declaring an existing array local no longer mutates the caller's.
- `x=foo; declare -a x` migrates the scalar to element 0 instead of
  installing an empty array that shadowed it, so ${#x[@]} is 1. An
  existing array is left alone.
- Array declarations are staged until the readonly guard passes, so
  `readonly -a a=(y)` on an already-readonly name fails with the old
  value intact instead of overwriting it first.
- `printf -v 'a[]'` is rejected as an invalid identifier with status 2;
  the subscript group now requires at least one character. `a[ ]` stays a
  valid arithmetic 0.
- `unset 'a[-2]'` on a shorter array reports `unset: [-2]: bad array
  subscript` with status 1 and keeps the array, rather than silently
  succeeding. bash prints only the bracketed part here, unlike the
  assignment and printf -v paths which name the base.

Also fixed in passing: a declaration-context `a+=(y)` stored under the
literal key "a+" instead of appending to `a`.

26 new bash-pinned cases per language in tests/shell/test_arrays.py and
shell_arrays.test.ts (79 each), plus unit tests for array_slice and the
two new error paths.
2026-07-25 08:50:18 -07:00
Zecheng Zhang 308b20483a Add TypeScript watch support (#634)
* Add TypeScript watch support

* Fix watch path ReDoS

* Address watch review: closed-workspace guard, move/overflow coalescing, ReDoS-free path strip
2026-07-25 08:44:36 -07:00
Zecheng Zhang 90a20bd169 fix: stop masking backend failures as missing paths, widen TS conformance (#633)
Three fixes from an audit of the TypeScript side.

1. WorkspaceFS.exists/isDir/isFile caught every exception and returned
   false, so an auth failure, timeout, or backend bug read back as "this
   path does not exist". Python already had the right answer: it swallows
   only (FileNotFoundError, ValueError), where ValueError is the registry's
   "no mount matches path". Added noMount() plus an isMissingPath()
   predicate and applied it to the three probes, to pathExists/isDirectory
   in the copy helpers, and to the same pattern in realpath and df, both of
   which had already diverged from their narrow Python twins.

2. LanceDB blob decoding called the Node global Buffer inside the
   runtime-agnostic core package, which breaks in browsers. It now uses the
   existing decodeBase64 helper, matching its qdrant sibling. The two gmail
   base64url helpers moved onto the shared helpers as well.

3. TypeScript conformance only ran against RAM while Python ran RAM, disk
   and Redis. The runner now covers all three with fresh backend state per
   case, and all 34 cases pass on every backend. Teardown is split so
   clearing Redis happens before Workspace.close() destroys the client,
   rather than silently opening a second connection per case.

Also wired integ/runners/parity.py into CI as integ-shared-parity and added
it to the gate; it existed but nothing ran it. Locally it compares 5386
case/target pairs with no mismatches. Added conformance/** to the python and
typescript workflow path filters, which did not trigger those suites before.

The test stubs that faked backend errors as new Error("not found") or
new Error("ENOENT"), with the code in the message instead of on the error,
now use enoent() like real backends do.
2026-07-24 23:02:00 -07:00
bytecii 5976bb5020 fix(stat): apply the namespace attr overlay in the bespoke opfs/s3/gridfs stat commands (py+ts) (#631)
* fix(stat): apply the namespace attr overlay in the bespoke stat commands (py+ts)

#626 wired the namespace attr overlay (chmod/chown/touch on backends with no
setattr op) into the generic_bind `stat` builder, but the three hand-written
`stat` command overrides bypass that builder entirely and kept reading the raw
backend stat. None of those backends override `ls`, so `ls -l` merged the
overlay while `stat -c` did not, and the two disagreed for the same file:

  $ touch -t 202401010000 f && chmod 640 f      # on an opfs mount
  $ stat -c "%y|%a" f
  2026-07-24T23:44:39.946Z|644                  # OPFS write time, default mode
  $ ls -l f
  -rw-r----- 1 user user 6 Jan  1 00:00 f       # touched + chmod'd

Fixed in browser opfs, core s3 and node gridfs (ts) plus s3 and gridfs (py).
On the Python side `stat_overlay` was not declared at all, so it fell into
`**_extra` and was silently swallowed.

TS gains an exported `overlaidStat(stat, overlay)` in generic_bind/adapter.ts,
mirroring the Python `overlaid_stat` that already existed, and all six stat/ls
call sites now bind through it instead of repeating the inline ternary.

`history` overrides both `ls` and `stat` with raw backend stats, so it already
self-agrees; it is a read-only view mount and is left alone.

Coverage: the existing meta_overlay cases assert through the harness `check`,
which routes via `ws.dispatch('stat')` (the ops facade) and therefore masked
this command-level gap, so the new integ cases assert on `stat -c` stdout
directly. Unit tests cover opfs (workspace-level) and py s3/gridfs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(stat): concrete mock types in the s3/gridfs stat tests

Codex review on #631: the fake backend helpers annotated the accessor and
index as `object`, which defeats type checking exactly where the mocks are
meant to mirror the production call signatures. AGENTS.md reserves `object`
for opaque `**flags` bags only.

Use S3Accessor/GridFSAccessor and IndexCacheStore, and replace `_render`'s
`**kw: object` pass-through with an explicit `stat_overlay: StatOverlay | None`
parameter — it forwards exactly one kwarg, so it was never a flag bag.

`_fake_stat_core` keeps its parameter literally named `index`: bound_op binds
it by keyword (`partial(fn, accessor, index=index)`), so renaming it to
`_index` would break the no-overlay path. The overlay path calls it
positionally, and both paths are covered by the two tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 23:01:08 -07:00
bytecii c18b9f21d3 fix(shell): single-quoted redirect target silently wrote nowhere (py+ts) (#632)
* fix(shell): single-quoted redirect target silently wrote nowhere

`> 'path'` exited 0 without creating the file. `TARGET_TYPES` — the
allowlist of tree-sitter node types accepted as a redirect operand —
listed STRING (double quotes) but not RAW_STRING (single quotes), so
parseFileRedirect never matched the operand and left target_node None
with target "". Downstream that empty string was used as the path
verbatim. Both hosts had the identical defect.

The empty path is a real key in the store, so every single-quoted
target in a session aliased onto one phantom file. Beyond the reported
`>` data loss this also meant:

  - `2> 'f'` swallowed stderr entirely (nonzero exit, empty stderr)
  - `< 'f'` returned an unrelated file's bytes
  - `>> 'f'` / `&> 'f'` wrote nothing
  - `<<< 'text'` inside a redirected statement delivered a bare
    newline (parseHerestringRedirect shares the same gate)

Fix is one entry per host. Pinned against GNU bash 5.2.37 in docker
(debian:stable-slim) with a 26-case diff harness: byte-identical after
the change.

Tests: 15 Python, 13 TypeScript, all failing on a reverted fix. The
double-quoted and unquoted parametrizations act as controls. New integ
group integ/bash/redirect/quoted_target.json (13 cases, seq
603200-603212) using the canonical 20-target mutation list copied from
redirect.json — hf/hf-prefix excluded, as they reject mutations. Both
hosts: 3248 passed, 0 failed on ram+disk; reverting the fix fails
exactly the 9 single-quoted cases.

Aliasing tests assert on bytes rather than the error message because
the missing-stdin-source wording still differs between the two hosts
and from GNU; that pre-existing divergence is filed separately and
reproduces with unquoted targets too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(shell): hoist redirect-target test helpers to module scope

Review follow-up on #632. `targetTypeOf` and `statementWith` were
declared inside the `describe` callback, which violates the repo-wide
no-nested-functions rule (AGENTS.md L107-108) and also contradicts the
file's own precedent — `node()` has always been module-scope.

Both now sit beside `node()`. `statementWith` is renamed
`redirectStatement` (a file-global name should say what it builds) and
constructs its own command node rather than capturing the describe-scope
`command`/`commandName` consts, so the move carries no state with it and
those two consts are gone.

Test-only; no production change. 30 tests in the file pass, the full
core suite is 4973 passed / 0 failed, and all 7 packages typecheck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-24 19:29:18 -07:00
bytecii d813bd342a feat(cp/mv): GNU update/backup/target-dir/exchange flag semantics (#609 Tier 1) (#629)
* feat(cp/mv): GNU update/backup/target-dir/exchange flag semantics (#609 Tier 1)

Implement the cp/mv "semantics flags" slice of #609 Tier 1 with Python/
TypeScript parity, pinned against GNU coreutils 9.7.

cp gains -u/--update[=all|none|none-fail|older], -b/--backup[=CONTROL]
+ -S/--suffix, -t/--target-directory, -T/--no-target-directory, the long
spellings of -r/-a/-f/-n/-v, and -f/-i/--strip-trailing-slashes as
documented no-ops, plus GNU overwrite-type guards and arity errors.

mv additionally gains --exchange (atomic swap via three renames),
--no-copy (cross-mount refusal), and rename-level -n/-u/-b gating, sharing
cp's transfer/backup/policy engine.

Supporting fixes that fell out of end-to-end testing:
- New Option.short_value flag so an optional-value short (cp -b/-u) stays a
  clusterable boolean (`-bv`) instead of eating the cluster remainder as a
  value; only --backup=/--update= carry values (GNU).
- cp/mv now route path-valued flags (cp -t /other/mount/dir) through
  cross-mount detection, and see the touch/chmod stat overlay so -u
  freshness matches ls/stat.
- Single per-source entryKind probe + overwrite-gate early-out so API
  backends pay no extra stat per entry when no gating flag is set.

New shared backup helper (utils/backup) implements GNU version-control
naming (simple/numbered/existing, ~ and .~N~ suffixes); env-less default
is `existing`, VERSION_CONTROL/SIMPLE_BACKUP_SUFFIX deliberately not read.

Coverage: Python + TS unit tests for every new flag and error path, and
26 new integ cases across integ/unix/{cp,mv}. Full integ battery green on
ram (1849) and disk (1837), 0 failures, on both hosts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cp/mv): address codex review + green the CI (#609 Tier 1 follow-up)

CI fixes
- Spec drift: the new cp/mv options were never regenerated, and the new
  Option.short_value field leaked into spec/python via asdict() while the
  explicit TS serializer omitted it, breaking the two trees' key parity.
  Emit short_value from gen-specs.ts too and regenerate all three trees.
- integ-shared-{py,ts}: the 26 new cp/mv cases listed hf/hf-prefix, whose
  backend rejects these mutations ("Operation not supported"). Every
  pre-existing cp/mv case excludes it; drop it to match the canonical set.
- integ-shared-ts opfs: OPFS_CP/OPFS_MV passed the raw backend stat, so -u
  freshness could not see touch -d (OPFS has no setattr; touched times live
  in the namespace overlay). Read opts.statOverlay like OPFS_LS already
  does. Fixed properly rather than by excluding the target — mtime cases
  include opfs by convention (the meta_overlay facet exists for it).

Codex findings, each pinned against GNU coreutils 9.7
- P1 --exchange staged through a fixed `<target>.~xchg~`, silently
  clobbering a real file of that name, and left the operands half-moved on
  a mid-sequence failure. GNU's renameat2(RENAME_EXCHANGE) touches nothing
  else, so probe for a free staging name and roll back on failure; report
  the leftover path when the rollback itself fails. Docstrings no longer
  claim three renames are atomic.
- P1 A files-only find loop dropped every directory holding no files once
  any update/backup mode was set, and copied an empty tree to nothing.
  Narrow per_entry_native so the no-op modes (--update=all, --backup=none)
  keep the whole-tree dir_copy, and recreate directories via a new optional
  NativeCopy.mkdir on the genuinely-gating path.
- P1 The backup version scan swallowed readdir failures and read them as
  "no numbered backups", which then picks .~1~ and overwrites backup
  history. Propagate, and abort the overwrite instead.
- P2 -u and --update are one GNU option, so the last spelling wins; flags
  are stored per spelling, which let a fixed read order override
  command-line order. Mirror aliases in the parser for optional-value
  options only — repeatable ones accumulate (sort -k/--key concatenates
  both lists and would double).
- P2 mv -b -T refused a nonempty directory target before the backup could
  displace it; GNU renames it aside and installs the source.
- P2 A cross-mount directory backup called read_bytes on a directory.
  Walk the tree for the primitive strategies and defer to dir_copy on the
  native one. Also fixes the same swallowed-readdir shape in mv's -T
  emptiness probe, where "empty" was the clobbering direction.

Coverage
- 18 new integ cases across both hosts: the behaviors above plus gaps the
  PR left uncovered — mv --no-copy (cross-mount refusal, source kept,
  same-mount no-op), mv --backup=numbered, mv -S, mv --update=none, cp
  --backup=none, and the long spellings (--suffix, --target-directory,
  --no-target-directory, --recursive, --verbose, --strip-trailing-slashes).
- 20 new unit tests (py+ts) for the staging-name collision, both rollback
  paths, dir preservation, no-op-mode dir_copy retention, backup-scan
  failure, the -b -T backup, and last-wins aliasing.

Verified: pre-commit clean, full py suite, ts core 4991 + browser 167,
integ ram/disk/opfs green and byte-identical across the py and ts hosts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(test): satisfy tsc --noEmit in the new cp/mv tests

vitest transpiles without typechecking, so these slipped past a local
`pnpm --filter mirage-core test` and only the CI Typecheck step caught them.

- cp.test.ts: the typed-find helper declared its own `{ type?: string }`
  options shape, which is not assignable to FindFn's FindOptions under
  exactOptionalPropertyTypes (FindOptions.type is `string | null`). Use
  FindOptions itself.
- mv.test.ts: eacces() takes one argument, not two.

Verified with the recursive typecheck CI runs (`pnpm -r typecheck`, all 7
packages clean) rather than the single-package filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: scope two new integ cases to capable backends; mirror attached short values

integ (the 6 remaining integ-shared failures, identical on both hosts)
- mv_b_T_backs_up_nonempty_dir backs the target up by *renaming a
  directory*, which the dirless object stores cannot do — a directory there
  is a key prefix with no object of its own, so s3 reports "The specified
  key does not exist" and gridfs "No such file or directory". Dropped s3,
  s3-prefix, gridfs, gridfs-prefix (20 -> 16 targets). No existing case
  asserts a successful same-mount directory rename, which is consistent
  with this being a backend limitation rather than a regression.
- xm_mv_no_copy_same_mount_ok needs a same-mount mv, i.e. a rename op,
  which hf does not register; its sibling cases pass there only because
  they refuse before touching the backend. Dropped hf/hf-prefix.
- Both crossmount groups now work inside their own subdirectories instead
  of writing to the top level of /data and /data2, so they leave no residue
  in the shared battery session.

parser
- The attached-short-value path (`-d10`) was the one write site my alias
  mirroring missed, so last-wins held for `--long=` but not for the short
  form: `split --numeric-suffixes=3 -d10` resolved to 3 instead of 10.
  Python only — the TS regex already covered all 7 call sites. Regression
  test on both sides pins the mirror and both orderings.

Verified: full py suite (8782 passed), all 7 packages typecheck, integ
ram/disk/opfs green on both hosts with identical counts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: yapf line-wrapping on the alias-mirroring call sites

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(cp/mv): drop the object-typed backup param, share the flag helpers

Follow-up to the review round. Claude's review listed the `object`-typed
backup helper params among what it examined; it ruled them out as a bug, but
CLAUDE.md forbids `object` on a parameter outright ("only acceptable as the
value type of an opaque flag bag"), and chasing it surfaced a py/ts
divergence next to it.

- backup_control's `value` was `object`. FlagView.raw() legitimately returns
  object (the bag IS opaque), but that stops at the boundary: for -b/--backup
  the real type is `str | bool | None`. Narrow it in a new shared
  `backup_raw()` and type the parameter accordingly. TS mirrors it —
  backupRaw/backupControl were `unknown`.
- mv.py had reimplemented backup_raw and target_flags inline, 15 lines of
  duplicated flag interpretation, while TS already exported backupRaw and
  targetFlags and mv.ts used both. Un-privatized the two cp.py helpers and
  used them from mv.py, so the two languages now share the same shape and
  flag semantics live in one place per CLAUDE.md ("adding or changing a flag
  should touch the spec and the generic, not N wrappers").

No behavior change: mypy clean (1555 files), pre-commit clean, full py suite
8782 passed, ts core 4992, integ ram/disk/opfs green on both hosts with
identical counts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:06:52 -07:00
Zecheng Zhang f7d0f3080c Add TypeScript OneDrive, SharePoint, and Mem0 parity (#628)
* Add TypeScript Graph and Mem0 parity

* Close the #628 review findings across Python and TypeScript

CodeQL flagged 12 polynomial-regex alerts, all the same `/^\/+|\/+$/g` path
trim on library input. They now use the existing loop-based stripSlash /
rstripSlash helpers.

Codex findings:

- `find` on an unscoped SharePoint mount returned nothing, because the
  synthetic site and library levels carry no drive id. It now walks those
  levels and delegates each library with a depth offset, so -maxdepth and
  -mindepth still count from the real start path. Python had the same bug.
- A missing Mem0 memory raised the provider error instead of ENOENT, so
  `test -e` and `cat` could not report "No such file or directory".
- `find -empty` treated every Graph folder as non-empty. It now reads the
  folder facet's childCount, which Graph returns by default.
- A Graph folder's `size` is aggregate subtree storage, not a rendered
  content length, so it moves from FileStat.size to extra.size_bytes.

Claude review findings:

- copyTree did not invalidate nested destination listings when a recursive
  copy merged into existing folders, unlike Python's copy_tree.
- OneDrive readdir had a no-index branch that bypassed the 404 to
  ENOENT/ENOTDIR translation.
- SharePoint du/duAll did not swallow a stale-cache ENOENT the way OneDrive
  and both Python backends do.

The shared find_arg_errors truth file only listed the Python backends, so
the TypeScript job diffed against a stale file. Both lists are now
alphabetical and identical, and Python covers mem0, onedrive and sharepoint
as well.

Adding Mem0 integration cases (7 to 19) surfaced two divergences. A failed
redirect write in TypeScript printed the raw error, leaking the op-registry
wording, and now goes through formatFsError. That exposed the read-only
guard reporting different labels: Python used the guard's own message where
TypeScript reported the operand, so Python now stamps errno and the path and
both print `<cmd>: <path>: Permission denied`.

* Drop the unused @smithy/node-http-handler dependency

These two edits belonged in the merge commit but were never staged, so the
merge kept the dependency while carrying main's lockfile without it, and CI's
frozen-lockfile install refused to run.

Main's per-client httpAgentProvider only needs http-proxy-agent and
https-proxy-agent, which it already declares, so the branch adds no
dependencies and the lockfile matches main. The changeset drops the S3 proxy
and LanceDB lines that #627 ships.

* Route Graph and Mem0 config through the secret-redaction machinery

The three new resources bypassed the machinery that backs the other 66
config files. `Mem0Config.apiKey` and `MsGraphConfig.accessToken` were plain
strings with no zod schema and no `secretStr()`, where Python types both as
`SecretStr`. Each resource then hand-wrote its redacted state literal, so any
field added to the config later would silently leak into snapshot state or
vanish from it.

They now declare schemas and derive state from them:

- `MSGRAPH_CONFIG_SHAPE` marks `accessToken` secret and accepts the provider
  callable as well as a literal, and the OneDrive and SharePoint schemas
  spread it, mirroring Python's MsGraphConfig base model.
- `Mem0ConfigSchema` marks `apiKey` secret.
- `getState()` calls `redactXConfig`, the analog of Python's
  `config_state` to `redacted_config_dump` path.

Registration was an unvalidated double cast on exactly these three of the 46
node factories, and the same three in the browser, so a bad config only
failed later at the first API call. They now call `normalizeXConfig`, which
camelCases the input and validates it against the schema like every other
resource.

Also regenerates spec/, which never picked up the three resources. That is a
separate CI step rather than a pre-commit hook, so a local `pre-commit run
--all-files` did not catch it.

`Mem0-User-ID` is documented rather than changed: it is the official SDK's
own client identifier, md5 of the API key, and the endpoints and pagination
match the SDK too, so the hand-rolled fetch client stays faithful to it.
2026-07-24 18:28:33 -07:00
7045 changed files with 531280 additions and 153375 deletions
+6
View File
@@ -0,0 +1,6 @@
*
!python
python/.venv
python/**/__pycache__
python/.mypy_cache
python/.pytest_cache
+36 -3
View File
@@ -128,11 +128,26 @@ runs:
cat /tmp/boxsrv.log
echo "BOX_ENDPOINT=http://127.0.0.1:5096" >> "$GITHUB_ENV"
- name: Start fake GitHub API
shell: bash
run: |
nohup ./python/.venv/bin/python integ/server/github_server.py \
--port 5098 --repo integ/repo-v1=github/repo-v1 \
--repo integ/repo-trunc=github/repo-v1:truncated \
--repo integ/repo-cli=github/repo-v1 \
> /tmp/ghsrv.log 2>&1 &
for i in $(seq 1 30); do
grep -q "GITHUB_ENDPOINT=" /tmp/ghsrv.log && break
sleep 1
done
cat /tmp/ghsrv.log
echo "GITHUB_URL=http://127.0.0.1:5098" >> "$GITHUB_ENV"
- name: Start fake Slack Web API (Prisma)
working-directory: integ
shell: bash
env:
SLACK_DB_URL: file:/tmp/mirage-slack-ci.db
INTEG_DB_URL: file:/tmp/mirage-slack-ci.db
run: |
./node_modules/.bin/prisma generate --schema prisma/schema.prisma
nohup ./node_modules/.bin/tsx server/slack.ts --port 5097 > /tmp/slacksrv.log 2>&1 &
@@ -143,10 +158,14 @@ runs:
cat /tmp/slacksrv.log
echo "SLACK_URL=http://127.0.0.1:5097" >> "$GITHUB_ENV"
- name: Start fake Trello API
- name: Start fake Trello API (Prisma)
working-directory: integ
shell: bash
env:
INTEG_DB_URL: file:/tmp/mirage-trello-ci.db
run: |
nohup ./python/.venv/bin/python integ/server/trello_server.py --port 5095 > /tmp/trellosrv.log 2>&1 &
./node_modules/.bin/prisma generate --schema prisma/schema.prisma
nohup ./node_modules/.bin/tsx server/trello.ts --port 5095 > /tmp/trellosrv.log 2>&1 &
for i in $(seq 1 30); do
grep -q "TRELLO_ENDPOINT=" /tmp/trellosrv.log && break
sleep 1
@@ -176,6 +195,20 @@ runs:
cat /tmp/difysrv.log
echo "DIFY_ENDPOINT=http://127.0.0.1:5093" >> "$GITHUB_ENV"
- name: Start jaeger all-in-one and seed traces over OTLP
shell: bash
run: |
for i in $(seq 1 5); do
docker pull jaegertracing/jaeger:latest && break
echo "docker pull failed (attempt $i), retrying"; sleep 10
done
docker run -d --name mirage-jaeger \
-p 16686:16686 -p 4317:4317 -p 4318:4318 \
jaegertracing/jaeger:latest
./python/.venv/bin/python integ/server/jaeger_seed.py \
--host http://localhost:16686 --otlp http://localhost:4318
echo "JAEGER_URL=http://localhost:16686" >> "$GITHUB_ENV"
- name: Start fake Databricks API
shell: bash
run: |
+49 -1
View File
@@ -30,7 +30,7 @@ jobs:
enable-cache: true
- name: Install pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v6.0.10
with:
version: 10.32.1
@@ -64,3 +64,51 @@ jobs:
./python/.venv/bin/python scripts/gen_specs.py
node --experimental-strip-types typescript/scripts/gen-specs.ts
git diff --exit-code spec/
- name: Spec parity
run: ./python/.venv/bin/python scripts/check_spec_parity.py
- name: Width table drift
run: |
./python/.venv/bin/python scripts/gen_width_table.py
git diff --exit-code python/mirage/utils/generated \
typescript/packages/core/src/utils/generated integ/fixtures/wc
# Runs --strict, which does not demand zero divergences: it fails only
# when the count moves off the committed baseline in either direction,
# so new drift is blocked and a closed divergence has to be locked in.
- name: Layout parity
run: ./python/.venv/bin/python scripts/check_layout_parity.py --strict
# Same ratchet, applied to battery coverage: a backend that cannot pass
# a case is normally just deleted from its `targets`, which turns a
# divergence into an omission nobody can see. This fails when a case
# file drops a target its siblings still exercise.
- name: Case target coverage
run: ./python/.venv/bin/python integ/runners/tools/check_case_targets.py --strict
# knip cannot police core's barrel: its project root is typescript/, so
# the barrel's real consumers (examples, docs, integ) sit outside its
# graph, and core's `./*` exports map makes every module an entry.
- name: Core barrel surface
run: ./python/.venv/bin/python scripts/check_barrel_surface.py
# `normalizeFields` already maps every unlisted key through
# `snakeToCamel`, so a rename entry that restates that mapping is not
# configuration -- it is a second place for one fact to be wrong, and it
# buries the handful that are real overrides (`endpoint_url`, `timeout`,
# `aws_profile`) in a wall of noise. 95 had accumulated.
# --selftest first: the gate's own patterns are what it trusts, and the
# first version matched neither `export const` nor a quoted key, so it
# reported success over a blind spot. The fixtures cover every
# declaration form in use.
- name: Redundant config renames
run: |
./python/.venv/bin/python scripts/check_normalize_renames.py --selftest
./python/.venv/bin/python scripts/check_normalize_renames.py
# The docs build parses frontmatter as YAML and stops on the first page
# it cannot read, so an unquoted description holding ": " fails the
# deploy with no local signal at all.
- name: Docs frontmatter
run: ./python/.venv/bin/python scripts/check_docs_frontmatter.py
+14 -50
View File
@@ -37,9 +37,10 @@ jobs:
- 'integ/cross.sh'
- 'integ/cross.yaml'
- 'integ/parity.sh'
- 'integ/cli_fuse.sh'
- 'integ/fuse/cli_fuse.sh'
- 'integ/cli_config.sh'
- 'integ/cli_runtime.sh'
- 'integ/cli_ref.sh'
- 'integ/fixtures/cli/**'
- '.github/workflows/test_cli.yml'
python:
@@ -149,13 +150,13 @@ jobs:
fi
echo
echo "--- per-mount command safeguards"
echo "--- per-mount command limits"
cat > /tmp/sg.yaml <<'YAML'
mounts:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: truncate
@@ -165,7 +166,7 @@ jobs:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: error
@@ -333,7 +334,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v6.0.10
with:
version: 10.32.1
@@ -436,13 +437,13 @@ jobs:
fi
echo
echo "--- per-mount command safeguards"
echo "--- per-mount command limits"
cat > /tmp/sg.yaml <<'YAML'
mounts:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: truncate
@@ -452,7 +453,7 @@ jobs:
/:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
cat:
max_lines: 2
on_exceed: error
@@ -629,10 +630,6 @@ jobs:
AWS_SECRET_ACCESS_KEY: minio123
CROSS_DISK_ROOT: /tmp/mirage-cross-disk
CROSS_REDIS_PREFIX: mirage-cross/
QUICKJS_BUILD: v0.15.1
WASI_BUILD: python-3.14.6-wasi_sdk-24
MIRAGE_QUICKJS_HOME: /tmp/quickjs
MIRAGE_WASI_HOME: /tmp/cpython-wasi
steps:
- uses: actions/checkout@v7
@@ -660,41 +657,8 @@ jobs:
working-directory: python
run: uv pip install .
- name: Cache the quickjs WASI build
id: quickjs-cache
uses: actions/cache@v6
with:
path: /tmp/quickjs
key: quickjs-wasi-${{ env.QUICKJS_BUILD }}
- name: Download the quickjs WASI build
if: steps.quickjs-cache.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p /tmp/quickjs
gh release download "$QUICKJS_BUILD" -R quickjs-ng/quickjs \
-p qjs-wasi.wasm -O /tmp/quickjs/qjs-wasi.wasm
- name: Cache the CPython WASI build
id: wasi-cache
uses: actions/cache@v6
with:
path: /tmp/cpython-wasi
key: cpython-wasi-${{ env.WASI_BUILD }}
- name: Download the CPython WASI build
if: steps.wasi-cache.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p /tmp/cpython-wasi
gh release download v3.14.6 -R brettcannon/cpython-wasi-build \
-p "$WASI_BUILD.zip" -O /tmp/wasi-build.zip
unzip -q /tmp/wasi-build.zip -d /tmp/cpython-wasi
- name: Install pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v6.0.10
with:
version: 10.32.1
@@ -758,15 +722,15 @@ jobs:
"python/.venv/bin/mirage" \
"node typescript/packages/cli/dist/bin/mirage.js"
- name: Run CLI runtime battery (yaml runtime selection, cross-language rejection)
- name: Run CLI ref battery (a program tree installed from a file)
run: |
bash integ/cli_runtime.sh \
bash integ/cli_ref.sh \
"python/.venv/bin/mirage" \
"node typescript/packages/cli/dist/bin/mirage.js"
- name: Run CLI end-to-end FUSE (two per-mount subtrees, real libfuse)
run: |
bash integ/cli_fuse.sh \
bash integ/fuse/cli_fuse.sh \
"python/.venv/bin/mirage" \
"node typescript/packages/cli/dist/bin/mirage.js"
+1 -4
View File
@@ -100,9 +100,6 @@ jobs:
"redis:mirage.resource.redis"
"email:mirage.resource.email"
"fuse:mirage.fuse.mount"
"pdf:mirage.core.filetype.pdf"
"parquet:mirage.core.filetype.parquet"
"hdf5:mirage.core.filetype.hdf5"
"nextcloud:mirage.resource.nextcloud"
"hf:mirage.resource.hf_buckets"
"langfuse:mirage.resource.langfuse"
@@ -146,7 +143,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v6.0.10
with:
version: 10.32.1
File diff suppressed because it is too large Load Diff
+41 -12
View File
@@ -10,6 +10,7 @@ permissions:
- python/**
- examples/python/**
- integ/**
- conformance/**
- .github/workflows/test_python.yml
pull_request:
branches: [main]
@@ -36,6 +37,7 @@ jobs:
- 'python/**'
- 'examples/python/**'
- 'integ/**'
- 'conformance/**'
- '.github/workflows/test_python.yml'
test:
@@ -94,22 +96,42 @@ jobs:
MONGODB_URI: mongodb://localhost:27017
run: uv run pytest --ignore=tests/agents/camel
# Each truth file owns the command it was captured against, so this
# list is paths only and cannot drift from what is asserted. The
# runner keeps going after a failure and fails the step at the end,
# so one stale truth file reports every other one in the same run
# instead of hiding the examples listed below it.
- name: Examples (output checked against integ/ truth files)
env:
REDIS_URL: redis://localhost:6379/0
run: |
run() { echo "## $1"; ./python/.venv/bin/python "$1" 2>&1 | bash integ/check_lines.sh "$2"; }
run examples/python/ram/ram.py integ/truth/python/ram.txt
run examples/python/ram/ram_vfs.py integ/truth/python/ram_vfs.txt
run examples/python/ram/ram_python.py integ/truth/python/ram_python.txt
run examples/python/disk/disk.py integ/truth/python/disk.txt
run examples/python/disk/disk_vfs.py integ/truth/python/disk_vfs.txt
run examples/python/other/custom_command.py integ/truth/python/custom_command.txt
run examples/python/redis_resource/example_redis.py integ/truth/python/redis.txt
run examples/python/redis_resource/example_redis_index.py integ/truth/python/redis_index.txt
run examples/python/redis_resource/example_redis_cache.py integ/truth/python/redis_cache.txt
run examples/python/redis_resource/example_redis_vfs.py integ/truth/python/redis_vfs.txt
run examples/python/version/branching.py integ/truth/version_branching.txt
bad=0
run() {
python3 integ/check_example.py "$1" --variant python || bad=1
}
# One truth file, a second interpreter mode: with UTF-8 mode
# off, pathlib passes io.open's "locale" sentinel, which is
# not a codec name and is what every read_text() sends on a
# host whose LC_CTYPE is already a UTF-8 locale.
variant() {
python3 integ/check_example.py "$1" --variant "$2" || bad=1
}
run integ/truth/python/ram.json
run integ/truth/python/ram_vfs.json
variant integ/truth/python/ram_vfs.json python_utf8_off
run integ/truth/python/ram_python.json
run integ/truth/python/disk.json
run integ/truth/python/disk_vfs.json
run integ/truth/watch_delta.json
run integ/truth/permissions.json
run integ/truth/python/custom_command.json
run integ/truth/python/filetype.json
run integ/truth/python/redis.json
run integ/truth/python/redis_index.json
run integ/truth/python/redis_cache.json
run integ/truth/python/redis_vfs.json
run integ/truth/version_branching.json
exit $bad
- name: Sync Python dependencies for camel
working-directory: python
@@ -244,6 +266,13 @@ jobs:
MIRAGE_QUICKJS_HOME: /tmp/quickjs
run: uv run pytest tests/runtime/js/ -v --no-cov
- name: Run the runtime conformance suite
working-directory: python
env:
MIRAGE_WASI_HOME: /tmp/cpython-wasi
MIRAGE_QUICKJS_HOME: /tmp/quickjs
run: uv run pytest tests/runtime/test_conformance.py -v --no-cov
- name: Install base package without the monty extra
working-directory: python
run: |
+72 -26
View File
@@ -10,6 +10,7 @@ permissions:
- typescript/**
- examples/typescript/**
- integ/**
- conformance/**
- .github/workflows/test_typescript.yml
pull_request:
branches: [main]
@@ -36,6 +37,7 @@ jobs:
- 'typescript/**'
- 'examples/typescript/**'
- 'integ/**'
- 'conformance/**'
- '.github/workflows/test_typescript.yml'
test:
@@ -57,7 +59,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v6.0.10
with:
version: 10.32.1
@@ -89,28 +91,40 @@ jobs:
REDIS_URL: redis://localhost:6379/0
run: pnpm test
# Each truth file owns the command it was captured against, so this
# list is paths only and cannot drift from what is asserted. The
# runner keeps going after a failure and fails the step at the end,
# so one stale truth file reports every other one in the same run
# instead of hiding the examples listed below it.
- name: Examples (output checked against integ/ truth files)
env:
REDIS_URL: redis://localhost:6379/0
run: |
run() { echo "## $1"; pnpm -C examples/typescript exec tsx "$1" 2>&1 | bash integ/check_lines.sh "$2"; }
run ram/ram.ts integ/truth/typescript/ram.txt
run ram/ram_vfs.ts integ/truth/typescript/ram_vfs.txt
run disk/disk.ts integ/truth/typescript/disk.txt
run disk/disk_vfs.ts integ/truth/typescript/disk_vfs.txt
run other/custom_command.ts integ/truth/typescript/custom_command.txt
run pyodide/basic.ts integ/truth/typescript/pyodide_basic.txt
run pyodide/env.ts integ/truth/typescript/pyodide_env.txt
run pyodide/heredoc.ts integ/truth/typescript/pyodide_heredoc.txt
run pyodide/script.ts integ/truth/typescript/pyodide_script.txt
run pyodide/ram.ts integ/truth/typescript/pyodide_ram.txt
run redis/redis.ts integ/truth/typescript/redis.txt
run redis/redis_index.ts integ/truth/typescript/redis_index.txt
run redis/redis_cache.ts integ/truth/typescript/redis_cache.txt
run redis/redis_vfs.ts integ/truth/typescript/redis_vfs.txt
run version/branching.ts integ/truth/version_branching.txt
bad=0
run() {
python3 integ/check_example.py "$1" --variant typescript || bad=1
}
run integ/truth/typescript/ram.json
run integ/truth/typescript/ram_vfs.json
run integ/truth/typescript/disk.json
run integ/truth/typescript/disk_vfs.json
run integ/truth/watch_delta.json
run integ/truth/permissions.json
run integ/truth/typescript/custom_command.json
run integ/truth/typescript/filetype.json
run integ/truth/typescript/pyodide_basic.json
run integ/truth/typescript/pyodide_env.json
run integ/truth/typescript/pyodide_heredoc.json
run integ/truth/typescript/pyodide_script.json
run integ/truth/typescript/pyodide_ram.json
run integ/truth/typescript/redis.json
run integ/truth/typescript/redis_index.json
run integ/truth/typescript/redis_cache.json
run integ/truth/typescript/redis_vfs.json
run integ/truth/version_branching.json
exit $bad
python-fs-shim:
python-fs:
needs: changes
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.hit == 'true') }}
runs-on: ubuntu-latest
@@ -118,11 +132,11 @@ jobs:
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v6.0.10
with:
version: 10.32.1
- name: Set up Node 24 (for --experimental-wasm-jspi)
- name: Set up Node 24
uses: actions/setup-node@v7
with:
node-version: "24"
@@ -140,16 +154,48 @@ jobs:
working-directory: typescript
run: pnpm -r --filter './packages/*' build
- name: Python FS shim example (output checked against integ/ truth file)
working-directory: examples/typescript
run: |
node --experimental-wasm-jspi --import tsx/esm pyodide/vfs.ts 2>&1 \
| bash ../../integ/check_lines.sh ../../integ/truth/typescript/pyodide_vfs.txt
# The truth file carries this one's cwd and `node --import tsx/esm`
# spelling, so it runs from the repo root like every other example.
- name: Python filesystem example (output checked against integ/ truth file)
run: python3 integ/check_example.py integ/truth/typescript/pyodide_vfs.json
audit:
needs: changes
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.hit == 'true') }}
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.32.1
# 24 rather than the 22 the test job uses: this step runs no project
# code, so it is free to track the newer line the integ workflow is
# already on. No `cache: pnpm` here on purpose: the audit step below
# resolves straight from the lockfile and never installs, so the pnpm
# store does not exist and the post-run save fails path validation on
# every cache miss.
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: "24"
# Advisories read against the resolved lockfile, which is what the 27
# hand-written pnpm.overrides in typescript/package.json pin. Those
# pins were the only thing between a published advisory and this
# tree, and nothing re-checked them. Informational like the Python
# audit: a fresh CVE reports without blocking a merge.
- name: Audit dependencies for known vulnerabilities (informational)
working-directory: typescript
run: pnpm audit --no-color
gate:
name: test-typescript-gate
runs-on: ubuntu-latest
needs: [changes, test, python-fs-shim]
needs: [changes, test, python-fs]
if: always()
steps:
- name: Check required jobs
+8
View File
@@ -144,6 +144,8 @@ venv/
ENV/
env.bak/
venv.bak/
# the env shell builtin package, not a virtualenv
!**/builtins/env/
# Spyder project settings
.spyderproject
@@ -233,5 +235,11 @@ models/
/paper/
/plan/
docs/plan/
docs/plans/
docs/learnings/
# Scratch dir the cli_ref battery writes its generated configs into; it
# lives under integ/ so a relative ref reaches the fixtures and the JS
# ones resolve mirage through integ/node_modules.
integ/.cli-ref.*
+20 -1
View File
@@ -80,12 +80,25 @@ repos:
- id: yapf
name: Format code
additional_dependencies: [toml]
# Generated tables are laid out by their generator, which packs
# ranges several to a line so 349 of them stay reviewable.
# Reformatting them here would fight scripts/gen_width_table.py on
# every run, the same way typescript/.prettierignore already exempts
# `**/generated/**`.
exclude: |
(?x)^(
python/mirage/utils/generated/.*
)$
- repo: https://github.com/pycqa/isort
rev: 7.0.0
hooks:
- id: isort
name: Sort imports
exclude: |
(?x)^(
python/mirage/utils/generated/.*
)$
- repo: https://github.com/PyCQA/flake8
rev: 7.3.0
@@ -147,5 +160,11 @@ repos:
name: Type check (mypy)
entry: bash -c 'cd python && uv run mypy'
language: system
files: ^python/(mirage/.*\.py|pyproject\.toml)$
files: ^python/(mirage/.*\.py|tests/.*\.py|pyproject\.toml)$
pass_filenames: false
- id: py-test-pathspec
name: PathSpec discipline (tests)
entry: python3 scripts/check_test_pathspec.py
language: system
files: ^python/(mirage/.*\.py|tests/.*\.py)$
pass_filenames: false
+545 -7
View File
@@ -24,7 +24,18 @@ Run Python commands from `python/`, TypeScript commands from `typescript/`.
- 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/`.
- Known gap: TypeScript does not support ORC files. Python registers `.orc` in its filetype factory (`mirage/core/filetype/orc.py` plus per-backend `read_orc` ops); the TypeScript filetype factory only covers parquet, feather/arrow/ipc, and hdf5/h5. Do not assume `.orc` commands work in TypeScript.
- **The layout half of that rule is gated.** `scripts/check_layout_parity.py` diffs the module-name sets of every `mirage/<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__.py` and `index.ts` are skipped because only Python needs one per directory. Intentional differences live in `spec/layout_exceptions.json` with 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 committed `baseline` in *either* direction, so new drift is blocked and closing a divergence has to be locked in by lowering the number. Run it without `--strict` for 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`/`pdf` extras, the `hyparquet`/`apache-arrow`/`h5wasm` dependencies, and the whole `commands/builtin/filetype_factory/` package in both languages (with its `filetype_read` / `filetypeRead` op 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 a `filetype` resolves as `(name, filetype)` before `(name, resource)` before `(name,)`. `examples/{python,typescript}/filetype/` register a `.tally` renderer end to end and are gated in CI against `integ/truth/*/filetype.json`; `tests/commands/custom/test_filetype_fns.py` and `test_unregister_removes_all_filetypes` cover 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.py` holds `RunArgs`/`RunResult`/`EvalValue`/`EvalResult`/`ScriptSource`). No logic.
- **`errors.py`** — the package's exception types (e.g. `runtime/errors.py` holds `EvalError`, `policy/errors.py` holds `PolicyError`).
- **`config.py`** — configuration knobs and their coercion (e.g. `runtime/config.py` holds `RuntimeConfig`, which fails loud on unknown fields).
- **`mixin.py`** — opt-in capability mixins: stateless, no constructor, abstract methods only (e.g. `runtime/mixin.py` holds `EvaluatorMixin`). 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.py` is just `Runtime`).
## History
@@ -36,12 +47,410 @@ Command history is a recording, not a command log. A hidden `Observer` records e
- **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; `git` is the one that does.** The two are
different tiers, told apart by `config_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.
`git` declares 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 makes `git` work on a
RAM mount, a disk mount or an object store without knowing which. It reaches
the dispatcher through `CLIDoors`, one door per state plane: `dispatch` and
`stat_path` for data, `ns` for the name plane (mount boundaries, links, attr
overlay), and `session_view` for session state. The mount prefix is
`ns.mounts.root_of`, not a fourth field. `handle_cli`/`handleCli` puts the
record on `inv.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 one `CLIInvocation`
and nothing is threaded through keyword injection. Every field is spelled the
way `CommandOpts` (`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 in
`commands/cli/types.test.ts`).
Do not give an account CLI a mount, and do not give `git` a `config_model`.
Nuance: an account CLI verb MAY read an unrelated workspace file the user
named on the line through `inv.doors.dispatch` (himalaya's `--attach` reads 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 no `install`/`uninstall` shell 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 with `unset -f`, bypassable
with `command <name>`, and visible in `type -a`. A deployment that needs a head
word pinned enforces that in the policy layer's `pre_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. `route` takes the first match (the winner, which is what
dispatch runs) and `route_all`/`routeAll` takes all of them (every layer, which
is what `type -a` prints). 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.**
`ntn` is the worked example: every case in `integ/cli/ntn.json` is asserted
twice, once by the shared battery inside a mirage workspace and once by
`integ/ntn_conformance.ts`, which runs the *same shell line* with the real
npm `ntn` binary pointed at the same fake through `NOTION_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 as `env` on the target in
`integ/targets.json` and 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 carry `NOTION_API_VERSION`, and the
runner fails loudly if it does not, because unset `ntn` resolves 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 a
`conformance_skip` string 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 `/notion` mount 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.
- **`UsageStyle` is 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 in `commands/spec/types.py`
(`types.ts`), not beside the CLI tree, because the help renderer is the
spec's and cannot import upward. `ARGPARSE` is the default; `GIT` rewords the
unknown-option refusal and its exit code; `CLAP` additionally governs help
layout (bare description line, `[OPTIONS]`/`<COMMAND>`, `Options:` not
`Flags:`, subcommands in declaration order rather than sorted) and the
missing-operand refusal. `ntn` is 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), and
`Option.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.env` is a fourth and is
**not** a synonym for `default`: 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 api` echoes its JSON parse failures verbatim, and
those words are serde_json's (`EOF while parsing an object at line 1 column 1`), which neither `json.loads` nor `JSON.parse` can produce and which the
two of them word differently from each other anyway. So
`commands/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 accepts `NaN`, serde refuses it, and
silently sending `NaN` is worse than either). Three rules that are easy to
get wrong and are pinned by a probed table in `test_serde.py` /
`serde.test.ts`: columns count **bytes**, not characters (`["é"x` fails 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 api` has three body sources, and both the order and the exit codes are
observable.** stdin, `--data` and inline `path=value` / `path:=json` inputs
are each validated in that order, and only then is "more than one source"
reported, so a malformed pipe outranks a malformed `--data` and 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 second
` hint:` 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==value` stays 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>` and `man <cli> <verb>...` render through `node_help`/`nodeHelp`, the
same renderer `--help` uses, so a manual cannot drift from the program; bare
`man` lists installs under `# clis`. `type` reports an installed CLI as its own
kind (`type -t` prints `cli`, a sixth word beside bash's five, because reusing
`file` would promise `type -p` a path that does not exist). `which` prints 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, so `GET /v1/databases/{id}` answers with
`data_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.json` is the container, and
`databases/<Title>__<db-id>/<Name>__<ds-id>/data_source.json` is the schema,
with the row pages under the data source. A row page therefore sits at depth
4 under `databases/`, not 3; `readdir`/`read`/`stat` all 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.
- **`/search` rejects `filter.value = "database"`** at this version; the
searchable schema-bearing object is `data_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.
- **`MountView` is how a command sees the boundaries** (`ops/types.py`,
`ops/types.ts`), and it is offered the way `LinkView` is: a command
opts in by naming a `mounts` parameter, `execute_cmd`/`executeCmd`
delivers it only to handlers that do, and there is no list of
boundary-aware commands anywhere. It carries `descendants` (mount
roots strictly under a path), `is_root`, and `root_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 why `tar` reads the boundaries itself.
- **Crossing into a descendant mount is refused, not attempted.** `tar`
and `zip` both keep the mountpoint as a directory entry and drop its
contents with GNU's own `--one-file-system` wording (`<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 own
`zip warning:` prefix). This is deliberate: descending would archive
by accident exactly what the mount-root refusal below forbids on
purpose.
- **`MountRootPolicy` refuses a mount root in a source slot** for `tar -c`,
`zip` and `cp`, on top of the POSIX EBUSY rules it already enforces
for `rm`/`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 why `CommandContext` carries `operands` beside
`paths`: `tar -xf a.tar -C /mnt` extracts INTO a mount and must stay
legal, while `tar -cf a.tar /mnt` must 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: only `tar -c` reads its operands from the filesystem, so
`is_create_mode` / `isCreateMode` gates the refusal. Under `-t` and
`-x` an 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 is `nargs=argparse.REMAINDER`,
which is POSIX's own option order (the first operand ends option
parsing; GNU's permuting default is the extension, and `POSIXLY_CORRECT=1 ls a -1` shows the difference). argparse spells
it on the positional slot, so mirage spells it on `Operand` too, and
`CommandSpec` does not change at all.
- **Not allowed: an option whose argument is a program.** `python3 -c 'code' -u x` must hand `-u` to the code, but
`add_argument("-c"); add_argument("rest", nargs=REMAINDER)` answers
`unrecognized 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 an `Option` field.
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 `opts` field, nothing else.**
Every namespace fact (`links`, `stat_overlay`, `stat_path`, `readdir_path`,
`child_mounts`, `mounts`) rides `CommandOpts` into every handler, identically
in both languages; the generic that wants one reads `opts.links` and the rest
ignore it, so there is no opt-in registry, spec field, or signature
convention that can fall out of step. `LinkView` bundles 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
through `execute_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. `exists` and `target_stat` answer 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 on `stat_path` alone
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 why `git add`
stored a symlink as a regular file holding its target's bytes (real git
stores mode `120000` with the target as the blob) and why `git status` never
listed a broken one.
- **Merge links in the generic, above the native-op/walk fork.** `find` and `du`
each have two paths: a backend with a native op (`find_core`, `du_size`/
`du_entries`) and a backend walked by `readdir`. Link merging lives in one
shared place per family (`link_results` in `generic/find.py`, `link_leaves` in
`generic/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`; `MirageFS` in `fs.py`/`fs.ts` is the
libfuse adapter and owns only the callback signatures plus errno
translation. Core methods raise ordinary exceptions; adapters classify them
through `classify_error`/`classifyErrno` (`fuse/errors.py`, `fuse/errors.ts`),
which is one shared table, not per-method `except` arms. Put new filesystem
behavior in the core, not the adapter.
- **One `backend` field per mount: `vfs | fuse | fskit`** (`MountBackend` in
`mirage/types.py`, beside `MountMode`). `vfs` is the default and means the
mount lives only inside mirage's own filesystem; `fuse` and `fskit` also
register a real mountpoint, with `mountpoint` pinning where. There is no
`fuse=True` boolean any more, and no `auto` backend.
`Mount(..., backend=MountBackend.FSKIT)` routes through macFUSE 5.x's FSKit
shim (no kernel extension). Rules live in `fuse/backend.py` and are enforced
at mount time: macOS-only, mountpoint must be under `/Volumes`, and every
mounted resource must set `SIZES_ALWAYS_KNOWN` (FSKit has no `direct_io`, so
a size-unknown resource would serve silent empty files). `resolve_backend`
rejects `vfs`: reaching it means a kernel mount was requested. In YAML the
keys are `backend:` and `mountpoint:`.
TypeScript serves fskit too: `fuse.node` links `/usr/local/lib/libfuse.2.dylib`
by absolute path (the bundled `libosxfuse.2.dylib` is a stub with that
install name), so `backend=fskit` + `volname` reach macFUSE 5.x's own
libfuse via `appendMountOptions` in `mount.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 when `os.path.ismount` says so, never when the
`/Volumes` entry 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 of
`fuse/darwin.py`.** The FSKit shim finalizes every created item through
macFUSE's Darwin-only `setattr_x` and routes rename through `renamex`;
mfusepy leaves those `fuse_operations` slots 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 the
`install_macfuse_extensions()` call in `mount.py`, and keep the struct
tail in sync with macFUSE's fuse.h if mfusepy changes layout. Pinned in
`integ/fuse/truth_fskit.json` on 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.py` is the only coverage of a real FSKit mount, and it only
runs on a Mac with macFUSE 5.x: on the `integ-fskit-macos` job 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.** `getattr` reports `st_size` 0 for directories and for API-backed size-unknown files that have not been opened recently. Reads stay correct because Python mounts with `direct_io` (kernel reads to EOF regardless of st_size) AND `attr_timeout=0` (post-open fstat routes to `getattr(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: without `attr_timeout=0`, `wc -c` prints 0, BSD `cp` copies 0 bytes, and `tail -c` dumps the whole file; without `direct_io`, `cat` reads 0 bytes on macOS. Do not "fix" getattr to report real sizes eagerly (one API fetch per `ls -l` entry), 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-native` doesn't serialize a `direct_io` option, so `mount.ts` appends it to the option string at runtime (`appendDirectIO`; a pnpm patch would not reach consumers), plus `attrTimeout: '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 with `Device not configured`/`ENOTCONN` — which looks exactly like a broken mount. Probe from a child process or use async APIs (see `examples/typescript/fuse/helper.ts`). Python is immune (FUSE loop runs on a thread).
- **`FileStat.size` must be the rendered content's byte length or `None`, never a storage-side or source-side number.** A confidently wrong size is worse than an unknown one: over FUSE it makes `wc -c`/`ls -l` lie and risks truncated copies, while `None` rides the unknown-size machinery above. Postgres `rows.jsonl` (on-disk `table_size_bytes` vs rendered JSONL), Dify documents (uploaded source size vs rendered segment text), Gmail messages (`sizeEstimate` vs rendered `.gmail.json`), and Drive-rendered google-apps files (Drive storage size vs rendered `.gdoc/.gsheet/.gslide` JSON; raw binary downloads keep Drive's size) made this mistake; their storage/source numbers now live in `extra` (`size_bytes` / `source_size` / `size_estimate`). Do not reintroduce it in new backends.
- **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.py` mounts two) pass only on Linux; do not debug them as regressions on macOS. A failed run leaks the first mount: list with `mount | grep MirageFS`, clean with `umount <mountpoint>`.
- **`FileStat.size` must be the rendered content's byte length or `None`, never a storage-side or source-side number.** A confidently wrong size is worse than an unknown one: over FUSE it makes `wc -c`/`ls -l` lie and risks truncated copies, while `None` rides the unknown-size machinery above. Postgres `rows.jsonl` (on-disk `table_size_bytes` vs rendered JSONL), Dify documents (uploaded source size vs rendered segment text), Gmail messages (`sizeEstimate` vs rendered `.gmail.json`), Drive-rendered google-apps files (Drive storage size vs rendered `.gdoc/.gsheet/.gslide` JSON; raw binary downloads keep Drive's size), and Microsoft Graph folders (OneDrive/SharePoint report an aggregate subtree `size` on the folder facet, not any content length) made this mistake; their storage/source numbers now live in `extra` (`size_bytes` / `source_size` / `size_estimate`). Do not reintroduce it in new backends. Graph's `folder.childCount` rides along in `extra` too, and is what `find -empty` reads 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.py` mounts two) pass only on Linux; do not debug them as regressions on macOS. A failed run leaks the first mount: list with `mount | grep MirageFS`, clean with `umount <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.ismount` never sees WinFsp directory mounts (readiness = bare existence), and there is no `fusermount` (WinFsp unmounts when the serving process exits). Ownership: mount with `uid=-1,gid=-1` (WinFsp builtin: files owned by the mounting user); never report raw POSIX ids into the SFU/Cygwin SID mapping, and `os.getuid` does 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). The `integ-fuse-windows` job is advisory (not in the gate).
## Development Setup
@@ -100,12 +509,117 @@ Invoke the venv's `pre-commit` binary directly (not via `uv --directory python r
- **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 -size` is strict and rounds up.** GNU `+N` keeps `ceil(size/unit) > N`, `-N` keeps `ceil(size/unit) < N`, bare `N` keeps `ceil(size/unit) == N` (so `-size -1k` matches only empty files and `+0c` excludes empty ones). The parsers (`_parse_size` / `parseSize`) translate this once into inclusive byte bounds; backend cores just keep `min_size <= size <= max_size` and 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 what `find` sees 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 directory `test -d` and `tree` both saw, and ssh called every directory non-empty so `-empty` never matched one. The generic stats the start point through the dispatcher (`resolve_start`, which asks both channels a backend can answer on via `resolve_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.
- **`find` classifies walked entries through `stat`, 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-backed `stat` that the same
readdir just populated, so the lookup is RAM, not another API call. There is
no per-backend `is_dir_name`/`isDirName` hint: 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, so `find -type f` missed 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.** `tar`
and `zip` decide every member first (`plan_create` / `planCreate` in
`generic/tar/create.*`, `plan_zip` / `planZip` in
`generic/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's `walk_find` / `walkFind`, so
an archiver classifies an entry through `stat` exactly 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 from
`PathSpec.raw_path`, so `tar -C d x` stores `x`, not `d/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 to `mkdir` for one. **A symlink is a symlink
member** (`SYMTYPE`, target in `linkname`), never a file of its
target's bytes, unless `-h` says to follow it.
**Two links to one target are not a loop**, and both are archived; the
only loop is one `resolve` refuses to resolve, since the namespace
already walks the chain under a hop limit and raises `CycleError` at
the end of it. That arrives as a fatal `Problem` carrying GNU's
`Too many levels of symbolic links`, reported per member with the
directory entry kept, rather than as an exception that aborts the
plan. **Every `-C` is 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
choice `du` makes, for the same reason), and a descendant mount is
never crossed (see "Mount boundaries"). Everything else is pinned
against GNU tar 1.35 on `debian:stable-slim`: the leading-slash
warning, `Cowardly refusing to create an empty archive` (exit 2), a
per-operand `Cannot stat` plus one trailer (exit 2, and the other
operands still archive), a `-C` it cannot enter (exit 2, no archive
written), and `archive 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 raw `IsADirectoryError` leaked the host path behind a disk mount.
- **`zip` is Info-ZIP, which inverts tar's two defaults.** A directory
operand contributes only its own entry unless `-r` says to descend,
and a symlink is *followed* unless `-y` says to store the link, where
tar always descends and always stores unless `-h`. Both are just the
`recurse` / `dereference` arguments to the shared scan. The rest is
pinned against Info-ZIP 3.0 on `debian:stable-slim`: a leading slash
is stripped **in silence** (tar warns, zip does not), `-j` junks to
the basename and drops directory entries entirely, `-x` is
**anchored** on the whole stored name (`d/sub/*` matches, `sub/*` does
not) where tar's `--exclude` is unanchored, an unreachable operand is
`\tzip warning: name not matched: <name>` and does not stop the run,
and a run that matched nothing prints `zip error: Nothing to do!`,
exits **12**, and writes no archive. `-q` silences the warnings but
never that error. Two deliberate divergences: `-x` takes one pattern
per occurrence (mirage's spec has no variadic option value, and
`-x a -x b` says the same thing), and the `adding:` 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 uses `modern-tar` behind `tar_helper.ts`, which
keeps the `TarEntry` shape (`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 ustar
`prefix` field 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 (so
`tar -t` on any archive GNU or Python wrote listed a phantom
`././@PaxHeader` row). `writeTar`/`readTar` are async for this reason.
Compression is a registry (`registerCompressionCodec`): gzip is built
in via `CompressionStream`, and a codec may be **decompress-only**,
which is the one deliberate py/ts divergence here. Python reads and
writes `.tar.bz2` because `bz2` is stdlib; TypeScript only reads one,
because every JavaScript bzip2 *compressor* is GPL (`compressjs`,
`archive-wasm`) and an Apache-2.0 package cannot ship that, while
`seek-bzip` (MIT) decodes. `tar -cj` therefore exits 1 with
`tar: bzip2 not supported`, the same answer browser core already gives
for an unregistered codec. If a permissively licensed bzip2 compressor
appears, adding `compress` to that one codec closes the gap with no
other change.
- **The TypeScript `walkFind` answers in mount-relative keys; the Python
`walk_find` answers 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 with
`mountPrefixOf` the way `findGeneric` does. That lift lives once, in
`generic_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.
- **`du` has one backend contract: `size` and `entries`.** Each backend exposes `core/<backend>/du/size.py` (recursive byte total for one path) and `core/<backend>/du/entries.py` (per-file breakdown), wired as `du_size` / `du_entries` on the adapter. `entries` returns `(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`, via `mount_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-list `du_multi` contract is gone.
- **`du` prints 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 by `readdir` (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-depth` prunes only what is printed, never the walk, because every printed total still covers the whole subtree. Verify changes with the differential harness against `debian:stable-slim`: paths, exit codes and stderr must match GNU exactly.
- **`du` usage errors exit 1, not 2.** `du` is absent from `USAGE_EXIT`, which is correct: GNU du exits 1 for `-s` with `-a` ("cannot both summarize and show all entries"), `-s` with `--max-depth` ("warning: summarizing conflicts with --max-depth=N"), and a bad depth ("invalid maximum depth 'x'"). All three are raised by `parse_flags` / `parseDuFlags` *before* 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 prints `0 total` under `-c` when every operand failed. With no operand at all, du measures the working directory; it never says "missing operand".
- **`du` walks are bounded.** Backends with no native du op are walked one `readdir` at a time, which on an API tree is one request per directory. `CommandIO.max_du_entries` caps that walk; when it trips, `du` prints 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.py` for each source file `mirage/<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__.py` files under `tests/`.** 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, so `import mirage.commands.builtin.<backend>.cat as mod`, `from ...<backend> import cat as mod`, and even pytest's string target `monkeypatch.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`, so `cat.__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.
- Do not create nested functions.
- **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 by `tests/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.
@@ -116,7 +630,31 @@ Invoke the venv's `pre-commit` binary directly (not via `uv --directory python r
- 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 wrappers and flags.** The dispatcher passes parsed command-line flags as keyword arguments. Wrappers must declare dispatcher-injected parameters (`stdin`, `index`, `prefix`) explicitly in their signature — never fish them out of `**flags` with `.get()`. Treat `**flags: object` as an opaque bag of true command-line flags and forward it wholesale to the generic command. When a wrapper genuinely needs a flag value itself (e.g. a search push-down), read it through `FlagView` (`fl = FlagView(flags)` then `fl.as_bool("F")`, `fl.as_int("m")`, `fl.as_str("type")`, `fl.as_list("e")`) or a shared domain accessor like `pattern_arg` — never raw `flags.get(...)` / isinstance chains.
- **Generic commands own flag interpretation.** Backend wrappers are wiring only (glob resolution, backend I/O injection, pass-through of `texts` and `flags`); 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.
- **Command handlers take `(accessor, paths, texts, opts)` — the same four positionals in both languages.** The dispatcher (`Mount.execute_cmd` / `Mount.executeCmd`) constructs one `CommandOpts` per invocation carrying stdin, the flag bag, cwd, the mount prefix, the index, and every namespace fact; the provision path builds the same bag with `command`/`spec` set (`ProvisionFn` has the same four-positional shape). Handlers never declare a flag or an injected fact as a parameter — `tests/commands/test_no_dead_flag_params.py` pins the exact signature. When a wrapper genuinely needs a flag value itself (e.g. a search push-down), read it through a spec-bound `FlagView` (`fl = FlagView(opts.flags, spec=SPECS["grep"])` then `fl.as_bool("F")`, `fl.as_int("m")`, `fl.as_str("type")`, `fl.as_list("e")`) or a shared domain accessor like `pattern_arg` — never raw `flags.get(...)` / isinstance chains, and never a raw `kwargs`/`_extra` read either (`tests/commands/test_no_raw_flag_reads.py`). To override a flag before delegating, pass `dataclasses.replace(opts, flags=bag)` down, never a hand-built `CommandOpts`. **A PATH-typed flag reaches a python command as a `PathSpec`, not a string** — the executor promotes it (`workspace/executor/command/flags.py`), so read it with `fl.as_paths(name)`; `as_str` reads it as absent and the operand is silently never used. TypeScript's bag carries the resolved virtual-path string instead, so its twin is `fl.asStr(name)`.
- **Generic commands own flag interpretation.** Backend wrappers are wiring only (glob resolution, backend I/O injection, pass-through of `texts` and `opts`); 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 literal `FlagView` query name must be a dest of a spec bound in the same module — `tests/commands/test_flag_query_names.py` and `commands/flag_query_names.test.ts` fail 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-level `parse_flags(fl, ...)` (mirroring the TS `parseFlags` struct); 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 a parameter as `object`.** Use the real type: a backend handle is `accessor: Accessor` (`mirage.accessor.base`), an index is `index: IndexCacheStore | None` (`mirage.cache.index`). Ignored variadics are still typed (`*texts: str`). `object` is only acceptable as the value type of an opaque flag bag (`**flags: object`).
- **Never annotate anything as `object`** — not a parameter, not a return, not a type argument (`dict[str, object]`, `Callable[..., object]`). `object` reads 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 | None` for a single raw read. The TypeScript side has always called it `FlagValue` too (`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 is `accessor: Accessor` (`mirage.accessor.base`), an index is `index: IndexCacheStore | None` (`mirage.cache.index`), a stat function is `StatFn` (`mirage.types`). Ignored variadics are still typed (`*texts: str`).
- a sentinel is a one-member `Enum`, never `object()`; that keeps it distinguishable from the real values sharing the variable.
`tests/commands/test_no_object_annotations.py` enforces 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.
- **Every session write goes through `SessionView.set`.** A deployment refuses a
name (`AWS_*`, a credential) with a `pre_session` rule, and a writer that
reaches `session.env` directly makes that rule advisory. `export` and a plain
assignment always cleared the gate; the expansion-time writers did not, so
`${X:=d}`, `$((X=5))`, `(( ))`, `printf -v` and `for ((X=0; ...))` each wrote
past every policy. The view is threaded into expansion explicitly rather than
read from ambient state, and defaults to `None` (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.py` and
`integ/session/writers.json` hold 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/`
(`DiskRecordClient` with an `O_CREAT|O_EXCL` lockfile and `rename(2)`,
`S3RecordClient` with 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.
+26 -21
View File
@@ -34,24 +34,29 @@
Mirage is **a Unified Virtual File System for AI Agents**: it mounts services and data sources like S3, Google Drive, Slack, Gmail, and Redis side-by-side as one filesystem. Any LLM that already knows bash can read, grep, and pipe across every backend out of the box, with zero new vocabulary.
```ts
const ws = new Workspace({
'/data': new RAMResource(),
'/s3': new S3Resource({ bucket: 'logs' }),
'/slack': new SlackResource({ token: process.env.SLACK_BOT_TOKEN! }),
})
```python
ws = Workspace(
{
"/tmp": (RAMResource(), MountMode.EXEC),
"/redis": (RedisResource(url=redis_url), MountMode.WRITE),
"/slack": (SlackResource(SlackConfig(token=slack_bot_token)), MountMode.EXEC),
},
# monty captures python, so scripts run sandboxed inside the workspace
runtimes=[MontyRuntime(captures=["python", "python3"]), "vfs"],
)
await ws.execute('grep -r alert /slack/channels/general__C04QX/ | wc -l')
await ws.execute('cp /s3/report.csv /data/local.csv')
await ws.execute('wc -l $(find /s3/data -name "*.jsonl")')
# one grep sweeps every source
await ws.execute("grep -rln session /redis /tmp")
// Commands are extensible: register new commands, or override one per
// resource + filetype, e.g. `cat` on S3 Parquet renders rows as JSON.
ws.command('summarize', ...)
ws.command('cat', { resource: 's3', filetype: 'parquet' }, ...)
# run a script that lives in Slack, file the report into Redis
await ws.execute(
"python3 /slack/channels/general__C0.../files/example__F0....py > /redis/report.txt"
)
await ws.execute('summarize /data/local.csv')
await ws.execute('cat /s3/events/2026-05-06.parquet | jq .user')
# install a typed CLI under a head word: dispatched by name, not by path,
# and discoverable through `man`, `type` and `which` like any other program
ws.register_cli("slack", SLACK, {"token": slack_bot_token})
await ws.execute('slack send-message --channel general --text "report is up"')
```
## About
@@ -153,13 +158,13 @@ mirage workspace load demo.tar --id demo-restored
## Agent Frameworks
Mirage plugs into agent frameworks as a sandbox or tool layer. POSIX operations such as `read` can also be customized per resource and filetype, e.g. reading a PDF returns parsed pages instead of raw bytes.
Mirage plugs into agent frameworks as a sandbox or tool layer. POSIX operations such as `read` can also be customized per resource and filetype: Mirage ships no filetype renderers, so a format renders however you register it, and a command registered for one resource and extension wins over the generic one.
| | Integrations |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Python | [OpenAI Agents SDK](https://docs.mirage.strukto.ai/python/agents/openai-agents), [LangChain](https://docs.mirage.strukto.ai/python/agents/langchain), [Pydantic AI](https://docs.mirage.strukto.ai/python/agents/pydantic-ai), [CAMEL](https://docs.mirage.strukto.ai/python/agents/camel), [OpenHands](https://docs.mirage.strukto.ai/python/agents/openhands), [Agno](https://docs.mirage.strukto.ai/python/agents/agno) |
| TypeScript | [Vercel AI SDK](https://docs.mirage.strukto.ai/typescript/agents/vercel), [OpenAI Agents SDK](https://docs.mirage.strukto.ai/typescript/agents/openai), [LangChain](https://docs.mirage.strukto.ai/typescript/agents/langchain), [Mastra](https://docs.mirage.strukto.ai/typescript/agents/mastra) |
| Coding agents | [Claude Code](https://docs.mirage.strukto.ai/python/agents/claude-code), [Codex](https://docs.mirage.strukto.ai/typescript/agents/codex), [Grok Build](https://docs.mirage.strukto.ai/typescript/agents/grok-build), [OpenCode](https://docs.mirage.strukto.ai/typescript/agents/opencode), [Pi](https://docs.mirage.strukto.ai/typescript/agents/pi) |
| | Integrations |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Python | [OpenAI Agents SDK](https://docs.mirage.strukto.ai/python/agents/openai-agents), [LangChain](https://docs.mirage.strukto.ai/python/agents/langchain), [Pydantic AI](https://docs.mirage.strukto.ai/python/agents/pydantic-ai), [CAMEL](https://docs.mirage.strukto.ai/python/agents/camel), [OpenHands](https://docs.mirage.strukto.ai/python/agents/openhands), [Agno](https://docs.mirage.strukto.ai/python/agents/agno) |
| TypeScript | [Vercel AI SDK](https://docs.mirage.strukto.ai/typescript/agents/vercel), [OpenAI Agents SDK](https://docs.mirage.strukto.ai/typescript/agents/openai), [LangChain](https://docs.mirage.strukto.ai/typescript/agents/langchain), [Mastra](https://docs.mirage.strukto.ai/typescript/agents/mastra) |
| Coding agents | [Claude Code](https://docs.mirage.strukto.ai/python/agents/claude-code), [Codex](https://docs.mirage.strukto.ai/typescript/agents/codex), [DeepSeek Harness](https://docs.mirage.strukto.ai/typescript/agents/dsh), [Grok Build](https://docs.mirage.strukto.ai/typescript/agents/grok-build), [OpenCode](https://docs.mirage.strukto.ai/typescript/agents/opencode), [Pi](https://docs.mirage.strukto.ai/typescript/agents/pi) |
## Cache
+27 -6
View File
@@ -10,8 +10,18 @@ empty output, and binary output are all part of the contract.
- Python: `python/tests/conformance/test_conformance.py` runs every case
against `ram` and `disk`, plus `redis` when `REDIS_URL` is set. Runs as part
of `uv run pytest`.
- TypeScript: `typescript/packages/node/src/conformance.test.ts` runs every
case against `ram`. Runs as part of `pnpm test`.
- TypeScript: `typescript/packages/node/src/conformance.test.ts` runs the same
matrix — `ram` and `disk`, plus `redis` when `REDIS_URL` is set. Runs as part
of `pnpm test`.
Both CI test jobs provide a `redis:7` service and set `REDIS_URL`, so the
redis rows run there rather than skipping.
A third runner, `integ/runners/parity.py`, is a different net: instead of
checking each language against a fixed expectation, it runs both integ
batteries over the shared targets (`ram`, `disk`, `redis`) and diffs them case
by case, so a change that breaks *identically* in both languages still fails.
CI runs it as the `integ-shared-parity` job.
## Files
@@ -27,7 +37,10 @@ empty output, and binary output are all part of the contract.
{
"id": "wc_default",
"cmd": "wc /data/a.txt",
"matrix": { "python": ["ram", "disk", "redis"], "typescript": ["ram"] },
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram", "disk", "redis"]
},
"expect": {
"exit": 0,
"stdout_text": " 5 5 24 /data/a.txt\n",
@@ -44,13 +57,21 @@ empty output, and binary output are all part of the contract.
- `matrix` is explicit: a case runs only on the listed backends. Listing a
backend is a claim of support — a missing command there is a failure, not a
skip. A case whose matrix is empty is a load-time error in both runners.
- The two languages must list the same backends. A case is a parity claim, so
narrowing one side reads as coverage while it is really an unexamined
divergence — and it goes unnoticed, because the side that still lists the
backend passes. Both runners reject an asymmetric matrix at load time.
- `divergence` is the override: a string saying why one language cannot run
the case everywhere the other does. Setting it permits an asymmetric matrix,
and it is the only thing that does.
## Policy
- One expected value per case. If a backend or language legitimately diverges,
that divergence is triaged first: either it is a bug (fix the
implementation) or it is intended semantics (document it and add an explicit
per-backend override mechanism — not yet needed).
implementation) or it is intended semantics, recorded in the case's
`divergence` key so the narrowing is stated rather than inferred from a
short matrix.
- This spec is an acceptance/parity net, not a replacement for backend tests.
API call counts, pushdown/fallback, cache invalidation, error injection, and
concurrency stay in hand-written per-backend tests.
@@ -76,7 +97,7 @@ expectations as the existing implementations:
```json
"matrix": {
"python": ["ram", "disk", "redis", "github"],
"typescript": ["ram", "github"]
"typescript": ["ram", "disk", "redis", "github"]
}
```
+9 -3
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -30,7 +32,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -49,7 +53,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+6 -2
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -30,7 +32,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+6 -2
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -30,7 +32,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+6 -2
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -30,7 +32,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+6 -2
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -30,7 +32,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+6 -2
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -30,7 +32,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+3 -1
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+153 -2
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -30,7 +32,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -38,6 +42,153 @@
"stdout_text": "deep\nnested.txt\n",
"stderr_text": ""
}
},
{
"id": "ls_missing_operand_exit",
"cmd": "ls /data/nope 2>/dev/null; echo rc=$?",
"matrix": {
"python": [
"ram",
"disk",
"redis"
],
"typescript": [
"ram",
"disk",
"redis"
]
},
"expect": {
"exit": 0,
"stdout_text": "rc=2\n",
"stderr_text": ""
}
},
{
"id": "ls_missing_operand_beside_good_exit",
"cmd": "ls /data/nope /data/sub 2>/dev/null; echo rc=$?",
"matrix": {
"python": [
"ram",
"disk",
"redis"
],
"typescript": [
"ram",
"disk",
"redis"
]
},
"expect": {
"exit": 0,
"stdout_text": "/data/sub:\ndeep\nnested.txt\nrc=2\n",
"stderr_text": ""
}
},
{
"id": "ls_missing_enoent",
"cmd": "ls /data/missing",
"matrix": {
"python": [
"ram",
"disk",
"redis"
],
"typescript": [
"ram",
"disk",
"redis"
]
},
"expect": {
"exit": 2,
"stdout_text": "",
"stderr_text": "ls: cannot access '/data/missing': No such file or directory\n"
}
},
{
"id": "ls_missing_nested_enoent",
"cmd": "ls /data/sub/missing",
"matrix": {
"python": [
"ram",
"disk",
"redis"
],
"typescript": [
"ram",
"disk",
"redis"
]
},
"expect": {
"exit": 2,
"stdout_text": "",
"stderr_text": "ls: cannot access '/data/sub/missing': No such file or directory\n"
}
},
{
"id": "ls_missing_parent_enoent",
"cmd": "ls /data/missing/deeper",
"matrix": {
"python": [
"ram",
"disk",
"redis"
],
"typescript": [
"ram",
"disk",
"redis"
]
},
"expect": {
"exit": 2,
"stdout_text": "",
"stderr_text": "ls: cannot access '/data/missing/deeper': No such file or directory\n"
}
},
{
"id": "ls_file_component_enotdir",
"cmd": "ls /data/a.txt/x",
"matrix": {
"python": [
"ram",
"disk",
"redis"
],
"typescript": [
"ram",
"disk",
"redis"
]
},
"expect": {
"exit": 2,
"stdout_text": "",
"stderr_text": "ls: cannot access '/data/a.txt/x': Not a directory\n"
}
},
{
"id": "ls_file_component_deeper_enotdir",
"cmd": "ls /data/a.txt/x/y",
"matrix": {
"python": [
"ram",
"disk",
"redis"
],
"typescript": [
"ram",
"disk",
"redis"
]
},
"expect": {
"exit": 2,
"stdout_text": "",
"stderr_text": "ls: cannot access '/data/a.txt/x/y': Not a directory\n"
}
}
]
}
+3 -1
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+8 -8
View File
@@ -6,7 +6,7 @@
"cmd": "sort -k2 /data/sk_fields.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
"typescript": ["ram", "disk", "redis"]
},
"expect": {
"exit": 0,
@@ -19,7 +19,7 @@
"cmd": "sort -k2,2 /data/sk_fields.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
"typescript": ["ram", "disk", "redis"]
},
"expect": {
"exit": 0,
@@ -32,7 +32,7 @@
"cmd": "sort -k2,2n -k1,1r /data/sk_fields.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
"typescript": ["ram", "disk", "redis"]
},
"expect": {
"exit": 0,
@@ -45,7 +45,7 @@
"cmd": "sort -k2,2n /data/sk_ties.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
"typescript": ["ram", "disk", "redis"]
},
"expect": {
"exit": 0,
@@ -58,7 +58,7 @@
"cmd": "sort -s -k2,2n /data/sk_ties.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
"typescript": ["ram", "disk", "redis"]
},
"expect": {
"exit": 0,
@@ -71,7 +71,7 @@
"cmd": "sort -rk2,2n /data/sk_ties.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
"typescript": ["ram", "disk", "redis"]
},
"expect": {
"exit": 0,
@@ -84,7 +84,7 @@
"cmd": "sort -t: -k1.2,1.3 /data/sk_colon.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
"typescript": ["ram", "disk", "redis"]
},
"expect": {
"exit": 0,
@@ -97,7 +97,7 @@
"cmd": "sort -k0 /data/sk_fields.txt",
"matrix": {
"python": ["ram", "disk", "redis"],
"typescript": ["ram"]
"typescript": ["ram", "disk", "redis"]
},
"expect": {
"exit": 2,
+15 -5
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -30,7 +32,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -49,7 +53,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -68,7 +74,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -87,7 +95,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+3 -1
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+21 -7
View File
@@ -11,7 +11,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -30,7 +32,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -49,7 +53,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -68,7 +74,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -87,7 +95,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -107,7 +117,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
@@ -127,7 +139,9 @@
"redis"
],
"typescript": [
"ram"
"ram",
"disk",
"redis"
]
},
"expect": {
+32
View File
@@ -0,0 +1,32 @@
# Mirage sandbox base image, built from the repo checkout so it always
# matches the code under test (no release required).
#
# docker build -f docker/sandbox/Dockerfile --target fuse \
# -t mirage-python-fuse .
#
# The fuse target installs the `sandbox` extra: every mountable backend
# plus fuse3, so one image can FUSE-mount whatever backend a workspace
# declares, not just S3. It deliberately excludes the agent frameworks
# and the sandbox-provider SDKs (daytona/e2b) that `all` pulls in: a
# sandbox is a filesystem host, it never builds agents or launches other
# sandboxes. Usable as-is for local docker (run with --cap-add SYS_ADMIN
# --device /dev/fuse), as a Daytona image/snapshot source, and as an E2B
# template base.
#
# Narrow the extras to just the backends you mount for a leaner image:
# docker build -f docker/sandbox/Dockerfile --target fuse \
# --build-arg MIRAGE_EXTRAS=s3,postgres -t mirage-fuse-lean .
# Or extend this image as a base: FROM mirage-python-fuse, then
# pip install any additional extra.
FROM python:3.12-slim AS fuse
ARG MIRAGE_EXTRAS=sandbox
RUN apt-get update \
&& apt-get install -y --no-install-recommends fuse3 libfuse3-dev \
&& rm -rf /var/lib/apt/lists/* \
&& echo user_allow_other >> /etc/fuse.conf
COPY python /src/python
RUN pip install --no-cache-dir "/src/python[${MIRAGE_EXTRAS},fuse]" && rm -rf /src
+83 -7
View File
@@ -49,7 +49,7 @@
"twitter:site": "@struktoai",
"twitter:creator": "@struktoai",
"twitter:image": "https://raw.githubusercontent.com/strukto-ai/mirage/main/assets/mirage-og-light.png",
"twitter:image:alt": "Mirage · Unified Virtual Filesystem for AI Agents | Strukto"
"twitter:image:alt": "Mirage \u00b7 Unified Virtual Filesystem for AI Agents | Strukto"
}
},
"navigation": {
@@ -75,6 +75,7 @@
"home/cache",
"home/snapshot",
"home/observer",
"home/policy-engine",
"home/auth"
]
},
@@ -148,7 +149,6 @@
"icon": "code",
"pages": [
"home/setup/github",
"home/setup/github_ci",
"home/setup/linear",
"home/setup/langfuse"
]
@@ -239,6 +239,7 @@
"python/agents/pydantic-ai",
"python/agents/camel",
"python/agents/agno",
"python/agents/haystack",
"python/agents/claude-code",
"python/agents/claude-agent-sdk",
"python/agents/codex",
@@ -324,7 +325,6 @@
"icon": "code",
"pages": [
"python/resource/github",
"python/resource/github_ci",
"python/resource/linear",
"python/resource/langfuse"
]
@@ -381,6 +381,20 @@
"python/resource/new"
]
},
{
"group": "CLIs",
"pages": [
"python/cli/index",
"python/cli/himalaya",
"python/cli/gws",
"python/cli/slack",
"python/cli/discord",
"python/cli/ntn",
"python/cli/linear",
"python/cli/gh",
"python/cli/git"
]
},
{
"group": "Watch",
"icon": "bell",
@@ -393,7 +407,20 @@
"group": "Runtimes",
"pages": [
"python/runtime/python",
"python/runtime/javascript"
"python/runtime/javascript",
{
"group": "Sandbox",
"icon": "server",
"pages": [
"python/runtime/sandbox",
"python/runtime/sandbox/sandlock",
"python/runtime/sandbox/docker",
"python/runtime/sandbox/smolvm",
"python/runtime/sandbox/daytona",
"python/runtime/sandbox/e2b",
"python/runtime/sandbox/ssh"
]
}
]
}
]
@@ -428,7 +455,8 @@
"typescript/agents/claude-code",
"typescript/agents/claude-agent-sdk",
"typescript/agents/codex",
"typescript/agents/grok-build"
"typescript/agents/grok-build",
"typescript/agents/dsh"
]
},
{
@@ -486,6 +514,14 @@
"typescript/setup/gslides"
]
},
{
"group": "Microsoft",
"icon": "microsoft",
"pages": [
"typescript/setup/onedrive",
"typescript/setup/sharepoint"
]
},
{
"group": "Cloud Files",
"icon": "folder-open",
@@ -500,7 +536,6 @@
"icon": "code",
"pages": [
"typescript/setup/github",
"typescript/setup/github_ci",
"typescript/setup/linear",
"typescript/setup/langfuse"
]
@@ -526,6 +561,13 @@
"typescript/setup/qdrant"
]
},
{
"group": "Memory",
"icon": "/images/mem0.svg",
"pages": [
"typescript/setup/mem0"
]
},
{
"group": "Notes",
"icon": "book",
@@ -542,11 +584,45 @@
}
]
},
{
"group": "CLIs",
"pages": [
"typescript/cli/index",
"typescript/cli/himalaya",
"typescript/cli/gws",
"typescript/cli/slack",
"typescript/cli/discord",
"typescript/cli/ntn",
"typescript/cli/linear",
"typescript/cli/gh",
"typescript/cli/git"
]
},
{
"group": "Watch",
"icon": "bell",
"pages": [
"typescript/watch",
"typescript/watch-matrix"
]
},
{
"group": "Runtimes",
"pages": [
"typescript/runtime/python",
"typescript/runtime/javascript"
"typescript/runtime/javascript",
{
"group": "Sandbox",
"icon": "server",
"pages": [
"typescript/runtime/sandbox",
"typescript/runtime/sandbox/docker",
"typescript/runtime/sandbox/smolvm",
"typescript/runtime/sandbox/daytona",
"typescript/runtime/sandbox/e2b",
"typescript/runtime/sandbox/ssh"
]
}
]
}
]
+124 -12
View File
@@ -108,6 +108,110 @@ Both bindings support cooperative cancellation observed at recursion boundaries
| Many isolated commands sharing scoped state | `session_id=...` (Py) / `sessionId` (TS) | a separate terminal |
| Persistent shell mutations | run without options | `cd /data; cmd` |
## JSON with `jq`
`jq` reads a **stream of JSON values** and runs the program once per
value, matching the real `jq` binary. The filename is irrelevant: a
`.json` file holding several concatenated or pretty-printed values is a
stream just like a `.jsonl` file, and so is multi-document input arriving
on stdin.
```bash
# Two documents in one file -> the program runs twice, one line each
cat /data/events.json
{"id": 1}
{"id": 2}
jq -c '.id' /data/events.json
1
2
# -s slurps the whole stream into a single array first
jq -c -s 'map(.id)' /data/events.json
[1,2]
```
Because evaluation is per document, commands that emit newline-delimited
JSON (such as [paginated `gws` list calls](/python/resource/gdrive#pagination))
pipe straight into `jq` with no reshaping.
Output arity follows the program, not the input. A jq program emits a
stream of values and each one prints on its own line, so `.a[]`, `.a, .b`
and `range(3)` all print several lines, while a program that collects
into an array (`[.a[] | .t]`) emits one value and prints one line.
```bash
jq -c '.name, .age' /data/user.json
"alice"
30
jq -c '[.name, .age]' /data/user.json
["alice",30]
```
### Flags
| Reading input | |
| --- | --- |
| `-n`, `--null-input` | run once against `null`; `inputs` still reads the operands |
| `-R`, `--raw-input` | each line is a string, not a JSON document |
| `-s`, `--slurp` | one value for the whole stream, spanning every operand |
| `--stream` | read each document as its `[path, leaf]` events |
| `--seq` | read and write RFC 7464 sequences (RS before each value) |
| `-f`, `--from-file` | read the program from a file |
| `--arg name value` | bind `$name` to a string |
| `--argjson name value` | bind `$name` to a JSON value |
| `--rawfile name file` | bind `$name` to a file's text |
| `--slurpfile name file` | bind `$name` to a file's documents, as an array |
| `--args`, `--jsonargs` | read the remaining operands into `$ARGS.positional` |
| Writing output | |
| --- | --- |
| `-r`, `--raw-output` | print string outputs unquoted |
| `-j`, `--join-output` | `-r` with no separator |
| `--raw-output0` | `-r` with a NUL after each output |
| `-c`, `--compact-output` | one line per output |
| `-a`, `--ascii-output` | escape non-ASCII (and keep strings quoted, as jq does) |
| `-S`, `--sort-keys` | sort object keys |
| `--tab`, `--indent n` | indent width (`--indent -1` is a tab) |
| `-e`, `--exit-status` | exit 1 when the last output is `false`/`null`, 4 when there was none |
| `-M`, `--monochrome-output`, `--unbuffered` | accepted; already how mirage writes |
Build JSON with a binding rather than by hand: the value arrives as a
value, so quotes and newlines in it need no escaping.
```bash
# text -> JSON array, no string surgery
printf 'alpha\nbeta\n' > /data/lines.txt
jq -Rn -c '[inputs]' /data/lines.txt
["alpha","beta"]
# a shell value that contains quotes
jq -n -c --arg v 'a"b' '{msg: $v}'
{"msg":"a\"b"}
# a whole file as one JSON string
jq -n -c --rawfile body /data/lines.txt '{text: $body}'
{"text":"alpha\nbeta\n"}
```
`$ARGS` is always defined, carrying `named` (the `--arg` family) and
`positional` (`--args` / `--jsonargs`).
Three limits worth knowing. `inputs` is bound to whatever is still
unread, so a program that drains it (`[., inputs]`, `reduce inputs as
$x`) runs once and sees everything, but the stateful single `input` and a
partial drain (`first(inputs)`) are not modeled. `--stream` reads whole
documents and expands them, which matches jq except that jq's incremental
parser splits the closing event of an input with no trailing newline into
its own `-s` group. And `--seq` reads and writes the separator, but drops
text before the first one silently where jq names it on stderr.
Not implemented, and reported as an unknown option rather than quietly
ignored: `-C` (colorized output, which an agent would only have to strip
again), `-L` (no module system, so `include` has nothing to search),
`--stream-errors` (it reports truncated-parse errors, which whole-value
reads never produce), and `--build-configuration`.
## Supported bash syntax
Mirage Bash is a tree-sitter-bash parser plus a custom executor. It implements the constructs LLMs reach for most often. What is not supported returns a clear, parseable error so an agent can self-correct on its next turn.
@@ -119,8 +223,9 @@ Mirage Bash is a tree-sitter-bash parser plus a custom executor. It implements t
- **Substitutions:** command substitution `` `cmd` `` and `$(cmd)`; arithmetic `$((expr))`; parameter expansion `${VAR}`, `${VAR:-default}`, `${VAR%suffix}`, etc.; input-direction process substitution `<(cmd)`.
- **Control flow:** `if`/`elif`/`else`/`fi`, `for`, `while`, `until`, `case`, `select`, `function name() {}`, `break`, `continue`, `return`.
- **Grouping:** subshells `(cmd)`, compound `{ cmd; }`, negation `! cmd`.
- **Builtins:** `cd`, `pwd`, `echo`, `printf`, `printenv`, `read`, `source`, `.`, `eval`, `export`, `unset`, `local`, `set`, `shift`, `trap` (no-op), `test`, `[`, `[[`, `true`, `false`, `sleep`, `xargs`, `timeout`, `bash`, `sh`, `python`, `python3`.
- **Builtins:** `cd`, `pwd`, `echo`, `printf`, `printenv`, `read`, `source`, `.`, `eval`, `export`, `unset`, `local`, `set`, `shift`, `trap` (no-op), `test`, `[`, `[[`, `true`, `false`, `sleep`, `xargs`, `timeout`, `bash`, `sh`, `python`, `python3`, `man`, `command`, `type`, `which`.
- **Builtin options (GNU semantics):** `echo -n/-e/-E` (leading-word option rule: `echo hi -n` prints `hi -n`), `read -r`, `xargs -n/-0/-d/-r/--` (batching, GNU exit codes: `123` when an invocation fails, `126`/`127` stop the run), `timeout DURATION` with `s`/`m`/`h`/`d` suffixes (kills at the deadline with exit `124`, usage errors exit `125`). `shift` and `return` report bash's `numeric argument required` errors.
- **Name lookup:** `type name` reports what a name resolves to (`type -t` prints one of `keyword`, `function`, `cli`, `builtin`; `type -a` lists every layer holding the name), `which name` prints the name of anything runnable (there is no PATH, so there is no path to print) and reports a miss through exit `1` alone, and `man name` renders a page: a command's spec, or an installed CLI's own `--help` tree (`man linear issue create`).
- **Globs:** `*`, `?`, `[...]` classes and `[!...]` negation (Python `fnmatch` semantics in both implementations), resolved by the shell or pushed down to the resource.
- **Comments:** `#`.
@@ -143,13 +248,15 @@ The daemon's `--background` flag detaches a job and returns a job id. It is not
## Per-session mount modes
A session can be created with its own per-mount modes, like a container that mounts the same volume `ro` while another mounts it `rw`. Each listed prefix carries a mode ceiling on the `read < write < exec` ladder, written as the words `read`/`write`/`exec` or the cumulative filesystem aliases `r`/`rw`/`rwx` (exec implies write implies read, so bit-style forms like a bare `w` are rejected). A command touching a mount the session was not given is rejected with `mirage: session 'agent' not allowed to access mount '/X'` and exit code 1; a command exceeding the session's mode fails exactly like it would on a read-only mount.
A session can be created with its own per-mount modes, like a container that mounts the same volume `ro` while another mounts it `rw`. Each named prefix carries a mode on the `read < write < exec` ladder, written as the words `read`/`write`/`exec` or the cumulative filesystem aliases `r`/`rw`/`rwx` (exec implies write implies read, so bit-style forms like a bare `w` are rejected). A command exceeding the session's mode fails exactly like it would on a read-only mount.
If a session is created without `mounts`, it is unrestricted: every mount behaves per its own configured mode. A list of prefixes (instead of a mapping) restricts the session to those mounts but keeps each at its own mode, and a bare `-m /data` on the CLI does the same for one mount. A session's mode can only narrow, never widen: the effective permission is the weaker of the mount's own mode and the session's mode, so `rw` on a `READ` mount is still read-only.
**Naming a mount narrows it; it is not an allowlist.** A mount a session does not name keeps its own configured mode, and a session's mode can only narrow, never widen: the effective permission is the weaker of the mount's own mode and the session's, so `rw` on a `READ` mount is still read-only.
This is a soft boundary, enforced inside the daemon process, not an OS or process-level isolation. Use it to shrink the blast radius of prompt-injection in multi-agent workspaces: a Slack-only agent cannot pivot to read `/linear`, `/github`, or any other mount it was not given. Note that FUSE mounts are not scoped by session modes: a FUSE mount is a host-level surface served under the default unrestricted view (mount modes still apply, session narrowing does not).
Keeping a session away from a mount entirely is a **hide**, written in the role the session runs under. A hidden path answers `No such file or directory` rather than a permission error, so a refusal never names something the agent was not meant to know is there. Creating under a hidden path is the one case that answers out loud (`Permission denied`), because silently succeeding would leave a file the session cannot see.
The check fires for every code path that reaches a mount: shell commands (`cat`, `ls`, ...), redirects (`>`, `<`), cross-mount `cp`/`mv`, `wget -O`, `curl -o`, command substitution `$(...)`, subshells `(...)`, pipes, `&&`/`||` chains, background jobs, and the programmatic `ws.ops.read/write/...` API. Infrastructure prefixes are always accessible: the history view (`/.bash_history`, which the `history` builtin and the GNU histfile render from) and the implicit scratch root (`/`, where stateless text-processing commands like `wc` resolve when given no path). A user-defined `/` mount is not infrastructure; sessions must be given `/` explicitly to touch it.
This is a soft boundary, enforced inside the daemon process, not an OS or process-level isolation. Use it to shrink the blast radius of prompt-injection in multi-agent workspaces: a Slack-only agent cannot pivot to read `/linear`, `/github`, or any other mount its role hides. Note that FUSE mounts are not scoped by session views: a FUSE mount is a host-level surface served under the default unrestricted view (mount modes still apply, session narrowing does not).
Both halves fire for every code path that reaches a mount: shell commands (`cat`, `ls`, ...), redirects (`>`, `<`), cross-mount `cp`/`mv`, `wget -O`, `curl -o`, command substitution `$(...)`, subshells `(...)`, pipes, `&&`/`||` chains, background jobs, and the programmatic `ws.ops.read/write/...` API. The history view (`/.bash_history`, which the `history` builtin and the GNU histfile render from) and the implicit scratch root (`/`, where stateless text-processing commands like `wc` resolve when given no path) stay reachable unless a role hides them.
<Tabs>
<Tab title="Python" icon="/images/python-logo.svg">
@@ -160,25 +267,30 @@ The check fires for every code path that reaches a mount: shell commands (`cat`,
"/linear": linear,
})
ws.create_session("slack-agent", mounts=["/slack"])
# A role is the whole permission document a session runs under.
ws.create_session("slack-agent",
profile={"paths": {"hide": ["/linear", "/s3"]}})
ws.create_session("data-agent", mounts={"/s3": "rw", "/github": "r"})
await ws.execute("ls /slack", session_id="slack-agent") # ok
await ws.execute("cat /linear/issues/SEC-42",
session_id="slack-agent")
# exit_code=1, stderr=b"session 'slack-agent' not allowed to "
# b"access mount '/linear'\n"
# exit_code=1, stderr=b"cat: /linear/issues/SEC-42: "
# b"No such file or directory\n"
```
</Tab>
<Tab title="CLI" icon="terminal">
```bash
# Repeat --mount (or -m) per allowed prefix; cap the mode with
# Repeat --mount (or -m) to narrow a mount's mode; cap it with
# :read/:write/:exec or the aliases :r/:rw/:rwx
mirage session create demo --id slack-agent --mount /slack
mirage session create demo --id data-agent -m /s3:rw -m /github:r
mirage session create demo --id data-agent -m /s3:rw -m /github:r
# Confinement is a role, named with --profile (roles live in the
# workspace's `profiles:` block)
mirage session create demo --id slack-agent --profile slack-only
mirage execute -w demo -s slack-agent -c "cat /linear/issues/SEC-42"
# mirage: session 'slack-agent' not allowed to access mount '/linear'
# mirage: cat: /linear/issues/SEC-42: No such file or directory
```
</Tab>
</Tabs>
+2 -2
View File
@@ -34,12 +34,12 @@ ws = Workspace(
```
```typescript TypeScript
import { RedisFileCacheStore, S3Resource, Workspace } from '@struktoai/mirage-node'
import { S3Resource, Workspace } from '@struktoai/mirage-node'
const ws = new Workspace(
{ '/s3': new S3Resource({ bucket: 'my-bucket' }) },
{
cache: new RedisFileCacheStore({ url: 'redis://localhost:6379/0', cacheLimit: '8GB' }),
cache: { type: 'redis', url: 'redis://localhost:6379/0', limit: '8GB' },
index: { type: 'redis', url: 'redis://localhost:6379/0', ttl: 600 },
},
)
+5 -10
View File
@@ -310,14 +310,9 @@ mirage workspace clone demo --at <version> --id demo_at_v1
| `mirage job wait JOB [--timeout SECS]` | Block until the job is done; returns the result. |
| `mirage job cancel JOB` | Cancel a running job. |
## Per-mount safeguards
## Per-mount command limits
<Note>
Per-mount `command_safeguards` are **Python CLI only** today. The TypeScript
CLI config schema does not carry them yet.
</Note>
Cap what a command may stream back per mount with `command_safeguards`, so a
Cap what a command may stream back per mount with `command_limits`, so a
runaway `cat`/`grep`/`rg` can't flood the agent or hang. Each entry sets
`max_lines` / `max_bytes` (output cap) and/or `timeout_seconds` (deadline),
with `on_exceed: truncate` (stop, exit 0, add a stderr notice) or
@@ -328,7 +323,7 @@ mounts:
/data:
resource: ram
mode: WRITE
command_safeguards:
command_limits:
head: # cap output, keep going
max_lines: 100
on_exceed: truncate
@@ -342,8 +337,8 @@ mounts:
Caps fire on the **terminal** command of a pipeline only, so
`cat big.txt | head -n 30` still shows 30 lines. Truncation exits `0`, `error`
exits `1`, and a timeout exits `124` -- each with a stderr notice. Without a
`command_safeguards` block, `cat`/`grep`/`rg`/`head`/`tail` still cap at 2000
lines by default. See [Output Safeguards](/python/quickstart#output-safeguards)
`command_limits` block, `cat`/`grep`/`rg`/`head`/`tail` still cap at 2000
lines by default. See [Output Limits](/python/quickstart#output-limits)
for the SDK form and the same fields.
## Daemon control
+1 -1
View File
@@ -226,7 +226,7 @@ grep -r "mirage" /slack /gmail /github
import { MirageShell, buildSystemPrompt } from '@struktoai/mirage-agents/openai'
const slack = new SlackResource({ token: process.env.SLACK_BOT_TOKEN! })
const github = new GitHubResource({
const github = await GitHubResource.create({
token: process.env.GITHUB_TOKEN!,
owner: 'strukto-ai',
repo: 'mirage',
+152
View File
@@ -0,0 +1,152 @@
---
title: Policy Engine
description: Script which runtime serves each command line; policy scripts run on the workspace's evaluator runtime.
icon: route
---
A workspace can hold several runtimes for the same command, with different
trade-offs. `monty` runs `python3` in-process, sandboxed, stdlib-only:
perfect for quick one-liners and pipe transforms, useless for a script that
imports `pandas`. A `docker` entry is the opposite: your container, your
installed packages, but every line pays the exec round-trip. The **policy**
decides per line which one serves it:
```yaml
mode: exec
runtimes:
- monty # captures python3 by default: sandboxed, stdlib-only
- name: docker
captures: ["python3"] # your container, has pandas installed
config:
container: my-box
- vfs
policy: ./policy.py
```
```python policy.py
# Jobs under /jobs need the container's packages; anything else
# stays on the fast in-process sandbox. The LAST EXPRESSION is
# the verdict.
stage = ctx["commands"][0]
in_jobs = any(p.startswith("/jobs/") for p in stage["paths"])
"docker" if in_jobs else "monty"
```
So `python3 /jobs/train.py --epochs 3` runs in the container, while
`cat data.csv | python3 -c "import sys; print(len(sys.stdin.read()))"`
stays on monty.
## Input: the line's context
The script sees one global, `ctx`, the parsed line before any routing.
This payload is captured from a live run of the config above, routing
`python3 /jobs/train.py --epochs 3`:
```json
{
"line": "python3 /jobs/train.py --epochs 3",
"commands": [
{
"command": "python3",
"words": ["python3", "/jobs/train.py", "--epochs", "3"],
"builtin": true,
"paths": ["/jobs/train.py"]
}
],
"command": "python3",
"builtin": true,
"cwd": "/",
"env": {},
"session_id": "019fb2fc-19a3-77cf-bbbe-c76069eed523",
"agent_id": "",
"mounts": ["/.bash_history/", "/data/", "/dev/", "/"]
}
```
- `commands`: one entry per pipeline stage (`cat x | python3 -` has two),
each with its full words and its absolute-path operands.
- `command`, `builtin`: mirror the first stage. `builtin` means mirage has
a builtin spec for the command (`python3` is a builtin that hands its
code to a runtime), as opposed to an unknown name.
- `env`: the session environment (empty here, no `export` yet).
- `session_id`: the executing session's id. `agent_id` is empty until an
agent identity is attached.
- `mounts`: the workspace's mount prefixes, including built-ins like
`/dev/`.
The payload round-trips through JSON (`PolicyContext.from_dict`), so you
can store one and replay a decision in tests.
## Output: the verdict
- **A runtime name** (`"docker"`): that entry serves every command it
captures on this line.
- **`None`**: no opinion; the first capturer in the `runtimes` list order
serves each command (here, `monty`).
- **`{"deny": reason}`**: the line is refused before anything runs. It
exits 126 with `<command>: policy denied: <reason>` on stderr.
- Anything else is a routing error; the line fails rather than guessing.
`{"runtime": "docker"}` is the dict spelling of a name, and the dict is
where the verdict grows: new powers arrive as new keys, never as new
return types.
In code, the same two arms have a typed spelling, `RouteResult` and
`DenyResult`; the dict is their wire form, the only shape a script can
return from inside the evaluator sandbox:
```python
from mirage.runtime.policy import (DenyResult, PolicyContext, PolicyResult,
RouteResult)
def policy(ctx: PolicyContext) -> PolicyResult | None:
if any(p.startswith("/prod/") for p in ctx.commands[0].paths):
return DenyResult("writes under /prod are blocked")
return RouteResult("docker") if "/jobs/" in ctx.line else None
```
Besides the global `policy`, each runtime entry can carry its own `script:`
with the same `ctx` input but a boolean verdict: am I willing to serve this
line? Unwilling entries step aside and the first willing capturer wins.
## What runs the scripts
`policy.py` is never imported: the workspace evaluates its source on the
**policy engine**, an entry in the `runtimes` list with the evaluator
capability. The script's file extension picks the engine: `policy:
./policy.py` runs on the first python evaluator (monty, or pyodide in
TypeScript) and `policy: ./policy.js` on the first JS evaluator
(quickjs), even when an evaluator of the other language sits earlier in
the list; with no language match the first evaluator serves. A config
with policy scripts but no evaluator entry fails at the first decision.
Evaluation is bounded: a policy script that hangs fails the line with a
policy error after 10 seconds instead of freezing the workspace.
When you build the workspace in code rather than from a config file,
`policy=` also accepts a plain function with the same decision contract,
sync or async. It is called directly, so no evaluator is involved:
```python
from mirage.runtime.policy import PolicyContext
def policy(ctx: PolicyContext) -> str | None:
stage = ctx.commands[0]
in_jobs = any(p.startswith("/jobs/") for p in stage.paths)
return "docker" if in_jobs else "monty"
ws = Workspace(mounts, runtimes=runtimes, policy=policy)
```
A per-entry `script=` likewise accepts `(ctx) -> bool` in code.
## Bring your own
The capability is open: inherit `EvaluatorMixin` (Python) or implement
`Evaluator` with the `EVALUATOR` brand (TypeScript) on your own runtime,
and it becomes eligible as the policy engine. `eval(code, inputs=...)`
returns the last expression's value; how the value travels is your
runtime's choice. The docker examples give a stock container the
capability by piping a harness to `python3 -`:
[python](https://github.com/strukto-ai/mirage/blob/main/examples/python/runtimes/docker/docker_eval.py),
[typescript](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/runtimes/docker/docker_eval.ts).
-6
View File
@@ -1,6 +0,0 @@
---
title: Python SDK
description: Start with the Mirage Python SDK quickstart for installing the package, creating a workspace, and mounting resources.
url: /python/quickstart
icon: python
---
+8 -3
View File
@@ -62,8 +62,8 @@ No external setup, these run locally or against a connection string you already
| Resource | Access | Setup | Docs | Notes |
| --- | --- | --- | --- | --- |
| OneDrive | read, write | [OneDrive](/home/setup/onedrive) | [<Icon icon="python" />](/python/resource/onedrive) | Files and folders through Microsoft Graph |
| SharePoint | read, write | [SharePoint](/home/setup/sharepoint) | [<Icon icon="python" />](/python/resource/sharepoint) | Document libraries through Microsoft Graph; `site`, `drive`, and `key_prefix` can scope a mount to one folder subtree |
| OneDrive | read, write | [OneDrive](/home/setup/onedrive) | [<Icon icon="python" />](/python/resource/onedrive) [<Icon icon="node-js" />](/typescript/setup/onedrive) [<Icon icon="globe" />](/typescript/setup/onedrive) | Files and folders through Microsoft Graph |
| SharePoint | read, write | [SharePoint](/home/setup/sharepoint) | [<Icon icon="python" />](/python/resource/sharepoint) [<Icon icon="node-js" />](/typescript/setup/sharepoint) [<Icon icon="globe" />](/typescript/setup/sharepoint) | Document libraries through Microsoft Graph; `site`, `drive`, and `key_prefix` can scope a mount to one folder subtree |
## Cloud Files
@@ -79,7 +79,6 @@ No external setup, these run locally or against a connection string you already
| Resource | Access | Setup | Docs | Notes |
| --- | --- | --- | --- | --- |
| GitHub | read | [GitHub](/home/setup/github) | [<Icon icon="python" />](/python/resource/github) [<Icon icon="node-js" />](/typescript/setup/github) [<Icon icon="globe" />](/typescript/setup/github) | Repository browsing |
| GitHub CI | read | [GitHub CI](/home/setup/github_ci) | [<Icon icon="python" />](/python/resource/github_ci) [<Icon icon="node-js" />](/typescript/setup/github_ci) [<Icon icon="globe" />](/typescript/setup/github_ci) | Runs, logs, artifacts |
| Linear | read, actions | [Linear](/home/setup/linear) | [<Icon icon="python" />](/python/resource/linear) [<Icon icon="node-js" />](/typescript/setup/linear) [<Icon icon="globe" />](/typescript/setup/linear) | Read, create, update, comment on, and organize issues |
| Langfuse | read | [Langfuse](/home/setup/langfuse) | [<Icon icon="python" />](/python/resource/langfuse) [<Icon icon="node-js" />](/typescript/setup/langfuse) [<Icon icon="globe" />](/typescript/setup/langfuse) | Trace exploration |
@@ -108,6 +107,12 @@ No external setup, these run locally or against a connection string you already
| Dify | read | [Dify](/home/setup/dify) | [<Icon icon="python" />](/python/resource/dify) | Knowledge documents and retrieval search |
| Chroma | read | [Chroma](/home/setup/chroma) | [<Icon icon="python" />](/python/resource/chroma) [<Icon icon="node-js" />](/typescript/setup/chroma) | ChromaDB collection exposed as files and vector search |
## Memory
| Resource | Access | Setup | Docs | Notes |
| --- | --- | --- | --- | --- |
| Mem0 | read, search | [Mem0](/home/setup/mem0) | [<Icon icon="python" />](/python/resource/mem0) [<Icon icon="node-js" />](/typescript/setup/mem0) [<Icon icon="globe" />](/typescript/setup/mem0) | Scoped memories as JSON files plus semantic search |
## Notes
| Resource | Access | Setup | Docs | Notes |
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Box
icon: box
icon: /images/box-logo.svg
description: Set up Box OAuth2 credentials and obtain a refresh token (Python, Node, and browser).
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Chroma
icon: database
icon: /images/chroma-logo.svg
description: Prepare a ChromaDB collection for the Chroma resource.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Databricks Volume
icon: folder-open
icon: /images/databricks-logo.svg
description: Set up Databricks credentials for the Unity Catalog volume resource.
---
-27
View File
@@ -1,27 +0,0 @@
---
title: GitHub CI
icon: circle-play
description: Set up a GitHub Personal Access Token for the GitHub CI resource.
---
## Credentials
### 1. Create a Personal Access Token
1. Go to https://github.com/settings/tokens
1. **Generate new token (classic)** or **Fine-grained token**
1. For classic tokens, select scopes:
- `repo` (includes `actions:read`)
1. For fine-grained tokens:
- **Repository access**: select the repos you need
- **Permissions**: Actions -> Read-only
1. Copy the token
### 2. Set Environment Variables
```bash
# .env.development
GITHUB_TOKEN=ghp_xxxx...
```
For Python configuration, see the [Python GitHub CI Setup](/python/setup/github_ci) guide.
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: GridFS
icon: database
icon: /images/mongodb-logo.svg
description: Set up a MongoDB connection for the GridFS file-storage resource.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Langfuse
icon: chart-line
icon: /images/langfuse-logo.svg
description: Set up Langfuse API keys for the Langfuse resource.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Linear
icon: chart-gantt
icon: /images/linear-logo.svg
description: Set up a Linear personal API key for the Linear resource.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: MongoDB
icon: database
icon: /images/mongodb-logo.svg
description: Set up a MongoDB connection for the MongoDB resource.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Nextcloud
icon: cloud
icon: /images/nextcloud-logo.svg
description: Set up Nextcloud / ownCloud / WebDAV credentials for the Nextcloud resource.
---
+2 -2
View File
@@ -1,6 +1,6 @@
---
title: Notion
icon: book
icon: /images/notion-logo.svg
description: Set up a Notion internal integration for the Notion resource.
---
@@ -14,7 +14,7 @@ description: Set up a Notion internal integration for the Notion resource.
1. Under **Content Capabilities**, enable:
- **Read content** (required)
- **Update content** (for write commands)
- **Insert content** (for `notion-comment-add`)
- **Insert content** (for creating pages and comments)
1. Click **Save changes**
1. Copy the **Internal Integration Secret** (starts with `ntn_`)
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Postgres
icon: database
icon: /images/postgres-logo.svg
description: Set up a Postgres connection for the Postgres resource.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Qdrant
icon: database
icon: /images/qdrant-logo.svg
description: Set up a Qdrant connection for the Qdrant resource.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Cloudflare R2
icon: cloud
icon: /images/cloudflare-logo.svg
description: Set up Cloudflare R2 credentials for the R2 resource.
---
+1 -1
View File
@@ -30,7 +30,7 @@ description: Set up a Slack Bot Token for the Slack resource.
### 4. Optional: Enable Workspace Search
Slack's `search.messages` API does not accept bot tokens. To use `slack-search` or workspace-level search push-down:
Slack's `search.messages` API does not accept bot tokens. To use `slack search` or workspace-level search push-down:
1. Add `search:read` under **User Token Scopes**.
1. Reinstall or reauthorize the app.
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Supabase Storage
icon: bolt
icon: /images/supabase-logo.svg
description: Set up Supabase Storage credentials for the Supabase resource.
---
-20
View File
@@ -1,20 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<style>
svg { color: #374151; }
@media (prefers-color-scheme: dark) {
svg { color: #ffffff; }
}
</style>
<path stroke="currentColor" d="M12.409 13.017A5 5 0 0 1 22 15c0 3.866-4 7-9 7-4.077 0-8.153-.82-10.371-2.462-.426-.316-.631-.832-.62-1.362C2.118 12.723 2.627 2 10 2a3 3 0 0 1 3 3 2 2 0 0 1-2 2c-1.105 0-1.64-.444-2-1" />
<path stroke="currentColor" d="M15 14a5 5 0 0 0-7.584 2" />
<path stroke="currentColor" d="M9.964 6.825C8.019 7.977 9.5 13 8 15" />
</svg>

Before

Width:  |  Height:  |  Size: 669 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#0061D5" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Box</title><path d="M.959 5.523c-.54 0-.959.42-.959.899v7.549a4.59 4.59 0 004.613 4.494 4.717 4.717 0 004.135-2.457c.779 1.438 2.337 2.457 4.074 2.457 2.577 0 4.674-2.037 4.674-4.613.06-2.457-2.037-4.495-4.613-4.495-1.738 0-3.295.959-4.074 2.397-.78-1.438-2.338-2.397-4.135-2.397-1.079 0-2.038.36-2.817.899V6.422a.92.92 0 00-.898-.899zM17.602 9.26a.95.95 0 00-.704.158c-.36.3-.479.899-.18 1.318l2.397 3.116-2.396 3.115c-.3.42-.24.96.18 1.26.419.3 1.016.298 1.316-.122l2.039-2.636 2.096 2.697c.3.36.899.419 1.318.12.36-.3.42-.84.121-1.259l-2.338-3.115 2.338-3.057c.3-.419.298-1.018-.121-1.318-.48-.3-1.019-.24-1.318.18l-2.096 2.576-2.04-2.695c-.149-.18-.373-.3-.612-.338zM4.613 11.154c1.558 0 2.817 1.26 2.817 2.758 0 1.558-1.259 2.756-2.817 2.756-1.558 0-2.816-1.198-2.816-2.756 0-1.498 1.258-2.758 2.816-2.758zm8.27 0c1.558 0 2.816 1.26 2.816 2.758-.06 1.558-1.318 2.756-2.816 2.756-1.558 0-2.817-1.198-2.817-2.756 0-1.498 1.259-2.758 2.817-2.758Z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Chroma--Streamline-Svg-Logos" height="24" width="24">
<desc>
Chroma Streamline Icon: https://streamlinehq.com
</desc>
<path fill="#ffde2d" d="M15.916575 19.52c4.326225 0 7.833325 -3.3668 7.833325 -7.519975 0 -4.153175 -3.5071 -7.519975 -7.833325 -7.519975 -4.326225 0 -7.833325 3.3668 -7.833325 7.519975 0 4.153175 3.5071 7.519975 7.833325 7.519975Z" stroke-width="0.25"></path>
<path fill="#327eff" d="M8.083325 19.52c4.326225 0 7.833325 -3.3668 7.833325 -7.519975 0 -4.153175 -3.5071 -7.519975 -7.833325 -7.519975C3.7571 4.48005 0.25 7.84685 0.25 12.000025 0.25 16.1532 3.7571 19.52 8.083325 19.52Z" stroke-width="0.25"></path>
<path fill="#ff6446" d="M15.916625 12.000025c0 4.1532 -3.507125 7.519925 -7.833375 7.519925V12.000025h7.833375Zm-7.833375 0c0 -4.153175 3.5071 -7.519975 7.833375 -7.519975v7.519975H8.08325Z" stroke-width="0.25"></path>
</svg>

After

Width:  |  Height:  |  Size: 946 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#F38020" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#FF3621" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Databricks</title><path d="M.95 14.184L12 20.403l9.919-5.55v2.21L12 22.662l-10.484-5.96-.565.308v.77L12 24l11.05-6.218v-4.317l-.515-.309L12 19.118l-9.867-5.653v-2.21L12 16.805l11.05-6.218V6.32l-.515-.308L12 11.974 2.647 6.681 12 1.388l7.76 4.368.668-.411v-.566L12 0 .95 6.27v.72L12 13.207l9.919-5.55v2.26L12 15.52 1.516 9.56l-.565.308Z"/></svg>

After

Width:  |  Height:  |  Size: 438 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 220 220" xmlns="http://www.w3.org/2000/svg"><title>Daytona</title><rect width="220" height="220" rx="32" fill="#fff"/><g transform="translate(4.5)" fill="#161616"><rect x="11.1428" y="149.025" width="76.2474" height="26.1421"/><rect x="113.522" y="57.5287" width="87.1399" height="26.1421"/><rect width="91.4971" height="26.142" transform="matrix(.707105 -.707108 .707105 .707108 67.7737 65.5858)"/><rect width="70.2691" height="26.142" transform="matrix(-.707105 -.707108 -.707105 .707108 68.1729 130.941)"/><rect width="74.0691" height="26.142" transform="matrix(-.707105 .707108 -.707105 -.707108 133.532 167.511)"/><rect width="82.7831" height="26.142" transform="matrix(.707105 .707108 .707105 -.707108 133.133 102.156)"/><rect x="67.7737" y="37.9227" width="63.1767" height="26.142" transform="rotate(90 67.7737 37.9227)"/><rect x="159.278" y="129.419" width="78.4262" height="26.142" transform="rotate(90 159.278 129.419)"/></g></svg>

After

Width:  |  Height:  |  Size: 971 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path id="path" d="M27.501 8.46875C27.249 8.3457 27.1406 8.58008 26.9932 8.69922C26.9434 8.73828 26.9004 8.78906 26.8584 8.83398C26.4902 9.22852 26.0605 9.48633 25.5 9.45508C24.6787 9.41016 23.9785 9.66797 23.3594 10.2969C23.2275 9.52148 22.79 9.05859 22.125 8.76172C21.7764 8.60742 21.4238 8.45312 21.1807 8.11719C21.0098 7.87891 20.9639 7.61328 20.8779 7.35156C20.8242 7.19336 20.7695 7.03125 20.5879 7.00391C20.3906 6.97266 20.3135 7.13867 20.2363 7.27734C19.9258 7.84375 19.8066 8.46875 19.8174 9.10156C19.8447 10.5234 20.4453 11.6562 21.6367 12.4629C21.7725 12.5547 21.8076 12.6484 21.7646 12.7832C21.6836 13.0605 21.5869 13.3301 21.501 13.6074C21.4473 13.7852 21.3662 13.8242 21.1768 13.7461C20.5225 13.4727 19.957 13.0684 19.458 12.5781C18.6104 11.7578 17.8438 10.8516 16.8877 10.1426C16.6631 9.97656 16.4395 9.82227 16.207 9.67578C15.2314 8.72656 16.335 7.94727 16.5898 7.85547C16.8574 7.75977 16.6826 7.42773 15.8193 7.43164C14.957 7.43555 14.167 7.72461 13.1611 8.10938C13.0137 8.16797 12.8594 8.21094 12.7002 8.24414C11.7871 8.07227 10.8389 8.0332 9.84766 8.14453C7.98242 8.35352 6.49219 9.23633 5.39648 10.7441C4.08105 12.5547 3.77148 14.6133 4.15039 16.7617C4.54883 19.0234 5.70215 20.8984 7.47559 22.3633C9.31348 23.8809 11.4307 24.625 13.8457 24.4824C15.3125 24.3984 16.9463 24.2012 18.7881 22.6406C19.2529 22.8711 19.7402 22.9629 20.5498 23.0332C21.1729 23.0918 21.7725 23.002 22.2373 22.9062C22.9648 22.752 22.9141 22.0781 22.6514 21.9531C20.5186 20.959 20.9863 21.3633 20.5605 21.0371C21.6445 19.752 23.2783 18.418 23.917 14.0977C23.9668 13.7539 23.9238 13.5391 23.917 13.2598C23.9131 13.0918 23.9512 13.0254 24.1445 13.0059C24.6787 12.9453 25.1973 12.7988 25.6738 12.5352C27.0557 11.7793 27.6123 10.5391 27.7441 9.05078C27.7637 8.82422 27.7402 8.58789 27.501 8.46875ZM15.46 21.8613C13.3926 20.2344 12.3906 19.6992 11.9766 19.7227C11.5898 19.7441 11.6592 20.1875 11.7441 20.4766C11.833 20.7617 11.9492 20.959 12.1123 21.209C12.2246 21.375 12.3018 21.623 12 21.8066C11.334 22.2207 10.1768 21.668 10.1221 21.6406C8.77539 20.8477 7.64941 19.7988 6.85547 18.3652C6.08984 16.9844 5.64453 15.5039 5.57129 13.9238C5.55176 13.541 5.66406 13.4062 6.04297 13.3379C6.54199 13.2461 7.05762 13.2266 7.55664 13.2988C9.66602 13.6074 11.4619 14.5527 12.9668 16.0469C13.8262 16.9004 14.4766 17.918 15.1465 18.9121C15.8584 19.9688 16.625 20.9746 17.6006 21.7988C17.9443 22.0879 18.2197 22.3086 18.4824 22.4707C17.6895 22.5586 16.3652 22.5781 15.46 21.8613ZM16.4502 15.4805C16.4502 15.3105 16.5859 15.1758 16.7568 15.1758C16.7949 15.1758 16.8301 15.1836 16.8613 15.1953C16.9033 15.2109 16.9424 15.2344 16.9727 15.2695C17.0273 15.3223 17.0586 15.4004 17.0586 15.4805C17.0586 15.6504 16.9229 15.7852 16.7529 15.7852C16.582 15.7852 16.4502 15.6504 16.4502 15.4805ZM19.5273 17.0625C19.3301 17.1426 19.1328 17.2129 18.9434 17.2207C18.6494 17.2344 18.3281 17.1152 18.1533 16.9688C17.8828 16.7422 17.6895 16.6152 17.6074 16.2168C17.5732 16.0469 17.5928 15.7852 17.623 15.6348C17.6934 15.3105 17.6152 15.1035 17.3877 14.9141C17.2012 14.7598 16.9658 14.7188 16.7061 14.7188C16.6094 14.7188 16.5205 14.6758 16.4541 14.6406C16.3457 14.5859 16.2568 14.4512 16.3418 14.2852C16.3691 14.2324 16.501 14.1016 16.5322 14.0781C16.8838 13.877 17.29 13.9434 17.666 14.0938C18.0146 14.2363 18.2773 14.498 18.6562 14.8672C19.0439 15.3145 19.1133 15.4395 19.334 15.7734C19.5078 16.0371 19.667 16.3066 19.7754 16.6152C19.8408 16.8066 19.7559 16.9648 19.5273 17.0625Z" fill-rule="nonzero" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Docker</title><path fill="#2496ED" d="M13.983 11.078h2.119a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.119a.185.185 0 0 0-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 0 0 .186-.186V3.574a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 0 0 .186-.186V6.29a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 0 0 .184-.186V6.29a.185.185 0 0 0-.185-.185H8.1a.185.185 0 0 0-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 0 0 .185-.186V6.29a.185.185 0 0 0-.185-.185H5.136a.186.186 0 0 0-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 0 0 .185-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.186.186 0 0 0-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 0 0-.75.748 11.376 11.376 0 0 0 .692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 0 0 3.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><title>E2B</title><rect width="32" height="32" rx="6" fill="#202020"/><path fill="#fff" d="M9 6h16a2 2 0 0 1 0 4H10v4h15a2 2 0 0 1 0 4H10v4h15a2 2 0 0 1 0 4H9a3 3 0 0 1-3-3V9a3 3 0 0 1 3-3Z"/></svg>

After

Width:  |  Height:  |  Size: 270 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Haystack</title><path d="M2.0084 0C.8992 0 0 .8992 0 2.0084v19.9832C0 23.1006.8992 24 2.0084 24h19.9832C23.1006 24 24 23.1007 24 21.9916V2.0084C24 .8992 23.1007 0 21.9916 0Zm9.9624 3.84c3.4303 0 6.2108 2.7626 6.2108 6.1709v6.4875a.2688.2688 0 0 1-.2697.2681c-1.3425 0-2.4306-1.0811-2.4306-2.415v-4.3409c0-1.9265-1.572-3.488-3.5105-3.488s-3.424 1.562-3.424 3.488v1.608a.2633.2633 0 0 0 .259.2681h1.5394a.2693.2693 0 0 0 .2753-.263V9.9453c0-.7412.6044-1.3414 1.3503-1.3414s1.3502.6002 1.3502 1.3414V20.029a.2747.2747 0 0 1-.2807.2682c-1.3362 0-2.4198-1.0766-2.4198-2.4043v-3.2307a.2747.2747 0 0 0-.2753-.268H8.8114a.2637.2637 0 0 0-.2646.263v1.0789c0 1.3338-1.1746 2.4152-2.517 2.4152a.2688.2688 0 0 1-.2698-.268v-7.8724c0-3.4083 2.7805-6.1709 6.2108-6.1709Z"/></svg>

After

Width:  |  Height:  |  Size: 844 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M254.75 302.25L285.25 326.75C285.25 326.75 308.587 309.418 325.75 306.875C343.75 304.208 362.954 314.244 380.75 326.208C407.629 344.279 430.25 367.208 430.25 367.208L456.75 341.208C456.75 341.208 383.686 262.047 325.75 269.208C287.75 273.905 254.75 302.25 254.75 302.25Z" fill="#FF5D5F"/>
<path d="M80.25 151.286L55.25 178.786C55.25 178.786 124.902 243.786 179.75 243.786C204.75 243.786 239.419 224.201 269.25 198.757C286.25 184.257 305.25 167.786 324.25 167.786C337.021 167.786 353.866 174.551 369.75 192.316C369.75 192.316 380.003 186.168 386.25 181.75C391.74 177.868 399.896 171.25 399.896 171.25C377.047 146.864 343.998 129.038 324.25 130.786C292.25 130.79 269.25 150.711 240.75 173.75C212.25 196.789 200.25 206.286 179.75 206.286C145.25 206.286 80.25 151.286 80.25 151.286Z" fill="#4E9CFF"/>
<path d="M80.25 360.75L55.25 333.25C55.25 333.25 124.902 268.25 179.75 268.25C204.75 268.25 239.419 287.835 269.25 313.279C286.25 327.779 305.25 344.25 324.25 344.25C337.083 344.25 353.799 337.207 369.75 319.25C369.75 319.25 379.339 325.161 385.25 329.25C391.328 333.455 400.25 340.407 400.25 340.407C377.39 364.987 344.1 383.007 324.25 381.25C292.25 381.246 273.25 364.289 244.75 341.25C216.25 318.211 200.25 305.75 179.75 305.75C145.25 305.75 80.25 360.75 80.25 360.75Z" fill="#4E9CFF"/>
<path d="M406.25 213.25C399.745 217.746 389.25 224.25 389.25 224.25C389.25 224.25 395.25 237.25 395.25 254.75C395.25 272.25 389.75 287.25 389.75 287.25C389.75 287.25 399.172 293.135 405.25 297.25C411.564 301.525 421.25 308.75 421.25 308.75C421.25 308.75 432.75 284.75 432.75 254.75C432.75 224.75 421.25 202.25 421.25 202.25C421.25 202.25 412.226 209.12 406.25 213.25Z" fill="#4E9CFF"/>
<path d="M256.25 209.25L285.25 185.25C285.25 185.25 308.587 202.04 325.75 204.583C343.75 207.25 362.954 197.214 380.75 185.25C407.629 167.179 430.25 144.25 430.25 144.25L456.75 170.25C456.75 170.25 383.686 249.411 325.75 242.25C287.75 237.553 256.25 209.25 256.25 209.25Z" fill="#FF5D5F"/>
<path d="M186.255 130.25C223.755 130.25 255.25 162.25 255.25 162.25C255.25 162.25 246.487 169.155 240.75 173.75C234.775 178.536 225.25 186.25 225.25 186.25C225.25 186.25 208.755 168.75 186.255 168.75C177.028 168.75 165.039 174.292 152.255 185.25C142.391 193.705 132.129 204.216 125.255 217.25C119.31 228.52 116.068 241.802 115.755 255.75C115.361 273.269 121.571 291.634 131.755 306.25C138.58 316.046 146.726 323.418 155.255 329.75C166.323 337.968 177.865 343.75 186.255 343.75C195.217 343.75 203.274 340.635 209.255 337.75C218.755 332.25 226.25 325.75 226.25 325.75L255.75 350.25C255.75 350.25 243.75 362.25 227.255 371.25C216.595 376.507 202.895 381.75 186.255 381.75C169.626 381.75 150.315 372.915 132.255 359.25C120.579 350.416 109.135 339.948 100.255 327.25C85.7005 306.438 78.2004 281.118 78.2502 255.75C78.3008 230.065 86.5823 204.625 101.255 183.75C124.255 153.75 158.273 130.25 186.255 130.25Z" fill="#FF5D5F"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#5E6AD2" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Linear</title><path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z"/></svg>

After

Width:  |  Height:  |  Size: 469 B

-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="48" height="48">
<rect width="160" height="160" rx="24" fill="#000000"/>
<polygon points="19,45 80,10 80,45 50,63" fill="#ffffff" stroke="#000000" stroke-width="1"/>
<polygon points="80,10 141,45 110,63 80,45" fill="#ffffff" stroke="#000000" stroke-width="1"/>
<polygon points="141,45 141,115 110,98 110,63" fill="#ffffff" stroke="#000000" stroke-width="1"/>
<polygon points="141,115 80,150 80,115 110,98" fill="#ffffff" stroke="#000000" stroke-width="1"/>
<polygon points="80,150 19,115 50,98 80,115" fill="#ffffff" stroke="#000000" stroke-width="1"/>
<polygon points="19,115 19,45 50,63 50,98" fill="#ffffff" stroke="#000000" stroke-width="1"/>
<polygon points="80,45 110,63 110,98 80,115 50,98 50,63" fill="#ffffff" stroke="#000000" stroke-width="1"/>
<line x1="80" y1="80" x2="50" y2="63" stroke="#000000" stroke-width="1"/>
<line x1="80" y1="80" x2="110" y2="63" stroke="#000000" stroke-width="1"/>
<line x1="80" y1="80" x2="80" y2="115" stroke="#000000" stroke-width="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#47A248" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MongoDB</title><path d="M17.193 9.555c-1.264-5.58-4.252-7.414-4.573-8.115-.28-.394-.53-.954-.735-1.44-.036.495-.055.685-.523 1.184-.723.566-4.438 3.682-4.74 10.02-.282 5.912 4.27 9.435 4.888 9.884l.07.05A73.49 73.49 0 0111.91 24h.481c.114-1.032.284-2.056.51-3.07.417-.296.604-.463.85-.693a11.342 11.342 0 003.639-8.464c.01-.814-.103-1.662-.197-2.218zm-5.336 8.195s0-8.291.275-8.29c.213 0 .49 10.695.49 10.695-.381-.045-.765-1.76-.765-2.405z"/></svg>

After

Width:  |  Height:  |  Size: 543 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#0082C9" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Nextcloud</title><path d="M12.018 6.537c-2.5 0-4.6 1.712-5.241 4.015-.56-1.232-1.793-2.105-3.225-2.105A3.569 3.569 0 0 0 0 12a3.569 3.569 0 0 0 3.552 3.553c1.432 0 2.664-.874 3.224-2.106.641 2.304 2.742 4.016 5.242 4.016 2.487 0 4.576-1.693 5.231-3.977.569 1.21 1.783 2.067 3.198 2.067A3.568 3.568 0 0 0 24 12a3.569 3.569 0 0 0-3.553-3.553c-1.416 0-2.63.858-3.199 2.067-.654-2.284-2.743-3.978-5.23-3.977zm0 2.085c1.878 0 3.378 1.5 3.378 3.378 0 1.878-1.5 3.378-3.378 3.378A3.362 3.362 0 0 1 8.641 12c0-1.878 1.5-3.378 3.377-3.378zm-8.466 1.91c.822 0 1.467.645 1.467 1.468s-.644 1.467-1.467 1.468A1.452 1.452 0 0 1 2.085 12c0-.823.644-1.467 1.467-1.467zm16.895 0c.823 0 1.468.645 1.468 1.468s-.645 1.468-1.468 1.468A1.452 1.452 0 0 1 18.98 12c0-.823.644-1.467 1.467-1.467z"/></svg>

After

Width:  |  Height:  |  Size: 874 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#9CA3AF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Notion</title><path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632z"/></svg>

After

Width:  |  Height:  |  Size: 994 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#DC244C" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Qdrant</title><path d="m12 16.5 3.897-2.25v-4.5L12 7.5 8.103 9.75v4.5zM1.607 18 12 24l3.897-2.25v-4.5L12 19.5l-6.495-3.75v-7.5L12 4.5l6.495 3.75v15L22.393 21V6L12 0 1.607 6Z"/></svg>

After

Width:  |  Height:  |  Size: 276 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#FF4438" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Redis</title><path d="M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"/></svg>

After

Width:  |  Height:  |  Size: 754 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none" role="img" aria-label="Sandlock"><title>sandlock</title>
<path fill="#265978" fill-rule="evenodd" d="M119.22,246.85C108.63,243.20 104.61,241.54 96.31,237.40C74.33,226.43 58.49,212.80 46.76,194.73C35.48,177.36 29.17,158.82 26.53,135.31C24.81,120.04 22.93,84.79 22.56,60.71L22.38,49.59L28.24,49.20C43.05,48.20 55.26,46.16 67.49,42.64C89.33,36.35 109.42,24.81 123.47,10.49L128.01,5.86L134.05,11.68C157.31,34.08 188.88,46.74 227.33,49.07L233.91,49.47L233.91,53.63C233.91,69.75 231.05,123.41 229.50,136.48C225.50,170.15 213.60,195.08 191.66,215.81C179.86,226.95 164.82,236.14 146.43,243.44C139.01,246.39 129.55,249.42 127.85,249.40C127.13,249.39 123.25,248.24 119.22,246.85ZM135.98,229.48C157.83,221.97 176.94,209.18 189.27,193.83C204.64,174.67 211.55,154.72 213.98,122.44C214.59,114.32 215.63,97.11 215.94,90.11C216.10,86.39 215.76,85.34 214.86,86.80C213.95,88.27 201.48,95.92 194.41,99.34C186.86,103.00 179.09,106.06 177.34,106.06C176.64,106.06 176.50,105.66 176.75,104.39C177.07,102.80 176.50,95.85 175.68,91.43L175.30,89.38L179.01,87.98C185.06,85.68 193.42,81.29 200.00,76.95C206.79,72.47 214.79,65.88 214.33,65.14C214.17,64.88 212.39,64.48 210.37,64.26C208.35,64.04 203.31,63.19 199.16,62.39C174.14,57.54 150.33,46.86 132.56,32.52L128.04,28.88L125.53,30.99C105.48,47.86 77.92,59.58 48.20,63.87L41.24,64.87L44.30,67.76C51.59,74.67 62.62,81.67 73.88,86.54C78.13,88.38 80.86,89.88 80.80,90.36C80.75,90.79 80.42,92.56 80.07,94.29C79.72,96.02 79.43,99.38 79.43,101.75C79.43,104.12 79.19,106.06 78.90,106.06C78.60,106.06 76.03,105.25 73.19,104.26C65.38,101.54 55.42,96.45 47.41,91.11L40.23,86.31L40.23,90.53C40.23,94.98 41.56,115.55 42.61,127.41C45.60,161.08 55.75,184.21 75.92,203.35C87.66,214.50 101.47,222.63 120.39,229.55C123.77,230.78 127.10,231.81 127.79,231.83C128.48,231.85 132.16,230.79 135.98,229.48ZM118.42,214.30C92.84,203.26 80.67,191.97 75.88,174.83C74.55,170.07 72.88,160.40 73.31,159.96C73.48,159.80 74.85,160.85 76.38,162.30C80.32,166.06 82.55,167.58 88.60,170.61C93.71,173.18 93.97,173.41 94.87,176.04C96.97,182.21 100.66,186.26 109.12,191.73C114.37,195.11 123.21,199.40 123.77,198.83C124.53,198.08 124.65,176.79 123.92,173.49C123.54,171.80 123.04,170.42 122.81,170.42C122.57,170.42 121.49,171.34 120.39,172.47C119.30,173.60 118.19,174.52 117.92,174.52C117.65,174.52 110.23,170.68 101.43,165.99C88.08,158.88 84.90,156.93 82.18,154.22C77.98,150.01 75.56,145.43 74.73,140.11C73.45,131.83 75.40,127.96 83.02,123.69L86.32,121.85L86.61,108.83C86.94,94.33 87.59,90.86 91.42,83.25C97.78,70.63 112.50,61.07 125.66,61.01L128.29,61.00L128.29,64.30C128.29,68.15 128.40,68.24 134.04,69.09C140.19,70.01 145.81,72.99 151.30,78.24C158.63,85.24 160.67,90.32 161.17,102.84C161.35,107.34 161.73,111.32 162.01,111.68C162.29,112.03 160.48,111.60 157.99,110.73L153.45,109.13L153.12,102.47C152.83,96.67 152.56,95.33 151.00,92.01C146.51,82.46 138.42,77.26 128.00,77.22C119.75,77.19 112.98,80.51 108.11,86.98C103.98,92.45 103.24,95.17 102.97,105.81L102.72,115.22L107.83,114.18C124.69,110.77 141.24,111.52 156.09,116.38C163.31,118.74 175.33,124.59 178.50,127.28C181.30,129.66 181.72,131.37 182.19,142.34L182.56,150.82L179.12,147.51C172.96,141.57 161.61,135.04 151.99,131.90C142.35,128.75 138.14,128.09 127.71,128.11C117.11,128.13 113.03,128.74 104.48,131.56C96.86,134.08 94.13,136.75 94.91,140.94C95.50,144.08 98.16,146.52 104.70,149.92C108.02,151.65 114.55,155.17 119.22,157.75C123.88,160.32 127.97,162.41 128.29,162.40C128.61,162.39 132.43,160.40 136.78,157.98C141.12,155.56 147.24,152.32 150.37,150.76C156.04,147.95 156.06,147.94 159.24,148.61C166.93,150.22 174.03,155.51 177.23,162.02C179.78,167.19 180.22,173.68 178.41,179.62C175.90,187.88 168.56,196.58 158.43,203.30C149.50,209.21 131.38,217.83 127.90,217.81C127.15,217.81 122.89,216.23 118.42,214.30ZM136.56,197.58C146.35,193.25 155.81,186.26 158.82,181.13C161.82,176.01 161.13,168.36 157.46,165.92C156.43,165.24 155.77,165.43 151.95,167.50C146.13,170.65 138.62,174.52 138.31,174.52C138.17,174.52 136.94,173.58 135.57,172.44C133.69,170.87 132.96,170.55 132.60,171.12C131.95,172.15 131.96,199.09 132.61,199.09C132.89,199.09 134.67,198.41 136.56,197.58ZM137.19,151.86C138.33,151.65 138.51,151.20 138.70,147.86L138.91,144.09L138.87,148.04C138.83,151.84 138.77,151.99 137.36,152.05C136.14,152.09 136.11,152.06 137.19,151.86ZM132.10,136.34C132.10,136.10 133.53,135.90 135.28,135.90C137.03,135.90 138.59,136.10 138.73,136.34C138.88,136.58 137.45,136.78 135.55,136.78C133.65,136.78 132.10,136.58 132.10,136.34Z"/>
<path fill="#cf6819" fill-rule="evenodd" d="M122.08,151.88C121.24,151.75 119.79,150.84 118.87,149.88C117.34,148.28 117.18,147.73 117.18,144.07C117.18,140.28 117.30,139.90 119.09,138.11C120.67,136.53 121.45,136.19 123.56,136.19C126.49,136.19 128.30,137.47 129.54,140.42C132.20,146.69 128.18,152.86 122.08,151.88ZM135.02,145.59C135.02,139.71 134.97,139.48 133.71,139.30C132.84,139.18 132.39,138.72 132.39,137.95C132.39,136.96 132.84,136.75 135.25,136.60C136.82,136.50 138.36,136.66 138.67,136.97C139.01,137.31 139.06,140.30 138.80,144.61L138.38,151.70L136.70,151.70L135.02,151.70L135.02,145.59ZM125.33,147.47C127.19,144.82 125.89,139.41 123.40,139.41C121.65,139.41 120.98,140.70 120.98,144.09C120.98,145.89 121.29,147.68 121.68,148.07C122.77,149.16 124.33,148.90 125.33,147.47ZM164.28,112.92L161.65,111.62L161.29,103.42C160.73,90.53 158.63,85.24 151.30,78.24C145.81,72.99 140.19,70.01 134.04,69.09C128.42,68.24 128.29,68.14 128.29,64.45L128.29,61.29L132.62,61.42C141.61,61.67 151.05,66.31 158.61,74.17C161.94,77.63 165.86,84.62 167.64,90.26C169.07,94.78 169.21,96.09 169.23,104.59C169.25,115.32 169.23,115.37 164.28,112.92Z"/>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 83.95 83.95"><title>smolvm</title>
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<rect class="cls-1" width="83.95" height="83.95" rx="18" ry="18"/>
<g>
<path d="M53.75,38.93c-1.6-.12-2.02-1.22-2.67-2.28-1.14,.89-2.36,1.69-3.38,2.68-1.12,1.09-.99,2.31,.09,3.95,.45,.67,.81,1.4,1.04,2.21-1.5-1.44-3.39-2.57-3.71-5.12-.33,.54-.52,.98-.82,1.33-.64,.77-1.32,1.53-2.01,2.25-.43,.44-1.04,.74-1.37,1.23-1.01,1.51-2.38,2.4-4.18,2.72,1.21,2.92,2.9,5.69,1.8,9.06-1.18-2.85-2.31-5.6-3.42-8.28-3.03,.4-3.06,.39-4.43-2.67q-3.36,.42-4.21-.06c1.62-.28,3.04-.78,4.22-2-1.61-.33-2.88-1.04-3.53-2.63,.21,.07,.42,.15,.63,.22,.64,.23,1.27,.52,1.93,.66,1.2,.27,2.21-.06,2.89-1.16,.35-.56,.75-1.09,1.24-1.6,.03,1.55-.53,3.27,1.91,3.94-.79,.28-1.27,.43-1.73,.62-.79,.33-1.25,1.22-.93,1.98,.14,.33,.63,.78,.89,.74,1.69-.25,3.36-.64,4.64-1.91,.61-.6,.88-1.25,.62-2.17-.55-1.9-.91-3.85-1.48-5.75-.45-1.51-1.33-2.03-2.84-1.68-3.5,.81-6.99,1.64-10.44,2.62-2.51,.71-3.52,2.44-3.03,4.99,.66,3.44,1.55,6.83,2.14,10.28,.66,3.84,.73,7.68-1.35,11.22-.3,.51-.61,1.12-1.33,.94-.45-.11-.84-.45-1.26-.69,1.02-2.02,1.88-3.94,2.04-6.13,.2-2.68-.35-5.24-.88-7.83-.53-2.61-1.03-5.23-1.53-7.84-.02-.12-.03-.25-.03-.37,.03-1.76,.04-3.52,.11-5.27,.06-1.6,.84-2.84,2.14-3.73,.47-.32,.74-.64,.77-1.25,.11-1.96,1.12-3.42,2.96-4.01,3.27-1.05,5.89-3.14,8.65-5.04,1.77-1.21,3.53-2.43,5.27-3.69,1.73-1.25,3.63-1.85,5.76-1.74,1.95,.1,3.92,.12,5.86,.35,1.77,.21,3.51,.69,5.27,1.01,1.37,.25,2.74,.52,4.12,.65,1.88,.18,3.03,.87,3.42,2.23,.48,1.67-.13,3.45-1.54,4.47-1.98,1.43-4.26,1.61-6.58,1.45-1.05-.07-2.08-.36-3.13-.43-.56-.03-1.14,.21-1.71,.31-1.24,.22-2.48,.43-3.76,.65,.5,2.82,2.14,5.36,1.19,8.34,1.43-1.17,2.86-2.34,4.28-3.51,1.77-1.46,3.65-2.72,5.95-3.12,1.51-.26,3.04-.26,4.44,.54,2.12,1.21,2.56,3.45,.89,5.21-1.64,1.73-3.44,3.33-5.13,5.01-3.1,3.09-5.43,6.7-7.06,10.75-1.33,3.32-3.42,5.93-6.52,7.77-1.58,.94-3.08,2.03-3.77,3.86-.26,.69-.46,1.41-.7,2.11-.17,.51-.18,1.17-1.04,.99-.9-.2-1.28-.65-1.09-1.51,.54-2.44,1.65-4.54,3.73-6.03,.53-.38,1.05-.77,1.61-1.11,2.65-1.6,4.52-3.91,5.72-6.7,2.56-5.99,6.51-10.9,11.35-15.16,.44-.39,.86-.81,1.25-1.25,.99-1.11,.66-2.15-.8-2.56-1.76-.49-3.4-.07-4.91,.83-1.11,.67-2.16,1.46-3.19,2.26-1.04,.79-1.11,1.73-.28,2.8,.24,.31,.51,.61,.92,1.08Zm-19.31-14.07c.03,.06,.06,.12,.09,.18,.09-.01,.18-.01,.26-.04,2.07-.62,4.12-1.32,6.22-1.84,2.22-.55,3.52,.08,4.69,2.04,.16,.27,.31,.54,.5,.78,.13,.16,.33,.4,.47,.38,1.51-.16,3.02-.37,4.69-.58-.59-.74-1.39-1.1-1.02-2.17,1.25,1.45,2.78,2.12,4.51,2.32,1.84,.22,3.66,.18,5.36-.7,.85-.44,1.46-1.09,1.43-2.14-.13-.04-.19-.07-.25-.07-.25,.02-.49,.06-.74,.07-1.81,.04-3.58-.21-4.84-1.64-.62-.7-1.27-1.17-2.18-1.25-2.84-.26-5.68-.5-8.52-.74-1.31-.11-2.55,.18-3.66,.87-2.36,1.47-4.69,3.01-7.03,4.51Zm6.63,17.34c.34-.3,.75-.54,.97-.89,.47-.77,1.29-1.69,1.17-2.41-.4-2.4-1.08-4.77-1.8-7.1-.54-1.74-1.55-2.27-3.32-1.81-4.25,1.11-8.49,2.26-12.7,3.52-1.12,.34-2.18,1.05-3.1,1.8-.58,.47-.85,1.31-1.34,2.1,1.34-.59,2.39-1.2,3.53-1.53,3.06-.86,6.15-1.62,9.25-2.38,.81-.2,1.67-.39,2.48-.32,1.94,.17,2.9,1.52,3.39,3.25,.53,1.89,.99,3.81,1.48,5.76Zm-16.9-10.25c2.71-.81,5.32-1.65,7.96-2.35,2.3-.61,4.63-1.11,6.98-1.55,1.38-.26,2.59,.31,3.21,1.56,.66,1.31,1.14,2.73,1.56,4.14,.43,1.44,.7,2.92,1.08,4.54,1.02-1.37,1.85-2.67,1.51-4.18-.55-2.47-1.24-4.92-2.05-7.31-.6-1.78-1.39-2.17-3.24-1.74-2.19,.52-4.36,1.19-6.51,1.87-2.8,.88-5.59,1.82-8.37,2.77-1.06,.36-1.83,1.07-2.13,2.25Zm37.27-9.89c.02-.1,.05-.21,.07-.31-1.36-.23-2.71-.45-4.28-.71,.77,1.09,2.8,1.48,4.21,1.02Z"/>
<path d="M29.37,63.44c1.53-.28,2.89-.38,4.15-.8,1.22-.41,2.34-1.15,3.73-1.87-1.04,2.11-2.61,2.99-4.58,3.27-1.11,.15-2.22,.13-3.3-.6Z"/>
<path class="cls-1" d="M34.44,24.86c2.34-1.51,4.66-3.04,7.03-4.51,1.1-.69,2.34-.98,3.66-.87,2.84,.24,5.68,.48,8.52,.74,.91,.08,1.55,.55,2.18,1.25,1.27,1.43,3.03,1.68,4.84,1.64,.25,0,.49-.05,.74-.07,.06,0,.12,.03,.25,.07,.04,1.05-.57,1.7-1.43,2.14-1.69,.88-3.52,.91-5.36,.7-1.74-.2-3.27-.88-4.51-2.32-.37,1.07,.43,1.43,1.02,2.17-1.67,.21-3.18,.42-4.69,.58-.14,.02-.35-.22-.47-.38-.19-.24-.34-.52-.5-.78-1.17-1.96-2.47-2.6-4.69-2.04-2.1,.52-4.15,1.22-6.22,1.84-.08,.03-.17,.03-.26,.04-.03-.06-.06-.12-.09-.18Z"/>
<path class="cls-1" d="M61.44,22.06c-1.41,.46-3.44,.07-4.21-1.02,1.56,.26,2.92,.49,4.28,.71-.02,.1-.05,.21-.07,.31Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none" role="img" aria-label="SSH"><title>ssh</title>
<rect width="256" height="256" rx="48" fill="#455A64"/>
<path d="M60 84l52 44-52 44" stroke="#fff" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M136 172h60" stroke="#fff" stroke-width="22" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#3FCF8E" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Supabase</title><path d="M11.9 1.036c-.015-.986-1.26-1.41-1.874-.637L.764 12.05C-.33 13.427.65 15.455 2.409 15.455h9.579l.113 7.51c.014.985 1.259 1.408 1.873.636l9.262-11.653c1.093-1.375.113-3.403-1.645-3.403h-9.642z"/></svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -1,26 +0,0 @@
# TS VFS/FUSE reads are not recorded in workspace ops
## Symptom
The trailing `Stats:` line diverges between the Python and TypeScript examples even when all content output is byte-identical:
- Python `notion_fuse.py`: `Stats: 13 ops, 42904 bytes`
- TypeScript `notion_fuse.ts`: `Stats: 0 ops, 0 bytes transferred`
Same for the `_vfs` examples (`patchNodeFs`): Python reports `4 ops, 20760 bytes`, TS reports `0 ops, 0 bytes`.
## Cause
In TypeScript, `ws.fs.readFile` / `ws.fs.readdir` (used by `patchNodeFs` and the FUSE layer in `packages/node/src/fuse/fs.ts`) bypass the record-keeping dispatch, so nothing lands in `ws.records`. In Python, the equivalent VFS and FUSE paths go through `apply_io`, which appends `OpRecord`s, so `ws.ops.records` counts every read.
## Scope
Cross-backend, not notion-specific: Linear's TS `_vfs`/`_fuse` examples have the same gap, and any resource read through `ws.fs` or a FUSE mount is unrecorded. Command execution (`ws.execute`) records normally in both languages.
## Fix direction
Route `ws.fs` reads (or at least the FUSE/`patchNodeFs` entry points) through the same dispatch/recording layer the executor uses, mirroring Python's `apply_io` accounting. Then the example `Stats:` lines become comparable across languages.
## Status
Open. Found 2026-06-09 while verifying notion example parity (PR #226); deferred because the fix belongs in the workspace fs op layer, not per-backend.
+2 -2
View File
@@ -17,14 +17,14 @@ Then install [Claude Code](https://docs.claude.com/en/docs/claude-code/setup) se
## Usage
```python
from mirage import Mount, MountMode, Workspace
from mirage import Mount, MountBackend, MountMode, Workspace
from mirage.resource.ram import RAMResource
from mirage.resource.s3 import S3Config, S3Resource
s3 = S3Resource(S3Config(bucket="my-bucket"))
with Workspace(
{"/": Mount(RAMResource(), mode=MountMode.WRITE, fuse=True),
{"/": Mount(RAMResource(), mode=MountMode.WRITE, backend=MountBackend.FUSE),
"/s3": Mount(s3, mode=MountMode.READ)},
) as ws:
print(f"cd {ws.fuse_mountpoint} && claude")
+2 -2
View File
@@ -17,11 +17,11 @@ Then install [OpenAI Codex](https://github.com/openai/codex) separately.
## Usage
```python
from mirage import Mount, MountMode, Workspace
from mirage import Mount, MountBackend, MountMode, Workspace
from mirage.resource.ram import RAMResource
with Workspace(
{"/": Mount(RAMResource(), mode=MountMode.WRITE, fuse=True)}) as ws:
{"/": Mount(RAMResource(), mode=MountMode.WRITE, backend=MountBackend.FUSE)}) as ws:
print(f"cd {ws.fuse_mountpoint} && codex")
input("Press Enter when done...")
```
+2 -2
View File
@@ -17,11 +17,11 @@ Install Grok Build separately.
## Usage
```python
from mirage import Mount, MountMode, Workspace
from mirage import Mount, MountBackend, MountMode, Workspace
from mirage.resource.ram import RAMResource
with Workspace(
{"/": Mount(RAMResource(), mode=MountMode.WRITE, fuse=True)}) as ws:
{"/": Mount(RAMResource(), mode=MountMode.WRITE, backend=MountBackend.FUSE)}) as ws:
print(f"cd {ws.fuse_mountpoint} && grok")
input("Press Enter when done...")
```
+77
View File
@@ -0,0 +1,77 @@
---
title: Haystack
description: Give a Haystack Agent a bash tool over a Mirage workspace via MirageShellTool.
icon: /images/haystack-logo.svg
---
[Haystack](https://haystack.deepset.ai) is deepset's framework for building LLM applications and agents. The integration is maintained by deepset and lives in their [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/mirage) repository, so it installs as its own package rather than as a `mirage-ai` extra.
## Install
```bash
uv add mirage-haystack
```
<Note>
`mirage-haystack` pins an exact `mirage-ai` version rather than tracking the latest, because Mirage is pre-1.0. Check the [integration's `pyproject.toml`](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/mirage/pyproject.toml) for which one, since installing it may move an existing `mirage-ai` in your environment.
</Note>
## Usage
Describe the mounts, wrap the workspace in a tool, hand the tool to an `Agent`.
```python
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack_integrations.tools.mirage import (
MirageMount,
MirageShellTool,
MirageWorkspace,
)
workspace = MirageWorkspace([
MirageMount(path="/data", resource="ram"),
MirageMount(path="/s3", resource="s3", config={"bucket": "my-bucket"},
read_only=True),
])
tool = MirageShellTool(
workspace,
allowed_commands=["ls", "cat", "grep", "head", "wc", "cp"],
)
agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
tools=[tool])
result = agent.run(messages=[
ChatMessage.from_user("How many lines in /s3/log.txt mention 'alert'?"),
])
print(result["messages"][-1].text)
```
The tool exposes a single `command` parameter, so the model writes ordinary bash and pipes across mounts. Its description is generated from the mount tree, so the model is told which paths exist without you writing a prompt for it.
## Guarding what the agent can do
Two controls, and they are not interchangeable.
`read_only=True` on a mount is the write boundary. Mirage refuses every write to that mount whatever command is used, so this is what prevents modification and deletion.
`allowed_commands` restricts which command names may run. It is checked against every command Mirage would execute, including ones nested in `$(...)`, backticks and subshells, so `ls "$(rm x)"` is rejected unless `rm` is allowed too. Treat it as steering, not a sandbox: allowing a command that runs other commands (`eval`, `bash`, `sh`, `source`, `xargs`) effectively allows anything.
Commands never reach the host shell either way. Mirage interprets them itself, so the blast radius is the mounts you attached.
## Exports
| Symbol | Purpose |
| --- | --- |
| `MirageWorkspace` | Declares the mount tree, and runs commands against it with `run` / `run_async`. Serializable with `to_dict` / `from_dict`. |
| `MirageMount` | One mount: `path`, `resource`, `config`, `read_only`. |
| `MirageShellTool` | The Haystack `Tool` that gives an `Agent` the bash surface. |
| `MirageError` | Base error, with `MirageConfigError` and `MirageCommandNotAllowedError`. |
## Links
- [Integration page](https://haystack.deepset.ai/integrations/mirage) on haystack.deepset.ai.
- [Source and README](https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/mirage) in `haystack-core-integrations`.
- [`mirage-haystack`](https://pypi.org/project/mirage-haystack) on PyPI.
+6 -1
View File
@@ -4,7 +4,7 @@ description: Drop a Mirage workspace into the Python agent framework you already
icon: robot
---
Each integration ships behind an optional extra. Install only what you use.
Most integrations ship behind an optional extra. Install only what you use.
```bash
uv add 'mirage-ai[openai]' # openai-agents
@@ -16,6 +16,8 @@ uv add 'mirage-ai[agno]' # Agno
uv add 'mirage-ai[claude-agent-sdk]' # Claude Agent SDK
```
Haystack is the exception: deepset maintains it in their own repository, so it installs as `mirage-haystack` rather than as an extra.
<CardGroup cols={2}>
<Card title="OpenAI Agents" icon="/images/openai-logo.svg" href="/python/agents/openai-agents">
For the openai-agents SDK.
@@ -35,6 +37,9 @@ uv add 'mirage-ai[claude-agent-sdk]' # Claude Agent SDK
<Card title="Agno" icon="/images/agno-logo.svg" href="/python/agents/agno">
For Agno's `Agent` via `MirageToolkit`.
</Card>
<Card title="Haystack" icon="/images/haystack-logo.svg" href="/python/agents/haystack">
For Haystack's `Agent`, via deepset's `mirage-haystack` package.
</Card>
<Card title="Claude Code" icon="/images/claude-logo.svg" href="/python/agents/claude-code">
Mount a workspace via FUSE and run `claude` against it.
</Card>
+81
View File
@@ -0,0 +1,81 @@
---
title: discord
description: "Discord REST API client speaking the OpenClaw Discord action vocabulary."
icon: discord
---
Discord REST API client speaking the OpenClaw Discord action vocabulary. Install it on the workspace and the whole tree is
discoverable with `discord --help`.
## Install
```python
from mirage import Workspace
from mirage.commands.cli.builtin.discord import DISCORD
from mirage.core.discord.config import DiscordConfig
from mirage.resource.discord import DiscordResource
config = DiscordConfig(token="bot-token")
ws = Workspace({"/discord": DiscordResource(config)})
ws.register_cli("discord", DISCORD, config.model_dump())
```
Two installs under different names are two accounts. In YAML, the same
install rides the `clis:` section; see the [CLI overview](/python/cli/index).
## Verbs
The verbs follow the OpenClaw Discord action vocabulary (bare verbs:
`send`, `read`, `edit`, `delete`, `react`, `search`, `thread-create`,
`poll`); `members` and `server-info` are mirage extensions. IDs are
Discord snowflakes, discoverable from the mounted tree
(`<name>__<id>` path segments).
### Messages
```bash
discord send --channel 1256522563555819574 --text "Hello from MIRAGE"
discord send --channel 1256522563555819574 --text "A reply" --reply-to 1489887688978075769
discord read --channel 1256522563555819574 --limit 20
discord edit --channel 1256522563555819574 --message 1489887688978075769 --text "Edited"
discord delete --channel 1256522563555819574 --message 1489887688978075769
```
| Verb | Flags | Writes |
| -------- | --------------------------------------- | ------ |
| `send` | `--channel --text [--reply-to]` | yes |
| `read` | `--channel [--limit]` | no |
| `edit` | `--channel --message --text` | yes |
| `delete` | `--channel --message` | yes |
`edit` only works on messages the bot authored.
### Reactions, threads, polls
```bash
discord react --channel 1256522563555819574 --message 1489887688978075769 --emoji "👍"
discord thread-create --channel 1256522563555819574 --message 1489887688978075769 --name "Budget talk"
discord poll --channel 1256522563555819574 --question "Lunch?" --answer Pizza --answer Sushi --duration 24
```
| Verb | Flags | Writes |
| --------------- | ------------------------------------------------------------ | ------ |
| `react` | `--channel --message --emoji` | yes |
| `thread-create` | `--channel --name [--message]` | yes |
| `poll` | `--channel --question --answer... [--duration] [--multiselect]` | yes |
`--answer` repeats, one per poll option.
### Guild metadata and search
```bash
discord server-info --guild 1256522563555819574
discord members --guild 1256522563555819574 --query "alice"
discord search --guild 1256522563555819574 --query "deploy" --channel 1256522563555819574
```
| Verb | Flags | Writes |
| ------------- | ---------------------------------- | ------ |
| `server-info` | `--guild` | no |
| `members` | `--guild [--query]` | no |
| `search` | `--guild --query [--channel]` | no |
+170
View File
@@ -0,0 +1,170 @@
---
title: gh
description: "Act on GitHub in the official CLI's vocabulary, alongside a github mount that reads the repository as files."
icon: github
---
Act on GitHub in the official [CLI](https://cli.github.com)'s vocabulary.
A repository is a tree, so mirage already reads one as files: the
[`github` mount](/python/setup/github) is the read half, and `ls`, `cat`
and `grep` are how an agent explores it. `gh` is the write half, plus the
account-level operations a filesystem has no shape for.
## Install
```python
import os
from mirage import Workspace
from mirage.commands.cli.builtin.gh import GH
from mirage.core.github.config import GhConfig, GitHubConfig
from mirage.resource.github import GitHubResource
token = os.environ["GITHUB_TOKEN"]
repo = GitHubResource(
config=GitHubConfig(token=token), owner="acme", repo="tools", ref="main")
ws = Workspace({"/repo": repo})
ws.register_cli("gh", GH, GhConfig(token=token, repo="acme/tools"))
await ws.execute("gh repo view")
```
In YAML the same install rides the `clis:` section; see the
[CLI overview](/python/cli/index).
| Field | Meaning |
| ---------- | ------------------------------------------------------------ |
| `token` | the API token, as `GH_TOKEN` carries for real gh |
| `repo` | the default repository, as `[HOST/]OWNER/REPO` |
| `branch` | the current branch, for `{branch}` in an endpoint |
| `base_url` | the API base, for GitHub Enterprise Server |
`repo` and `branch` are what real gh reads off the current directory's git
remote and checkout. A workspace has neither, so the install carries them:
`repo` answers a line that names no repository, and both feed the
`{owner}`/`{repo}`/`{branch}` placeholders. Two installs under different head words are two
accounts.
## Reading is the mount, acting is the CLI
```bash
ls /repo/src # the tree, from the mount
cat /repo/README.md # a blob, from the mount
grep -r TODO /repo # the mount again
gh api repos/acme/tools/contents/README.md -X PUT \
-f message='docs: fix a typo' -f content="$(base64 -w0 new.md)" -f sha=<blob-sha>
```
A write through `gh` lands on the same repository the mount reads, but it
lands **by repository name rather than by any vfs path**, so the mount has
nothing to aim a per-path invalidation at. The spec declares which
resource it serves, and the executor expires that mount's index after the
line, so the next `cat` or `ls` refetches instead of serving the pre-write
bytes. Nothing is required of the caller.
## Verbs
```
gh repo view [<REPOSITORY>]
gh repo fork [<REPOSITORY>] --fork-name
gh repo rename <NEW-NAME> -R/--repo
gh api <ENDPOINT> -X/--method -f/--raw-field -F/--field
```
Every level answers `--help`, and `man gh`, `man gh repo` and
`man gh api` render the same text from the same spec.
### repo
```bash
gh repo view # the install's repository
gh repo view acme/tools
gh repo view github.com/acme/tools # the host segment is accepted
gh repo fork acme/tools
gh repo fork acme/tools --fork-name tools-patched
gh repo rename tools-v2 -R acme/tools
```
`view` prints what gh prints: a `name:` line, a `description:` line, then
`--` and the README, with the separator omitted when the repository has
none. For the repository object as JSON, use `gh api repos/OWNER/REPO`.
`rename` takes the **new name** as the operand and the repository to
rename on `-R`, which is the reverse of what the shape of the line
suggests; that is upstream's grammar, not a mirage choice.
`[HOST/]OWNER/REPO` is parsed from the right, so the owner and the
repository are the last two segments and a leading host is dropped. The
host is accepted for compatibility with lines copied from real gh but
does **not** route: every call goes to the install's `base_url`. A second
host means a second install.
### api
`gh api` reaches every endpoint that has no typed verb, which is most of
them.
```bash
gh api repos/acme/tools
gh api /user
gh api repos/acme/tools/issues -f title='Bug' -f body='Steps...'
gh api -X GET search/code -f q='repo:acme/tools TODO'
gh api repos/acme/tools/issues -F draft=false -F milestone=3
gh api repos/acme/tools/contents/NOTES.md -X DELETE -f message=rm -f sha=<blob-sha>
gh api graphql -f query='{viewer{login}}'
gh api 'repos/{owner}/{repo}/releases'
gh api 'repos/{owner}/{repo}/branches/{branch}'
```
`{owner}`, `{repo}` and `{branch}` expand from the install, the way real
gh expands them from the current repository. Any other brace pair is left
exactly as typed and reaches the wire, which is gh's behavior too. Quote
the endpoint so the shell does not eat the braces.
The rules are gh's own:
- The method is `GET` with no fields and `POST` once a field is given,
unless `-X` says otherwise.
- A `GET` carries its fields in the **query string**; every other method
carries them in a **JSON body**.
- `-f/--raw-field` is always a string. `-F/--field` reads `true`, `false`,
`null` and integers as their JSON types.
- A call with no fields sends **no body at all**, so a bare `DELETE` is a
bare `DELETE` rather than an empty JSON object with a content type. Some
endpoints read those differently.
- The leading slash is optional.
- A placeholder expands in an endpoint and in a `-F` value, but not in a
`-f` one, which is the split gh's own `--help` describes.
- A read (`GET`, `HEAD`, `OPTIONS`) leaves the mount's cache alone; only a
write expires it.
Output is JSON on stdout, so the rest of the shell composes with it:
```bash
gh api repos/acme/tools | jq -r .default_branch
gh api repos/acme/tools/issues | jq -r '.[].title'
```
## Divergences from upstream gh
`gh` is virtualized, not wrapped, so the table below is the whole of what
differs. Everything else matches `gh` 2.85.
| Divergence | Why |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `gh api` pretty-prints the response; real gh prints the body verbatim | the reader is usually an agent or `jq`, and an indented body is what a human reads |
| `gh repo view` has no `--json`/`-q`/`-t`; the default text view matches | upstream's field set is GraphQL's, with 75 names; `gh api repos/O/R` is the JSON route |
| `--paginate`, `-q/--jq`, `-H`, `--input`, `--silent`, `-i` are not built | not built yet; pipe to `jq` for selection, and paginate with `-f page=2` |
| `-F` reads no `@file` values, and `key[sub]=` / `key[]=` nesting is flat | not built yet |
| `repo rename` has no `-y/--yes` | there is no prompt to skip: nothing in a workspace is interactive |
| `repo fork` has no `--clone`, `--remote`, `--org` | those act on a host checkout and a git remote, which a workspace has neither of |
| a `HOST/` prefix is accepted but never routes | one install is one account on one host; a second host is a second install |
The interactive and host-side verbs (`auth`, `gist`, `codespace`,
`browse`, `repo clone`) are out of scope for a virtualized CLI, the same
way they are for the other account CLIs.
+131
View File
@@ -0,0 +1,131 @@
---
title: git
description: "Read and change a git repository that lives on any mount, in git's own vocabulary."
icon: git-alt
---
Read and change a git repository that lives on any mount, in git's own
vocabulary. Unlike the account CLIs, `git` needs no credentials and takes
no config: it is a program tree with nothing to authenticate to, so
installing it is one line.
## Install
```python
from mirage import Workspace
from mirage.commands.cli.builtin.git import GIT
from mirage.resource.ram import RAMResource
ws = Workspace({"/repo": RAMResource()})
ws.register_cli("git", GIT)
await ws.execute("git -C /repo status --short")
```
In YAML the same install rides the `clis:` section; see the
[CLI overview](/python/cli/index).
## The repository is read through the mount
`-C` names a directory inside a mount, and everything under it is read
with the same ops any command uses. The repository is never opened from
the host filesystem, so a repository on a RAM mount, a disk mount or an
object store all read the same way, and packfiles, loose objects and the
index are all read through the mount.
`-C` defaults to the working directory, and the repository is found by
walking up from there, so a path inside the tree works:
```bash
git -C /repo/src status
```
## Verbs
### Inspect
```bash
git -C /repo status
git -C /repo status --short
git -C /repo status --porcelain -b
git -C /repo status -uall
git -C /repo log --oneline -n 20
git -C /repo log --reverse
git -C /repo log -S delta
git -C /repo log HEAD~2
git -C /repo log --all --oneline
git -C /repo log --format='%h %an %s'
git -C /repo log --pretty=fuller -n 3
git -C /repo show 265ec3a
git -C /repo show --stat HEAD
git -C /repo show --name-only HEAD
git -C /repo show -s --format=%H HEAD
git -C /repo diff HEAD~1 HEAD
git -C /repo branch
```
`log` and `show` take `--pretty`/`--format` with git's grammar: the
`oneline`, `short`, `medium`, `full` and `fuller` presets, plus
`format:`/`tformat:` placeholder strings (a bare `%` string is
`tformat:`). Placeholders cover ids (`%H %h %T %t %P %p`), author and
committer fields (`%an %ae %ad %at %cn %ce %cd %ct`), the message
(`%s %b %B`), decorations (`%d %D`), and `%n %% %xHH`; an unknown
placeholder stays verbatim, exactly as git prints it. `log --all` walks
every ref, tags peeled. `show` takes `--stat` (git's scaled diffstat
table), `--name-only`, and `-s`/`--no-patch`, which suppresses every
diff section just as it does in git.
`status` honors `.gitignore` at every level, including negated rules, and
collapses an untracked directory to one entry the way git does (`-uall`
descends, `-uno` hides untracked files entirely).
### Change
```bash
git -C /repo add -A
git -C /repo add src/main.py
git -C /repo add -u
git -C /repo reset
git -C /repo reset src/main.py
git -C /repo commit -m "message"
git -C /repo branch topic
git -C /repo branch -d topic
git -C /repo branch -D topic
git -C /repo checkout topic
```
| Verb | Notes |
| ---------- | ---------------------------------------------------------------- |
| `add` | `-A` everything, `-u` tracked only, `-f` to stage an ignored path |
| `reset` | mixed only, from HEAD; a pathspec limits it to those paths |
| `commit` | `-m` required, `--author "Name <email>"` optional |
| `branch` | `-d` deletes a merged branch, `-D` any branch |
| `checkout` | refuses rather than overwrite work you have not committed |
`commit` records `mirage <mirage@localhost>` unless `--author` says
otherwise: git reads `user.name` from config files that a mount does not
serve, and inventing a name would put an unreviewed one into history.
## Deliberate limits
- **No network verbs.** `clone`, `fetch`, `pull` and `push` are absent.
- **No `reset --hard`.** It destroys uncommitted work with no reflog here
to recover it from.
- **`reset` takes no revision.** Real git resets the index to any commit
named as an operand; this build resets it from HEAD only and refuses a
revision by name rather than doing nothing quietly.
- **`checkout` refuses a staged change** rather than merging it into the
target, so nothing is silently resolved.
- **Diff hunk bodies can differ from git's** on the same change. Headers,
mode lines and blob abbreviations match; the line grouping inside a
hunk comes from a different algorithm than git's xdiff. `show --stat`
line counts come from the same algorithm, so a rewritten hunk can
count slightly differently than git counts it.
- **`--pretty` knows the block presets, not the wire formats.** `raw`,
`email`, `mboxrd` and `reference` are refused by name (`unsupported
--pretty format`), not silently misrendered. `--date=` relative
formats and `--decorate` are absent; decorations render only through
`%d`/`%D`, matching git's piped default of no decorations.
- **`log` dates are strict.** `--since`/`--until` read ISO-8601 or an
epoch second; git's relative wording (`2 weeks ago`) is refused
rather than misread.
+126
View File
@@ -0,0 +1,126 @@
---
title: GWS
description: "Google Workspace API client: Discovery passthroughs plus ergonomic helpers."
icon: google
---
Google Workspace API client: Discovery passthroughs plus ergonomic helpers. Install it on the workspace and the whole tree is
discoverable with `gws --help`.
## Install
```python
from mirage import Workspace
from mirage.commands.cli.builtin.gws import GWS
from mirage.core.google.config import GoogleConfig
from mirage.resource.gmail import GmailResource
config = GoogleConfig(client_id="...", client_secret="...", refresh_token="...")
ws = Workspace({"/mail": GmailResource(config)})
ws.register_cli("gws", GWS, config.model_dump())
```
Two installs under different names are two accounts. In YAML, the same
install rides the `clis:` section; see the [CLI overview](/python/cli/index).
## Verbs
The syntax mirrors the official
[Google Workspace CLI](https://github.com/googleworkspace/cli): one
passthrough leaf per Discovery method, plus hand-written helpers under
each service.
```bash
gws <service> <resource> <method> [--params JSON] [--json JSON] # API passthrough
gws <service> <helper> [flags] # ergonomic helper
```
Services: `drive`, `sheets`, `docs`, `slides`, `gmail`. The whole
surface is discoverable from the shell:
```bash
gws --help # services
gws drive files --help # methods on a Discovery resource
gws gmail send --help # helper flags
```
### API passthrough
Passthrough commands call the corresponding API method directly:
`--params` fills URL path and query parameters, `--json` is the request
body, and the output is the compact API response JSON. List methods
follow `nextPageToken` to the end by default (pages print as NDJSON);
`--page-limit N` stops early.
```bash
# List Drive files
gws drive files list --params '{"q": "name contains \'report\'"}'
# Read a Gmail message raw
gws gmail users messages get --params '{"userId": "me", "id": "msg123"}'
# Batch-update a spreadsheet
gws sheets spreadsheets batchUpdate \
--params '{"spreadsheetId": "SHEET_ID"}' \
--json '{"requests": [...]}'
# Share a Drive file
gws drive permissions create \
--params '{"fileId": "FILE_ID"}' \
--json '{"role": "reader", "type": "anyone"}'
```
### Folder scope
A config carrying `folder_id` scopes what the CLI **creates**, so a file
it makes lands in the same folder a `GDriveResource` sharing that config
mounts, and the agent's own `ls` shows what it just made:
```python
config = GoogleConfig(client_id="...", refresh_token="...", folder_id="FOLDER_ID")
ws = Workspace({"/data": GDriveResource(config)})
ws.register_cli("gws", GWS, config.model_dump())
```
```bash
gws sheets spreadsheets create --json '{"properties": {"title": "Q3"}}'
ls /data # Q3.gsheet.json
```
Three things worth knowing:
- **Two divergences from the official CLI's passthrough.** `drive files
create` and `copy` get `parents` defaulted into the request body, and
the Docs/Sheets/Slides `create` methods have no `parents` field at all,
so mirage issues a second Drive call to move the new file. Both also
send `supportsAllDrives`, which is what lets a scope name a Shared
Drive folder.
- **An explicit `parents` array always wins**, and then nothing is
injected into the query either: you typed the call, you own it. The key
being present is what counts, so `"parents": []` is honored too rather
than read as absent.
- **Reads are not scoped.** `gws drive files list` still sees the whole
account. The scope is about where new files go, not a fence.
### Helpers
| Helper | Description |
| ------------------------- | ---------------------------------------------- |
| `gws gmail send` | Send a new email (`--to --subject --body`) |
| `gws gmail reply` | Reply to the sender (`--message-id --body`) |
| `gws gmail reply-all` | Reply to all recipients (To + CC) |
| `gws gmail forward` | Forward a message (`--message-id --to`) |
| `gws gmail read` | One message as processed JSON (`--id`) |
| `gws gmail triage` | Summaries for a search query (`--query --max`) |
| `gws sheets read` | Read a cell range (`--spreadsheet --range`) |
| `gws sheets write` | Overwrite a range (`--values` / `--json-values`) |
| `gws sheets append` | Append rows after a range |
| `gws docs write` | Append text to a document (`--document --text`) |
```bash
gws gmail send --to "user@example.com" --subject "Hello" --body "Hi there"
gws gmail triage --query "is:unread" --max 10
gws sheets read --spreadsheet SHEET_ID --range "Sheet1!A1:C10"
gws sheets append --spreadsheet SHEET_ID --values "alice,42"
gws docs write --document DOC_ID --text "New paragraph"
```
+235
View File
@@ -0,0 +1,235 @@
---
title: Himalaya
description: "IMAP/SMTP mail client following the pimalaya/himalaya vocabulary."
icon: envelope
---
IMAP/SMTP mail client following the pimalaya/himalaya vocabulary. Install it on the workspace and the whole tree is
discoverable with `himalaya --help`.
## Install
```python
from mirage import Workspace
from mirage.commands.cli.builtin.himalaya import HIMALAYA
from mirage.core.email.config import EmailConfig
from mirage.resource.email import EmailResource
config = EmailConfig(
imap_host="imap.example.com",
smtp_host="smtp.example.com",
username="agent@example.com",
password="app-password",
)
ws = Workspace({"/mail": EmailResource(config)})
ws.register_cli("himalaya", HIMALAYA, config.model_dump())
```
Two installs under different names are two accounts. In YAML, the same
install rides the `clis:` section; see the [CLI overview](/python/cli/index).
## Sent copies
Sending is SMTP and keeps no record of itself, so the copy in your own
Sent mailbox is a second, separate IMAP `APPEND` that mail clients make
on your behalf. mirage makes it too, `\Seen`, on every `--send`.
Which mailbox it lands in is asked, not guessed: a server that
implements RFC 6154 tags one of its mailboxes `\Sent` in its folder
listing, which is `[Gmail]/Sent Mail` on Gmail and `Sent Items` on
Exchange. Set `sent_folder` to pin a name and skip the probe, or
`save_copy=False` to file nothing.
```python
config = EmailConfig(
imap_host="imap.example.com",
smtp_host="smtp.example.com",
username="agent@example.com",
password="app-password",
save_copy=True, # the default
sent_folder="Sent", # unset asks the server
)
```
`--save <MAILBOX>` overrides both for one line, and on its own (without
`--send`) it files the message without sending it, which is how a draft
is written. The two failure modes differ on purpose: a copy that fails
*after* a successful send is a warning on stderr and exit 0, because
the mail is already gone and a non-zero exit invites a retry that would
send it twice; a `--save` that sends nothing fails loudly, because
nothing happened yet.
## Verbs
The verbs follow the [himalaya](https://github.com/pimalaya/himalaya)
CLI structure: `himalaya envelope list|search` to triage, `himalaya
message read|compose|send|reply|forward` to act. Messages are addressed
by positional id, the mailbox by `-m/--mailbox`, and reads return JSON
rather than a rendered table.
Upstream aliases resolve too: `envelope ls`, `envelope sr`, `message
write`, `message new`, `message fwd`.
### `himalaya envelope list`
List a mailbox, most recent first.
```bash
himalaya envelope list -m INBOX --page 2 --page-size 10
```
| Option | Required | Description |
| ----------------- | -------- | ----------------------------- |
| `-m, --mailbox` | no | Mailbox name (default: INBOX) |
| `-p, --page` | no | Page number, starting from 1 |
| `-s, --page-size` | no | Max envelopes per page (25) |
Only the newest `page * page_size` messages are fetched, so listing the
first page costs one page of header fetches rather than a scan of the
whole mailbox. The account's `max_messages` (default 200) bounds how far
back paging can reach; an `order by` is unrelated to arrival order, so a
sorted search considers that whole window.
### `himalaya envelope search`
Filter and sort with himalaya's own query DSL. The query is the trailing
operand, so it is words rather than flags.
```bash
himalaya envelope search -m INBOX not flag seen and from alice@example.com
himalaya envelope search after 2026-01-01 order by subject asc
himalaya envelope search subject '"quarterly review"'
```
Conditions: `date <yyyy-mm-dd>`, `before <yyyy-mm-dd>`,
`after <yyyy-mm-dd>`, `from <pattern>`, `to <pattern>`,
`subject <pattern>`, `body <pattern>`, and
`flag <seen|answered|flagged|draft|deleted>`. Combine them with `and`,
`or` and `not`, group with parentheses, and sort with
`order by <date|from|to|subject> [asc|desc]`.
Three things to know about the grammar. The date conditions read the
message's own `Date:` header, not the mailbox's received-at timestamp, so
imported or delayed mail lands on the day it was sent. `after` is
strictly greater than the given day, unlike IMAP's inclusive `SENTSINCE`.
And a pattern containing spaces needs *literal* double quotes inside the
shell's quoting (`'"quarterly review"'`), because the shell's own quotes
are gone by the time the query reaches the parser, exactly as upstream
behaves.
The same paging flags as `envelope list` apply. A query that does not
parse exits 1 without contacting the server.
### `himalaya message read`
```bash
himalaya message read -m INBOX 12345
himalaya message read -m INBOX 12345 --raw
```
| Option | Required | Description |
| --------------- | -------- | -------------------------------- |
| `<ID>` | yes | Message id, positional |
| `-m, --mailbox` | no | Mailbox name (default: INBOX) |
| `--raw` | no | Write the RFC 5322 bytes instead |
### `himalaya message compose`
The built-in flag composer. Without `--send` it writes the assembled RFC
5322 message to stdout, so it can be piped into `message send` or into
another composer.
```bash
himalaya message compose --to you@example.org --subject Hello --body Hi --send
himalaya message compose --to you@example.org --subject Hello --body Hi | himalaya message send
echo "the body" | himalaya message compose --to you@example.org --subject Hello --send
himalaya message compose --to you@example.org --subject Report --body 'see attached' --attach /data/report.pdf --send
```
| Option | Required | Description |
| --------------- | -------- | ---------------------------------------------- |
| `--from` | no | Sender address (default: the account username) |
| `-t, --to` | no | Recipient(s), repeatable or comma-separated |
| `--cc` | no | Carbon-copy recipient(s) |
| `--bcc` | no | Blind carbon-copy recipient(s) |
| `-s, --subject` | no | Subject line |
| `--body` | no | Inline body (falls back to stdin) |
| `--attach` | no | Attachment file path, repeatable |
| `--signature` | no | Signature appended after a `-- ` line |
| `--send` | no | Send through SMTP instead of writing to stdout |
| `--save` | no | File a copy in this mailbox (see above) |
### `himalaya message send`
Sends a raw RFC 5322 message taken from the operand or from stdin. This
is the sink a composer chain feeds.
```bash
himalaya message send < message.eml
himalaya message send --save Sent < message.eml
himalaya message compose --to you@example.org --subject Hi --body yo | himalaya message send
```
| Option | Required | Description |
| --------- | -------- | -------------------------------------- |
| `<ID>` | no | The message itself, inline |
| `--save` | no | File a copy in this mailbox |
### `himalaya message reply`
Fetches the source message, prefills `Re:` on the subject plus
`In-Reply-To` / `References`, derives the recipient from the source's
`Reply-To` (else its `From`), and quotes the source body. Like
`compose`, it writes MIME to stdout unless `--send` is passed.
```bash
himalaya message reply -m INBOX 12345 --body 'Thanks for the update' --send
himalaya message reply -m INBOX 12345 --cc team@example.org --body Ack --send
```
It carries every composer flag above, plus the mailbox and:
| Option | Required | Description |
| ---------------------- | -------- | ------------------------------------------ |
| `<ID>` | yes | Source message id, positional |
| `-P, --posting-style` | no | `top` (default) or `bottom` |
| `-Q, --quote-headline` | no | Literal line placed before the quoted body |
There is no `--all` flag, matching upstream: reply-all is spelled by
naming the other recipients with `--cc`, which `message read` reports.
### `himalaya message forward`
```bash
himalaya message forward -m INBOX 12345 --to colleague@example.com --send
```
Same flags as `reply`. The subject gains `Fwd:`, `References` carries
over, and `In-Reply-To` does not.
## Divergences from upstream
Deliberate gaps, all of which fail loudly rather than silently:
- `message add`, `copy`, `move`, `delete`, `flag add`, `attachment
download`, `mailbox` and the protocol-specific subgroups (`imap`,
`jmap`, `gmail`, `msgraph`, `smtp`) are not implemented. An unknown
verb exits 1 with git's wording.
- `message read --seen` is absent because the mount is read-only and
never flips `\Seen`.
- The account-level sent copy is mirage's own, and defaults on. Upstream
v2 files a copy only when `--save <MAILBOX>` names one; a mirage agent
that never learned the flag still leaves the record a human sender
would. Turn it off with `save_copy` per account.
- Resolving the sent mailbox from the server's RFC 6154 `\Sent` tag is
ahead of upstream, whose own IMAP backend still pins `INBOX` alone
while it waits on `LIST RETURN (SPECIAL-USE)` support in io-imap.
- `envelope list` renders JSON, not a table, so its table flags
(`--max-width`, `--recipient`, `--has-attachment`) do not exist.
- Body and signature files (`--body-file`, `--signature-file`) are
not wired up; `--attach` is, reading each path through the
workspace, with the content type guessed from a fixed extension
table rather than a full mime database.
Server behavior can differ too: whether `from alice` matches
`alice@example.com` as a substring is up to the IMAP server, not mirage.
+179
View File
@@ -0,0 +1,179 @@
---
title: CLIs
description: "Install typed command-line programs beside your mounts and let agents act on services by name."
icon: terminal
---
Mounts make a service readable as files; CLIs make it actionable as a
program. A CLI is a typed program tree (`CLISpec`) installed on the
workspace by name and separate from the mounts: an account CLI
initializes from its own config, consults no mount, and takes no operand
path. `git` is the credential-free tier, so it takes no config at all and
reads the repository `-C` names through the mount ops. The shell
dispatches a line to a CLI when its first word matches an installed
name.
```python
from mirage import Workspace
from mirage.commands.cli.builtin.himalaya import HIMALAYA
ws = Workspace({"/mail": EmailResource(config)})
ws.register_cli("himalaya", HIMALAYA, config.model_dump())
await ws.execute("himalaya envelope list --unseen --max 5")
```
In YAML the same install rides the `clis:` section:
```yaml
mounts:
/mail:
resource: email
config: { ... }
clis:
himalaya:
cli: himalaya
config: { ... }
```
Every level of the tree answers `--help`, unknown verbs fail with git's
wording (exit 1), and missing required flags fail with argparse's
wording (exit 2). Two installs under different head words are two
accounts.
## Authoring your own CLI
A CLI is code you point at, the same way a mount points at a resource.
In application code you build a `CLISpec`, and each leaf takes the
line's one `CLIInvocation`:
```python
from mirage import CLIInvocation, CLISpec
from mirage.io import IOResult
async def send(inv: CLIInvocation[MyConfig]):
return f"sent to {inv.flags['to']}\n".encode(), IOResult()
TREE = CLISpec(name="mine",
config_model=MyConfig,
subcommands=(CLISpec(name="send", fn=send), ))
```
The record carries both views of the line: the process view (`argv`,
`stdin`, `env`) and the parsed view (`config`, `paths`, `texts`,
`flags`).
In YAML, `cli:` references that spec by builtin or registered name, by
`module:ATTR` import, or by `./file.py:ATTR` path. A package can also
publish one through the `mirage.clis` entry-point group.
### A CLI as a script
`script:` points at an ordinary program instead of a spec tree, so a CLI
can be authored with no mirage import at all:
```yaml
clis:
pager:
script: ./cli/pager.py
config: { width: 80 }
```
The file's content is embedded at load (relative paths resolve next to
the config file) and the program runs on the workspace's runtime world:
`.py` on the first Python runtime, `.js`/`.mjs` on the first JavaScript
one. What it gets is what a native binary would get. The words after the
head arrive verbatim, piped input arrives on standard input, the
install's config arrives as `MIRAGE_CLI_CONFIG` in the environment as JSON,
and the workspace mounts are visible as ordinary files through the
runtime's bridge. Its exit code becomes the line's `$?` and its stderr
reaches the shell.
A Python program reads that variable either way the language spells it,
`os.getenv('MIRAGE_CLI_CONFIG')` or `os.environ['MIRAGE_CLI_CONFIG']`, on
every Python runtime and on both host languages.
How arguments and input are spelled is the runtime's own contract, the
same one the `python3` and `node` commands follow:
| Runtime | Arguments | Standard input |
| ----------------- | --------------------- | ------------------- |
| monty (default) | `argv` global | `stdin` global |
| wasi, local | `sys.argv` | `sys.stdin` |
| quickjs | `scriptArgs` | `std.in` |
Slot 0 is the installed name, so `pager --width 80` reads as
`['pager', '--width', '80']` and a program's own messages can say
`pager:` like any other tool. Two installs of one program are told apart
the same way. Arguments therefore start at index 1 on monty and quickjs.
The exception is `wasi` and `local`, where a real CPython runs the code
as `-c` and defines `sys.argv[0]` itself; mirage cannot fill that slot,
so it stays `-c` and arguments start at index 1 there too.
A script CLI is one program rather than a verb tree, so it parses its
own arguments, and mirage stays out of the way: it recognizes no flags
of its own, so `pager --width 80` reaches the program instead of being
refused, and `pager --help` is the program's to answer. A spec that
declares `options` or `positional` opts back in, and then mirage parses
the line, renders `--help` and `man` from the declaration, and refuses
an undeclared flag; that is only reachable in code, since a YAML entry
declares no grammar.
`runtime:` pins which entry runs it, and `runtime: local`
escalates a Python script to the host interpreter, where third-party
packages are available. The sandboxed runtimes are files plus compute:
no sockets, no third-party imports on monty, no Node builtins on
quickjs.
Snapshots carry a script CLI by value: the embedded program travels in
the snapshot and `Workspace.load` rebuilds the install from it, because
there is no name for the loading process to resolve. Its config travels
verbatim, since a script CLI declares no config model and so declares
no secrets; keep credentials in the environment rather than the install
config, or supply them through `clis=` on load.
## Discovering an installed CLI
An install is discoverable from inside the shell, so an agent that was
never told about it can still find it. This works for your own
registered CLI exactly as for a builtin one: every page is rendered from
the spec, so there is nothing extra to write.
```bash
man # lists installs under "# clis", beside the mounts
man linear # the tree: description, verbs, flags
man linear issue create # one leaf, same text as `linear issue create --help`
type linear # linear is a mirage CLI
type -t linear # cli
which linear # linear
```
`type -t` prints one of `keyword`, `function`, `cli` or `builtin`.
`which` prints the bare name rather than a path, since mirage has no
PATH, and reports a miss through exit `1` with no output, like GNU
`which`.
A shell function may shadow a head word, exactly as in bash. It is
reversible with `unset -f`, bypassable with `command linear ...`, and
`type -a linear` lists both layers. Installing and uninstalling a CLI is
a host-side API only (`ws.register_cli` / `ws.unregister_cli`): there is no shell
verb for it, so an agent cannot uninstall the tools it was given.
## Builtin CLIs
| Program | Acts on | Vocabulary |
| ------------------------------------ | ---------------- | ------------------------------------------- |
| [himalaya](/python/cli/himalaya) | IMAP/SMTP mail | pimalaya/himalaya (`envelope`, `message`) |
| [gws](/python/cli/gws) | Google Workspace | official Google Workspace CLI |
| [slack](/python/cli/slack) | Slack | OpenClaw Slack actions (`send-message`, …) |
| [discord](/python/cli/discord) | Discord | OpenClaw Discord actions (`send`, `poll`, …)|
| [ntn](/python/cli/ntn) | Notion | official Notion CLI (`pages`, `datasources`)|
| [linear](/python/cli/linear) | Linear | noun/verb (`issue create`, `team list`) |
| [gh](/python/cli/gh) | GitHub | official GitHub CLI (`repo`, `api`) |
| [git](/python/cli/git) | git repositories | git (`status`, `log`, `add`, `commit`) |
Reading stays on the mount (`cat`, `grep`, `jq` over the virtual
files); acting goes through the CLI. The mounted tree's
`<name>__<id>` path segments supply the IDs the CLI flags take.
+81
View File
@@ -0,0 +1,81 @@
---
title: linear
description: "Linear GraphQL API client with the noun/verb grammar of the mount commands."
icon: /images/linear-logo.svg
---
Linear GraphQL API client with the noun/verb grammar of the mount commands. Install it on the workspace and the whole tree is
discoverable with `linear --help`.
## Install
```python
from mirage import Workspace
from mirage.commands.cli.builtin.linear import LINEAR
from mirage.core.linear.config import LinearConfig
from mirage.resource.linear import LinearResource
config = LinearConfig(api_key="lin_api_...")
ws = Workspace({"/issues": LinearResource(config)})
ws.register_cli("linear", LINEAR, config.model_dump())
```
Two installs under different names are two accounts. In YAML, the same
install rides the `clis:` section; see the [CLI overview](/python/cli/index).
## Verbs
The grammar keeps the noun/verb structure the mount commands spoke
(`linear issue create`, `linear team list`). Issues are addressed by a
positional key or ID (`linear issue get ENG-42`); every command emits
normalized JSON, so output pipes straight into `jq`.
### Reads
```bash
linear team list
linear team get ENG
linear team members ENG
linear issue list --team ENG
linear issue get ENG-42
linear project list --team ENG
linear project get <project-id> --team ENG
linear cycle list --team ENG
linear cycle current --team ENG
linear label list --team ENG
linear comment list ENG-42
linear user list
linear user get sam@example.com
linear document list --team ENG
linear document get <document-id> --team ENG
linear search "login bug"
```
`--team` accepts a team key, name, or ID.
### Writes
```bash
linear issue create --team ENG --title "Title" --description "Body"
linear issue update ENG-42 --title "New title"
linear issue assign ENG-42 --assignee-email user@example.com
linear issue transition ENG-42 --state-name "In Review"
linear issue set-priority ENG-42 --priority 2
linear issue set-project ENG-42 --project-name "Search"
linear issue add-label ENG-42 --label-name "bug"
linear comment add ENG-42 --body "comment"
linear comment update --comment <comment-id> --body "edited"
```
| Verb | Notes |
| ---------------------- | ---------------------------------------------- |
| `issue create` | `--team` and `--title` required |
| `issue update` | `--title` and/or `--description` |
| `issue assign` | `--assignee-email` or `--assignee-id` |
| `issue transition` | `--state-name` or `--state-id` |
| `issue set-priority` | `--priority 0..4` (0=none, 1=urgent, ... 4=low) |
| `issue set-project` | `--project-name` or `--project` (ID) |
| `issue add-label` | `--label-name` or `--label` (ID); appends to the issue's existing labels |
Descriptions and comment bodies also read from stdin:
`echo "body" | linear comment add ENG-42`.
+131
View File
@@ -0,0 +1,131 @@
---
title: ntn
description: "Notion API client following the official Notion CLI grammar."
icon: /images/notion-logo.svg
---
Notion API client following the official Notion CLI grammar. Install it on the workspace and the whole tree is
discoverable with `ntn --help` or `man ntn`.
## Install
```python
from mirage import Workspace
from mirage.commands.cli.builtin.ntn import NTN
from mirage.core.notion.config import NotionConfig
from mirage.resource.notion import NotionResource
config = NotionConfig(api_key="secret_...")
ws = Workspace({"/notion": NotionResource(config)})
ws.register_cli("ntn", NTN, config.model_dump())
```
Two installs under different names are two accounts. In YAML, the same
install rides the `clis:` section; see the [CLI overview](/python/cli/index).
## Verbs
The grammar matches the official
[Notion CLI](https://developers.notion.com/cli) verb for verb, and every
case is gated against the real `ntn` binary in CI, so what is written
here is what the program does.
**Ids are positional, not flags.** There is no `--page`, `--block` or
`--datasource`; each verb names its own operand.
```
ntn api <PATH>... Call the public Notion API (beta)
ntn auth token Print the current authentication token
ntn datasources query <ID_OR_URL>
ntn datasources resolve <ID>
ntn pages get <PAGE_ID> Retrieve a page as Markdown
ntn pages create Create a page from Markdown content
ntn pages edit <PAGE_ID> Edit a page's content from Markdown
ntn pages trash <PAGE_ID> Trash a page
ntn whoami Show the authenticated Notion user
```
There is no `ntn blocks`, `ntn comments` or `ntn search`. Those are
reached through `ntn api` with the REST API's own paths, exactly as
upstream reaches them. Upstream's interactive and deploy verbs (`login`,
`logout`, `update`, `workers`, `notion-as-code`, `doctor`, `files`) are
out of scope for a virtualized CLI.
### Pages
Page bodies are **Markdown**, not property JSON. `create` takes the body
on `--content` or from stdin, and the first heading becomes the title.
```bash
ntn pages get a1b2c3d4-...
ntn pages get a1b2c3d4-... --json
ntn pages create --content '# Title' --parent page:a1b2c3d4-...
echo '# Title' | ntn pages create --parent data-source:e5f6a7b8-...
ntn pages edit a1b2c3d4-... --content '# Replaced body'
ntn pages trash a1b2c3d4-... --yes
```
| Verb | Operand | Options | Writes |
| -------- | ----------- | -------------------------------------- | ------ |
| `get` | `<PAGE_ID>` | `--json` | no |
| `create` | none | `--content` `--parent` `--json` | yes |
| `edit` | `<PAGE_ID>` | `--content` `--json` | yes |
| `trash` | `<PAGE_ID>` | `--yes` | yes |
`--parent` takes `page:<id>`, `database:<id>` or `data-source:<id>`.
`edit` replaces the page body wholesale. `trash` refuses without `--yes`
unless a prompt can be answered, and sets `in_trash`.
To set a row's **property values** rather than its body, use `ntn api`:
```bash
ntn api v1/pages/<row-id> -X PATCH \
-d '{"properties":{"Stage":{"select":{"name":"Draft"}}}}'
```
### Data sources
Since `2025-09-03` a database is a container of *data sources*, and the
rows and the column schema live on the data source. `resolve` turns a
database id into its data source ids; `query` accepts either in the same
slot.
```bash
ntn datasources resolve e5f6a7b8-...
ntn datasources query d5000000-... --limit 10
ntn datasources query d5000000-... -s 'Priority desc'
ntn datasources query d5000000-... --filter '{"property":"Stage","select":{"equals":"Done"}}'
ntn datasources query d5000000-... --json
```
`query` prints one tab-separated line per row: the page id, then the
property values in alphabetical order by column name. The columns are the
ones the returned rows actually carry, so a result set that does not cover
the whole schema prints narrower.
### Raw API
`ntn api` reaches every route that has no typed verb, including the only
delete verb the public API has (`DELETE /v1/blocks/{id}`, which trashes a
block, a page, or a database row).
```bash
ntn api v1/users/me
ntn api v1/search -d '{"query":"Roadmap"}'
ntn api v1/search query=Roadmap
ntn api v1/blocks/<page-id>/children page_size==10
ntn api v1/blocks/<block-id> -X DELETE
ntn api v1/comments -d '{"parent":{"page_id":"a1b2c3d4"},"rich_text":[{"text":{"content":"hi"}}]}'
printf '{"query":"Roadmap"}' | ntn api v1/search
```
The body comes from exactly one source: stdin, `--data`/`-d`, or inline
`path=value` / `path:=json` inputs. Naming two is an error. `name==value`
stays a query parameter whatever the method is, and `Header:Value` sets a
header. Any body source makes the call a POST unless `-X`/`--method` says
otherwise; `GET`, `POST`, `PATCH`, `PUT` and `DELETE` are accepted.
Use the `<page-id>` / `<database-id>` / `<data-source-id>` from a mounted
path segment as the operand.
+82
View File
@@ -0,0 +1,82 @@
---
title: slack
description: "Slack Web API client speaking the OpenClaw Slack action vocabulary."
icon: slack
---
Slack Web API client speaking the OpenClaw Slack action vocabulary. Install it on the workspace and the whole tree is
discoverable with `slack --help`.
## Install
```python
from mirage import Workspace
from mirage.commands.cli.builtin.slack import SLACK
from mirage.core.slack.config import SlackConfig
from mirage.resource.slack import SlackResource
config = SlackConfig(token="xoxb-...", search_token="xoxp-...")
ws = Workspace({"/slack": SlackResource(config)})
ws.register_cli("slack", SLACK, config.model_dump())
```
Two installs under different names are two accounts. In YAML, the same
install rides the `clis:` section; see the [CLI overview](/python/cli/index).
## Verbs
The verbs follow the OpenClaw Slack action vocabulary (kebab verbs:
`send-message`, `read-messages`, `pin-message`, `list-pins`,
`member-info`, `emoji-list`); `search` and `list-members` are mirage
extensions. Search needs a user token (`search_token`), the rest run on
the bot token.
### Messages
```bash
slack send-message --channel C04KEPWF6V7 --text "Hello from MIRAGE"
slack send-message --channel C04KEPWF6V7 --thread-ts 1712345678.123456 --text "Thread reply"
slack read-messages --channel C04KEPWF6V7 --limit 20
```
| Verb | Flags | Writes |
| -------------- | ---------------------------------------------- | ------ |
| `send-message` | `--channel --text [--thread-ts]` | yes |
| `read-messages`| `--channel [--limit]` | no |
### Reactions and pins
```bash
slack react --channel C04KEPWF6V7 --ts 1712345678.123456 --emoji thumbsup
slack reactions --channel C04KEPWF6V7 --ts 1712345678.123456
slack pin-message --channel C04KEPWF6V7 --ts 1712345678.123456
slack list-pins --channel C04KEPWF6V7
slack unpin-message --channel C04KEPWF6V7 --ts 1712345678.123456
```
| Verb | Flags | Writes |
| --------------- | --------------------------- | ------ |
| `react` | `--channel --ts --emoji` | yes |
| `reactions` | `--channel --ts` | no |
| `pin-message` | `--channel --ts` | yes |
| `unpin-message` | `--channel --ts` | yes |
| `list-pins` | `--channel` | no |
### Members, emoji, search
```bash
slack member-info --user U04K21SEVR9
slack list-members --query "alice"
slack emoji-list
slack search --query 'from:@priya in:#general launch' --count 20 --page 1
```
| Verb | Flags | Writes |
| -------------- | ------------------------------ | ------ |
| `member-info` | `--user` | no |
| `list-members` | `[--query]` | no |
| `emoji-list` | | no |
| `search` | `--query [--count] [--page]` | no |
`search` supports Slack query operators (`from:@user`, `in:#channel`,
`after:YYYY-MM-DD`).
+8 -8
View File
@@ -105,12 +105,12 @@ decorator (reuse a helper like `make_file_read_provision(my_stat)` or
reports `unknown`. Full semantics live in the
[CLI provision docs](/home/cli#5-dry-run-with-provision).
## Output Safeguards
## Output Limits
To keep huge reads from flooding an agent, `cat`, `grep`, `rg`, `head`,
and `tail` cap their **final** output at 2000 lines by default. When a
cap fires, the agent sees the truncated bytes plus a stderr notice
(`output truncated at safeguard limit (2000 lines); ...`); exit code
(`output truncated at limit (2000 lines); ...`); exit code
stays 0.
Caps fire only on the **terminal** command of a pipeline, so
@@ -119,7 +119,7 @@ Caps fire only on the **terminal** command of a pipeline, so
### Configure per mount
Limits are per-command and per-mount. Attach them when you mount a
resource by passing a `(resource, mode, {command: CommandSafeguard})`
resource by passing a `(resource, mode, {command: Limit})`
tuple. Each guard sets `max_lines` / `max_bytes` (output cap) and/or
`timeout_seconds` (deadline); `on_exceed` is `TRUNCATE` (default, exit 0
plus notice) or `ERROR` (exit 1 plus notice):
@@ -127,7 +127,7 @@ plus notice) or `ERROR` (exit 1 plus notice):
```python
from mirage import MountMode, Workspace
from mirage.resource.ram import RAMResource
from mirage.types import CommandSafeguard, OnExceed
from mirage.types import Limit, OnExceed
ws = Workspace(
{
@@ -135,9 +135,9 @@ ws = Workspace(
RAMResource(),
MountMode.WRITE,
{
"head": CommandSafeguard(max_lines=100), # cap, keep going
"grep": CommandSafeguard(max_lines=50, on_exceed=OnExceed.ERROR),
"rg": CommandSafeguard(timeout_seconds=30), # deadline
"head": Limit(max_lines=100), # cap, keep going
"grep": Limit(max_lines=50, on_exceed=OnExceed.ERROR),
"rg": Limit(timeout_seconds=30), # deadline
},
),
},
@@ -145,7 +145,7 @@ ws = Workspace(
)
```
The same limits are available to the CLI as a `command_safeguards`
The same limits are available to the CLI as a `command_limits`
block in the workspace YAML.
## Next Steps
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Box
description: Mount a Box account (or a single folder of it) as a read/write Mirage filesystem with async access and shell commands.
icon: box
icon: /images/box-logo.svg
---
The Box resource mounts a Box account at some prefix such as `/box/`. All
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Chroma
icon: database
icon: /images/chroma-logo.svg
description: Mount a ChromaDB collection as a read-only virtual filesystem.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Databricks Volume
icon: folder-open
icon: /images/databricks-logo.svg
description: Mount a Databricks Unity Catalog volume as a filesystem.
---
+7 -65
View File
@@ -236,8 +236,8 @@ jq -r '.[] | "\(.id) [\(.author.username)] \(.content)"' \
jq -r '.[] | select(.content | test("hello")) | .id' \
"/discord/My Server__111222333444555666/channels/general__777888999000111222/2026-04-04/chat.jsonl"
# → 1489887688978075769
discord-send-message --channel_id 1256522563555819574 \
--text "Reply" --message_id 1489887688978075769
discord send --channel 1256522563555819574 \
--text "Reply" --reply-to 1489887688978075769
```
## Working with Large Channels
@@ -283,66 +283,8 @@ Standard commands available on the mounted Discord tree:
| `find` | Recursive search with `-name`, `-maxdepth` |
| `tree` | Directory tree view |
Resource-specific commands:
### `discord-send-message`
Post a message to a channel, optionally as a reply.
```bash
discord-send-message --channel_id 1256522563555819574 --text "Hello from MIRAGE"
discord-send-message --channel_id 1256522563555819574 --text "Reply" --message_id 1489887688978075769
```
| Option | Required | Description |
| -------------- | -------- | ---------------------------- |
| `--channel_id` | yes | Discord channel snowflake ID |
| `--text` | yes | Message text to send |
| `--message_id` | no | Message ID to reply to |
The channel ID can be found in directory names under
`/discord/<guild>/channels/` or via `stat`. Returns the
posted message JSON.
### `discord-add-reaction`
Add an emoji reaction to a message.
```bash
discord-add-reaction --channel_id 1256522563555819574 --message_id 1489887688978075769 --reaction 👍
```
| Option | Required | Description |
| -------------- | -------- | ---------------------------- |
| `--channel_id` | yes | Discord channel snowflake ID |
| `--message_id` | yes | Message snowflake ID |
| `--reaction` | yes | Emoji (unicode or name) |
### `discord-list-members`
Search guild members by name.
```bash
discord-list-members --guild_id 1256522563555819574 --query "alice"
```
| Option | Required | Description |
| ------------ | -------- | ----------------------- |
| `--guild_id` | yes | Discord guild snowflake |
| `--query` | yes | Username search query |
Returns matching members as JSON array.
### `discord-get-server-info`
Get full guild metadata from the Discord API.
```bash
discord-get-server-info --guild_id 1256522563555819574
```
| Option | Required | Description |
| ------------ | -------- | ----------------------- |
| `--guild_id` | yes | Discord guild snowflake |
Returns the guild object JSON (name, icon, member count, etc.).
Acting on Discord (sending, editing, reactions, threads, polls,
member and guild info, search) goes through the
[discord CLI](/python/cli/discord) when installed; the mounted tree
stays read-oriented. The `<name>__<id>` path segments supply the
snowflake IDs the CLI flags take.
-14
View File
@@ -195,20 +195,6 @@ on real file content (text, binary, JSON, CSV, etc.):
| -------- | -------------------- |
| `base64` | Base64 encode/decode |
### Data Format Support
Commands with format-specific variants for structured data files:
| Format | Extension | Variants |
| ------- | ---------- | ---------------------------------------------- |
| Parquet | `.parquet` | cat, head, tail, wc, stat, cut, grep, ls, file |
| Feather | `.feather` | cat, head, tail, wc, stat, cut, grep, ls, file |
| ORC | `.orc` | cat, head, tail, wc, stat, cut, grep, ls, file |
| HDF5 | `.hdf5` | cat, head, tail, wc, stat, cut, grep, ls, file |
These variants auto-detect the format by extension and convert to
tabular text (CSV) for processing.
## Use Cases
- **Local directory access**: Mount local directories for AI agents to read and process
+2 -91
View File
@@ -224,94 +224,5 @@ Standard commands available on the mounted email tree:
| `realpath` | Resolve path to absolute form |
| `nl` | Number lines of output |
Resource-specific commands follow the
[himalaya](https://github.com/pimalaya/himalaya) CLI command structure
(`himalaya envelope ...` and `himalaya message ...`). Mirage keeps the
message body as files in the tree, so these commands take `--uid` and
`--folder` flags and return JSON rather than opening an interactive editor.
The write commands (`send`, `reply`, `forward`) require the email mount to
be mounted with write access; on a read-only mount they are rejected. The
read commands (`list`, `read`) always work.
### `himalaya message send`
Send a new email. Requires write access.
```bash
himalaya message send --to "user@example.com" --subject "Hello" --body "Hi there"
```
| Option | Required | Description |
| ----------- | -------- | ----------------------- |
| `--to` | yes | Recipient email address |
| `--subject` | yes | Email subject line |
| `--body` | yes | Email body text |
Returns the sent message status JSON.
### `himalaya message reply`
Reply to a message. Pass `--all` to reply to every recipient (To and CC).
Requires write access.
```bash
himalaya message reply --uid 12345 --folder INBOX --body "Thanks for the update"
himalaya message reply --uid 12345 --folder INBOX --body "Acknowledged" --all
```
| Option | Required | Description |
| ---------- | -------- | --------------------------------- |
| `--uid` | yes | Message UID |
| `--folder` | no | IMAP folder (default: INBOX) |
| `--body` | yes | Reply body text |
| `--all` | no | Reply to all recipients (To + CC) |
Returns the sent reply JSON.
### `himalaya message forward`
Forward a message to another recipient. Requires write access.
```bash
himalaya message forward --uid 12345 --folder INBOX --to "colleague@example.com"
```
| Option | Required | Description |
| ---------- | -------- | ---------------------------- |
| `--uid` | yes | Message UID |
| `--folder` | no | IMAP folder (default: INBOX) |
| `--to` | yes | Recipient email address |
Returns the forwarded message JSON.
### `himalaya envelope list`
List and triage messages in a folder.
```bash
himalaya envelope list --folder INBOX --unseen --max 10
```
| Option | Required | Description |
| ---------- | -------- | ---------------------------- |
| `--folder` | no | IMAP folder (default: INBOX) |
| `--max` | no | Max results (default: 20) |
| `--unseen` | no | Only show unread messages |
Returns matching messages as JSON.
### `himalaya message read`
Read a message by its UID.
```bash
himalaya message read --uid 12345 --folder INBOX
```
| Option | Required | Description |
| ---------- | -------- | ---------------------------- |
| `--uid` | yes | Message UID |
| `--folder` | no | IMAP folder (default: INBOX) |
Returns the full message JSON.
Acting on mail (list, read, send, reply, forward) goes through the
[himalaya CLI](/python/cli/himalaya) when installed.
-14
View File
@@ -211,20 +211,6 @@ from range reads to avoid downloading entire objects.
| -------- | -------------------- |
| `base64` | Base64 encode/decode |
### Data Format Support
Commands with format-specific variants for structured data files:
| Format | Extension | Variants |
| ------- | ---------- | ---------------------------------------------- |
| Parquet | `.parquet` | cat, head, tail, wc, stat, cut, grep, ls, file |
| Feather | `.feather` | cat, head, tail, wc, stat, cut, grep, ls, file |
| ORC | `.orc` | cat, head, tail, wc, stat, cut, grep, ls, file |
| HDF5 | `.hdf5` | cat, head, tail, wc, stat, cut, grep, ls, file |
These variants auto-detect the format by extension and convert to
tabular text (CSV) for processing.
## Use Cases
- **AI agents accessing GCS data**: Mount GCS buckets for agents to read and process datasets
+20 -46
View File
@@ -59,6 +59,21 @@ If the modified date is unavailable, the date prefix is omitted.
Reading a document file returns the full Google Docs API JSON for
that document.
### Shared Drives
The listing covers every corpus the account can reach, so a document that
lives in a Shared Drive appears here too. A Shared Drive document has no
owner (the drive owns it), so it lands under `shared`, which is what that
directory means. This matches the `gdrive` mount, where Shared Drives are
top-level directories: without it the same account would see a document
under one Google mount and not the other, with no error explaining the
difference.
Drive answers an all-corpora search best-effort. When it reports that it
skipped a corpus, the short listing is still returned but is not cached as
the directory, so the next `ls` re-lists instead of serving the gap until
the cache expires.
## Cache
The Google Docs resource uses `IndexCacheStore`. Index entries store
@@ -74,6 +89,7 @@ import os
from dotenv import load_dotenv
from mirage import MountMode, Workspace
from mirage.commands.cli.builtin.gws import GWS
from mirage.resource.gdocs import GDocsConfig, GDocsResource
load_dotenv(".env.development")
@@ -88,6 +104,7 @@ resource = GDocsResource(config=config)
async def main():
ws = Workspace({"/gdocs": resource}, mode=MountMode.READ)
ws.register_cli("gws", GWS, config.model_dump())
# List structure
r = await ws.execute("ls /gdocs/")
@@ -123,7 +140,7 @@ async def main():
# Append text to a document
r = await ws.execute(
'gws docs +write --document 1AbCdEf --text "Appended via MIRAGE."')
'gws docs write --document 1AbCdEf --text "Appended via MIRAGE."')
print(await r.stdout_str())
@@ -151,48 +168,5 @@ Standard commands available on the mounted Google Docs tree:
| `basename` / `dirname` / `realpath` | Path utilities |
| `nl` | Number lines |
Resource-specific commands:
### `gws docs documents create`
Create a new Google Docs document.
```bash
gws docs documents create --json '{"title":"MIRAGE Example Doc"}'
```
| Option | Required | Description |
| -------- | -------- | ---------------------------- |
| `--json` | yes | JSON body with `title` field |
Returns the created document JSON.
### `gws docs documents batchUpdate`
Batch update a document.
```bash
gws docs documents batchUpdate --params '{"documentId":"1AbCdEf"}' --json '{"requests":[]}'
```
| Option | Required | Description |
| ---------- | -------- | -------------------------- |
| `--params` | yes | JSON with `documentId` |
| `--json` | yes | JSON with `requests` array |
Returns the batch update response JSON.
### `gws docs +write`
Append text to a document.
```bash
gws docs +write --document 1AbCdEf --text "Appended via MIRAGE."
```
| Option | Required | Description |
| ------------ | -------- | ------------------ |
| `--document` | yes | Google document ID |
| `--text` | yes | Text to append |
Returns the update response JSON.
Acting on documents (appending text, raw API calls) goes through
the [gws CLI](/python/cli/gws) when installed.
+27 -225
View File
@@ -115,6 +115,27 @@ a write mount can still hold items you may not edit. A mutation the
API denies fails with `EACCES` (Permission denied) on that operand,
like a real filesystem; the rest of the mount keeps working.
### Writing inside a Shared Drive
A Shared Drive is not a read-only corner of the mount. Every Drive call
mirage makes carries `supportsAllDrives`, so create, write, rename, copy
and delete work the same inside a Shared Drive as in My Drive, and the
commands above behave identically there.
What you may actually do is decided by Drive, not by mirage: a Shared
Drive has its own role model (`viewer`, `commenter`, `contributor`,
`content manager`, `manager`), and some organizations restrict deletion
or moving content out of the drive. mirage does not attempt to predict
those rules. It issues the operation and reports the answer, so a role
that forbids the change fails with `EACCES` on that operand, exactly like
an unwritable item in My Drive.
The practical consequence is that a write mount spanning Shared Drives is
partly writable, and the boundary follows your roles rather than the
mount. If you want a mount that cannot write at all, use
`MountMode.READ`; if you want to scope one to a single drive, set
`folder_id` to the Shared Drive id.
## Snapshots
The resource supports workspace snapshots. Recorded reads capture the
@@ -138,6 +159,7 @@ import os
from dotenv import load_dotenv
from mirage import MountMode, Workspace
from mirage.commands.cli.builtin.gws import GWS
from mirage.resource.gdrive import GoogleDriveConfig, GoogleDriveResource
load_dotenv(".env.development")
@@ -152,6 +174,7 @@ resource = GoogleDriveResource(config=config)
async def main():
ws = Workspace({"/gdrive": resource}, mode=MountMode.WRITE)
ws.register_cli("gws", GWS, config.model_dump())
# List root
r = await ws.execute("ls /gdrive/ | head -n 10")
@@ -252,229 +275,8 @@ Standard commands available on the mounted Google Drive tree:
| `tsort` | Topological sort |
| `file` | Detect file type |
## Data Format Support
## Acting on Drive
Google Drive may contain data files in binary columnar formats.
These are auto-converted to CSV on read. Specialized variants
of common commands handle them natively:
| Format | Extension | Specialized commands |
| ------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Parquet | `.parquet` | `cat-parquet`, `head-parquet`, `tail-parquet`, `wc-parquet`, `stat-parquet`, `grep-parquet`, `cut-parquet`, `ls-parquet`, `file-parquet` |
| Feather | `.feather` | `cat-feather`, `head-feather`, `tail-feather`, `wc-feather`, `stat-feather`, `grep-feather`, `cut-feather`, `ls-feather`, `file-feather` |
| HDF5 | `.hdf5` | `cat-hdf5`, `head-hdf5`, `tail-hdf5`, `wc-hdf5`, `stat-hdf5`, `grep-hdf5`, `cut-hdf5`, `ls-hdf5`, `file-hdf5` |
| ORC | `.orc` | `cat-orc`, `head-orc`, `tail-orc`, `wc-orc`, `stat-orc`, `grep-orc`, `cut-orc`, `ls-orc`, `file-orc` |
Example:
```bash
cat-parquet /gdrive/data/sales.parquet
head-parquet -n 5 /gdrive/data/sales.parquet
grep-parquet "revenue" /gdrive/data/sales.parquet
wc-parquet /gdrive/data/sales.parquet
```
## Resource-Specific Commands
Google Drive registers the `gws` command family so Google-native
files can be created and updated from the Drive mount. The syntax
mirrors the official
[Google Workspace CLI](https://github.com/googleworkspace/cli):
```bash
gws <service> <resource> <method> [--params JSON] [--json JSON] # API passthrough
gws <service> +<helper> [flags] # ergonomic helper
```
Passthrough commands call the corresponding API method directly:
`--params` fills URL path and query parameters, `--json` is the
request body, and the output is the compact API response JSON.
Helpers (`+write`, `+read`, `+append`) wrap the common operations
in plain flags.
### `gws docs documents create`
Create a new Google Doc.
```bash
gws docs documents create --json '{"title": "My Doc"}'
```
| Option | Required | Description |
| -------- | -------- | ---------------------------- |
| `--json` | yes | JSON body with `title` field |
Returns the created document JSON.
### `gws docs documents batchUpdate`
Apply batch updates to a Google Doc.
```bash
gws docs documents batchUpdate \
--params '{"documentId": "DOC_ID"}' \
--json '{"requests": [...]}'
```
| Option | Required | Description |
| ---------- | -------- | ------------------------------- |
| `--params` | yes | JSON with `documentId` |
| `--json` | yes | JSON body with `requests` array |
Returns the batch update response JSON.
### `gws docs +write`
Append text to a Google Doc.
```bash
gws docs +write --document DOC_ID --text "Hello from MIRAGE"
```
| Option | Required | Description |
| ------------ | -------- | -------------- |
| `--document` | yes | Google Doc ID |
| `--text` | yes | Text to append |
Returns the update response JSON.
### `gws sheets +read`
Read values from a spreadsheet range.
```bash
gws sheets +read --spreadsheet SHEET_ID --range "Sheet1!A1:C10"
```
| Option | Required | Description |
| --------------- | -------- | ----------------- |
| `--spreadsheet` | yes | Spreadsheet ID |
| `--range` | yes | A1 notation range |
Returns the range values.
### `gws sheets +write`
Write values to a spreadsheet range.
```bash
gws sheets +write --spreadsheet SHEET_ID --range "Sheet1!A1:C3" --values "a,b,c"
```
| Option | Required | Description |
| --------------- | -------- | ---------------------------------------------- |
| `--spreadsheet` | yes | Spreadsheet ID |
| `--range` | yes | A1 notation range to overwrite |
| `--values` | no | Comma-separated values for a single row |
| `--json-values` | no | JSON array of rows (alternative to `--values`) |
One of `--values` or `--json-values` is required. Returns the
update response JSON.
### `gws sheets +append`
Append rows to a spreadsheet.
```bash
gws sheets +append --spreadsheet SHEET_ID --range "Sheet1!A1" --values "a,b,c"
```
| Option | Required | Description |
| --------------- | -------- | ---------------------------------------------- |
| `--spreadsheet` | yes | Spreadsheet ID |
| `--range` | no | A1 notation range (defaults to `A1`) |
| `--values` | no | Comma-separated values for a single row |
| `--json-values` | no | JSON array of rows (alternative to `--values`) |
One of `--values` or `--json-values` is required. Returns the
append response JSON.
### `gws sheets spreadsheets create`
Create a new spreadsheet.
```bash
gws sheets spreadsheets create --json '{"properties": {"title": "My Sheet"}}'
```
| Option | Required | Description |
| -------- | -------- | --------------------------------- |
| `--json` | yes | JSON body with `properties.title` |
Returns the created spreadsheet JSON.
### `gws sheets spreadsheets batchUpdate`
Apply batch updates to a spreadsheet.
```bash
gws sheets spreadsheets batchUpdate \
--params '{"spreadsheetId": "SHEET_ID"}' \
--json '{"requests": [...]}'
```
| Option | Required | Description |
| ---------- | -------- | ------------------------------- |
| `--params` | yes | JSON with `spreadsheetId` |
| `--json` | yes | JSON body with `requests` array |
Returns the batch update response JSON.
### `gws slides presentations create`
Create a new Google Slides presentation.
```bash
gws slides presentations create --json '{"title": "My Deck"}'
```
| Option | Required | Description |
| -------- | -------- | ---------------------------- |
| `--json` | yes | JSON body with `title` field |
Returns the created presentation JSON.
### `gws slides presentations batchUpdate`
Apply batch updates to a presentation.
```bash
gws slides presentations batchUpdate \
--params '{"presentationId": "PRES_ID"}' \
--json '{"requests": [...]}'
```
| Option | Required | Description |
| ---------- | -------- | ------------------------------- |
| `--params` | yes | JSON with `presentationId` |
| `--json` | yes | JSON body with `requests` array |
Returns the batch update response JSON.
### Drive API Passthroughs
The Drive v3 files and permissions methods are available with the
same passthrough shape:
| Command | Description |
| ------------------------------ | ---------------------------------------------- |
| `gws drive files list` | List files (`--params` supports `q`, `pageSize`) |
| `gws drive files get` | Get file metadata by `fileId` |
| `gws drive files create` | Create a file (`--json` metadata) |
| `gws drive files update` | Update file metadata |
| `gws drive files copy` | Copy a file |
| `gws drive files delete` | Delete a file |
| `gws drive files export` | Export a native file (`fileId`, `mimeType`) |
| `gws drive permissions create` | Share a file |
| `gws drive permissions list` | List a file's permissions |
| `gws drive permissions delete` | Remove a permission |
Examples:
```bash
gws drive files list --params '{"q": "name = '"'"'report.pdf'"'"'"}'
gws drive files export --params '{"fileId": "DOC_ID", "mimeType": "text/plain"}'
gws drive permissions create \
--params '{"fileId": "FILE_ID"}' \
--json '{"role": "reader", "type": "anyone"}'
```
Acting on Drive files by id (create, update, copy, delete, share,
export, plus the Docs/Sheets/Slides helpers) goes through the
[gws CLI](/python/cli/gws) when installed.

Some files were not shown because too many files have changed in this diff Show More