Terminal

OpenEng Terminal is a real login shell that runs on your own machine, driven from a browser at terminal.openeng.app. The web app is a thin client: it renders command blocks and streams keystrokes, while every PTY, every byte of output, and every stored credential lives in a local engine bound to 127.0.0.1:50052. The two halves talk over an end-to-end-encrypted gRPC channel — each frame individually sealed — so nothing about your terminal ever leaves the device. The engine keeps processes alive across page reloads, so a refresh, a second tab, or even a browser restart reattaches to running commands instead of losing them.

Overview

The product is split across two artifacts that ship and update independently:

The engine (local)

A native Rust binary — openeng-terminal — that owns the PTYs, the block model, command history, the learned suggestion model, the SSH manager, and the encrypted secret vault. It binds loopback only and self-updates at boot. One engine per machine, enforced by a PID lock.

The web app (browser)

A React surface hosted at terminal.openeng.app. It negotiates a sealed session, lists groups/tabs, renders blocks and raw shell output, and drives suggestions — but never executes anything itself. All state that matters is engine-owned, so every browser on the same machine sees identical data.

Because the shell is a persistent local process rather than a browser sandbox, the Terminal behaves like a native app: cd persists, environment variables persist, background commands keep running, and interactive full-screen programs (vim, htop, less, a REPL) work as they do in a native terminal.

Note The engine coexists with the OpenEng AI engine. It roots its state at ~/.openeng/terminal/ and binds port 50052, keeping a completely separate lock, endpoint, token, and port from the AI engine (~/.openeng/runtime/, port 50051).

Architecture & the sealed connection

The single boundary between the browser and the shell is one gRPC service, openeng.terminal.v1.Terminal, transcoded to gRPC-Web on the loopback socket. Two independent layers protect it.

TLS on loopback with a publicly-trusted name

