expose-synq
Prototype. This is a personal experiment, not one of Spiri's product offerings, and it isn't supported.
Expose a local folder to remote AI agents over SpiriSynq. The remote can read files, and optionally write files and run commands in a bubblewrap sandbox.
expose-synq share ./ -w -x # read/write + sandboxed exec on <hostname>/<absolute path of .>
expose-synq share ./ -w -x --no-sandbox # exec runs unconfined, as you
expose-synq share ~/proj -x --confirm # approve each write and command
expose-synq share . -w -c tcp/relay:7447
expose-synq share . --public # findable by any zenoh peer on the network
expose-synq mount host/home/me/proj ~/mnt -c tcp/host:7447 # the share as a local filesystem (FUSE)
expose-synq run -c tcp/host:7447 --topic host/home/me/proj bash # an interactive shell in the share
expose-synq run -c tcp/host:7447 --topic host/home/me/proj make test # or any command, with its exit status
expose-synq file pull -c tcp/host:7447 host/home/me/proj:src/app.py # one file to stdout; see "File commands"
Only reading is allowed unless you ask for more. Each capability is its own object, created only when granted, so what a share grants is found with SpiriSynq's discovery, by which objects are there:
<topic>/fs/read FsRead always
<topic>/fs/write FsWrite --allow-write
<topic>/shell Shell --allow-execute; its sandboxed field says whether commands run in bwrap
<root>/tickets/<secret> byte streams: file reads/writes, processes
The filesystem RPCs follow the Unix calls they're named after. They act on symlinks, never through them, and skip or refuse excluded paths:
FsRead stat(path) lstat → {type, size, mtime, mode}
list_dir(path) streams {name, type, size, mtime, mode}, by name
glob(path, pattern) streams paths; pattern relative to path, ** for any depth
grep(path, regex) streams {path, line, text}; binary files skipped
hash(path, algorithm) "sha256:<hex>"
open_read(path) read ticket
FsWrite open_write(path, truncate, exclusive=False) write ticket; exclusive is O_EXCL
edit_file(path, old, new) replace exactly one match, by temp file + rename (sed -i)
rename(path, to) rename(2): atomic, replaces to; EXDEV across mounts
chmod(path, mode)
mkdir(path, parents=False) parents: mkdir -p
delete(path, recursive=False) unlink, rmdir, or rm -r
delete(recursive=True) refuses a tree holding excluded or read-only paths before removing anything, since the caller can't see what it would destroy. Directory patterns match directories as in .gitignore, so .git/** makes .git itself read-only.
File contents and process output never travel through SpiriSynq RPCs. open_read, open_write and spawn return a ticket: a few raw zenoh queryables under <root>/tickets/<secret>, where <root> is the node's base topic. Each ticket's stream protocol is in the docstrings of FileReadStream, FileWriteStream and Process.
A ticket is an open handle. It lasts until <t>/close, or until it goes ttl seconds (returned by open_read/open_write) without a request. Every ticket has <t>/heartbeat for a client that holds one without using it. Work the server does on its own, like publishing a stream, doesn't count, so a client that disappears takes its streams with it. client.stream and client.RemoteFile heartbeat for as long as they're in use, every third of the ttl the heartbeat reply reports.
Ticket routes
@t is the ticket key. route/{a, b?} is a zenoh query on @t/route with selector parameters a and optionally b (@t/write?offset=0), and data is the query's payload. Unless noted, a route replies {} (JSON) or an error.
Read ticket, from FsRead.open_read(path) → {ticket, size, mtime, ttl}:
@t/read/{start, end?, chunk?, readahead?} → raw byte replies, one per chunk, then the query ends.
Always at least one reply (empty past EOF). The slow path.
@t/stream/{id, start, end?, chunk?, readahead?, max_rate?}
→ {} once started; the bytes go to @t/data/{id}
@t/stream/{stop, id} → stops stream id
@t/stream/{rate, id, max_rate} → changes a running stream's max_rate
@t/data/{id} published samples, told apart by their attachment:
o=<offset> data at that offset
end=<offset> empty, the stream is done
err=<message> empty, the stream failed
@t/close → stops every stream; later reads are refused
endis exclusive. Left out, a read or stream goes to the current end of the file.chunkis in bytes, clamped to 4 KiB–4 MiB (default 1 MiB).readaheadisauto(default),onoroff. Withauto, read-ahead starts after the first few chunks.max_rateis in bytes per second; 0 means no limit. While it's set, each chunk is at most one second's worth.
How a client streams:
- It picks
idand subscribes to@t/data/{id}, then sends thestreamrequest. - It checks each sample's offset against what it expects next.
- If it finds a gap, or nothing arrives for a while, or its buffer overflows, it sends
stopand streams again from its last offset.
There's no backpressure: zenoh's applies to the whole link, so one slow consumer would stall everything else on the session. Instead, the client controls the stream's rate, much as TCP does:
- Buffer overflow (the consumer fell behind): restart at 90% of the consumer's pace. That pace is the median over its last 32 samples, so a single pause doesn't count.
- Gap (samples lost): restart at half the rate.
- No loss: every half second the client raises the rate 1.5× with
rate, without restarting. Past 8 GiB/s the limit is lifted, or returns to the caller's ownmax_rateif it set one.
A consumer that keeps up never meets a limit.
The file may grow, as with tail -f: to follow it, stream again from where the last stream ended. A read or stream fails with FileChangedError if the file is replaced (a different inode at the path, which is what most editors do on save) or truncated (smaller than the ticket has seen it). Call open_read again for the new file. Bytes rewritten in place aren't detected, as with any Unix reader. To check a read, hash what you received and compare it with FsRead.hash(path).
Write ticket, from FsWrite.open_write(path, truncate, exclusive) → {ticket, size, ttl}:
@t/write/{offset, data} → pwrite data at offset
@t/append/{seq, data} → write data at the end of the file (O_APPEND)
@t/truncate/{size} → ftruncate: cut the file to size bytes, or extend it with zeros
@t/flush → fsync
@t/close → closes the file
writelands where it says, in any order. Resending one is harmless, so a client keeps many in flight and resends any that go unacked.appendneeds the ack before the next one goes out.seqcounts the ticket's appends from 0. A resend of one that landed is acked without being written twice, and aseqfurther ahead than the next one is refused.- Writes land in the file as they arrive. There's no temp file, rename or commit.
truncate,flushandclosecan be repeated.RemoteFile.truncatewaits for its writes in flight first, so it lands after them, as for one Unix writer. Afterclosethe ticket keeps answering repeats for 30 s, then it goes away.
Process ticket, from Shell.spawn(command, stdin=False, pty=None, timeout=None) → {ticket, ttl, pty}:
A str command runs through --shell, a list runs as argv. The working directory is the workspace root. The environment is PATH, HOME and LANG, plus each --pass-env. pty is True or {rows, cols, term} (default 24, 80, xterm-256color). timeout can only shorten --exec-timeout.
@t/stdout/read/{start, end?, chunk?, wait?} → output, as a read ticket's read, but like a pipe (below)
@t/stdout/stream/{id, start, end?, ...} → as a read ticket's stream, to @t/stdout/data/{id}
@t/stderr/... the same for stderr. Not there with a pty
@t/stdin/append/{seq, data} → written to stdin, as a write ticket's append
@t/stdin/close → EOF; on a pty, the terminal's EOF character (^D)
@t/resize/{rows, cols} → pty only: the window size; the process gets SIGWINCH
@t/status/{wait?} → {running, exit_code, pty, stdout_size, stderr_size}.
wait: up to that many seconds for the exit
@t/kill/{signal?} → signals the process group (default SIGTERM)
@t/close → ends the process, drops its output
Output works like a file that's still being written. Only one thing differs, and it's the difference between a file and a pipe:
- A
readwaits up towaitseconds (default 10, at most 30) for output paststart, then replies with what there is, like read(2) on a pipe. Each reply carrieso=<offset>. If there's nothing, a single empty reply saysend=<offset>for EOF orat=<offset>for nothing yet. - A
streamwith noendruns until the process closes that stream. While the process is quiet, the server publishes an emptyat=<offset>sample every second, so the client can tell quiet from lost. - Each stream keeps its last 64 MiB. Offsets count every byte ever produced, and asking for older output fails with
OutputDropped. Output stays readable after the process exits, until the ticket closes.
Plain and interactive commands use the same calls. On a pty, stdout and stderr are one stream (the terminal), stdin is always open, and the terminal echoes what's typed. Ctrl-C is the byte \x03 on stdin: the line discipline turns it into SIGINT, as on a local terminal. exit_code is negative for a signal (-2 for SIGINT), as in subprocess.
A process lasts as long as its ticket. When the ticket is closed, or goes ttl seconds without a request, the process group gets SIGHUP (on a pty) or SIGTERM, then SIGKILL 2 s later. client.Process heartbeats for as long as it's open.
Every ticket, process tickets included:
@t/heartbeat → {ttl}. Counts as use, nothing else
A share's topic is the folder's absolute path under the node's base topic: share /home/me/proj on host is host/home/me/proj, so any number of shares on one host stay apart. --topic picks another one, still under the base topic. A path that can't be a key expression (a chunk with *, $, # or ?, or starting with @), / and /tickets need --topic. Shares nest: host/home/me and host/home/me/proj can both be shared, and discovery at one ignores the other. Asking for a topic with no share at it lists the shares under it.
Find shares with synq topic list --type FsRead: a share's topic is its FsRead's, without /fs/read. Or press Tab: see Tab completion.
Mounting
expose-synq mount TOPIC MOUNTPOINT mounts a share with FUSE (libfuse 3, through mfusepy), until Ctrl+C or fusermount3 -u MOUNTPOINT. It takes the same zenoh options as share. It mounts read/write if discovery finds the share's FsWrite, read-only otherwise. On NixOS, point FUSE_LIBRARY_PATH at libfuse3.so.
Each FUSE call is the RPC of the same name, and an open file is a ticket: a read ticket, a write ticket, or both for O_RDWR. A remote error's errno reaches the program as is: excluded paths are ENOENT, read-only ones EROFS.
- Writes return once they're sent.
close(2)andfsyncwait for them, so a failed write shows up there at the latest, as on NFS. - The mount sees its own writes:
statand reads wait for its writes in flight to that path first. - A file truncated or replaced by someone else fails a read with EIO (the ticket's
FileChangedError). Truncates through the same mount don't. - There are no RPCs for
readlink,symlink,link,chownor timestamps, so those fail with ENOSYS. Symlinks are listed but can't be followed, andtouchon an existing file fails.
Running commands
expose-synq run --topic TOPIC COMMAND... runs a command in a share that has --allow-execute, attached to your terminal like ssh, and exits with its status (128 + N if signal N killed it, 255 if the share stops answering). --topic defaults to $EXPOSE_SYNQ_TOPIC. It takes the same zenoh options as share, and options go before COMMAND: everything from it on belongs to the command.
- On a terminal (stdin and stdout both are, or
-t): the command gets a pty the size of yours, and your terminal goes raw. Every keystroke goes to the remote, Ctrl-C included, and resizing your terminal resizes the remote's. - Otherwise (or
-T): stdin is forwarded until EOF (-nto not), stdout and stderr come back separately, and Ctrl-C sends SIGINT; a second one gives up.
Both are the same process ticket, through client.Process.
File commands
expose-synq file VERB is one small command per FsRead/FsWrite method, plus pull and push over tickets, for scripts and agents that would rather not mount. A remote path is TOPIC:PATH, as scp writes host:path. Without a TOPIC: the topic comes from $EXPOSE_SYNQ_TOPIC, so an agent's shell can set it once; write :PATH or ./PATH for a path holding a colon. They take the same zenoh options as share.
export EXPOSE_SYNQ_TOPIC=host/home/me/proj
expose-synq file ls src # list_dir: mode, size, mtime, name (--json for the raw entries)
expose-synq file stat src/app.py # stat (--json too)
expose-synq file hash src/app.py # hash: sha256:… (-a for another algorithm)
expose-synq file glob '**/*.py' src # glob: one path per line
expose-synq file grep 'def main' src # grep: path:line:text; exit 1 if no match, 2 on error
expose-synq file pull src/app.py ./app.py # open_read; to stdout without LOCAL; --start/--end for a byte range
expose-synq file push ./app.py src/app.py # open_write, truncating (--append, --exclusive); - for stdin
expose-synq file edit src/app.py 'old' 'new' # edit_file; --old-file/--new-file for multi-line text
expose-synq file mkdir -p a/b # also rm [-r], mv, chmod 644 PATH
Each does what its method does and no more: push writes in place, with no temp file and rename (push, then mv, for that), and mv works only within one share.
Tab completion
expose-synq completion zsh > ~/.zfunc/_expose-synq # with ~/.zfunc in $fpath before compinit
expose-synq completion bash > ~/.local/share/bash-completion/completions/expose-synq # or source it from ~/.bashrc
Commands and options complete, and so do share topics (for mount and run --topic) and remote paths: TOPIC:PATH in the file commands, or a bare path with $EXPOSE_SYNQ_TOPIC set.
A Tab is answered by expose-synq _complete-server, a background process with a zenoh session kept open, in a few milliseconds: the shell talks to it directly, so no Python starts. zsh connects over a Unix socket in $XDG_RUNTIME_DIR/expose-synq/, and bash, which can't, over TCP on 127.0.0.1 with a token from a file only you can read. The server:
- checks for shares every 5 seconds, so a new one shows up within about that;
- lists directories with the share's
list_dir, keeping each listing for 5 seconds. A Tab gets an answer within 0.3 seconds, and a listing that takes longer is there for the next Tab. Each listing is an ordinary read, so it shows in the share's--audit-log; - exits after 10 minutes without a Tab, or after a Tab once your zenoh config or expose-synq itself has changed;
- uses your default zenoh config (
setup's, and theEXPOSE_SYNQ_ZENOH*variables), not the--connecton the line being completed.
When it isn't running, the first Tab starts it and is answered the slow way (about 0.1 seconds), from the topics it last saw (~/.cache/expose-synq/topics) and without paths. Regenerate the script after upgrading.
Limitation: no access control
This prototype has no access control. expose-synq doesn't check who's calling, and it doesn't configure or rely on Zenoh access_control. Anyone who can reach the node over Zenoh can call every capability you granted, including --allow-execute. Only run it on networks you trust. The default config has no connectivity, while --public makes it reachable by every Zenoh peer on the network.
A ticket (<root>/tickets/<secret>) is a bearer secret: anyone who learns its key can use it. Its routes ignore any query that doesn't name them exactly, so a wildcard get can't read, write, close or kill anything. But the secret is an ordinary chunk, so a wildcard subscription on <root>/** does see stream data. It used to be a verbatim @ chunk, which no wildcard matches, but no zenoh ACL rule can match one either, so a router confining a node to <root>/** blocked every ticket.
Who can reach the node is zenoh's job: TLS and transport/auth in its config (see below). Within the workspace, PathPolicy keeps paths inside the root (no .., no symlinks along the way) and applies the excludes and read-only paths.
Zenoh configuration
By default expose-synq doesn't listen, connect or scout at all. You open up connectivity, and secure it, through zenoh's own config: TLS, transport/auth and access_control. Each layer below overrides the ones above it:
- the locked-down base, or with
--publiczenoh's own default config instead (scouting, and a listener on a random port). - a JSON5 config file:
--zenoh-config FILE,EXPOSE_SYNQ_ZENOH_CONFIGorZENOH_CONFIG, else~/.config/expose-synq/zenoh.json5ifexpose-synq setupwrote one; then the config each server handedshareon enrollment - one environment variable per key:
EXPOSE_SYNQ_ZENOH__followed by the key path, with__between segments -Z KEY=VALUEflags--connect/--listen
Values are parsed as JSON5 and fall back to a plain string. Dicts merge key by key, and any other value replaces what was there.
# scout and listen on one interface only
export EXPOSE_SYNQ_ZENOH__SCOUTING__MULTICAST__ENABLED=true
export EXPOSE_SYNQ_ZENOH__SCOUTING__MULTICAST__INTERFACE=eth1
export EXPOSE_SYNQ_ZENOH__LISTEN__ENDPOINTS='["tcp/10.0.0.5:0"]'
expose-synq share . -w
expose-synq setup
An interactive wizard that writes ~/.config/expose-synq/zenoh.json5 (or -o FILE). It asks for:
- Interfaces to listen on, ZeroTier ones first with their network names, plus multicast scouting on the first one and any peers to connect to. Port 0 lets several expose-synq processes run on one machine; peers find each other by scouting. Network names come from the ZeroTier service's local API, with the token from
~/.zeroTierOneAuthTokenorZEROTIER_AUTH_TOKEN. Without one, the wizard offerssudo zerotier-cli -j listnetworks, or lists thezt*devices without names. - Authentication, in the forms zenoh can do from a config file: none (trust the network, e.g. ZeroTier membership), a shared username and password (
transport/auth/usrpwd), TLS, mTLS, or mTLS plus a password. TLS links can betlsorquic. It can create a CA and sign this node's certificate (EC P-256, with the hostname and the chosen IPs as names), sign one with an existing CA, or use certificate files you already have.
Keys, the password file and the config are written mode 0600 next to the config. On the next machine, copy tls/ca.crt and tls/ca.key over and choose existing at the certificates step; for a password, enter the same one.
zenoh's transport/auth/pubkey isn't offered. In zenoh 1.10, known_keys_file is read into the config but not into the authenticator, so every key is rejected as unauthorized.
Enrolling with a server
For share, a --connect that is a host or a URL instead of a zenoh endpoint names a server to ask for access:
sudo uvx expose-synq share / -x --no-sandbox -c hermes.example.com
Asking https://hermes.example.com for access as boxa.
code: 7K2Q-M9XD approve it there if the code matches
Waiting for approval (Ctrl+C to stop; running again resumes the request)
The first time, share makes an Ed25519 key, sends the server its hostname (the node's base topic) and public key, and prints a code: a fingerprint of the key, which the server's approval page shows too. A person approves it there if the two match. share polls until the request is approved, denied or expires. Ctrl+C keeps the request, and running again picks it up.
The approval carries a zenoh config layer (endpoints, certificates) and the files it refers to. They're saved, mode 0600, under ~/.config/expose-synq/servers/<host>/ and used until the approval expires, then share asks again with the same key. The server's endpoints are added to any other --connect endpoints. The protocol is in the docstring of synq_expose/enroll.py.
Development
Design rule: we are not smarter than the people who made Unix. We can add niceties and change names, but we live with the same physical limits they did, so the correct way to do something is correct both on Unix and on expose-synq. For example, rewriting a file that others may be reading is done by writing a new file and renaming it over the old one, here as on Unix. It's not something tickets guarantee.
uv sync
uv run pytest
uv run python bench/bench.py [--quick] [--json out.json]
uv run expose-synq share --help
Tests and benchmarks run a real two-node network: the share on one SpiriSynq session, the agent on another, joined over TCP on loopback with scouting off (synq_expose.testing). The agent side uses SpiriSynq mirrors for RPCs and synq_expose.client for ticket streams. Behaviour that's specified but still a stub is tested under a strict xfail, so implementing it makes the test fail until the marker is removed. Sandbox tests skip when bwrap isn't installed, and mount tests without FUSE (/dev/fuse, fusermount3, libfuse).
The benchmark's file cases go layer by layer, with each layer's share of native file I/O: native, zenoh serving the same bytes from memory, then the ticket. Run it after every change to the data path; the layer whose share drops is where the regression is. It runs on tmpfs, since disk writeback makes the native baseline swing. Both nodes share one process (and the GIL) there; for numbers that matter, run a real expose-synq share and a client in separate processes.
To see where a layer's time goes, profile the server with py-spy (0.4.2 or later for Python 3.14): uvx py-spy@0.4.2 record --gil --format raw -o out.txt -- .venv/bin/python …. --gil keeps only samples that hold the GIL; compare it with a run without it to see what's blocking versus what's waiting.
Layout (src/synq_expose/):
cli.py: the Typer app (share,mount,runandsetup); validates flags into anExposeConfigand servesmount.py:SynqFS, the FUSE operations over the RPCs and ticketsrun.py:run_command, a remote process attached to local fds, on a pty or pipesconfig.py:ExposeConfigand friends, plus the layered zenoh config andreopen_session()wizard.py:expose-synq setup;build_config()turns its answers into a zenoh config, and it makes the certificatesinterfaces.py: network interfaces (ip -j addr) and ZeroTier networks from the local serviceworkspace.py:build_objects(), which creates one object per granted capability, anddiscover(), which finds them from the client sidetickets.py:TicketRegistry, which creates, serves and revokes ticketsfs.py:FsRead,FsWrite, the file read/write streams, andByteStream, the read/stream routes shared with process outputshell.py:Shell.spawn, andProcess: a process's output as pipe-like read streams, its stdin as appendspolicy.py: path confinement, excludes, read-only globsguard.py:--confirmand the audit logsandbox.py: bubblewrap argvclient.py: the agent side of the ticket streamstesting.py: the loopback test network
License
GPL-3.0-or-later. See LICENSE.
Metadata
Release files for expose-synq 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| expose_synq-0.3.0.tar.gz | 99.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| expose_synq-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 199.6 kB
Release files / expose_synq-0.3.0.tar.gz
| Download URL | expose_synq-0.3.0.tar.gz |
|---|---|
| Size | 99.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c0cf26ddb4df3504a133edaef102c6e6143580ebe7311905db333ec80b05fad8
|
|
BLAKE2b-256 checksum How to use checksums |
f4ccfc0da2951ff8f12b620fa1fdf16cf2373c01eb846042d2750467db5f332d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / expose_synq-0.3.0-py3-none-any.whl
| Download URL | expose_synq-0.3.0-py3-none-any.whl |
|---|---|
| Size | 100.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c13af6bb54fee62fd5778e14ac7b5a740d92746b330b642b26feb9e3d8fbaae0
|
|
BLAKE2b-256 checksum How to use checksums |
2346ca759c99d7e10e784d574fcc8e1d1f97082c23d9d90a2a7e1de86864cd58
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|