Remote sandboxed terminal sessions: a small runtime that hands out a shell inside a locked-down container, reached over a WireGuard tailnet.
MIT licensed, images published to GHCR. See LICENSE — and
NOTICE before pulling an image, because the images aggregate a
third-party agent CLI whose terms are its vendor's, not MIT.
A terminal that lives beside your ACP agents' workspace, in a pod rather than on
your host. The differentiator is the deployment and credential model, not terminal
features: the shell opens as uid 1000 with no sudo, no service-account token,
no host credentials, a read-only root filesystem, and an ephemeral workspace, and
it is reached over a tailnet with per-session tokens that carry no signing key.
openabdev/openab is an Open Agent
Broker: it bridges a chat client to an ACP-compatible coding CLI, brokering
stdio JSON-RPC so you can instruct an agent from Discord or Slack. You talk; the
agent acts.
openab-pty is the other half of that loop. It gives you a shell — you act,
directly, on the same machine the agent is working on. It speaks no ACP and
brokers nothing:
openab |
openab-pty |
|
|---|---|---|
| What you get | an agent you send instructions to | a terminal you type in |
| Protocol | ACP over stdio JSON-RPC, bridged to chat | a PTY streamed over WebSocket |
| Who performs the work | the agent, on your behalf | you |
| Front end | Discord, Slack, Telegram, … | any client speaking the contract below |
The images are FROM ghcr.io/openabdev/openab, and that is the point rather than
convenience: the shell opens inside the same image the agent runs in, with the same
agent CLI and the same workspace already present. When an agent leaves something
half-finished, you attach and look at it in situ instead of reproducing it
somewhere else.
The two are independent, though. Neither requires the other — this runtime is
useful on its own as a sandboxed remote terminal, and openab does not need a
terminal to broker agents.
Two containers in one pod. The runtime never listens on a routable address — the tailnet sidecar is the only thing with a network identity, and the two talk over loopback inside the pod's network namespace.
flowchart TB
C["Client<br/>admin credential in keychain"]
subgraph tailnet["WireGuard tailnet"]
TS_NET(("encrypted overlay"))
end
subgraph pod["Pod / ECS task — no host credentials"]
SC["tailscale sidecar<br/>userspace mode<br/>the only network identity"]
subgraph rt["openab-pty runtime — uid 1000"]
SRV["axum listener<br/>127.0.0.1:8090 only"]
ADMIN["admin plane<br/>sha256 verifier<br/>constant-time compare"]
TOK["token store<br/>per-session, TTL'd<br/>no signing key"]
SESS["session<br/>portable-pty + ring buffer"]
end
SH["shell<br/>no sudo · read-only rootfs<br/>no SA token · ephemeral ws"]
end
C -->|"HTTPS + WSS"| TS_NET
TS_NET --> SC
SC -->|"loopback"| SRV
SRV --> ADMIN
ADMIN -->|"mints attach token"| TOK
TOK -->|"verifies"| SESS
SESS -->|"pty master"| SH
classDef sec fill:#fff4e6,stroke:#d9822b,color:#000
classDef net fill:#e8f4fd,stroke:#3a87c8,color:#000
class ADMIN,TOK sec
class SC,TS_NET net
| Admin plane | Attach plane | |
|---|---|---|
| Endpoints | GET/POST /admin/sessions, …/{name}/renew, …/{name}/restart, DELETE …/{name} |
WS /pty/{session} |
| Credential | long-lived admin credential, checked against a sha256: hash |
short-lived per-session token, minted by the admin plane |
| Held by | the operator's client | whatever attaches, once |
A session shell can reach the listener over loopback, so the split is what keeps a compromised shell from managing its siblings: the shell never holds the admin credential, and an attach token authorises exactly one session and expires.
- The admin plane's boundary is the credential. A managed session can reach
the listener over loopback — the claim is that it cannot authenticate to, or
successfully invoke, admin operations, which is why the adversary test asserts
a
401rather than a refused connection. There is no in-container admin socket, so no code path treats being inside the container as authorization. - Teardown is best-effort. Tier 1 is the only kill domain implemented, and a process that leaves its process group may outlive its session until the pod or task is replaced. Label it as such wherever it is surfaced.
| Path | What |
|---|---|
runtime/ |
The Rust runtime. Static musl binary, no dependency on the OAB monorepo. |
runtime/CLIENT-CONTRACT.md |
Implementation spec for any client, captured from the running service rather than transcribed from source. Start here to write one. |
deploy/ |
Kubernetes pod and ECS Fargate task definitions, both verified. |
docs/k8s-howto.md |
Deploying on Kubernetes, including the failures worth knowing about in advance. |
docs/ecsctl-howto.md |
Deploying on ECS Fargate with ecsctl, same. Operator notes. |
There is no client in this repository. The contract above is the interface;
OpenAB Connect is one implementation of it and is not open
source. Anything that can hold a credential and speak WebSocket can be another —
§8 of the contract is a minimum viable client.
Published to ghcr.io/openabdev/openab-pty, one tag per agent CLI variant:
| Channel | Tag | Built from |
|---|---|---|
push to main |
pre-beta-<variant> |
openab:pre-beta-<variant> |
v* tag |
beta-<variant> |
openab:beta-<variant> |
Every build also publishes an immutable <variant>-<sha>. Deploy that if you ever
need to answer "which code is running" after the fact.
The native variant carries no agent CLI, and is the only one whose contents are
covered entirely by MIT-licensed code — see NOTICE.
The runtime links against musl for a static binary:
cd runtime && cargo build --release --target x86_64-unknown-linux-muslPhase 1, dogfooded on a k3s cluster and on ECS Fargate. The largest known gap is local echo prediction in clients — the runtime measures 1.0 ms of echo latency from its own host against 78–82 ms from a laptop over WiFi, so perceived quality is set almost entirely client-side. A client that does not predict will feel sluggish no matter what the runtime does.
See CONTRIBUTING.md. Security issues go to
SECURITY.md, not the public tracker.
The design of record is
docs/adr/openab-pty-runtime.md
in openabdev/openab, and it stays there: it is a decision record for the OAB
project, not for this repository.