Skip to content

Latest commit

 

History

History

using-mirage

Folders and files

README.md

Using mirage against Backlot

mirage (mirage-ai) is a virtual filesystem for AI agents: it mounts a SaaS backend and lets you read it with plain bash — ls, cat, grep, find, jq. These scripts point mirage at Backlot, so you can exercise a mirage-based agent over a corpus you supply, entirely offline.

uv sync --all-extras --locked
python examples/using-mirage/slack.py       # or gmail.py, gdrive.py, notion.py, s3.py, github.py, linear.py, unified.py

Each script spins up its own throwaway server on a tiny in-code corpus, points a mirage VFS at it, and runs a few filesystem commands. Pass --url http://host:port to use an already-running server instead (it falls back to a local one if that's unreachable).

python examples/using-mirage/unified.py --url https://your-backlot-host.example.com

Add --fuse to any source's script to expose the mount as a real OS filesystem instead of driving it in-process (see FUSE mode below).

Sources

Script mirage VFS mount what it shows
slack.py SlackVFS /slack ls channels → dated chat.jsonl; cat + scoped grep
gmail.py GmailVFS /gmail ls labels → dates → messages; cat + jq .subject
gdrive.py GoogleDriveVFS /gdrive ls -F folders → files; cat a native Google doc; in-process, gws docs documents create a Doc that the next ls lists
notion.py NotionVFS /notion ls pages/ + databases/; cat a page.json / database.json
s3.py S3VFS /s3 ls recursively → cat an object; grep -r across the bucket
github.py GitHubVFS /github ls a repo's file tree → cat a source file; grep -r across it
linear.py LinearVFS /linear ls teams → a team's team.json states; cat an issue.json, jq its comments.jsonl
unified.py all three /slack /gmail /gdrive one Workspace, one set of commands, three backends

The scripts navigate top-down (ls one level, cat one file) rather than walking a whole mount with find / grep -r. mirage materializes each directory by calling the API, so a whole-mount walk over a large corpus (like the live deploy) means thousands of round-trips — bounded navigation keeps them fast. find/grep -r still work; just scope them to a subtree.

Not included: Jira / Confluence (mirage has no connector for them). GitHub is included, but only its code side: mirage's GitHub connector mirrors a repo's file tree via the git trees/contents/blobs API — it has no notion of issues/PRs, which Backlot also serves as GitHub documents. Use examples/using-mirage/github.py for the code crawl and examples/using-official-sdk/github.py for issues/PRs (or both, together).

Pointing mirage at Backlot

Slack, Notion and Linear take a base_url config, so you point them straight at Backlot — no glue:

from backlot import serve_or_connect
with serve_or_connect(CORPUS) as s:
    vfs = SlackVFS(SlackConfig(token=s.token,
                               base_url=f"{s.base_url}/slack/api"))
    ws = Workspace({"/slack": vfs}, mode=MountMode.READ)
    print(await (await ws.shell("ls /slack/channels/")).stdout_str())

Notion is the same one-liner — NotionConfig(base_url=f"{s.base_url}/notion/v1"), no monkeypatch. mirage sends Notion-Version: 2025-09-03, which Backlot's version-aware router serves (the data_sources shape on a database, and its rows through data_sources/{id}/query), so pages and databases both read correctly.

Linear is one line too — LinearConfig(api_key=token, base_url=f"{s.base_url}/linear/graphql"). mirage sends the key bare in Authorization, the way Linear takes a personal API key, so a Backlot token goes into api_key unchanged. A team's directory holds team.json beside issues/, members/, projects/, cycles/ and documents/; documents/ is empty, because a corpus carries no Linear documents.

S3 is also plain config, no monkeypatch and no pin bump: S3Config(endpoint_url= f"{s.base_url}/s3", path_style=True, aws_access_key_id=ak, aws_secret_access_key=sk). path_style=True keeps the bucket in the path (/s3/<bucket>/...) rather than the hostname. S3 uses an AWS keypair (not a bearer token): --access-key/--secret-key are required with --url (real AWS keys, or a pair from GET <url>/_meta/users — the keys the SigV4 verifier accepts); without --url the local throwaway server uses its own admin keypair.

Google has a knob, GoogleConfig.api_base, and it is the single that rules it out rather than its absence: mirage composes Docs, Slides and Forms alike as {api_base}/v1, while Backlot serves docs at /docs/v1 and slides at /slides/v1, so all three would arrive on one prefix it cannot route apart. So backlot.integrations.mirage exposes point_google_at(base_url), which rewrites the per-API constants the helpers fall back to when api_base is unset:

from backlot.integrations.mirage import point_google_at
point_google_at(s.base_url)              # googleapis.com  ->  Backlot
gmail = GmailVFS(GmailConfig(**creds))

