rest-pty

command module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: MIT Imports: 13 Imported by: 0

README

rest-pty

Terminals as a service — drive PTY sessions over REST and MCP.

CICD Go Version Go Report Card

rest-pty wraps pseudo-terminal (PTY) sessions behind a REST API. You define named sessions that each run a command, start and stop them on demand, feed them input, and read back their scrollback output — all over HTTP. It is useful for orchestrating interactive or long-running terminal programs from a service, a UI, or another automation.

Overview

A session is a named, persisted object that describes a command to run and how to run it. Sessions move between two states:

  • IDLE — defined but not running.
  • READY — running and accepting input.

Each session executes through one of two drivers:

  • PTY — runs the command in a local pseudo-terminal on the host.
  • DOCKER — runs the command inside a hardened, isolated container (read-only root filesystem, all Linux capabilities dropped, no networking by default).

Session metadata is persisted in SQLite, while input and output are streamed through Redis ring buffers, which also retain a bounded scrollback history you can read at any time.

Features

  • Two execution drivers — local PTY or hardened Docker container.
  • Hardened-by-default containers — read-only rootfs, dropped capabilities, no-new-privileges, and no network access unless explicitly opted in.
  • Persistent sessions — session definitions survive restarts via SQLite.
  • Shared workspaces — an optional cairn workspace volume mounted into a Docker session, so several containers can share artifacts.
  • Scrollback — bounded output history backed by Redis ring buffers.
  • OpenAPI spec — authoritative schema in docs/swagger.yaml, with a generated TypeScript client SDK target.
  • Observability — Prometheus metrics endpoint plus health and readiness probes.

Architecture

            HTTP                 ┌──────────────┐
 client ───────────► REST API ─► │   Manager    │
                                 └──────┬───────┘
                                        │
                                 ┌──────▼───────┐
                                 │    Runner    │   (one per running session)
                                 └──────┬───────┘
                                        │
                                 ┌──────▼───────┐
                                 │    Driver    │  PTY  │  Docker
                                 └──────┬───────┘
                                        │
           input  ──► Redis ring buffer ──► process stdin
           output ◄── Redis ring buffer ◄── process stdout/stderr

           session metadata ◄──► SQLite

The REST API delegates lifecycle operations to a Manager, which spins up a Runner for each started session. The Runner drives a PTY or Docker core process, bridging its stdin/stdout through Redis ring buffers. Session definitions are persisted in SQLite.

Requirements

  • Go 1.26 (to build from source)
  • A reachable Redis instance (used for session I/O buffers)
  • SQLite — the database file is created automatically
  • The Docker daemon — only required when using the DOCKER driver

Quick start

  1. Start Redis (a local development instance via the bundled compose stack, listening on 127.0.0.1:6479):

    make up
    
  2. Build the binary:

    go build -o rest-pty .
    # or: make build
    
  3. Create a config file (config.yml):

    metrics:
      service:
        listenOn: 127.0.0.1
        appPort: 3001
    
    api:
      service:
        listenOn: 127.0.0.1
        appPort: 38281
    
    redis:
      host: 127.0.0.1
      port: 6479
      dbNumber: 0
    
    sqlite:
      file: ./tmp/rest-pty.db
    
  4. Run the server:

    ./rest-pty -l info server -c config.yml
    

    By default the REST API listens on port 38281 and the metrics endpoint on 3001.

Configuration

The server is configured with a YAML file (loaded via Viper) and a few CLI flags. Any value omitted from the config falls back to a built-in default (see InstallDefaultServerConfigValues in models/configs.go).

Config file
# Prometheus metrics server
metrics:
  service:
    listenOn: 0.0.0.0        # bind address
    appPort: 3001            # port
    timeoutSecs:
      read: 60
      write: 60
      idle: 60
  metricsEndpoint: /metrics  # path the metrics are exposed on
  maxRequests: 4             # max parallel metrics requests
  features:
    enableAppMetrics: false  # Go runtime metrics
    enableHTTPMetrics: true  # HTTP request metrics

