OpenEng Terminal is a real login shell that runs on your own machine, driven from a browser at terminal.. 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 a gRPC channel that is encrypted on the loopback connection — each frame individually sealed — so nothing about your terminal leaves the device except what you send to a remote host yourself, over SSH or a cloud connector. 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.. 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 ~/ and binds port 50052, keeping a completely separate lock, endpoint, and token from the AI engine (~/), which binds no local port at all: it connects out to the OpenEng Broker instead.
Install and pair the engine
Terminal is free. It is one of the five engineering apps on the OpenEng platform and shares your OpenEng sign-in, but it runs on its own engine, separate from the OpenEng AI engine. Terminal runs real commands; it does not chat, plan, or run agents. For agents on your own hardware, see OpenEng AI.
-
Install the engine. On Linux (x86_64) or macOS (Apple silicon or Intel; for now the macOS build is the previous release, see Platforms), run:
bashcurl -fsSL https://openeng.app/engine/terminal/install.sh | bashThe installer downloads the binary for your platform, checks it against its published SHA-256 checksum when it can (it prints
checksum verified; see Check a download yourself), and installsopeneng-terminalinto~/.local/bin(setOPENENG_BIN_DIRto use another directory). If that directory is not on yourPATH, it adds it to your shell startup files. On macOS it also clears the download quarantine flag, so Gatekeeper does not prompt. There is no native Windows build; on Windows, use WSL. -
Sign in. Open terminal.openeng.app and sign in with your Google account (the sign-in runs through
auth.).openeng. app -
Start the engine. Until an engine is reachable, the app keeps you on its pairing screen (
/pair), which shows a start command with a one-time key, ready to copy:bashopeneng-terminal serve --oauth=<one-time key>Run it on the same machine. The app connects as soon as the engine is up (or when you click Retry connection) and takes you to the page you originally opened.
Note
The one-time key expires after about five minutes. Refreshing the pairing screen reuses a key that is still valid; Regenerate mints a new one. The OpenEng API mints at most five unused keys an hour per account, shared by all OpenEng apps (a key an engine has used no longer counts), and each app also counts the keys it mints in your browser. Install, platforms and updates covers updating and uninstalling, and Getting started shows the flow for every app.
Using the app
This section walks through the workspace at terminal.. The sections after it are the reference for what the engine does underneath.
The workspace
- Left menu: Terminal, Saved, History, SSH, Parallel execution, and Compare blocks, with Settings at the bottom. Each item has its own URL (
/terminal,/saved,/history,/ssh,/parallel,/compare,/settings), so a refresh, a bookmark, or the browser's back and forward buttons land on the same section; any other path opens/terminal. Clicking the section you are already in hides its side panel. Collapse, at the bottom of the menu, shrinks the menu to icons, and the app remembers that choice. - Header: a button that hides or shows the side panel, the group switcher (every group, with a check on the active one, a disabled Cloud Terminal row marked "soon", and a Settings shortcut), a status dot for the engine (hover it to see whether it is online, connecting, or offline), a search button for the command palette, and your account menu.
- Main area: one tab strip holds every kind of tab (terminals, SSH sessions, parallel runs, comparisons, and imported read-only views), each with its own icon.
Groups and tabs
- The Terminal section's side panel lists your groups as cards. Create a group with New group (the button at the top of the panel, the dashed card, or Settings → Groups). The dialog asks for a Group name and, optionally, a Working directory (type it, or click Select to use your system's folder picker), a Visibility (Normal or Incognito), Environment Variables, Aliases, and a Start script. A new group becomes the active group and gets its first terminal automatically. Groups explains each setting.
- Switch groups from the side panel, the header's group switcher, or Settings → Groups. Switching into a group that has no tabs opens a new terminal in it. Edit a group with Configure group. Delete group is disabled for your last remaining group, and deleting the active group switches you to the next one.
- New terminal (the + at the end of the tab strip) opens a terminal in the active group. Double-click a tab's title to rename it: Enter saves and Esc cancels. Custom names survive a refresh. The × on a tab closes it.
Running commands
- Type a command in the bar at the bottom and press Enter. Shift+Enter starts a new line, for multi-line commands and heredocs. Each command becomes a block with its output and a status footer; see the run-block model.
- As you type, a suggestion list opens above the bar, with SUGGESTED, IN THIS FOLDER, and RECENT rows, plus a pinned RECOVER row after a command fails (Command suggestions explains where they come from). On an empty bar, ↑ or ↓ browses your history. → (with the cursor at the end of the line) or Tab copies the highlighted row into the bar without running it, Enter runs, and Esc closes the list.
- While a command runs, a bar above the command bar offers Stop and a field that sends text to the command's standard input. Ctrl+Enter (or ⌘+Enter) in the command bar sends input too. A stopped command's block is marked
killed. - Editors, pagers, monitors, and interactive sessions (
vim,less,top,tmux, a barepythonorpsql, an interactivessh,kubectl exec -it, and similar) open in a live terminal pane that takes over the tab. When you close the pane, what was on screen is kept as a finished block. - Commands that keep running, such as
tail -fornpm run dev, switch the tab to shell view while they run and switch back to blocks when they stop.
Working with blocks
- Each finished block has a row of buttons in its header: Copy input, Copy output, Save as command, Save as template, and Find / filter in output, which narrows that block's output to matching lines and shows how many matched. A long command collapses to one line with an expand arrow, and a long output gets its own scroll-to-top and scroll-to-bottom buttons. A failed block shows its
exitcode, a stopped one showskilled, and a block created by a parallel run shows aparallelbadge. - Select text in a block's output and right-click it to get Copy and Run with template. Run with template lists your saved templates; picking one puts that template into the command bar with your selection in place of its
::VALUE::placeholder. Nothing runs until you press Enter. - The toolbar above the blocks shows the tab's environment as
KEY=valuechips (the group's variables, overridden by the tab's own) and has Go to start, Go to bottom, Search / filter, and Clear blocks. Search / filter works across every block in the tab: search highlights matches and steps through them with Enter and Shift+Enter, and filter hides every block and line that does not match. - The button at the right end of the command bar switches the tab between block view and shell view (the same session drawn two ways; see the run-block model). The choice you make sticks across refreshes. You cannot switch while a command is running.
Saved commands and templates
Save as command stores a block's exact command. Save as template opens a dialog where you name the template and replace the part that changes with the placeholder ::VALUE::; Save template stays disabled until the template has a name and at least one placeholder. Both kinds appear in the Saved section (/saved), under Commands and Templates. From there you can put any entry into the command bar (it never runs by itself), edit it, or delete it. The library is stored by your engine, so every browser on the machine sees the same entries.
History
The History section (/history) lists every command you have run, newest first and without duplicates: the commands the engine recorded, and, marked separately, the login-shell history you already had before you installed the engine (see History). Type to filter the list, and click a row to put that command into the command bar (it never runs by itself). Esc closes the panel.
Parallel execution
Parallel execution (/parallel) runs one command in two terminals at the same time. In the side panel, tick exactly two open terminals (the active one starts ticked, and a counter shows how many of the two you have picked), then click Start. A parallel tab opens: type the command there and click Run (then Run again for the next command). Both outputs stream live, one above the other, each as a normal block with its own stop and input controls. Each block is also added to its own terminal, marked parallel.
Compare blocks
Compare blocks (/compare) shows the difference between the outputs of two commands. Tick two open terminals and click Compare. In the compare tab, pick a finished block on each side (each side starts on that terminal's newest finished block) to see a line-by-line diff, with added, changed, and removed lines tinted and a count of each. Swap sides flips them. The diff is computed in your browser.
Export and open tabs
Each tab in the strip has an Export tab button that downloads it as a JSON file named openeng-<type>-<title>.. A terminal or parallel tab exports its blocks, a compare tab exports its diff, and an SSH tab exports the text on screen. Open an exported tab (the folder button at the end of the tab strip) opens such a file as a read-only view tab, labeled as imported, with no command bar. Export and import happen entirely in your browser; the engine is not involved.
SSH hosts and cloud connectors
The SSH section (/ssh) lists your saved connections, with buttons to refresh the list, edit a connection, or delete it. New connection opens a form with two connection types:
- SSH host: a label, username, host, and port, and an auth method: SSH Key (a path to a key file) or Password (you are prompted inside the session, and nothing is stored). Save & open opens the host in an SSH tab, a live terminal that runs your own
sshclient (see Brokered SSH). - Cloud connector: a label, the connector URL, its API key, an optional authorization token, and an optional write key. Opening it shows a console where you send commands such as
LIST,RUN echo hello, orSTATUSto the connector, with an optional target field and ↑/↓ to recall earlier commands (see Cloud connectors).
The engine caches this list, so it appears instantly and refreshes in the background.
Settings
Settings (/settings, or the last row of the group switcher) takes over the main area. Its categories:
| Category | What it does |
|---|---|
| Groups | Every group, with switch, configure, and delete, plus New group. |
| High persistency | Turns on the engine's per-terminal command log and sets the folder it writes to. View previous tabs opens a read-only viewer of the saved logs; see High persistency. |
| SSH keys | Named references to key files on disk: a name and a path, never the key itself. |
| Credentials | The encrypted vault: add a named value, replace it, or delete it. The list shows names only, and you use a value in a command as @name; see the vault. |
| Cloud Terminal | Disabled and marked "soon". |
Command palette
⌘+K (Ctrl+K on Windows and Linux), or the search button in the header, opens a palette. From it you can run what you typed as a command, open a new terminal in the active group, jump to any group or terminal, or pick one of your 30 most recent commands. Use ↑, ↓, and Enter to choose; Esc closes it.
When the engine is offline
If the engine stops answering, the header's status dot shows it. If you open or refresh a page while the engine is down, the app shows the pairing screen and then returns you to the same page as soon as the engine is back, without a reload. Panels that the engine caches, such as the SSH list, keep showing their last data while the app reconnects, and suggestions fall back to your local history. When a newer engine version has been downloaded, a banner offers to restart the engine; see Mandatory auto-update.
The account menu (your avatar, at the right of the header) shows your email, the engine and app versions, a Release notes link, and Sign out.
Architecture & the sealed connection
The single boundary between the browser and the shell is one gRPC service, openeng., 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.. 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. Every signed-in engine receives the same certificate and private key, so the certificate proves the name, not which engine answers; the name resolving to 127.0.0.1 is what keeps the connection on your machine.
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. Sealing applies only to a session whose client offered a public key at CreateSession (a session opened without one isn't sealed), a request frame that fails to unseal is passed on as it arrived, and the key exchange itself isn't authenticated. 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 browser requests that don't come from the real app. This lockdown is in the current Linux build; the macOS builds are older releases pending a rebuild and don't enforce the exact-origin allow-list yet. The current Linux build 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.
Builds without the allow-list answer any website
An engine build without the allow-list answers every web origin with a wildcard Access-Control-Allow-Origin: *, so any website you visit while it runs can fetch the handshake token and drive your shell. On macOS, don't browse untrusted sites while the engine runs, or run the engine on a Linux machine until the macOS rebuild ships.
Allowed browser origins : https://terminal.openeng.app, https://terminal-75b6c.web.app,
https://terminal-75b6c.firebaseapp.com
(the app's own host and its Firebase default domains; 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, including at the handshake-token endpoint (GET /), so any program running on the machine, under any user account, can fetch the token and drive the engine.
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. (the current macOS builds, older releases, still poll the release service's former address), 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_ + staged_version). The UI shows a restart banner; RestartEngine re-execs in place, carrying the live session internally so you never re-pair. The one exception, from the release notes: restarting this way when updating from Terminal 0.0.3 asks for a fresh one-time key once; updating at the next start needs nothing.
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.
| Field | Purpose |
|---|---|
name | Display label for the group. |
env | Environment variables (name → value) injected into every tab's shell in the group. |
aliases | Shell aliases (name → value) applied before each command runs in the group's tabs. |
init_script | A 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_directory | The default working directory for new tabs created in the group. |
incognito | When 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 .) 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; acdupdates only that tab. - Per-tab
env— merged over the group's env, so the tab wins on any key collision. The effective environment isgroup.envextended bytab.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 BlockStarted → OutputDelta* → 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. 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 asudoprompt, feed a REPL). The UI binds this toCtrl+Enterfor 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= 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 (PtyStarted → PtyOutput* → PtyDone) which xterm.js renders directly.
Because gRPC-Web can't open a bidirectional stream, the inbound leg is unary:
| RPC | Role |
|---|---|
OpenInteractive | Start the program; server-streams raw PTY output. |
WriteInteractive | Send a keystroke/byte chunk to the PTY (serialized per-keystroke for correct ordering). |
ResizeInteractive | Propagate a terminal resize (cols/rows). |
KillInteractive | Terminate 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 (BGE-small-en-v1.5) once from huggingface.co into ~/ — only the model comes down; no command history is sent. 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. ~/), 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
0600file (secrets.key) and the blob (secrets.enc) isnonce(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.
Warning
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= — 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, theapi_key,auth_token, andwrite_keyare write-only — stored in the engine's encrypted secrets vault and never returned byListConnections(which reports onlyhas_write_keyso 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 itsWRITE_KEYhash 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_ 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..
Configuration, storage & scopes
Endpoint & process model
| Setting | Value |
|---|---|
| Bind address | 127.0.0.1:50052 (fixed — a zero-config product; the port is not configurable) |
| Browser endpoint | https:// (resolves to loopback; publicly-trusted cert) |
| Instances | One engine per machine, guarded by an O_EXCL PID lock (appserver.lock) |
| Home override | OPENENG_HOME relocates the whole ~/.openeng root |
| Boot | openeng-terminal serve --oauth= (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
connections.json saved SSH / connector connections (no secrets)
client-state/ engine-side UI store, one file per key
cache/ the stale-while-revalidate cache
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>/). Configured via GetPersistence/SetPersistence (a toggle + a directory you pick, default under the OS temp dir, / on Linux); 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.
The default folder can be readable by other users
The engine creates the folder and its files without setting permissions, so they follow your umask; with the usual default, other users on a shared machine can read every recorded command and its output. On a shared machine, pick a folder under your home directory, such as ~/.
Security invariant & egress scope
The proto schema has no field for an OpenEng secret or product prompt; a schema-shape test scans it for secret-like field names, with the marked exceptions below exempted. The payload is your own terminal data: the command you type, the stdin/output bytes, your cwd/env/aliases. Your own credentials are the marked exceptions — a connector's api_key, auth_token and write_key, and a vault entry's value — and they reach only your engine over the sealed loopback. They cross the boundary only inbound (you setting your own value) and are never sent back: 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 over the loopback channel. In the current Linux build that channel admits browsers only from the app's own origins (terminal.openeng.app and its two Firebase default domains) and one set of hosts; local programs that send no Origin can reach it too.