It redirects the OAuth token endpoint, the Google Drive API, and the Docs/Sheets/Slides APIs (mirage reads native Google docs structurally through those, not via Google Drive export) — each to a distinct Backlot path, so Docs and Slides don't collide. The --url / --user / --token flags behave exactly as in the using-official-sdk examples: serve_or_connect comes from backlot itself, and google_oauth_user (Backlot-specific OAuth glue, not general API) from examples/_common/google_creds.py.

GitHub is the same shape as Google, but with one constant: mirage.core.github.constants.API_BASE = "https://api.github.com", which mirage falls back to whenever GitHubConfig.base_url is unset. point_github_at(base_url) rebinds that constant (and any already-imported copy) before the VFS is built:

from backlot.integrations.mirage import point_github_at
point_github_at(s.base_url)                       # api.github.com  ->  Backlot
repo = GitHubVFS(GitHubConfig(token=T, owner="acme", repo="gateway"))

The copy-sweep matters here for the same reason it does for Google: mirage/core/github/client.py imports API_BASE by value and reads that copy in github_url, so patching only the constants module would leave every request pointed at api.github.com. What differs from Google is that GitHubConfig.base_url is a real seam — mirage threads it to every call site — so the rebind is what redirects a VFS built without that field, rather than the only way in.

FUSE mode (--fuse)

Everything above drives the filesystem in-process with ws.shell("ls …"). mirage can also expose a mount as a real filesystem via FUSE, so any process — cat, grep, rg, an editor, an indexer — reads Backlot's data as ordinary files:

python examples/using-mirage/slack.py  --url https://your-backlot-host.example.com --fuse
python examples/using-mirage/gmail.py  --url https://your-backlot-host.example.com --user ceo@acme.com --fuse
python examples/using-mirage/gdrive.py --url https://your-backlot-host.example.com --fuse

Each prints a real mountpoint (e.g. /tmp/mirage-xxxx), reads a file through the kernel's FUSE layer with plain os/open(), and runs an external grep against it to prove it's a genuine filesystem. While it's running you can ls/cat/grep the mountpoint from another terminal.

unified.py --fuse mounts the whole workspace root at a single mountpoint, so slack/, gmail/, and gdrive/ show up as subdirectories of one mount — all three at once, on macOS included. (macFUSE limits a process to one mountpoint, not one source, so a single mount that serves several sources is fine; what fails is opening several separate mountpoints in one process.)

Requirements:

  • uv sync --all-extras --locked already pulls mirage-ai[fuse,s3] — the mfusepy binding, and the aioboto3 that s3.py's backend imports.
  • An OS FUSE driver: macFUSE on macOS, fuse3 on Linux. Without it, --fuse prints install guidance and exits cleanly (the non---fuse path needs no driver).

Performance notes

Against a remote deploy the cost is dominated by (a) TLS/round-trip latency and (b) how many entries a directory has, because mirage materializes a directory by calling the API for every entry:

  • Connection reuse — run_mirage() (used by every script here) shares one keep-alive connection across mirage's many calls; without it each call pays a fresh TLS handshake (≈3x slower end to end over a remote hop).
  • Navigate, don't sweep — ls one level and cat one file. find / grep -r over a whole mount force mirage to fetch every file; scope them to a subtree.
  • Listing a huge directory is inherently proportional to its size — e.g. ls of a Google Drive folder with thousands of files, or a large mailbox label (mirage fetches every message to group by date). Prefer a --user whose ACL-scoped view is smaller, or a narrower path.

Backlot's side of these paths was tuned alongside these examples (see git history): Slack conversations.list is memoized and its created comes from an aggregate (was a full per-channel message scan); Google Drive folder listings are SQL-scoped/paginated, resolve folder ids without an ACL scan, batch the per-file ACL lookup, and honor the fields mask.

Testing per-user ACL

Same as the official-SDK examples: pair the identity flag with --url.

# Slack — a bearer token from GET /_meta/users
python examples/using-mirage/slack.py --url http://localhost:8000 --token <usr-token>

# Gmail / Google Drive — a user email (authorized-user credential, impersonating that user)
python examples/using-mirage/gdrive.py --url http://localhost:8000 --user mia@acme.com

The mounted filesystem then contains only what that identity is allowed to read.

Backlot endpoints this exercises

Pointing mirage at Backlot surfaced a few gaps that are now part of Backlot (see the git history): Google Drive's root is navigable ('root' in parents returns folder objects; shared-drives enumeration is present-but-empty), the Docs/Sheets/Slides read APIs serve native-doc content, a Slack channel's created never postdates its messages, and conversations.history honors oldest/latest so a per-day fetch returns only that day (not the whole channel). Coverage lives in tests/test_google.py.