# REST API server
api:
  service:
    listenOn: 0.0.0.0
    appPort: 38281
    timeoutSecs:
      read: 60
      write: 0               # 0 = no timeout (needed for long output reads)
      idle: 0
  apis:
    endPoint:
      pathPrefix: /          # base path prepended to all routes
    requestLogging:
      logLevel: warn
      healthLogLevel: debug
      requestIDHeader: X-Request-ID
    enableMCP: false           # expose the MCP endpoint (see "MCP API")

# Redis connection (used for session I/O buffers)
redis:
  host: 127.0.0.1
  port: 6479
  dbNumber: 0

# SQLite persistence (created automatically)
sqlite:
  file: ./tmp/rest-pty.db

# cairn integration - OPTIONAL. Omit the whole section to run without it; a session that
# names a workspace then fails to start (see "Workspaces").
cairn:
  enable: true
  baseURL: http://cairn:38271   # required once enable is true
  client:                       # required once enable is true
    retry:
      maxAttempts: 3
      initialWaitTimeInSec: 1
      maxWaitTimeInSec: 5
    oauth:                      # optional; client-credential flow
      issuerURL: https://issuer.example.com
      clientID: rest-pty
      clientSecret: ...
      targetAudience: https://cairn.example.com

The cairn retry budget bounds how long a session start can block on a slow or unreachable cairn — the workspace lookup happens inline during start, before the container is created.

CLI flags
Flag Alias Env var Default Description
--log-level -l LOG_LEVEL warn Log level: debug, info, warn, error
--json-log -j LOG_AS_JSON false Emit logs as JSON
--config-file -c CONFIG_FILE Required. Path to the server config file (server command)
./rest-pty -l info server -c config.yml

REST API

The authoritative API schema is the OpenAPI spec at docs/swagger.yaml. The summary below groups the available endpoints; all v1 routes are relative to the configured pathPrefix.

Endpoint summary
Area Method & path
Health GET /liveness/alive, GET /liveness/ready
Sessions POST /v1/sessions, GET /v1/sessions
Session GET /v1/sessions/{name}, DELETE /v1/sessions/{name}
Attributes PUT /v1/sessions/{name}/{command,driver,name,description,output-buf-cap,run-mode,workspace}
Lifecycle POST /v1/sessions/{name}/start, POST /v1/sessions/{name}/stop
Input POST /v1/sessions/{name}/io/input/commands
Output GET /v1/sessions/{name}/io/output/{chunk,newest,tail}

Session names may contain only letters, digits, and hyphens (^[a-zA-Z0-9-]+$). Workspace names additionally allow underscores (^[a-zA-Z0-9-_]+$) — they belong to cairn's namespace, not this one.

Worked example

The flow below creates a PTY session running bash, starts it, runs a command, reads the output, then stops and deletes the session. The examples assume the API is on http://localhost:38281.

1. Create a session

curl -X POST http://localhost:38281/v1/sessions \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "demo",
    "description": "interactive bash shell",
    "command": { "cmd": "bash", "args": [] },
    "io_buf_cap": 65536,
    "driver": "PTY",
    "driver_metadata": { "display_rows": 40, "display_cols": 120 }
  }'

io_buf_cap is the scrollback buffer capacity in bytes and must be at least 16384.

2. Start it (transitions IDLEREADY)

curl -X POST http://localhost:38281/v1/sessions/demo/start

3. Send input. Commands are submitted as a list. Use a TEXT command for literal input and an ENTRY command to press Enter. (Other supported types: CTRL for control characters such as CTRL+C, and RAW for Base64-encoded raw key input.)

curl -X POST http://localhost:38281/v1/sessions/demo/io/input/commands \
  -H 'Content-Type: application/json' \
  -d '{
    "commands": [
      { "type": "TEXT", "content": "echo hello from rest-pty" },
      { "type": "ENTRY" }
    ]
  }'

4. Read the newest output (limit is the number of bytes to read from the tail):

curl 'http://localhost:38281/v1/sessions/demo/io/output/newest?limit=4096'

To page through the full scrollback instead, use output/chunk?offset=<n>&limit=<n>.

5. Stop and delete