The engine binds 127.0.0.1:50052 but serves TLS for the host engine.openeng.app. That name is a global DNS A-record pointing at 127.0.0.1, so a publicly-trusted (Let's Encrypt) certificate for it validates in every browser — Chrome and Safari — with no manual trust step, while the socket never leaves loopback. HTTPS is mandatory: Safari refuses plain-http loopback as mixed content.

Per-frame sealing

CreateSession is the only unsealed RPC — it bootstraps the seal via an X25519 key exchange (the client and engine each send a base64 public key). Every other RPC is additionally sealed per-frame with ChaCha20, so even the loopback payload is encrypted on the wire. The handshake also returns a protocol-version pair (major/minor) plus a compatible flag and a mismatch_reason, so a client and engine that drift apart surface a clear "update your client/engine" message rather than failing silently.

Origin and Host lockdown (drive-by defense)

A loopback engine is reachable by any web page, so the gRPC-Web layer refuses anything that is not the real app. It never emits a wildcard Access-Control-Allow-Origin: *; it reflects the ACAO header only for exact allow-listed origins, and it validates the request Host to defeat DNS rebinding.

Allowed browser origins : https://terminal.openeng.app   (loopback dev origins only in debug builds)
Allowed Host/authority  : engine.openeng.app, localhost, 127.0.0.1, ::1
Private Network Access  : preflight granted for the public app → loopback engine (Chrome 130+)

A request whose Host is an attacker domain rebound to 127.0.0.1 is rejected outright; a browser request from a non-allow-listed origin gets no ACAO and is blocked by the browser. Native/curl loopback callers (no Origin) are permitted.

Boot & pairing

OAuth is the only supported boot path. The signed-in browser mints a short-lived one-time key (~5 min TTL, cache-first, rate-limited) and shows a copy-paste start command:

openeng-terminal serve --oauth=<one-time-key>

The engine advertises its endpoint only after it binds; the app polls, then connects the moment it is up and returns you to the deep link you originally aimed at. CreateSession returns a boot_id minted once per engine process. The browser caches its restored command-output blocks under that id: a matching boot_id on refresh means the same engine boot (restore the blocks); a changed/absent one means the engine restarted (the blocks are stale and discarded — only persisted groups survive a restart).

Mandatory auto-update

The engine self-updates at boot and cannot skip it: it polls releases.openeng.ai/v1/<product>/stable/latest, downloads the platform artifact, verifies its published sha256: checksum, and atomically swaps. While serving, a newer stable is staged in the background and reported via GetUpdateStatus (also surfaced on CreateSession as update_available + staged_version). The UI shows a restart banner; RestartEngine re-execs in place, carrying the live session internally so you never re-pair.

Groups

A group is an engine-owned, persisted named collection of tabs that defines a shared shell context. Every tab in a group inherits the group's settings, so a group is effectively a reusable workspace profile.

FieldPurpose
nameDisplay label for the group.
envEnvironment variables (name → value) injected into every tab's shell in the group.
aliasesShell aliases (name → value) applied before each command runs in the group's tabs.
init_scriptA script run when a tab's shell starts, for setup that env/aliases can't express (activating a virtualenv, sourcing an rc, exporting a computed value).
start_directoryThe default working directory for new tabs created in the group.
incognitoWhen set, the group lives in memory only — nothing touches disk, and no history/suggestion learning is recorded for its tabs.

Groups are managed via ListGroups, CreateGroup, UpdateGroup, and DeleteGroup. A normal group persists under the engine's state dir; an incognito group is created and torn down entirely in RAM.

Tip Put project-wide setup (a KUBECONFIG path, an AWS_PROFILE, a source .venv/bin/activate) on the group. Every tab you open under it starts pre-configured, and you never leak that config into unrelated work.

Tabs

A tab is a single shell context — one persistent login shell with its own working directory. Tabs belong to a group and merge their own settings over it.

  • Per-tab cwd — each tab tracks its own directory; a cd updates only that tab.
  • Per-tab env — merged over the group's env, so the tab wins on any key collision. The effective environment is group.env extended by tab.env.
  • Per-tab shell — the resolved login shell for that tab.
  • Incognito — a tab can be incognito (or inherit it from an incognito group). Incognito tabs record no history, learn no patterns, and are excluded from high-persistency logging and folder history.

New tabs start in the group's start_directory when set, otherwise the request's cwd, otherwise the user's home. Tabs are managed via ListTabs, CreateTab, RenameTab, and CloseTab. The engine keeps a tab's shell process alive independent of the browser, which is what makes reload survive (see below).

The run-block model

The Terminal's execution model is "one data, two renderings." A single persistent login shell per tab is the sole source of truth, and it is exposed simultaneously two ways over the same session:

Block mode (cards)

Each command becomes a Warp-style card: the command line, its styled output, and a status footer (exit code, duration, timestamps). Runs stream as one BlockStartedOutputDelta*BlockDone.

Shell mode (raw)

A raw xterm view of the exact same session's byte stream. Toggling between block and shell is instant and lossless because both render the identical underlying Block[] — shell mode just paints the blocks as one continuous scroll.

Running a command

Run is a server-streaming RPC: one command in, a stream of styled output deltas, then a terminal BlockDone. Output is delivered as OutputDelta events that replace lines starting at a given index — so a \r progress bar overwrites its previous line rather than piling up. A cd reports its resolved directory in BlockDone.new_cwd so the tab's cwd stays in sync.

Out-of-band controls ride unary RPCs so they work even mid-run:

  • WriteStdin — send input to a running block (answer a sudo prompt, feed a REPL). The UI binds this to Ctrl+Enter for a running command.
  • Kill — terminate a running block.

Refresh-resilience with AttachBlock

The engine keeps a block's process alive across UI reconnects. On reload, the browser calls AttachBlock for every block it had persisted as still running. The engine re-subscribes it to the live OutputDelta stream and the eventual BlockDone; if the block already finished, it emits a single BlockDone(status="ended") so the UI clears its spinner. There is only a small log gap during the refresh window itself.

Free-typed commands become blocks (OSC 633)

The persistent shell is booted with per-engine rc files (stored 0700/0600 under the state dir) that inject OSC 633 prompt hooks. That means even a command you type directly into shell mode is captured as a first-class block, not just raw bytes. The shell-core RPCs expose this directly:

OpenTabStream  → ensure the tab's shell exists, REPLAY its blocks
                 (BlockStarted + OutputDelta + BlockDone each), emit
                 TabSnapshotEnd, then stream the live tail as BOTH raw
                 PtyOutput AND parsed block events
RunInTab       → write an OSC-633-wrapped command (captured as a block)
WriteTab       → write raw keystrokes (shell-mode typing)
ResizeTab      → resize the tab PTY
Note OpenTabStream/RunInTab/WriteTab/ResizeTab are the Stage-2 shell-core surface. Alongside them, the simpler Run/AttachBlock block path is the reliable, shipped model the UI renders today.

Interactive PTY

Full-screen and interactive programs need raw PTY semantics, not parsed blocks. OpenInteractive spawns a program (or, with an empty command, a login+interactive shell) inside a tab and server-streams raw PTY bytes as PtyEvents (PtyStartedPtyOutput*PtyDone) which xterm.js renders directly.

Because gRPC-Web can't open a bidirectional stream, the inbound leg is unary:

RPCRole
OpenInteractiveStart the program; server-streams raw PTY output.
WriteInteractiveSend a keystroke/byte chunk to the PTY (serialized per-keystroke for correct ordering).
ResizeInteractivePropagate a terminal resize (cols/rows).
KillInteractiveTerminate the interactive session.

Nagle is disabled on the loopback connection so interactive output streams with minimal latency. The same PtyEvent transport powers brokered SSH (below).

Command suggestions

Suggestions are served by one fast unary RPC, Suggest (partial command text, cwd, tab id in; three ranked lists out). Everything runs fully on-device — there is no cloud suggestion tier. Under the hood is an outcome-conditioned learned pattern engine: the model conditions not just on what you ran, but on what happened when you ran it.

The tiers

Results are assembled with strict cross-tier de-dup in priority order RECOVER > RECENT > FOLDER > AI:

RECOVER (pinned)

When the tab's last command failed, the fixes you run to recover from that failure class are pinned to the top. Binary-scoped recoveries outrank cross-command ones. On a first-ever failure with no learned fix, a bundled corpus seed for the class appears, then defers to your real fix once you run it.

RECENT

Your globally recent commands, deduped by text, substring-filtered against what you're typing.

IN THIS FOLDER

Commands from this exact directory's history — what you actually do here.

AI / PREDICTED

Learned success-successors ("after A you usually run B", recency-weighted), plus bundled-corpus completions of the partial, plus on-device semantic recall (INFERRED) that surfaces relevant commands by meaning the lexical tiers miss.

How outcomes are learned

At each BlockDone, a tool-agnostic classifier turns the exit code + status + a bounded tail of output into a stable class token — matching the English of a failure, never a tool name. So an expired SSO token from any binary clusters into one auth-expired recovery bucket. Classes include auth-expired, unauthorized, not-fast-forward, merge-conflict, not-found-binary, missing-dep, host-unreachable, daemon-unreachable, perm-denied, disk-full, rate-limited, timeout, and syntax; unmatched-but-salient failures get a stable fp:<hash> fingerprint (learnable on the 2nd occurrence) and everything else an x:<exit> backstop, so the model is total.

The pattern store projects four learned namespaces onto one recency-weighted table (30-day half-life), persisted as JSONL:

S  ␟<cmdkey>           successors of a command that SUCCEEDED
E  ␟<class>            cross-command recovery for a failure class
EB ␟<class>␟<binary>   binary-scoped recovery (higher precision)
A  ␟<binary>␟<flag>    the concrete VALUES you pass a flag (profile/namespace/region)

The A namespace is what lets a suggestion carry the argument you actually use — a corpus template like aws sso login --profile <profile> is auto-filled to aws sso login --profile dev. Argument mining deliberately never learns values behind sensitive flags (--token, --password, -p, -a, -u, -w, …), never learns @vault references, and never learns <placeholder> spans, so no secret is ever persisted or re-suggested.

History

Two history surfaces complement suggestions:

  • History — folder-scoped command history (command, cwd, run count, last-run time), skipped for incognito tabs.
  • ShellHistory — a read-only import of the login-shell history you already had before installing (~/.zsh_history / ~/.bash_history / $HISTFILE). The engine reads it locally, strips the zsh : <epoch>:<dur>; prefix, de-dupes keeping the most recent, and returns it newest-first. Fail-soft: a missing/unreadable file yields an empty list, never an error.
Note The semantic (INFERRED) tier is a Cargo feature semantic, on by default. It pulls a native on-device embedder and downloads a ~130 MB model; it is prewarmed at boot and time-bounded per keystroke, so it never blocks the instant lexical tiers and simply appears on a later keystroke once its index is ready. All learning is suppressed for incognito tabs.

SSH, saved connections & the vaults

Brokered SSH

OpenSsh opens an interactive session to a saved connection by running the system ssh binary locally — true local brokering through your own ssh config, keys, and agent. The engine builds a real argv (never a shell string, so there is no injection surface):

ssh [-p PORT] [-i KEYPATH] USER@HOST
    -p  only for a non-default port
    -i  only for the key method WITH a key path

It streams the same PtyEvent stream as OpenInteractive and reuses WriteInteractive/ResizeInteractive/KillInteractive. For the password ("prompt-on-connect") method, no secret is ever put on the command line — ssh prompts interactively inside the PTY.

Saved connections

A Connection is your own remote target — label, username, host, port, an auth_method tag ("key" or "pw"), and an optional key-file path. Managed via ListConnections, SaveConnection, DeleteConnection. The record itself carries no secret: a passphrase is never stored; a key is referenced by path, never by bytes.

SSH key store

Named SSH key references ({name, path}) are managed in Settings via ListSshKeys/SaveSshKey/DeleteSshKey. These are filesystem paths you typed (e.g. ~/.ssh/id_ed25519), not key material, so they are stored as plain JSON (ssh_keys.json).

The encrypted secret vault (@name)

The vault holds your own named values — e.g. a DB password — that you reference inside a command as @name. Managed via ListVault/SaveVaultEntry/DeleteVaultEntry. The value is write-only across the boundary: ListVault returns names only, there is no read-back RPC, and the value leaves the engine only as the expanded @name inside your own command, substituted just before exec:

psql -h db.internal -U admin -W @dbpass   # you type this
                                          # history + learning record the ORIGINAL, un-expanded line
                                          # only the running PTY ever sees the real value
  • Values are encrypted at rest with ChaCha20-Poly1305; the 32-byte data key is a 0600 file (secrets.key) and the blob (secrets.enc) is nonce(12) || ciphertext.
  • The engine never logs a value and never returns one over gRPC.
  • A bare @ or an unknown name is emitted literally — only a matching [A-Za-z0-9_]+ name expands.
Warn A lost secrets.key is unrecoverable by design: the vault simply starts empty rather than wedging the engine. Treat the key file as the root of trust for your stored credentials.

Cloud connectors (cloud terminal execution)

Beyond SSH, a saved connection can be kind="connector" — an HTTPS service (a cloud-shell / run-command / container-exec connector) that runs your command in your own cloud and streams back the output. The engine never holds cloud credentials; it holds only the connector's API key.

Connectivity

The engine POSTs to the connector's OpenEng tools protocol. It prefers the streaming endpoint and transparently falls back to the buffered one for older connectors:

POST {base}/call-stream          # preferred (NDJSON); on 404-with-no-error-body → POST {base}/call
  x-api-key: <api_key>
  Authorization: Bearer <auth_token | api_key>   # empty token ⇒ the api key is reused as bearer
  x-write-key: <write_key>                       # sent ONLY when set (grants write access)
  { "name": "run_query", "arguments": { "query": "<command>", "database": "<target>" } }

  ← NDJSON frames:  {"t":"meta"} {"t":"chunk"} … {"t":"end"}   |   {"t":"error"}

Two RPCs drive it:

ConnectorExecStream

Streams the connector output progressively as a run block (BlockStarted → OutputDelta* → BlockDone) — live cloud-shell output rather than one buffered blob. This is the path the UI uses.

ConnectorExec

The unary twin: reduces the NDJSON frames back into one buffered result. Kept for back-compat; safe to retry only when the engine lacks the streaming RPC entirely.

An optional target is passed as the run_query "database" argument to select an environment/instance. NDJSON frames are rendered as a compact table + summary message in the console.

Credentials, read-only, and write access

  • On SaveConnection, the api_key, auth_token, and write_key are write-only — stored in the engine's encrypted secrets vault and never returned by ListConnections (which reports only has_write_key so the UI can show write state).
  • A connection is read-only by default. Providing a plaintext write key sends it as x-write-key; the connector verifies it against its WRITE_KEY hash and grants that request write access. Command-restrict / read-only / force-exec gating is enforced connector-side — the write key is the switch the Terminal side controls.
Note When a connector rejects the credentials (HTTP 401/403), the engine stamps the sentinel CONNECTOR_UNAUTHORIZED onto the stream, surfaced by the UI as a re-auth prompt ("edit the connection and re-enter the api key"). Generate a write key at connectors.openeng.app → Write key.

Configuration, storage & scopes

Endpoint & process model

SettingValue
Bind address127.0.0.1:50052 (fixed — a zero-config product; the port is not configurable)
Browser endpointhttps://engine.openeng.app:50052 (resolves to loopback; publicly-trusted cert)
InstancesOne engine per machine, guarded by an O_EXCL PID lock (appserver.lock)
Home overrideOPENENG_HOME relocates the whole ~/.openeng root
Bootopeneng-terminal serve --oauth=<key> (OAuth is the only supported path)

On-disk layout

~/.openeng/terminal/
  runtime/                 (0700)   lock, endpoint, handshake token, TLS material
    appserver.lock                  the one-per-machine PID lock
    appserver.endpoint              the URL a surface reads after bind
    handshake.token        (0600)   local handshake token
    ca-cert.pem / leaf-*.pem        self-signed loopback chain
    engine-fullchain.pem / -key.pem publicly-trusted cert (served when present)
    secrets.key            (0600)   the 32-byte vault data key
  state/                   (0700)   persisted, non-incognito data
    groups, history, patterns.jsonl the model + folder history
    secrets.enc            (0600)   the encrypted credential vault
    ssh_keys.json                   plaintext key-file PATH references
    client-state.json               engine-side UI cache store
    shell-integration/     (0700)   per-engine OSC 633 rc files (0600)

Incognito groups/tabs write nothing here — they live in memory only.

Engine-owned client state & caches

State the browser used to keep in per-browser localStorage is instead persisted on the local engine, so every browser on the machine sees identical data:

  • GetClientState / SetClientState — a generic key→opaque-JSON store for UI caches (command-output blocks, the saved command/template library).
  • CacheGet / CacheSet / CacheDelete — a stale-while-revalidate cache (each value engine-stamped with its refresh time), so the UI renders last-known state instantly, then refreshes.
  • Auth sessions and the OAuth-key bootstrap cache deliberately stay per-browser and never touch these stores.

High persistency (opt-in)

An engine-managed log of every finished command block to a JSON-lines file per terminal, keyed by tab id (<dir>/<tab_id>.jsonl). Configured via GetPersistence/SetPersistence (a toggle + a directory you pick, default under the OS temp dir); browsed read-only via ListPersistedTabs/GetPersistedTab (the "View previous tabs" viewer has no command bar). Records are your own block data (command + output + exit/timing); the @name expansion is never recorded, and incognito tabs are excluded.

Security invariant & egress scope

The proto schema is enforced (by a schema-shape test) to carry no field for a secret, credential, API key, password, or private key — and no product-prompt carrier. The only legitimate payload is your own terminal data: the command you type, the stdin/output bytes, your cwd/env/aliases. Values that are secret cross the boundary only inbound (you setting your own value) and never egress: vault values are write-only, connector credentials are write-only, and the SSH key store holds paths, not bytes.

Bottom line The browser is a rendering and input surface. Execution, credentials, history, and the learned model all live on your machine, reachable only over a sealed loopback channel that trusts exactly one origin and one set of hosts.