Web Terminal

The web terminal is a browser view of a tmux session, reached through a link and streamed over NATS. It lets you watch — and, as the operator, optionally steer — an agent work without SSH access to the host, including agents running on remote/HPC machines. Views are read-only by default; operators get a control-room index of all their sessions and can opt into writable mode.

How it works

Terminal bytes are ephemeral, so they travel over core NATS, never JetStream:

A five-stage web-terminal path from browser through gateway and NATS to a remote responder and terminal, with no direct inbound shortcut to the host. Browser Gateway Core NATS Responder tmux target No inbound shortcut Operator Funder Signed link
A browser terminal without inbound host access. All frames and authorized input cross the gateway and core NATS. The crossed shortcut paths show the direct connection that deliberately does not exist.
  1. 1Browser — terminal viewer; read-only unless an entitled operator requests steer mode.
  2. 2Gateway — Kubernetes WebSocket bridge that authenticates the viewer and mints a scoped grant.
  3. 3Core NATS — ephemeral bidirectional terminal transport; JetStream is not used.
  4. 4Responder — validates the grant on the tmux host and owns the PTY lifecycle.
  5. 5tmux target — read-only attach by default; writable only for an entitled operator.
  6. 6Viewer roles — the three labels below the browser distinguish operator, capsule funder, and signed-link viewer; each carries different scopes and write privileges.
  • Gateway — a Kubernetes service that serves the embedded xterm.js frontend and the WebSocket. It verifies the signed link, mints a short-lived grant, and bridges the browser to per-viewer NATS subjects in the requested vignoble’s namespace. No inbound connection to the tmux host is needed. One gateway serves all of your vignobles (see Multi-vignoble gateway).
  • Responder — runs on every tmux host: in-process in the daemon on the Pinard host, and via aoc webterm-responder on standalone/HPC hosts. It verifies the gateway’s grant (including the vignoble), then tmux attach -r (read-only) through an embedded PTY and streams frames back with flow control. It tears the PTY down on disconnect, idle, or target exit.
  • Signed links…/sessions?v=<vignoble>&target=<target>&exp=<unix>&sig=<hmac>, where the signature is an HMAC over the vignoble, target, and expiry. The vignoble is bound into the signature, so a link for one vignoble cannot be replayed against another.

There are three ways to get a link:

aoc webterm-link --target <session> [--vignoble-name <v>]

--vignoble-name defaults to the resolved vignoble (NATS_VIGNOBLE); pass it explicitly when generating links for a specific vignoble.

Add --auto to make the command suitable for automated callers: it prints nothing and exits 0 when webterm or post_links is not configured, so scripts can invoke it unconditionally and only append a link when one is returned.

From the conductor (régisseur/maître) TUI, the /webterm [name] slash command mints a link for a vendangeur on demand — with no name it shows a picker of the active vendangeurs.

Links can also be posted automatically onto MRs when webterm.post_links: true (default off), once on first tracking. A link is only posted when a real vendangeur session exists — tracking-only entries (e.g. aoc track-mr called without an active worker) and cuvée MRs produce no link, because there is no live tmux session to stream:

  • With SSO enabled — an unsigned link (…/sessions?v=&target=) is posted. It carries no bearer credential, so it’s safe to advertise publicly: the gateway grants access only to the SSO-authenticated operator.
  • Without SSO (Phase 1) — the link is the signed, expiring form (below).

“Vendangeur attached” comment

When a vendangeur starts work it posts a “🧰 Vendangeur attached” note on the driving issue/MR (session name, process, parcelle, run ID, start time). When webterm.post_links: true the comment automatically appends a 🖥️ Live terminal link, so reviewers can open the browser terminal immediately without generating a separate link. This works even before aoc track-mr runs (i.e. before the MR is opened). The link appears only when webterm is configured; if it is not, the comment posts as before without the link.

Multi-vignoble gateway

The gateway serves a set of vignobles rather than a single one. One deployed gateway instance covers all of your vignobles — no redeploy when you add one.

The served set is determined as follows, in priority order:

  1. Explicit allowlist — set WEBTERM_VIGNOBLES (comma-separated) on the gateway container, or configure gateway.vignobles in the Helm chart values. Only the listed vignobles are served; all others get a 403.
  2. KV-derived (preferred, default) — when WEBTERM_VIGNOBLES is unset or empty, the gateway serves any vignoble that has published an owner to the pinard-vignobles NATS KV. The daemon publishes on startup, so new vignobles appear automatically without a gateway redeploy; vignobles with no live daemon/responder simply return “session not found” when a viewer tries to connect.

Per request, the gateway reads ?v=<vignoble> from the link, checks that the vignoble is served, resolves the owner for that vignoble, and bridges the WebSocket to pinard.<vignoble>.webterm.*. Operator authorization (preferred_username matching) is also per-vignoble.