curl -X POST   http://localhost:38281/v1/sessions/demo/stop
curl -X DELETE http://localhost:38281/v1/sessions/demo

MCP API

Alongside the REST API, rest-pty can expose the session surface to AI agents as Model Context Protocol tools. This lets an agent define, start, drive, and read back sandboxed terminal sessions using the same core the REST API uses.

Enabling it

The MCP endpoint is disabled by default. Turn it on with enableMCP under api.apis:

api:
  apis:
    enableMCP: true

Once enabled, an MCP server (streamable HTTP transport, stateless) is mounted at POST /v1/mcp, relative to the configured pathPrefix.

Server instructions

The initialize result carries server instructions describing what a session is and how to drive one — the things no single tool description can carry: that the process outlives the call, that submitting input returns no result for what was typed, and that the container is read-only apart from the writable_dirs a session declares. They are fixed prose and not configurable.

A section explaining workspaces, persistent volumes, and artifacts is appended only when the cairn integration is configured. Without it, every session naming a workspace refuses to start, so advertising them would be misleading.

Tools

The endpoint registers the following tools. Their input schemas mirror the REST DTOs (and advertise the same enumerated values and validation rules).

Session management

Tool Purpose
define_new_session Define a new session (always docker-backed and hardened — see below)
list_sessions List sessions, optionally filtered by name, driver, or state
get_session Fetch a single session by name
update_session_output_buffer_capacity Change an IDLE session's scrollback capacity (clears buffered output)
update_session_command Change the command an IDLE session runs
update_session_name Rename a session
update_session_description Change (or clear) a session's description
update_session_workspace Assign (or clear) an IDLE session's shared workspace
delete_session Delete an IDLE session
start_session Start a session (IDLEREADY), synchronously
stop_session Stop a session (READYIDLE), synchronously

Session IO

Tool Purpose
submit_user_input Submit a batch of keystroke input events to a READY session's STDIN
read_session_output_chunk Read output from a given stream offset
read_session_output_newest Read the most recently written output bytes
How it differs from the REST API

The MCP surface is a deliberately narrowed, agent-safe projection of the REST API:

  • Docker-only, hardened sessions. define_new_session fixes the driver to DOCKER; there is no driver-type selection and no raw driver metadata. Only a restricted set of docker settings is exposed (image, TTY size, working dir, writable dirs, network mode, published ports, extra hosts, environment). Host bind-mounts, added Linux capabilities, and the hardening toggles are not exposed and keep their hardened defaults. If a session needs more, an operator can adjust it out-of-band via the REST API.
  • Always synchronous. There is no equivalent of the REST block query parameter; each tool call is a discrete request/response, so operations always complete before returning.
  • inputs, not command. submit_user_input takes inputs — keystroke-level input events fed to the running process's STDIN — named distinctly from the {cmd, args} command a session is defined to run, to keep the two from being conflated.
  • ANSI-stripped, structured output reads. The output-read tools always strip ANSI escape sequences and return the decoded text both as a text content block and as structured fields (output, actual_offset, read) so an agent can advance its read offset. There is no MCP equivalent of the SSE tail stream.

Docker driver

When a session uses "driver": "DOCKER", the command runs inside a container that is hardened by default. Unless overridden, the container:

  • mounts its root filesystem read-only,
  • drops all Linux capabilities and sets no-new-privileges,
  • has no network access (network_mode: none),
  • runs as nobody:nogroup in working directory /tmp,
  • is limited to a 32m memory reservation / 128m hard limit,
  • is removed on exit, and
  • is stopped with SIGINT.

These defaults are defined by SessionDriverDockerParams in models/sessions.go. The driver_metadata object accepts:

Field Description
image Required. Container image to run
display_rows / display_cols TTY size (min 30 rows / 80 cols)
writable_dirs tmpfs mounts that provide writable paths within the read-only rootfs
host_mounts host paths bind-mounted in (read-only by default)
add_caps Linux capabilities to add back (e.g. NET_BIND_SERVICE)
network_mode container network mode; must be routable to publish ports
publish_ports container ports published to the host for inbound connections
extra_hosts extra host-to-IP mappings injected into /etc/hosts
environment environment variables for the container process
mem_reservation / mem_limit soft/hard memory limits (e.g. "32m", "128m")
run_as_user / run_as_group / working_dir process identity and working directory
stop_signal signal used to stop the process (SIGINT, SIGTERM, SIGQUIT, SIGHUP, SIGKILL)
read_only_rootfs / drop_all_caps / no_new_privileges / remove_on_exit hardening toggles (each defaults to true)

Example driver_metadata for a network-isolated container with a writable scratch directory:

{
  "image": "alpine:3.20",
  "display_rows": 40,
  "display_cols": 120,
  "writable_dirs": [{ "path": "/work", "tmpfs_size": 33554432 }],
  "environment": [{ "name": "TZ", "value": "UTC" }]
}
Workspaces

A DOCKER session may carry an optional workspace_name — a shared workspace belonging to cairn, which pairs an object store for durable artifacts with a Docker named volume for scratch space that several containers share. It is set at create time or through PUT /v1/sessions/{name}/workspace, and cleared by sending {"workspace_name": null}. Both are permitted only on an IDLE session.

PTY sessions may not hold one: they run directly against the host filesystem, so there is no container to mount a workspace volume into. Assigning a workspace to a PTY session is rejected, and moving a session onto the PTY driver clears any workspace it was holding.

The cairn integration must be configured (see Configuration) for a workspace to be usable — a session that names one on a server without it will not start.

What happens at session start

The workspace name is resolved against cairn on every POST /v1/sessions/{name}/start, so a workspace assigned while the session was IDLE takes effect on the next run with nothing else to do. cairn's persistent volume for that workspace is then mounted read-write at /mnt/cairn/ws inside the session container.

rest-pty is a mount-only cairn client: cairn owns the workspace record, the volume, and the volume's name. rest-pty reads the name cairn persisted and mounts the volume cairn names, at the path cairn fixed — it derives none of the three, holds no object-store credentials, and cannot create or destroy a workspace or a volume.

Four things make the session start fail rather than starting unmounted, since a session that asked for a workspace and got a container with nothing mounted would write into a scratch layer discarded on exit with no sign anything went wrong:

Cause Fix
the cairn integration is not configured on this server enable it, or clear the session's workspace
the lookup against cairn failed check cairn's reachability and the baseURL
cairn knows no workspace by that name create it in cairn, or correct the name
the workspace's volume_state is NONE an operator must provision its volume in cairn; rest-pty cannot
File ownership inside the volume

The mount root is world-writable, but the files in it are not uniformly owned:

  • a file the session container creates is owned by nobody (65534:65534) — the user the hardened container profile runs as;
  • a file cairn's transfer sidecar wrote — anything downloaded into the workspace as an artifact — is owned by root:root, mode 0644.

So a downloaded artifact can be read and unlinked (removal is governed by write permission on the directory), but not modified in place: opening it r+ fails EACCES. A session that wants to edit one must write to a different path, or unlink and recreate. Nothing on this side can change that — rest-pty neither writes those files nor controls the sidecar's umask.

Development

Common tasks are wrapped in the Makefile:

  • make build — build the binary
  • make test — run unit tests
  • make doc — regenerate the OpenAPI spec in docs/
  • make ts-sdk — generate the TypeScript client SDK

License

Released under the MIT License.

Documentation

Overview

Package main - application entry point

Directories

Path Synopsis
Package api - application REST API
Package api - application REST API
Package app - application entry points
Package app - application entry points
Package db - database controllers for system persistence
Package db - database controllers for system persistence
mocks
db
Package models - application data models
Package models - application data models
poc
pty command
Package main - application entry point
Package main - application entry point
Package session - shell session operation package
Package session - shell session operation package
Package test - various support components used in unit-testing.
Package test - various support components used in unit-testing.
utils
atlas-migrate command
Package main - Atlas GORM migration support binary
Package main - Atlas GORM migration support binary
Package workspace - client for cairn's workspace API
Package workspace - client for cairn's workspace API

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL