Backends (runtimes)¶
For anyone who wants a session's agent to run somewhere other than this machine. After this page you will know what each backend needs, how to point a repository at one, and what changes about tabs, archive, and restore when a session is off-box.
A session's backend decides where its workspace and agent run. Every backend exposes the same session surface — attach, preview, prompt delivery, the live PTY stream — so the TUI, CLI, and daemon drive a containerised session much like a local one. Tab admission follows what each kind needs: shell/process tabs need a local PTY and process, and VS Code needs a daemon-side worktree/editor, so those remain local-only. A metadata-only web tab spawns neither and can be added to an off-box session (docker, ssh, sandbox, hook) when its target is an external HTTPS URL. Loopback targets still need an agent-side relay and are refused for now; plain HTTP cannot be framed by the HTTPS web UI. Admitted remote web tabs can be closed, renamed, and reordered like local web tabs.
| Backend | Where the agent runs | Selected with |
|---|---|---|
local (default) |
a git worktree + tmux on the daemon's own machine | nothing (the default), or backend = "local" |
docker |
a container on the daemon's Docker host | backend = "docker" + docker.image |
ssh |
a remote host over SSH | backend = "ssh" + ssh.host |
sandbox |
whatever the operator's own sandbox.ssh command reaches (free-form ssh: jump hosts, ProxyCommand, bastions) |
backend = "sandbox" + global sandbox.ssh |
hook |
wherever your provisioner scripts put it | backend = "hook" (see Hook backend) |
The operator-owned backend settings use [docker], [ssh], and [sandbox] in
global TOML. Their former flat spellings (docker_mount_agent_credentials,
ssh_host_key_verification, and sandbox_ssh) remain permanent aliases for
existing TOML and JSON. When both spellings are present, the grouped value wins,
including explicit false and empty-string values.
Select a backend per-repo in .agent-factory/config.json, or per-session on any
surface — each overrides the repo config for that one session:
- CLI —
af sessions create --backend <name>;af sessions backendsfirst if you want to see which values this repo can actually use. - TUI — press
ctrl+rwhile naming the new session and pick from the list. Its rows come from the daemon, so each one says whether this repo can use that backend; picking one it cannot names the config key to fix. Leaving the field alone (or choosing Repo default) keeps the repo's own setting. - Web — the backend select in the new-session modal, same list, same rule.
af agent-server is a backend, not the web UI
The non-local backends work by running an af agent-server in the remote
workspace — a headless, single-workspace process that a daemon dials and
drives. It serves no frontend: opening its port in a browser gets you a
404 telling you so. The web UI is served by the daemon — run af daemon
and open http://localhost:8443. See The web client.
Docker backend¶
With backend = "docker", a session runs entirely inside a container:
- The daemon
docker runs a container from your image, publishing an internal port on a random loopback host port. - It clones the repo (from the repo's
originremote) into/workspacein the container. - It copies the
afbinary into the container and starts anaf agent-serverthere — a headless, single-workspace server over the same bearer-token HTTP/WS protocol the daemon speaks (plain HTTP, no TLS). - The daemon drives that in-container agent-server over
http://127.0.0.1:<port>(a container-published loopback port). Attach, preview, prompts, and the live terminal stream all flow over that one authed connection. - On kill, the container is torn down (
docker rm -f) — no leaked containers.
The container is disposable: durability lives in GitHub (the workspace is a
clone of repo@origin), not in the container filesystem — so archive pushes the
branch and reaps the container, and restore re-provisions a fresh container that
clones the branch back (see Archive & restore).
Configuration¶
{
"backend": "docker",
"docker": {
"image": "my-org/af-runtime:latest",
"run_args": ["--memory", "4g", "-e", "MY_VAR=1"]
}
}
| Key | Required | Description |
|---|---|---|
docker.image |
yes | The container image the session runs in (see requirements below). |
docker.run_args |
no | Extra arguments appended verbatim to docker run (mounts, env, resource limits). |
Letting a containerised agent authenticate is not a docker key — it is the
global-only, operator-owned docker.mount_agent_credentials grant. See
Agent credentials in a container.
The Docker runtime does not copy the daemon's whole environment into the
container. Because repository config selects the image and its binaries, af
does not automatically grant that image the built-in agent, GitHub, proxy, or
CA variables used by local sessions. Only names explicitly listed in the
global session_env_passthrough setting cross this boundary. Docker receives
each as -e NAME, so the value does not appear in the docker command line.
Container-native HOME and PATH remain owned by the image. An environment
added through docker.run_args still has to be built in or named in
session_env_passthrough before the agent pane may inherit it.
Treat each Docker pass-through name as a trust grant to the configured image,
and prefer an image pinned by digest. For example, a private GitHub Codex
session using environment-backed credentials may explicitly list
GH_TOKEN and OPENAI_API_KEY; that keeps clone, gh, HTTPS push, and Codex
authentication working after the operator has made that trust decision. If you
use stored credentials instead, mount only the relevant config or
credential-helper resources deliberately with docker.run_args, or set the
global docker.mount_agent_credentials grant (below); host paths and native
keyrings are not implicitly mounted into a container.
Agent credentials in a container¶
Most agents authenticate from a file in the daemon user's home (an OAuth token or an API key on disk), not from an environment variable — so the default-deny env boundary above, on its own, leaves a containerised agent unauthenticated.
The operator (not a repo) opts in by setting the global
docker.mount_agent_credentials:
When it is on, a docker session bind-mounts read-only the credential file for that session's own agent — and only that agent. The mapping is:
| The session's agent | Host file(s) mounted read-only → container path under /root |
|---|---|
| claude | ~/.claude/.credentials.json |
| codex | ~/.codex/auth.json |
| gemini | ~/.gemini/{oauth_creds,gemini-credentials,google_accounts}.json (whichever exist) |
| amp | ~/.local/share/amp/secrets.json (amp's stored API key; if you enabled amp's native keyring storage there is no file, so name AMP_API_KEY in session_env_passthrough instead) |
| opencode | ~/.local/share/opencode/auth.json |
| aider | (none — authenticates via API-key env vars; name it in session_env_passthrough) |
| devin | ~/.config/devin/config.json (no effect unless your image also carries the devin CLI) |
Exactly one row applies per session — the one for the session's resolved agent. A codex session is never handed the Claude token, and vice versa (a docker session's agent is fixed for its life). af resolves the file under the daemon user's real home and mounts it only if it exists, so:
- Minimum surface, per agent. It mounts the credential file, not the whole
config directory: an agent writes state and refreshes its token inside that
directory at runtime, so a read-only whole-dir mount would break it, and some of
those dirs reach gigabytes of history (
~/.codex,~/.local/share/opencode).~/.claude.jsonis deliberately not mounted — it is the config/privacy blob (MCP server tokens, every project path and prompt history, account and machine ids), not the credential;~/.claude/.credentials.jsonis what authenticates. - Read-only. The agent authenticates but cannot refresh or rewrite the host credential (a session-lifetime token still works; the disposable container discards its own writes on kill regardless).
-
SELinux-relabeled, on hosts that need it. On a host running SELinux in enforcing mode — the Fedora, RHEL and CentOS default — the mount is
:ro,zinstead of plain:ro. Without the relabel the container still starts and only the read is denied, which surfaces as a session that is silently unauthenticated rather than as an error.zis the shared label rather thanZ: every concurrent session running that agent mounts the same host file, and the private label would relabel it out from under the others. Note that on such a host this does relabel the credential file itself tocontainer_file_t.af reads
/sys/fs/selinux/enforceto decide. Permissive mode is treated as not needing it — SELinux logs the denial there rather than acting on it, so the read succeeds and your file is left untouched. A host switched to enforcing while sessions are running needs those sessions restarted to pick the label up.The rule is: af relabels unless it can prove the relabel is unnecessary.
zis inert wherever SELinux is not enforcing, while skipping it on an enforcing host costs you a silently unauthenticated session — so the two directions are not worth treating alike, and every uncertain case resolves to relabeling. Concretely, af keeps the relabel when:- it cannot read the SELinux mode at all;
- the Docker endpoint is not local (
DOCKER_HOST/DOCKER_CONTEXTpointing off this machine). Docker resolves and labels a bind source on the daemon host, so this host's SELinux mode describes the wrong machine; - no enforce file is visible but the kernel registers
selinuxfs. This is the af-in-a-container case: with the host's Docker socket mounted, af sees a localunix://endpoint, yetselinuxfsis usually not mounted inside a container — so an absent enforce file there means "af cannot see it", not "the machine has no SELinux"./proc/filesystemsis kernel-global rather than namespaced, so it still describes the machine that will label the mount.
One case af cannot see through: a
unix://socket forwarded to a different kernel (socat, or a VM reached through a local path) is indistinguishable from a local daemon. Docker Desktop is the common instance and its VM does not enforce SELinux, so this is theoretical — but if you forward a socket to an enforcing host, set the mount mode yourself viadocker.run_args.The account mount (below) takes the same decision, for the same reasons — af installs both mounts into the same container on the same engine, so they never disagree. One consequence to know about: an account path containing a
:needs Docker's--mountform, which cannot carry a relabel, so af refuses that combination on a host that needs one. MoveAGENT_FACTORY_HOMEto a path without a colon.
A global, operator-owned grant — and a deliberate partial hole
docker.mount_agent_credentials is global-only: a repository selects the
docker image, but only the operator decides whether that image may see
their credentials. This is enforced by source scoping, not a trust gate —
the key simply is not a repo key, so a cloned .agent-factory/config.json
that sets it hits the standard "global setting, cannot be set per-repo" error
(the same model as session_env_passthrough). Were it repo-settable, a cloned
third-party repo could grant itself the operator's credentials by shipping
backend=docker + its own image + this flag — which is exactly what the
default-deny boundary (#2329) exists to prevent.
When on, it re-exposes one credential file to the configured image — the named exception to the boundary, not a relaxation of it: everything else stays blocked, so containment is partial by design. Turn it on only for images you trust with read access to that agent's credential.
Image requirements (bring-your-own image)¶
There is no af-published image: you bring your own (Sachin-locked
decision). The af binary is copied in for you at session start, so your image
only needs the workspace tooling:
git— to clone the workspace.tmux— the in-container agent-server drives the agent through tmux.shandsleep— the container is kept alive withsleep, and setup runs throughsh.dd— used to stream the live PTY (present in both busybox and coreutils).cat— reads the agent-server's startup banner, and, for an account-scoped session, the container's own/proc/1/mountinfowhen af verifies the account boundary (#3598).stat,mkdirandchown— account-scoped sessions ONLY: af asks the mounted account who owns it, then prepares a writable HOME for that same uid. A session with no--accountnever runs these. Both these entries andcatwere already required and simply missing from this list (#3602 review); busybox and coreutils each provide all of them.- The agent CLIs you intend to run (claude, codex, aider, gemini, …).
- A libc/architecture compatible with the daemon's
afbinary. The copied-in binary is the daemon's own executable, and release builds are a plaingo build(cgo on) onubuntu-latest— i.e. dynamically linked against glibc. So your base must be glibc, and its glibc must be ≥ the daemon build's (ubuntu-latestis glibc 2.39 today):debian:trixie/ubuntu:24.04work,debian:bookworm(2.36) is too old, and a musl base (alpine) will not run the binary at all. If you rebuild the daemon on a newer-glibc distro, rebuild the image on a matching-or-newer base. (A staticaf,CGO_ENABLED=0, would run on any base including alpine — but that is not how release builds are produced; the integration test force-builds a staticafprecisely so it can use an alpine test image.)
The container's internal agent-server port is 8000; avoid binding it in your
image.
The image this repo ships¶
This repo carries a starting-point image so a docker-backed session works out of the box — build it with:
It is based on node:22-trixie-slim (glibc 2.41; Node 22 for the codex CLI) and
includes claude and codex — the two agents this repo's sessions use. The
Dockerfile carries commented, one-uncomment install lines for the other agents
(gemini, amp, opencode, aider); devin is not installed because af
forwards it no per-agent credentials. Override the tag with
make session-image SESSION_IMAGE=my-org/af-runtime:latest, then set that in the
repo's docker.image. There is no registry step (bring-your-own, built locally).
A minimal hand-rolled example, if you would rather not use the shipped one:
FROM node:22-trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends git tmux ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code @openai/codex
# a musl base (alpine) works only if the daemon's af is a static (CGO_ENABLED=0) build
Operations¶
- List managed containers:
docker ps -a --filter label=af.session. Each also carriesaf.home=<AF home>, the daemon (home) that created it. - Automatic orphan reaping: on daemon startup, af removes any leaked session
container this home created (
af.homematches) that no live or mid-provisioning session owns — the cases where a previous daemon died without reaping, a session record was deleted, or a reap raced a create. It is scoped to this AF home and to the currently-targeted Docker engine, so it never touches another home's or another engine's containers, and a container with noaf.homelabel (created by a pre-upgrade af) is left alone. Kill still reaps synchronously; this is only the backstop for the container that outlived its session. - Reap a leaked container by hand (should never be needed):
docker rm -f <id>
Requirements on the daemon host¶
- The
dockerCLI onPATHand a reachable Docker daemon. - A local Docker engine —
DOCKER_HOST/DOCKER_CONTEXTpointing at this machine.docker runpublishes the in-container agent-server port on the engine host's loopback (-p 127.0.0.1::8000), and the daemon dials its own loopback to reach it, so a remote engine yields an endpoint this daemon can never connect to. af refuses a remote engine at create time (and reports it unavailable at choose time) rather than provisioning a session that fails later with an opaqueconnection refusedto127.0.0.1. - At most one of
DOCKER_HOSTandDOCKER_CONTEXT, unless both point at the same endpoint. When both are set, the docker CLI dialsDOCKER_HOST, but its reference documentsDOCKER_CONTEXTas the override. af cannot tell which one you meant, so it refuses the combination and names both variables in the error — including in the daemon-startup orphan sweep, which skips its destructive pass rather than guess an engine. Endpoint spellings that provably name one engine still pass: letter case, a scheme's default port, and trailing dots or slashes are normalized before af compares them. With only one of them set, af follows the CLI:DOCKER_HOSTwins over the context selected withdocker context use, andDOCKER_CONTEXTwins over it too. - The repo must have an
originremote the container can clone from (GitHub for a real repo; afile://path + arun_argsbind-mount for a self-contained test).
Testing¶
make backend-docker-roundtrip runs the real end-to-end container round-trips on
a host with Docker: it builds a slim git+tmux image, creates a session on the
docker backend, drives the in-container agent-server over http:///ws:// (input →
stream echo → preview/snapshot/liveness), and asserts the container is reaped on
kill. A second case commits real work on the session branch, archives it
(asserting the branch is pushed to origin and the container reaped), then
restores it (asserting a fresh container clones the branch back, the commit
is present, and the session is drivable again). It skips cleanly where Docker is
unavailable.
These builds resolve nothing against a registry. The public base is fetched once
per test binary, with retry, and retagged to a local-only name the Dockerfiles
build FROM; a base that cannot be fetched at all skips, the same way a missing
Docker daemon does. make registry-free-check proves it, by building the real
Dockerfiles inside a second Docker daemon whose egress is dropped — and re-running
this bug's own reproduction each time, so it cannot pass by measuring a network
that was quietly reachable (#2521).
Making docker the default backend for a repo¶
To run a repo's sessions in containers by default, set backend = "docker" in
its .agent-factory/config.json. This is an operator decision, not a default af
ships — the steps below must be done in order, because the flip does not
degrade gracefully on an unprepared box:
- Build the image:
make session-image(or pointdocker.imageat your own — see Image requirements). - Grant credentials (operator, global):
af config set docker.mount_agent_credentials true, so the containerised agent can authenticate (see Agent credentials in a container). This is global-only on purpose and cannot be set from the repo config. - Set the repo default: put
"backend": "docker"and"docker": {"image": …}in the repo's.agent-factory/config.json.
Only after all three does a new session in that repo come up in a container. A flip with no image built, no reachable Docker daemon, or the grant unset does not fall back to local — it fails session creation with a backend error. So make docker the default only once the box is ready.
Existing sessions are unaffected by the flip. A session's backend is recorded
on its own row (InstanceData.BackendType, written from backend.Type()), and
the daemon reconstructs each session from that stored discriminator on
restart/restore — it never re-reads backend from config after create (which is
consulted only when a session is first created). So a running local session comes
back local regardless of the repo's new default; only new creates read it. This is
pinned by TestFromInstanceData_SandboxBackends, so a future change that made
restore re-resolve from config would fail a test rather than silently reinterpret
someone's session.
SSH backend¶
With backend = "ssh", a session runs on a remote host you reach over SSH — the
built-in, opinionated version of what a hook launch_cmd did by hand:
- The daemon runs
ssh(OpenSSH 7.6 or newer must be on the daemon host's PATH; 7.6 is whatssh.host_key_verification = "accept-new"needs, and the other postures need less), with every setting af owns passed explicitly and no configuration file read at all — reusing your keys and verifying the host key againstknown_hosts. - It creates a fresh per-session directory under
~/.af-sessionson the remote and clones the repo (from the repo'soriginremote) intoworkspace/there. - It streams the
afbinary onto the remote and starts anaf agent-serverbound to127.0.0.1:0(a random loopback port — never exposed on the remote's public interface), behind the same bearer-token protocol (plain HTTP). - It opens an SSH local-forward tunnel from a daemon-local loopback port to
that remote port and drives the agent-server over
http://127.0.0.1:<localport>. Attach, preview, prompts, and the live terminal stream all flow through the one tunneled, authed connection (the SSH tunnel encrypts it; the bearer token still applies end to end inside the tunnel — defense in depth). - On kill, the remote
af agent-serveris stopped, the session directory is removed, and the tunnel + SSH connection are closed — no leaked process, dir, or tunnel. The remote host itself is left running (it is your machine, not a disposable sandbox).
Like docker, the workspace is disposable: durability lives in GitHub (the
workspace is a clone of repo@origin) — archive pushes the branch and reaps the
remote session, and restore re-provisions a fresh remote that clones the branch
back (see Archive & restore).
Configuration¶
{
"backend": "ssh",
"ssh": {
"host": "build-box.example.com",
"user": "af",
"port": 22,
"identity_file": "~/.ssh/id_ed25519",
"known_hosts": "~/.ssh/known_hosts"
}
}
| Key | Required | Description |
|---|---|---|
ssh.host |
yes | The remote host (host or host:port) the session runs on. |
ssh.user |
no | The SSH login user (default: the current OS user). |
ssh.port |
no | The SSH port (default: 22). Give the port either here or as host:port in ssh.host, not both with different values — a conflict is rejected with an error naming both, rather than one silently winning. Identical values are fine. |
ssh.identity_file |
no | Path to the private key for auth. Empty ⇒ ssh-agent (SSH_AUTH_SOCK) and the default ~/.ssh keys are tried. ~ is expanded. |
ssh.known_hosts |
no | Path to the known_hosts file the remote's host key is verified against (default: ~/.ssh/known_hosts). ~ is expanded. |
Your
~/.ssh/configdoes not apply, and never has. af runssshwith-F none, so neither~/.ssh/confignor/etc/ssh/ssh_configis read. Only thessh.*settings above,ssh.host_key_verification, and your keys decide how af connects. This is unchanged behaviour — the backend has always ignoredssh_config— and it is deliberate: this backend's contract is that af enforces the host-key posture in code, and aHostblock could otherwise supplyProxyJump,RemoteCommand,SendEnvorForwardAgentbehaviours that no af setting can override.If you need a bastion, a
ProxyJump, aProxyCommand, or any transport af does not model, usebackend = "sandbox"with a free-formsandbox.sshcommand — that backend exists precisely so you can express the connection yourself, and there yourssh_configandknown_hostsare the whole authority. See the sandbox section below.A
ssh.hostwith several addresses stays on one machine. af runs a separatesshfor each step — the setup commands, the port-forward, and the cleanup — so a name answering with more than one address (a round-robin record, several A records, a dual-stack host) could once put the workspace on one machine while the agent-server or the cleanup landed on another. Nothing looked wrong: every step succeeded against a valid host, and the cleanup then removed the wrong machine's directory and reported success while the real one leaked.af now resolves
ssh.hostonce, when the session is created, and every later step dials that one address. The address is recorded with the session, so the cleanup reaches the same machine even after the daemon restarts.This fixes DNS multiplicity, and only DNS multiplicity. Read the next paragraph before assuming it covers you.
A load-balancer VIP is pinned to one machine too. If
ssh.hostreaches a virtual IP in front of several machines — an L4/TCP load balancer, an AWS NLB, a keepalived/IPVS pair, a Kubernetes service — then pinning the address would not have been enough, because the address is not a machine identity: the balancer picks a backend per TCP connection, and af opens one for every provision command, for the tunnel, and for the cleanup.So af asks the machine who it is. On the first connection it reads
SSH_CONNECTION, which tells it the address that backend accepted the connection on, and pins every later step to that. The address is recorded with the session, so the cleanup reaches the same machine even after the daemon restarts.Two cases where af leaves the pin alone, both logged:
- The backend's own address is not reachable from the daemon — a private address in another VPC, or across a NAT boundary. Pinning it would turn a working host into one where every step fails, so af checks first and keeps the address pin instead.
- The balancer preserves the destination address (direct server return), so the backend reports the VIP itself. There is nothing to re-pin to.
In both cases the session behaves as it did before, and a multi-machine target can still split it — point
ssh.hostat one machine if that matters.Your host key configuration is unaffected, which is the part worth knowing:
sshis still given the name as its destination, soknown_hostsis matched and host certificate principals are checked exactly as before. The pin applies only to the TCP connection, through aProxyCommandthat runsafitself — nothing new to install. An earlier attempt pinned the address as ssh's destination and had to restore the name withHostKeyAlias; that rejected host certificates on every non-default port, and was reverted (#3086).One genuine, if small, reduction comes with it:
CheckHostIPis switched off. OpenSSH disables it whenever aProxyCommandis in use, and it defaulted to on for OpenSSH 7.6–8.4 (upstream turned the default off in 8.5). On those clients a pinned session no longer records or cross-checks the host key against the address inknown_hosts, only against the name. Verification against the name — the guarantee that matters, and the one certificates rely on — is unchanged, and af resolving the address itself and reusing that one address for every step covers the driftCheckHostIPwas watching for. It is a trade, not a free win: ssh's IP cross-check for af's single-resolution guarantee.What this changes for an existing
ssh.host, deliberately: a session is tied to the machine it was created on for its whole life. Before, if that machine went away mid-session, the next step would resolve the name again and might reach a different one and appear to keep working — which is the bug, not a feature: the workspace it needed was on the first machine. Now that step fails against the machine holding the workspace, which is the honest answer. Choosing a machine still tries each address the name answers with, so a host with one dead address and one live one is picked correctly at create time.Nothing else about connecting changes: same
known_hostsmatching, same certificate principals, same keys, samessh_config-is-not-read rule, and no new software to install. There is nothing to migrate — existing sessions created before this keep working and are cleaned up by name exactly as they were.If af cannot resolve the name at create time, or cannot run itself as the relay, it says so in the log and connects by name, exactly as it did before — a host af cannot look up still works. The pin is an improvement on connecting by name, so when it cannot be applied af falls back rather than failing the session.
Which resolver picks the address, and when it matters. af resolves with Go's built-in resolver, which reads
/etc/hostsand DNS.sshresolves withgetaddrinfo, which followsnsswitch.confand so can also use LDAP,sssdor mDNS. Once af pins,sshdoes no resolving at all — the connection is made for it — so every step of a session goes to the same place regardless.The one place this can bite is a
known_hostsentry recorded earlier, by a plainsshor an older session, against whichever machinegetaddrinfopicked. If your resolvers select different machines and those machines have different host keys, the pinned one presents a key that entry does not match and the session fails to start, naming the host. Nothing runs on the wrong machine — verification refuses it before authenticating. Pointssh.hostat one machine if you hit this.
accept-newpins only once it knows the host. On the very first connection to a host there is no entry to check against, soaccept-newwould simply record whatever key the pinned machine presented — and if that were the wrong machine, the session would run there silently and the name would stay bound to it. af therefore does not pin that first connection, and pins normally afterwards.insecureverifies nothing, so it is never pinned. Both cases are logged, and both keep the multi-address exposure this section describes.
Host-key verification is strict by default (secure by default — an unverified
host could MITM the connection and capture the bearer token). The operator can
relax it with the global-only ssh.host_key_verification key:
ssh.host_key_verification |
Behavior |
|---|---|
strict (default) |
Verify against a known_hosts file; refuse an unknown or changed key. Existing behavior — no change. |
accept-new |
Trust-on-first-use: record an unknown host's key on first connect and proceed, but still refuse a changed key. This removes the pre-seed step for ephemeral hosts. |
insecure |
No verification. A man-in-the-middle can capture the session bearer token; use only on a trusted network. |
It is global-only (operator-owned), not part of the repo-settable ssh table:
a repo selects ssh.host, but only the operator decides whether to relax
verification — a repo-settable waiver combined with a repo-settable ssh.host
would be a one-commit man-in-the-middle. Set it with
af config set ssh.host_key_verification accept-new.
accept-new records learned keys in an af-owned store under the AF home
($AGENT_FACTORY_HOME/ssh_known_hosts) by default — never your shared
~/.ssh/known_hosts — or in ssh.known_hosts if you set that explicitly.
If you keep the default strict and need to reach a freshly-provisioned host,
either switch that host to accept-new for the first connect, or add its key to
your known_hosts out of band (ssh-keyscan -H host >> ~/.ssh/known_hosts, or
point ssh.known_hosts at a dedicated file), then create the session.
Legacy cleanup records. The ssh.host_key_verification posture is stored alongside a
killed session's cleanup record, but records written before that field existed
carry no posture, so they are cleaned up strictly. If such a record's host is not
in the strict known_hosts file, no retry can ever complete it — af says so once
and stops retrying, naming the host to add, rather than backing off and retrying
forever. Restarting the daemon re-attempts it, so adding the key is enough to let
the cleanup finish. This applies only to those pre-existing records · every
session created since records its posture and is unaffected.
af's ssh invocation never forwards the daemon's environment. The remote
agent-server and pane use credentials already present for the remote login
account, filtered through the same built-in allowlist. Names in the daemon's
global session_env_passthrough list are sent to the remote as names only; if a
matching variable exists in the remote account's environment, the agent may
inherit it. A local token is never copied to the SSH host implicitly.
Requirements on the remote host¶
- An SSH server you can log into with a key, permitting TCP forwarding
(
AllowTcpForwarding yes— the default on most distros; the runtime reaches the remote agent-server through an SSH local-forward tunnel). git,tmux,sh, and a libc/architecture compatible with the daemon'safbinary (a staticafbuild,CGO_ENABLED=0, runs on any base). Theafbinary is streamed onto the remote for you — always version-matched to the daemon, so there is nothing to pre-install.- The agent CLIs you intend to run (claude, codex, aider, gemini, …).
- The repo must have an
originremote the remote host can clone from (GitHub for a real repo; afile://path for a self-contained test).
Operations¶
- Find a session's remote files: they live under
~/.af-sessions/<title>.XXXXXXonssh.host. - Reap a leaked session (should never be needed — kill reaps automatically):
ssh <host> 'pkill -f "agent-server --listen"; rm -rf ~/.af-sessions/<dir>'.
Testing¶
make backend-ssh-roundtrip runs the real end-to-end SSH round-trips on a host
with Docker: it stands up a throwaway sshd+git+tmux container as the ssh target
(no external host, no dependency on the box's own sshd), creates a session on the
ssh backend pointing at it, drives the remote agent-server over the ssh-tunneled
http:///ws:// (input → stream echo → preview/snapshot/liveness), and asserts the remote
process is reaped + the session dir removed + the tunnel closed on kill. A second
case commits real work, archives it (branch pushed to origin, remote
sandbox reaped), then restores it (a fresh remote clones the branch back, the
commit is present, the session is drivable) — the identical push/pull-branch
flow, over ssh. It skips cleanly where Docker is unavailable.
Sandbox backend¶
backend = "sandbox" reaches the target through your own ssh invocation. The
global sandbox.ssh key holds a free-form command line — whatever already works
in your terminal, including a jump host, a ProxyCommand, a bastion, or any flag
backend = "ssh" does not model — and af runs the same provision, tunnel, and
cleanup steps over it. Your ssh_config, known_hosts, and host-key posture are
the whole authority here; af adds none of its own. It is global-only and
operator-owned, because af executes it on the daemon host (see
Configuration).
Known limitation: af cannot pin a
sandbox.sshtarget to one machine. af runs a separate invocation of your command for each step — the setup commands, the port-forward, and the cleanup. If it reaches a name with several addresses (a round-robin record, a load balancer, a dual-stack host), each invocation resolves independently, so the workspace can end up on one machine while the agent-server or the cleanup lands on another. Nothing looks wrong: every step succeeds against a valid host, and the cleanup then removes the wrong machine's directory and reports success while the real one leaks.
backend = "ssh"no longer has this problem because af composes that command and knows which token is the host.sandbox.sshis your command, and af cannot know which of its words is a hostname — an argument to-o, a jump-host spec, a wrapper's own flag, or the target — so there is nothing it can safely substitute. Guessing would silently rewrite the command you asked for.Two workarounds, both under your control:
- Point the command at a single machine — a literal address, or a name with one address. This is the simplest fix and needs no other change.
- Pin it yourself, the same way af does for
backend = "ssh": keep the name as ssh's destination soknown_hostsand certificate principals still match, and put the address in aProxyCommand— for examplessh -o ProxyCommand='nc 198.51.100.8 22' build-box.example.com. Do not pin by making the address the destination: that forces aHostKeyAlias, and no alias value satisfies both a plain[name]:portentry and a certificate principal.Tracked in #3086, which is deliberately left open for this half.
Hook backend (bring-your-own-provisioner)¶
backend = "hook" is the escape hatch for infrastructure the built-in docker
and ssh runtimes don't model — Kubernetes, Modal, Daytona, a bastion with
exotic auth, a bespoke orchestrator. Instead of an opinionated built-in, you
provide the provisioning: two shell scripts that stand the workspace up and tear
it down.
Since #1592 Phase 4 PR7 the hook backend follows the same
provision-and-expose contract as docker/ssh. Your launch_cmd clones the
repo on your infra, starts an af agent-server there, and echoes that
server's authed endpoint ({url, token}); the daemon then
drives the session over that ws:// stream — so a hook session matches a local,
docker, or ssh one on attach, type, resize, preview, archive/restore, and kill.
Like the other off-box backends it admits only external HTTPS web tabs; shell,
process, and VS Code tabs need a daemon-side worktree.
{
"backend": "hook",
"remote_hooks": {
"launch_cmd": "./.agent-factory/hooks/launch.sh",
"delete_cmd": "./.agent-factory/hooks/delete.sh"
}
}
The mechanics of launch_cmd (its arguments, the JSON endpoint it must echo,
how to start an af agent-server on your infra), delete_cmd, session-name
slugging, and af doctor validation live in the dedicated guide — see
Remote hooks. This is intentionally the built-in ssh
runtime done by hand: if plain SSH-to-a-host covers your case, prefer ssh
(zero scripting); reach for hook only when it doesn't.
Migrating from the old remote_hooks contract¶
PR7 is a breaking, clean-break change. The old hook contract — launch_cmd
returning a session id, plus list_cmd/attach_cmd/terminal_cmd for
enumeration, terminal proxying, and preview capture — has been removed;
launch_cmd now returns an af agent-server endpoint and the only other script
is delete_cmd. A config that still sets a removed key is rejected with an error
pointing at the guide.
The migration is mechanical (your launch_cmd gains an af agent-server start
and echoes its URL). Rather than duplicate it here, follow the copy-pasteable
recipe in
Remote hooks → Migrating from the old contract.
Archive & restore¶
For the disposable sandbox backends (docker, ssh, and sandbox), archive and restore
are push/pull of the session branch — the durable workspace is the branch on
GitHub (origin), not the sandbox:
- Archive (
af sessions archive) pushes the session branch toorigin, then tears the sandbox down (reaps the container / removes the remote session dir + closes the tunnel). The session record is preserved as an inert Archived row — restorable, but consuming no container or remote process. - Restore (
af sessions restore) re-provisions a fresh sandbox that clones the pushed branch back, restarts theaf agent-server, and relaunches the agent. The session resumes from the pushed branch state.
This is the same flow for every off-box backend (it is written once against the runtime
seam), and it is why docker/ssh/sandbox/hook match local on the lifecycle
capabilities — Archive and Recover are both supported. All four are declared
off-box in one place (backendProvisionsOffBox), and all four share a single
capability declaration, so none of them can differ from the others here without
that being a deliberate change. Parity is not total: they declare
TabManagement and Handoff off, which is why an off-box session carries only
its agent tab plus any external HTTPS web tabs (the one metadata-only kind
admitted off-box). A Lost sandbox session (one whose
sandbox answered that its agent is gone) is reachable, so recovery pushes its
work to origin before replacing it (anything unpushed would be destroyed by
the re-clone) — and refuses to replace if that push fails. Unreachability alone
is not death — it does not mark a sandbox Lost, and restore refuses to replace a
sandbox it cannot reach.
What survives, and what doesn't¶
Because the sandbox is thrown away, only what reaches origin survives an
archive:
- Committed work on the session branch is pushed, so it is fully preserved.
- Uncommitted work is snapshotted into a WIP commit
(
af: pre-archive snapshot (uncommitted work)) and pushed too, so the working tree is not lost — matching thelocalworktree-move archive's "nothing lost" guarantee as closely as the disposable model allows. If you would rather not carry that WIP commit, commit your work yourself before archiving. - The agent's conversation history lived only in the disposed sandbox and is
not restored — a fresh agent runs on the restored branch. (The
localbackend, which relocates the worktree in place, does resume the conversation; this is the one place the disposable model differs.)
Requirements¶
The sandbox must be able to push to the repo's origin — the same remote it
cloned from. For a real repo that means the container/remote has git credentials
for origin (an HTTPS token or an SSH deploy key); for a self-contained test a
read-write file:// remote works. If the push fails, the archive fails and the
session is left running (nothing is torn down), so no work is lost.