The responder verifies that a received grant is scoped to its own vignoble and silently rejects grants intended for a different namespace.

Authentication & authorization

With a webterm.auth block configured (Cognito issuer + client ID), the gateway runs the OIDC login flow itself (Authorization Code + PKCE, public client), validates the ID token against the pool’s JWKS, and stores a stateless signed session cookie. Without it, access is signed-link-only.

Authorization is deny-by-default:

  • Operator (the vignoble owner, matched on the token’s preferred_username) → any target in that vignoble; writable on request.
  • Capsule funder (SSO-authenticated, matched to the worker’s contract funder) → that one target, always read-only (steer toggle hidden).
  • Viewer (a valid signed link) → that one target in that vignoble, read-only.
  • Everyone else → denied.

Funder access is automatic: when a funded capsule worker starts, Pinard records the funder’s username in a NATS KV (pinard-capsule-funders). When the SSO-authenticated funder opens the terminal URL, the gateway recognises them and grants read-only access without requiring a separately issued signed link. They see a 🔒 capsule monitor (read-only) indicator and no steer toggle.

The owner is discovered automatically from a NATS KV published from credentials.yaml; there’s no manual user-to-vignoble mapping.

Writable “steer”

Views are read-only by default. An operator (never a scoped-link viewer) can opt into writable mode via a toggle in the terminal header (?mode=rw). The gateway mints a writable grant only for an operator who requested it, forwards browser keystrokes, and the responder attaches without -r and writes input to the PTY — single-writer, gated on the grant at both ends, and audited as mode=rw. Anyone without the entitlement silently falls back to read-only.

Toggling the steer mode requires a full page reload: the writability grant is minted by the gateway at WebSocket upgrade time (based on ?mode=rw in the URL). There is no in-protocol way to upgrade or downgrade an existing connection without a new grant, so the toggle triggers a reload rather than an inline switch.

Terminal header features

The terminal header shows the vignoble name, the session target, and the current mode (read-only / steer / capsule monitor). Two additional elements are present for every viewer:

  • ⛔ Interrupt button — available to all viewers (read-only, steer, and funder). Clicking it sends an interrupt signal to the agent’s NATS …interrupt subject (the same channel used by the conductor’s interrupt_worker tool), cleanly cancelling the current turn without terminating the agent. This is safe because the signal goes through NATS, not a destructive tmux send-keys C-c.
  • Clickable issue link — when the worker was spawned from a GitLab issue, a 📋 #N link appears in the header pointing to the driving issue. The link is resolved from the worker’s KV state (PINARD_ISSUE_URL set at spawn time).

Control-room index

Opening the gateway with no target (/sessions) serves an authenticated, operator-only two-pane control-room index:

  • a sidebar of the vignobles you own, and
  • for the selected vignoble, its live sessions — the régisseur, each maître window, and each vendangeur — enumerated over NATS in real time, each linking to a read-only terminal view.

Vendangeur rows are enriched with their parcelle and current state. Remote agents (standalone/HPC workers not on the local tmux host) appear in the index as long as their lastSeen timestamp is fresh (within 5 minutes). A vignoble with no live local responder still shows any recently-active remote workers from the KV; it degrades gracefully rather than erroring. Scoped (signed-link) viewers never see the index — it’s operators only.

Configuration

The webterm: block in credentials.yaml holds base_url, the link/grant/cookie secrets, TTLs, idle_timeout, max_viewers, post_links, and the auth: sub-block (issuer, client_id, redirect_url, scopes, session_ttl). A gateway needs the link, grant, and cookie secrets; a responder needs only the grant secret.

For Helm deployments, the served vignoble set can be configured via gateway.vignobles in the chart values (leave it empty to use KV-derived auto-discovery).

A window target (session:window, e.g. conductor:3) is viewed through a per-viewer grouped session, so opening a régisseur or maître window doesn’t move the operator’s active window; the grouped session is torn down with the viewer. Plain vendangeur sessions attach directly.

aoc attach — local terminal streaming

For operators on the Pinard host (or any machine with NATS access), aoc attach provides a direct local terminal view without opening a browser:

aoc attach <session>               # stream by session name, agentId, or runId
aoc attach <session> --timeout 5m  # detach after 5 min idle

On local sessions, aoc attach starts an in-process PTY pump automatically, so no separate aoc webterm-responder process is needed. For remote sessions, the output is received over NATS (the remote host must be running a responder). Press Ctrl+C to detach.

This is the CLI equivalent of the browser view — read-only, authenticated with operator NATS credentials only (no SSO grant required).

Known limitations

Terminal traffic currently shares one NATS connection (a dedicated account for terminal traffic is a follow-up).