A reach is a teleoperated remote manipulator: the operator stays behind the barrier, the manipulator works inside. That is the whole design. The agent — the brain, and the credentials it holds — never leaves your machine. Only the observation and action space is remote.
This document is the authoritative description of how AgentReach is built and why. It covers the design decisions, the rejected alternatives, and the constraints that shaped both. For a quick overview, the README is the right starting point.
Coding agents assume their tools act on the machine they run on. Three constraints break that assumption simultaneously:
- The agent cannot be installed on the target. Many servers cannot run a ~300 MB Node SEA or a Bun runtime.
- Credentials must not reach the target. A client's server is untrusted; an API key or OAuth token placed there is disclosed.
- The target must stay unmodified. No daemon, no binary, no footprint.
The standard answers each fail one of these: running the agent remotely fails (1) and (2); an MCP tool server changes the tool names the model sees, which breaks transparency; a FUSE mount fails loudly on macOS (kernel extension, reboot) and fails silently on an unstable link — stalled I/O in uninterruptible sleep, with the agent frozen mid-call and no error to reason about.
Every failure must be a value the agent can reason about, never a process that stops responding.
An RPC timeout becomes a tool error the model sees, retries, or routes around. That single property is why reach is built on request/response over SSH rather than on a filesystem mount.
┌─ your machine ─────────────────────────────────────────────┐
│ harness (claude / codex / kimi / opencode) │
│ │ native tool calls — the model sees no new tools │
│ ┌───▼─────────────────────────────────────┐ │
│ │ adapter (per harness, thin, no fork) │ │
│ └───┬─────────────────────────────────────┘ │
│ │ argv, hook JSON on stdin, or a generated tool │
│ ┌───▼─────────────────────────────────────┐ │
│ │ session target · cwd · capabilities │ a file, │
│ │ tier decision │ not a daemon │
│ ├─────────────────────────────────────────┤ │
│ │ fileops posix · pipe · helper │ │
│ ├─────────────────────────────────────────┤ │
│ │ transport ssh · docker · podman · local │ │
│ └───┬─────────────────────────────────────┘ │
└──────┼─────────────────────────────────────────────────────┘
│ system ssh (multiplexed where the client supports it)
┌──────▼─────────────────────────────────────────────────────┐
│ target: stock sshd only. no node, no python, no reach bits │
└────────────────────────────────────────────────────────────┘
Only the top layer is harness-specific.
Session state — which target, which directory, which tier, what the target's
userland supports — lives in a file under ~/.reach. Every reach invocation is
a short-lived process that reads it.
A daemon would buy exactly one thing: connection reuse. SSH's ControlMaster
already provides that — measured against real hosts at 4–5× faster per command
than reconnecting: 171 ms against 772 ms on one, 557 ms against 2.85 s on
another. Paying for it a second time would mean a lifecycle, a socket, crash
recovery, version skew between a running daemon and an upgraded binary, and
orphaned processes holding connections to someone else's server — in exchange
for nothing.
One consequence is worth stating early, because it shapes the tier design more than anything else: reach runs one process per tool call.
Sharing one connection has a limit that a daemon would have had too: sshd caps
concurrent channels per connection with MaxSessions, 10 by default, and reach
runs one channel per tool call. An agent that fans out past that has its
eleventh tool call refused — administratively prohibited, ssh exit 255, which
reach used to report as "command did not complete". reach now opens a second
connection instead, up to a small bound, and says so on stderr. The retry is
safe in a way retrying a failed command generally is not: a refused channel
means the remote shell was never started, so there is nothing to have
half-happened. A connection that dropped mid-command is never retried, because
it says nothing about whether the command ran.
reach runs on Linux, macOS and Windows, and targets any POSIX host. The split matters because the two sides need different things: the operator's machine runs a harness and needs to intercept its shell, while the target only ever sees shell commands.
Every operating-system difference lives in two files — platform_other.go and
platform_windows.go — so the cost of supporting Windows is visible in one
place rather than spread through the adapters. Windows needs four things Unix
gives for free: a launcher that is not execve, shims that are not symlinks,
executability decided by PATHEXT rather than a mode bit, and a search-path
variable matched case-insensitively.
The fifth difference cannot be abstracted away. Win32-OpenSSH does not implement
ControlMaster, so a Windows operator pays a full connection setup per command
rather than ~7 ms on a shared one. That is not a portability detail: the
argument for having no daemon is precisely that
ControlMaster already provides connection reuse, and on Windows that premise is
false. reach therefore probes for multiplexing rather than assuming it, records
the answer, and reports it — see WINDOWS.md.
Users reach real hosts through jump hosts, certificate authorities, hardware
tokens, gpg-agent, Kerberos, 1Password, and Match exec blocks.
Reimplementing that surface faithfully is not realistic, and getting it subtly
wrong strands people on exactly the hosts they most need to reach. reach shells
out to the ssh they already have, so ~/.ssh/config keeps working unchanged.
The cost is that ssh reports its own failures as exit 255, which is indistinguishable from a command that genuinely exited 255. reach therefore carries the real status in-band behind an unguessable marker, and its absence is the signal that the transport, rather than the command, failed. Getting this wrong in either direction is bad: a transport failure reported as a command failure sends the agent chasing a phantom bug, and a command failure reported as a transport failure makes reach retry something that must not be retried.
Closing the channel is the whole of reach's control over a command it started.
A stock sshd offers no way to signal a remote process group, and a command that
produces no output never notices that the pipe it would have got EPIPE from
has gone — so a timeout, an interrupt, or codex's Esc key ends reach's interest
in a command without necessarily ending the command. sleep 600 survives; a
quiet build survives; anything writing steadily to stdout usually does not.
reach does not paper over this. A timed-out command says the command may still
be running and how to check, rather than reporting only that reach gave up. The
alternative — a shell wrapper that watches for its own stdin to close and kills
a process group — needs setsid or job control that a POSIX floor does not
guarantee, and getting it wrong means signalling the wrong process group on
somebody else's server. A local target is not affected: that process is reach's
own child and is killed.
reach separates reaching a target from performing file operations on it.
// internal/transport — how to reach a target and run a command.
type Transport interface {
Run(ctx, ExecRequest) (ExecResult, error) // to completion, bounded output
Open(ctx, command string) (Stream, error) // long-lived, piped stdio
Describe() string
Close() error
}// internal/fileops — how to act on files, in four interchangeable ways.
type FileOps interface {
Read(ctx, path string, off, n int64) ([]byte, error)
Write(ctx, path string, data []byte, mode fs.FileMode) error
Stat(ctx, path string) (*FileInfo, error)
List(ctx, path string) ([]FileInfo, error)
Mkdir(ctx, path string, mode fs.FileMode) error
Remove(ctx, path string, recursive bool) error
Rename(ctx, from, to string) error
Search(ctx, SearchRequest) ([]Match, error)
Glob(ctx, root, pattern string) ([]string, error)
Hash(ctx, path string) (string, error)
Tier() Tier
Close() error
}Search and Glob are first-class operations, not helpers derived from List,
and no tier has ever implemented them any other way: a search is one command on
the target, and only matches cross the network.
They execute on the target and return only matches, at every tier — which is
precisely what a mount cannot do, and the main reason reach beats one on the
operation that matters most. Deriving them client-side would mean dragging every
candidate file across the network to answer a question the target could have
answered locally.
Three strategies implement FileOps. They share almost no code — a shell
pipeline, a Python handler, a Go binary — and a user cannot tell which is in
use. That interchangeability claim is only worth something
because every tier runs one identical conformance suite
(internal/fileops/fileopstest): over the local transport in unit tests, and
over a real sshd in test/integration, which additionally asserts that a file
written through any tier reads back byte-for-byte through every other. A tier
that cannot pass it does not ship.
Full detail, including what each tier requires and writes, is in TRANSPORTS.md. The architectural points:
- Every tier answers one file operation in one round trip. A protocol that cannot — SFTP, which hands out a handle before it will read — was implemented and then removed for that reason, and the reasoning is kept in TRANSPORTS.md.
- Tier 0 is the floor and needs only a POSIX shell. Everything above it is an optimisation that is never required.
- Negotiation follows measurement, not the tier numbering. The numbers rank capability; reach ranks tiers by what they actually cost in a process-per-call design, where an interpreter or binary starting up is pure overhead. That makes tier 1 the negotiated choice where available, and the nominally fastest the helper tier the slowest to start.
- A pinned tier is an instruction.
--fileops=Xfails rather than substituting something else, because areach statusreporting a tier the session is not using is a lie the operator will act on. An autonegotiated tier may still step down, and says so on stderr. - Only the helper tier writes to the target, only when asked, never on an
--untrustedsession. Everything it installs is listed byreach doctorand removed byreach helper uninstall.
A session runs in one of two modes, chosen by what the harness's file tools can be made to do.
Correct and zero-copy. Used when the harness's file tools cannot be redirected (Claude Code, Codex, Kimi) and mirroring is not wanted.
Because the harness's native Read/Edit/Write would still silently act on
the local filesystem — reading the wrong file while the agent believes it is
remote — reach denies those tools in the generated harness config for this
mode. Silent wrong-target file access is the worst failure this design can
produce, so it is made structurally impossible rather than documented as a
caveat. The agent uses the shell for file access, which is transparently remote.
For harnesses that can shadow tools by name (opencode), exec mode is full
fidelity: read/write/edit/grep/glob are backed by the target directly,
and no mirroring is needed.
Gives Claude Code native file tools without MCP and without FUSE. A PreToolUse
hook rewrites the tool's file_path to a local copy that reach fetches at that
moment; a PostToolUse hook writes the result back.
This is deliberately not a sync engine, and not a bulk copy of the workspace. Nothing is mirrored until a tool asks for it, and there is no background reconciliation. A sync engine has to answer questions reach has no good answer to — both sides changed, deleted or never fetched — and getting them wrong loses the operator's work. Fetching exactly the file a tool is about to touch, at the moment it touches it, raises none of them.
Writes are guarded by a digest taken at fetch time. If the file changed on the target in between — a build, a deploy, another session — the write is refused with an error the agent can act on, rather than overwriting from a stale base. A refusal the agent can see is always better than a quiet loss.
Grep and Glob stay denied in mirror mode. The mirror holds only files
already opened, so a search across it would report confidently incomplete
results, and an agent told "no matches" concludes the code does not exist. The
agent is pointed at rg/find over the shell, which run on the target and are
faster anyway.
Where the mirror lives. Under ~/.reach/mirror/<session>/, with the
target's absolute path reproduced beneath it: /srv/app/main.go becomes
~/.reach/mirror/default/srv/app/main.go. Placing it at the identical absolute
path would make compiler output and stack traces line up with no translation at
all, which is genuinely attractive — but it would require reach to write to
/srv on the operator's own machine. That is usually impossible without root,
and an unacceptable thing for this tool to do even where it is possible.
The residual cost is that the agent sees a local path in a Read result and
could try to use it in a shell command, where it does not exist. The mirror-mode
system prompt tells it to use the target's own paths, and the hook leaves every
path outside the workspace alone so the harness's own files keep working.
Paths are cleaned before being joined to the mirror root, so a path containing
.. cannot escape it. File paths can originate in content read from an
untrusted target, which makes that a real attack path rather than a theoretical
one.
An honest assessment: mirror is the weakest of reach's mechanisms, kept because Claude Code offers nothing stronger. Its known costs, stated plainly rather than discovered by the operator:
- Reads can be stale; only writes are guarded. The digest protects the write-back, but a file that changes on the target right after the fetch is read and reasoned about in its old state. On a host with active builds or deploys, that window is real.
- It is read-modify-write over the network. An
Editrewrites the whole file back, not the changed lines. Grep/Globstay denied even here, so the mode does not actually deliver the full native tool surface it appears to promise.- The path leak above is mitigated by a system-prompt instruction, not eliminated — and instructions to models are probabilistic.
Where a harness offers a direct seam — opencode's tool shadowing, or Codex's
exec-server protocol, both of which execute file operations on the target
with no copy, no staleness window, and full search fidelity — that mechanism
is strictly better and mirror should not be used. Mirror exists for the one
harness whose tools can be neither shadowed nor redirected, only re-pointed at
a local file. Prefer exec mode for shell-shaped work, prefer a direct-seam
harness for edit-heavy work, and treat mirror as the fallback for when it must
be Claude Code and it must be native file tools.
| harness | seam | shell | file tools | verified |
|---|---|---|---|---|
| Claude Code | CLAUDE_CODE_SHELL_PREFIX |
✓ remote | exec/mirror | yes, 2.1.233 |
| Codex | exec-server (environments.toml) |
✓ remote (via exec-server) | ✓ remote (via exec-server) | yes, 0.148 — see harnesses/codex.md |
| Kimi Code | KIMI_SHELL_PATH → shim (npm patch) |
✓ remote | denied (use shell) | yes, 0.37.2 — see harnesses/kimi.md |
| opencode | generated tools shadowing built-ins by name | ✓ remote | ✓ remote | yes — see harnesses/opencode.md |
| Goose (Block) | GOOSE_SHELL env var |
✓ remote | denied via available_tools: [shell] |
yes — see harnesses/goose.md |
| Crush (Charm) | server mode (crush server --host) |
✓ remote | ✓ remote | yes — see harnesses/crush.md |
| Gemini CLI | PATH shim (bare bash name) |
✓ remote | denied via excludeTools |
yes — see harnesses/gemini.md |
For harnesses where the seam could regress across versions (Codex, Kimi,
Goose, Gemini), reach harness verify drives the harness against an embedded
offline mock model and checks where a scripted command actually ran. The launch
guard refuses versions measured to bypass the seam. The --task-prefix flag
probes a specific operation type (file read, file write) in addition to the
default shell execution canary.
No harness is forked. Claude Code and Codex keep their own authentication, so subscription logins continue to work and no key is introduced anywhere.
Harnesses want a program path for their shell hook, not a command line —
Claude Code stats the value of CLAUDE_CODE_SHELL_PREFIX directly, so
reach shell-prefix would be looked up as a single filename and fail. reach
dispatches on argv[0] through a symlink instead, which costs nothing per tool
call.
Claude Code wraps every Bash call. reach parses that envelope rather than forwarding it, because two segments are local-only. Full shape and rationale in RESEARCH.md; the operational summary:
- strip
source <local-snapshot>.sh— references local paths, and leaks the local username and directory layout to the remote host - strip
pwd -P >| /tmp/claude-<rand>-cwd— this is howcdpersists between calls; forwarded verbatim it would be written on the remote while Claude Code reads it locally, andcdwould silently stop working. reach tracks cwd itself and writes the local file the envelope named. - forward everything else
Envelope shape is version-specific, so internal/envelope parses defensively,
falls back to forwarding the whole string when the shape is unrecognised, and
is covered by a conformance test that fails when a Claude Code upgrade changes
it.
reach exists because the target is not trusted. Consequences, in full in SECURITY.md:
- No credential, token, or key is ever sent to the target.
- SSH agent forwarding is refused by default. On a host with a hostile root, a forwarded agent socket lets that host authenticate as you everywhere else you can reach.
- The harness's shell snapshot is stripped from every forwarded command, because sourcing it would disclose your username and directory layout to the target for no benefit. reach does not inspect or rewrite arbitrary commands: a false positive that mangled one would be worse than the leak it prevented.
- Output from the target is untrusted input. It flows into the context of an agent that holds your credentials and can write to your local disk; reach frames it as untrusted data.
Everything below is optional; reach works with none of it set.
| Variable | Effect |
|---|---|
REACH_HOME |
Where sessions, mirrors and audit logs live. Default ~/.reach. Setting it per-shell gives you independent sets of sessions. |
REACH_SESSION |
The session commands use when --session is absent. reach claude and the other harness launchers set it for the process they start, which is how a harness's tool calls find the right target. |
REACH_SSH_CONFIG |
An alternate ssh_config, passed as ssh -F. Lets reach's connections be configured separately from your interactive ones without duplicating host definitions. |
REACH_CONTROL_PERSIST |
How long the authenticated connection outlives its last command — a duration, or yes to keep it until reach down. Default one hour. Every connection after reach up runs in batch mode and cannot prompt, so on a host wanting a password or a hardware token this is the difference between a reconnect and a failed tool call. |
REACH_NO_AUDIT |
Set to any value to stop recording what reach did. A record of every command is occasionally the wrong thing to keep — a shared machine, a command line carrying a secret — and that judgement is the operator's. |
REACH_HELPER_BINARY |
A helper binary to install instead of the one reach would locate or build. For the helper tier only. |
REACH_LOCAL_SHELL |
Windows only: a POSIX shell to use for local:// targets. reach will not guess one, because guessing wrong runs your command under a shell that quotes differently. |
The session file records the target, the negotiated tier, and the capability
probe's results. It carries a schema version, and a file from a newer reach is
refused rather than partly read: encoding/json drops fields it does not
recognise without a word, and a session reach has only partly understood is
exactly the uncertainty about which machine this project exists to remove.