# Kubernetes

> Reference for the OpenEng Kubernetes app: a local sealed engine on 127.0.0.1:50054 plus the k8s.openeng.app UI, covering kubeconfig and connector clusters, scopes, resources, pods, logs, exec, helm, nodes, and Chat reuse.

The OpenEng Kubernetes app is a full cluster console — dashboard, resource explorer, topology, nodes, an interactive pod terminal, and live events — split across two halves that trust each other and almost nothing else: a **local sealed engine** bound to `127.0.0.1:50054` that does all cluster access, and a thin **web UI at k8s.openeng.app** that only renders panels and issues operations. Your kube credentials — tokens, client certs, exec-credential plugins — are resolved entirely inside the engine, on your machine, from your own kubeconfig. They never cross the wire to the browser, and they never egress to OpenEng.

## Overview

The browser cannot reach a Kubernetes API server directly, and you would not want it to hold your cluster credentials if it could. The app resolves that by keeping the two concerns physically apart:

- **The engine (local)** — A native binary that binds loopback `127.0.0.1:50054`. It reads `~/.kube/config` plus any imported fragments, builds an authenticated client per context using the `kube` and `k8s-openapi` crates (no `kubectl` shell-out), and executes every operation — list, get, apply, logs, exec, metrics — against the API server itself. Credential resolution happens here and only here.
- **The UI (browser)** — A static single-page app served from `k8s.openeng.app`. It is a pure client: it renders the clusters / resources / pods / logs / events / metrics / nodes panels and sends gRPC-Web calls to the local engine. It never reads your `~/.kube/config`: cluster auth for kubeconfig contexts is resolved by the engine. It does handle a kubeconfig you paste (sent once to the engine), a connector's keys (echoed back to pre-fill the re-auth dialog), and any resource YAML you open, which for a Secret includes its data.

Every call between them travels the same single encrypted boundary. The transport is gRPC + gRPC-Web over loopback HTTPS, with each frame sealed to the public key the other side sent in `CreateSession` (the client sends `client_pub`, the engine returns `engine_pub`). When the client offered a key, every subsequent RPC is sealed per frame with a fresh ephemeral X25519 key, HKDF-SHA256 and ChaCha20-Poly1305; the key exchange isn't authenticated, and a request frame that fails to unseal is passed on as it arrived. A default-deny auth interceptor guards the port, and the browser pairs by fetching a `0600` handshake token the engine mints at boot. The security properties are covered under [Security model and engine updates](#security-and-updates).

> [!NOTE]
> One engine per machine. The engine holds a spawn lock, advertises its endpoint only after a successful bind, and serves every browser on the host from the same local state — so preferences, caches, and the current context are consistent no matter which browser you open.

## Install and pair the engine

Kubernetes 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. The engine talks to the Kubernetes API itself, so you do not need `kubectl` installed. The app is a direct client for your own clusters and does not run agents; to let an agent work with a cluster, see [Reusing a cloud cluster in Chat](#reuse-in-chat) or [OpenEng AI](ai.md).

1. **Install the engine.** On Linux (x86_64 or ARM64) or macOS (Apple silicon or Intel; for now the macOS build is the previous release, see [Platforms](install.md#platforms)), run:

   ```bash
   curl -fsSL https://openeng.app/engine/kubernetes/install.sh | bash
   ```

   The 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](install.md#verify-a-download)), and installs `openeng-kubernetes` into `~/.local/bin` (set `OPENENG_BIN_DIR` to use another directory), adding that directory to your `PATH` if needed. There is no native Windows build; on Windows, use WSL.
2. **Sign in.** Open [k8s.openeng.app](https://k8s.openeng.app) and sign in with your Google account.
3. **Start the engine.** The pairing screen (`/pair`) shows a start command with a one-time key, ready to copy:

   ```bash
   openeng-kubernetes 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 opens the page you were heading to.

> **Note:** The one-time key expires after about five minutes, a refresh reuses a key that is still valid, and **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. The engine reads your existing `~/.kube/config`, so your kubeconfig contexts appear as soon as it connects. [Install, platforms and updates](install.md) covers updating and uninstalling.

## Using the app

This section walks through the console at `k8s.openeng.app`. The sections after it are the reference for what the engine does underneath.

### The workspace

- **Left menu:** **Dashboard**, **Explorer**, **Topology**, **Nodes**, **Terminal**, and **Events**, with **Settings** at the bottom. Each section has its own URL (`/overview`, `/explorer`, `/topology`, `/nodes`, `/terminal`, `/events`, `/settings`), and the active cluster (`?ctx=`), namespace (`?ns=`), and open resource (`?name=`) ride along in the query string, so a refresh or a shared link opens the same view. Any other path opens `/overview`.
- **Header:** a button that hides or shows the side panel, the **cluster switcher**, a status dot for the engine, a search button for the command palette, and your account menu. The cluster switcher shows each cluster's health and nodes ready, marks cloud clusters and read-only clusters, and ends with a **Settings** row. Switching clusters resets the namespace to all namespaces and closes any open resource.
- Every section except **Settings** needs a connected cluster; without one, it asks you to pick one.

### Adding and switching clusters

**Settings → Clusters** lists every cluster the engine knows: your kubeconfig contexts and any cloud clusters. The active one is marked **Connected**, and every other row has a **Connect** button. A cloud cluster shows a **read-only** or **read-write** badge and a remove button; kubeconfig contexts cannot be removed here.

- **Add a kubeconfig:** enter a cluster name, paste the kubeconfig YAML, and click **Add kubeconfig**. See [Importing a kubeconfig](#importing-kubeconfig).
- **Add a cloud cluster:** enter a name, the connector URL, and its API key. Tick **Use a separate auth token** to send a different bearer token, or **Configure a write key (grants write access)** to enter a write key. The result reads "Connected read-only." or "Connected read-write." A write key that the connector does not accept still adds the cluster, read-only. See [Cloud clusters via connector](#connector-clusters) and [Connectors](connectors.md).

### Dashboard

**Dashboard** (`/overview`) is a live view of the active cluster: stat cards for **CPU**, **Memory**, **Pods**, **Nodes**, **Workloads**, and **Alerts**; **CPU Usage** and **Memory Usage** charts; workload health; per-node capacity; recent cluster events; load per namespace; the top CPU consumers; and recent Deployment rollouts. The **5M**, **15M**, **1H**, **3H**, **6H**, and **24H** buttons set the charts' time window. CPU and memory figures come from the cluster's metrics-server; without one, the dashboard shows zeros and empty states instead of failing.

### Explorer and the resource drawer

- **Explorer** lists resource kinds in its side panel (on a kubeconfig cluster, every kind the API server reports, custom resources included), with a namespace picker on top (one namespace, or all). Picking a kind shows its resources in a table you can filter; the trash button on a row deletes it after you confirm. On kubeconfig clusters the table updates live as resources are added, changed, or removed; on connector clusters it refreshes by listing again (see [Generic resource operations](#generic-resource-operations)).
- Click a row to open the resource drawer. Every kind has the **overview** (details, containers, conditions, and labels), **yaml**, **events**, and **actions** tabs. Pods and nodes also get **metrics**; pods get **logs** and **files**; pods and workloads (Deployments, StatefulSets, DaemonSets, ReplicaSets, Jobs, and CronJobs) get **env**. The open tab is part of the URL. <kbd>Esc</kbd> closes the drawer.
  - **yaml:** the live manifest. Switch to edit mode, change it (an "edited" badge appears), and click **Apply** to server-side apply it.
  - **logs:** a live stream of a container's log. Choose the container (init containers included), search, wrap long lines, refresh, or download the output as a `.log` file.
  - **metrics:** live CPU and memory charts. Network and disk I/O show as unavailable, because metrics-server does not report them.
  - **env** and **files:** the container's environment variables (the running values when exec is allowed, otherwise the values declared in the pod spec) and a browser for the container's file system.
  - **actions:** **Edit YAML**, **Scale**, **Restart Rollout**, **Port Forward**, **View Logs**, **Cordon** and **Drain** (nodes only), and **Delete**. Drain and Delete ask you to confirm first.

### Topology

**Topology** draws the active cluster's Services, workloads (Deployments, StatefulSets, and DaemonSets), and Pods as a graph, built in your browser from the resource lists. It has a namespace filter, a refresh button, a health legend, a count of workloads and pods, and pan, zoom, and minimap controls.

### Nodes

**Nodes** shows a card per node with its readiness, a cordoned marker, roles, kubelet version, CPU and memory capacity, taints, and conditions. **Cordon** and **Uncordon** ask you to confirm, and **Drain** opens a dialog that streams the drain's progress line by line.

### Pod terminal

**Terminal** lists pods. Pick one (and a container, if it has several) to open a shell. On a kubeconfig cluster this is a live interactive terminal. On a connector cluster each command runs on its own through the connector, with the working directory and environment carried from one command to the next, and the app provides <kbd>↑</kbd>/<kbd>↓</kbd> history and line editing (see [Interactive exec](#interactive-exec)). The engine keeps what you saw, so it comes back when you return to the pod, refresh, or reconnect.

### Events

**Events** (`/events`) is the full-screen live event stream for the current namespace scope. Filter it by **All**, **Warning**, or **Normal**, search across reason, message, object, and namespace, and click **Pause** to hold the list still while you read. When you resume, events that arrived during the pause are not added back.

### Read-only clusters and re-authentication

- On a read-only cloud cluster, every control that would change the cluster is disabled, with the tooltip "Read-only cluster — actions are disabled", and a banner says so above each affected pane. Reading stays available: logs, search, refresh, and **View Logs** keep working. A pod that matches the connector's force-exec allowlist still gets a terminal; other pods show "Read-only — exec not available". See [Scopes: read-only, write, and gating](#scopes-and-gating).
- If the active cloud cluster rejects its stored credentials (HTTP 401 or 403), the app opens a dialog pre-filled with the stored API key, where you can correct the key or add an auth token and click **Save & retry**. If you cancel, the cluster is marked inaccessible, and a bar offers **Re-enter credentials** to reopen the dialog. Kubeconfig clusters never show this dialog.

### Command palette and account menu

<kbd>⌘</kbd>+<kbd>K</kbd> (<kbd>Ctrl</kbd>+<kbd>K</kbd> on Windows and Linux), or the search button, opens a palette that jumps to any section or switches the namespace (`namespace: <name>`); switching the namespace closes any open resource. The account menu (your avatar) shows your email, the engine and app versions, a **Release notes** link, and **Sign out**.

## Connections and clusters

The engine presents a single flat list of clusters via `ListClusters`, blending two sources into one uniform `Cluster` row. The active one is the *current context*; you switch with `SetContext` and the header cluster picker.

- **Kubeconfig contexts** — Every context in your merged kubeconfig (`~/.kube/config` plus imported fragments) becomes a row. These are backed by a real local `kube::Client`: the engine resolves the context's token / cert / exec-credential locally when it first builds and caches the client. Watch, port-forward and drain are available; Helm is engine-only for now (see [Helm releases](#helm)).
- **Connector clusters** — A cluster whose *source* is an HTTPS connector rather than a local client, identified by the context id `connector:<name>`. The engine fetches resources by POSTing tool calls to the connector instead of talking to an API server itself. Ideal for managed clusters reachable only from inside a VPC. See [Cloud clusters via connector](#connector-clusters).

### Switching and connecting

The active context is URL-addressable: `?ctx=` in the UI drives it, and the header switcher writes it. Switching to a context runs a small pipeline — `SetContext`, then `ConnectCluster` (which builds/validates the client, or for a connector calls `cluster_info`), then discovery (`ListNamespaces` + `ListResourceKinds`). The relevant RPCs:

```
ListClusters      list every kubeconfig context + connector cluster
GetContext        the engine's current context
SetContext        switch the active context (validated against known contexts)
ConnectCluster    build + verify the client; returns the API server version
ClusterHealth     api reachable? server version, nodes ready / total
```

Both kinds switch identically from the UI's point of view — the header picker, the Settings clusters list, and a deep-linked `?ctx` all funnel through the same switch.

## Importing a kubeconfig

You do not have to depend on whatever is already in `~/.kube/config`. In **Settings → Clusters**, paste a kubeconfig YAML with a name and click *Add kubeconfig*; the UI calls `AddKubeconfig`. The engine:

1. Parses the YAML and rejects anything with no contexts (or invalid syntax).
2. Writes the fragment `0600` under `~/.openeng/kubernetes/kubeconfigs/` so it survives restarts — the file may carry credentials, so it is never logged.
3. Merges it into the live registry and returns the newly available context names, which appear immediately in `ListClusters`.

> [!TIP]
> On boot the engine folds every imported fragment on top of `~/.kube/config`. A broken fragment is skipped rather than fatal, so one bad paste never stops the engine from serving your other clusters.

## Cloud clusters via connector

A connector cluster is a cluster reached through an OpenEng cloud-Kubernetes connector — a deployed service (e.g. a Lambda) that runs inside your own cloud/VPC, speaks the OpenEng tools protocol over HTTPS, and reaches the API server from there. The engine never holds the cluster's credentials; it only holds *your* key to *your* connector, and it sees only the connector's trimmed JSON responses.

### Adding one

**Settings → Clusters → Add a cloud cluster** calls `AddConnectorCluster`. The form fields map directly onto the request:

| Field | Wire field | Meaning |
| --- | --- | --- |
| Name | `name` | Assigned the context id `connector:<name>`. |
| Connector URL | `url` | The connector base URL (must start with `http://` or `https://`; trailing slashes trimmed). |
| API key | `api_key` | Your connector key, sent as the `x-api-key` header. |
| Auth token (optional) | `auth_token` | An explicit `Authorization: Bearer` value. Empty ⇒ the API key is reused as the bearer. |
| Write key (optional) | `write_key` | A plaintext write key sent as `x-write-key`. Empty ⇒ read-only. See [Scopes](#scopes-and-gating). |

On add, the engine probes the connector's `GET /manifest` to (a) validate reachability and the credentials and (b) detect whether the connector is serving read-only. It then persists the connector `0600` under `~/.openeng/kubernetes/connectors/` and returns the assigned context plus the detected `read_only` flag. A `401/403` comes back stamped with the `CLUSTER_UNAUTHORIZED` sentinel so the UI can say "check the API key" rather than "unreachable".

### How reads are served

For a connector context the engine routes each operation to a connector tool over `POST /call-stream` (NDJSON), reducing the streamed frames back into one result for unary RPCs. An older connector that predates streaming (which `404`s `/call-stream`) is transparently retried over the buffered `POST /call`, so a not-yet-redeployed connector is never broken. A connector exposes no discovery API, so the engine advertises a static set of common kinds for the Explorer.

```
ListNamespaces  -> list_namespaces        ConnectCluster/Health -> cluster_info
ListResources   -> list_<plural>          GetResource/Describe  -> get_<kind>
ListPods        -> list_pods              GetPodLogs (stream)   -> pod_logs
ListNodes       -> list_nodes             GetMetrics            -> node_metrics | pod_metrics
ExecPod         -> pod_exec (per-command) ApplyResource         -> apply_manifest
DeleteResource  -> delete_resource        Cordon/Uncordon       -> node_cordon | node_uncordon
```

### Re-authentication

When a connector later rejects the stored credentials mid-session (any RPC returning `CLUSTER_UNAUTHORIZED`), the UI raises a re-auth dialog — but only if that connector is still the active cluster, so a stale late 401 from a cluster you just left cannot pop a dialog. `GetConnector` reads the stored `api_key`, `auth_token`, and `write_key` back to the local UI to pre-fill the dialog; you edit, save, and retry. That echo stays strictly on the loopback seal boundary — the same trust domain that entered the values — and echoing the write key back means a re-auth save never silently drops your write access. Remove a connector with `RemoveConnectorCluster` (it deletes the persisted file).

## Scopes: read-only, write, and gating

Local kubeconfig clusters carry no OpenEng-level scope — whatever your kube RBAC allows, the engine allows; RBAC is your authority. Connector clusters, by contrast, have an explicit read-only / write scope enforced at two layers.

- **Read-only by default** — A connector added with no write key attaches read-only. The engine's `guard_read_only` hook refuses every mutation routed to a read-only connector with `permission_denied` ("cluster is read-only") — `ApplyResource`, `DeleteResource`, `CordonNode`, `UncordonNode`. The connector itself also hides and blocks mutating tools, so the guard is defense in depth, not the only line.
- **Write via the write key** — Supply a write key and the engine sends it as `x-write-key`; the connector grants write access when it matches its configured `WRITE_KEY` hash. The Settings row then shows a **read-write** badge instead of **read-only**. Generate the key at `connectors.openeng.app → Write key`.

### Live read-only re-probe

Read-only status is not frozen at add time. Every `ListClusters` re-probes each connector's `/manifest`, so a connector that flips read-only ⇄ write (e.g. its env changed) takes effect on the next refresh — the UI enables or disables mutating actions immediately, with no re-add.

### Force-exec prefixes

A read-only connector can still be allowed to exec into specific pods via its `K8S_FORCE_EXEC` allowlist — a set of pod-name prefixes surfaced on each cluster row as `force_exec` (refreshed on every `ListClusters` manifest re-probe). The UI uses these to decide, per pod, whether to offer an exec console even on a read-only cluster. Because the connector enforces the allowlist itself and refuses non-matching pods, the engine deliberately does *not* apply `guard_read_only` to connector exec — a refused pod comes back as an error in `PtyDone.status`.

> [!NOTE]
> Metrics is always a read and is never gated by read-only, on either kind of cluster.

## The workspace

Once a cluster is active, the console is organized into sections; each (except Settings) requires a connected context and otherwise prompts you to pick one.

- **Overview** — The dashboard — cluster health, node readiness, namespace rollups, recent rollouts, top consumers, and metric sparklines.
- **Explorer** — The generic resource browser: pick a kind and namespace, list rows, and open the detail drawer for YAML / describe / edit / delete.
- **Topology** — A visual map of workloads and their relationships across the selected scope.
- **Nodes** — Node inventory with readiness, roles, kubelet version, capacity, taints, and cordon / uncordon / drain actions.
- **Terminal** — The pod exec console and per-pod interactive shell.
- **Events** — A live stream of cluster events (Normal / Warning) for the current namespace scope.

Settings is reachable without a connected cluster — it hosts the local engine status and the clusters management (kubeconfig import + connector add / switch / remove). The current context (`?ctx`) and namespace (`?ns`) are carried across sections in the URL; per-view state like the open drawer is dropped when you navigate.

## Namespaces and resources

Discovery is two RPCs: `ListNamespaces` for the namespace switcher and `ListResourceKinds` for the kind picker (group / version / kind / plural / namespaced). A local cluster enumerates kinds from the API server's discovery; a connector advertises a static common set.

### Generic resource operations

Everything in the Explorer runs through a small, kind-agnostic set of RPCs that work for any kind via the dynamic API:

| RPC | Returns | Purpose |
| --- | --- | --- |
| `ListResources` | summaries | Rows for a kind in a namespace: status, humanized age, and kind-specific columns (Ready, Restarts, IP, …). |
| `GetResource` | full YAML | The complete object manifest, for viewing and editing. |
| `DescribeResource` | pretty text | A `kubectl describe`-style rendering with status, conditions, and events. |
| `ApplyResource` | ok | Server-side apply from edited YAML (write-gated on connectors). |
| `DeleteResource` | ok | Delete by kind / api-version / name / namespace (write-gated on connectors). |
| `WatchResources` | stream | Live table updates: ADDED / MODIFIED / DELETED events feed the row list. |

> [!WARNING] Watch is local-only
> `WatchResources` is served through a local `kube::Client` and is not routed to connector tools. On a connector cluster the resource tables refresh by re-listing rather than by a live watch stream.

## Pods, logs, and exec

`ListPods` returns the compact pod shape — phase, node, IP, restarts, ready/total, age, containers — for the pods pane. Two richer surfaces sit on top.

### Streaming logs

`GetPodLogs` streams `LogEvent`s. It takes container, `follow` (tail), `previous` (the crashed container's prior instance), `tail_lines`, and `since` (e.g. `1h`, `10m`). On a connector the logs stream over `/call-stream`, forwarding each NDJSON text chunk into the same `LogEvent` stream as it arrives, with the same tail semantics.

### Interactive exec

The exec surface mirrors the Terminal engine's PTY shape: a server stream of `PtyEvent` (`started` / `output` / `done`) for output, and unary RPCs for input — `WriteExec`, `ResizeExec`, `CloseExec`. There are two execution modes depending on the cluster kind:

- **Local: a live PTY** — `ExecPod` opens a real interactive PTY into the container over the kube client. The container's own shell handles line editing and its own tab-completion. Full-screen programs, cursor keys, and Ctrl-C behave as expected.
- **Connector: per-command exec** — The connector transport is stateless per request, so instead of a live shell the engine runs each command via the connector's `pod_exec` and streams its output. It round-trips the shell state (cwd + env) the connector returns back into the next command — keyed per (context, namespace, pod, container) — so `cd` and `export` persist command to command. The resulting cwd is surfaced as `PtyDone.cwd` and rendered in the prompt.

### Tab-completion for the connector console

Because a connector console has no live shell to complete paths, the engine serves completion explicitly via `CompleteExec`. Given the input line up to the cursor it resolves the trailing path token against the pod's filesystem with a single `ls` run under the session's stored cwd/env — deliberately *without* advancing that state (completion must never mutate the session it inspects). It returns the directory's full listing (dirs end in `/`, which the UI caches and filters by prefix), the fragment being completed, and the current cwd. A first bare word is treated as a command name and completed by the UI from its own catalog + history. Failures come back as a `message`, never a hard error — a completion that can't answer is a no-op at the prompt, not a broken console.

## Port-forwarding and events

### Port-forward

`StartPortForward` opens a local listener that tunnels `local_port → pod_port` through the engine; `ListPortForwards` enumerates active tunnels and `StopPortForward` tears one down. Port-forward is served through a local `kube::Client`, so it is available on kubeconfig clusters only.

### Events

`WatchEvents` streams cluster `Event`s for a namespace — type (Normal / Warning), reason, message, involved object, last-seen, and count — feeding the Events pane and the Overview's recent-activity views. Like watch and port-forward, the events stream runs through the local client and is not routed to connectors.

## Helm releases

The engine manages Helm releases through four RPCs: `HelmList`, `HelmInstall` and `HelmUpgrade` (each a `ProgressEvent` stream of live output lines), and `HelmUninstall`. Install/upgrade take release, chart, version, and values; uninstall takes an optional `keep_history`. The current console has no Helm section, so these RPCs are implemented in the engine but not exposed in the app yet.

**Listing needs no `helm` binary.** Helm 3 stores each release as a Secret of type `helm.sh/release.v1` (labeled `owner=helm`). `HelmList` reads those Secrets directly, decodes each one, and returns the latest revision of every release (name, namespace, revision, status, chart, app version, and last deploy time), for one namespace or across all of them.

> [!WARNING] Install, upgrade, and uninstall need a local `helm` binary
> The shipped engine drives these by shelling out to a `helm` binary resolved on `PATH`, streaming its stdout/stderr as progress. If `helm` is absent the operation returns a clean "helm binary not found on PATH" rather than failing opaquely. Helm operations run against local kubeconfig contexts only — they are not routed to connector clusters. The engine does not pass `--kube-context` to `helm`, so install, upgrade (run as `helm upgrade --install`), and uninstall act on the context your kubeconfig's `current-context` names.

## Nodes and metrics

### Nodes

`ListNodes` returns each node's readiness, schedulability, roles, kubelet version, age, CPU/memory capacity, taints, and conditions. Node operations:

- `CordonNode` / `UncordonNode` — mark a node un/schedulable. Routed to `node_cordon` / `node_uncordon` on a connector, and write-gated by `guard_read_only` there.
- `DrainNode` — evict pods, streaming progress. Served through the local client only (kubeconfig clusters).

### Metrics

`GetMetrics` takes a `scope` of `node` or `pod` and returns CPU cores, memory bytes, and their percentages per entity — powering the Overview sparklines, top-consumers, and node-capacity views. On a local cluster it reads the metrics API (a metrics-server must be present); on a connector it routes to `node_metrics` / `pod_metrics`. Metrics is a read and is never read-only-gated.

## Local vs connector capability matrix

Because a connector reaches your cluster through a tool API rather than a live client, a handful of streaming/tunneling operations are available only on kubeconfig clusters. The full picture:

| Capability | Kubeconfig context | Connector cluster |
| --- | --- | --- |
| Namespaces, kinds, resource list/get/describe | Yes | Yes (static kind set) |
| Apply / Delete | Yes (RBAC) | Yes, if read-write |
| Live resource watch (`WatchResources`) | Yes | No — re-list instead |
| Pods list & streaming logs | Yes | Yes |
| Pod exec | Interactive live PTY | Per-command (state round-trip + `CompleteExec`) |
| Port-forward | Yes | No |
| Events (`WatchEvents`) | Yes | No |
| Helm | Engine only, not in the app yet (listing needs no binary; install, upgrade, and uninstall need `helm` on PATH) | No |
| Nodes list, cordon / uncordon | Yes | Yes (cordon/uncordon write-gated) |
| Drain node | Yes | No |
| Metrics | Yes (metrics-server) | Yes |

## Reusing a cloud cluster in Chat

A connector cluster you add here is not private to this app. Because connectors are persisted by the engine under `~/.openeng/kubernetes/connectors/` and exposed through the standard `ListClusters` RPC, the OpenEng **Chat** app's coding agent can drive the very same cluster — without ever re-entering the credentials.

Chat's engine opens its own sealed loopback session to the Kubernetes engine (the same `127.0.0.1:50054`, addressed as `engine.openeng.app:50054` pinned to loopback, SNI + Host `engine.openeng.app`) and calls the typed RPCs on your behalf: `ListClusters`, `ListNamespaces`, `ListPods`, `DescribeResource`, `GetPodLogs`, `ListResources`, and so on. A Chat group's config carries a `connection_id` that is exactly the Kubernetes context id — a plain context like `prod`, or a connector like `connector:my-cloud` — and an empty id means the K8s engine's current context.

The key property: the connector's API key, bearer, and write key **never enter Chat**. Chat asks the Kubernetes engine by context id; the Kubernetes engine is the sole holder of those credentials and performs the connector call itself. The read-only / write scope you configured here is enforced there too — if the connector is read-only, the agent's mutations are refused the same way the UI's are.

> [!TIP]
> Add and validate a cloud cluster once, in this app's Settings. It then shows up automatically in Chat's connection picker, already scoped, already authenticated.

## Security model and engine updates

### What crosses the wire

The proto that defines this boundary has no field for an OpenEng secret or product prompt. A schema test scans it for fields named like a secret, credential, API key, password, private key or token, with the marked exceptions below exempted. Cluster auth from the kubeconfigs on your machine is resolved locally by `kube` and doesn't cross the wire. The rest of the payload is your own cluster data — resource YAML, logs, exec bytes.

There are two deliberate exceptions, both your own credentials, and they reach only your engine over the sealed loopback:

- A connector cluster's `api_key` / `auth_token` / `write_key`, explicitly marked `connector-credential` and exempted from the test. That is *your* key for *your* connector — handed to the engine on `AddConnectorCluster`, stored locally `0600`, and echoed back to the local UI (over the loopback seal) to pre-fill the re-auth dialog. It is not a product secret leaking out.
- A kubeconfig you paste into the app. Its YAML, which may carry tokens and client keys, travels to the engine in `AddKubeconfigRequest`, and the engine stores it `0600`.

### Boundary hardening

- **Sealed loopback HTTPS** — Every frame after `CreateSession` is sealed with a fresh ephemeral key when the client offered a key at `CreateSession` (the exchange isn't authenticated, and a request frame that fails to unseal is passed on as it arrived). The engine serves TLS on loopback using the publicly-trusted `engine.openeng.app` certificate (DNS-pinned to `127.0.0.1`) so an `https://` web app avoids Mixed-Content blocking, with a persisted self-signed cert as fallback.
- **Origin + Host allowlists** — In the current Linux builds, CORS is not a wildcard: only the app's own origins — `https://k8s.openeng.app` and its Firebase default domains `https://kubernetes-b53a9.web.app` and `https://kubernetes-b53a9.firebaseapp.com` (and localhost in dev) — may reach the engine from a browser, and the allowed origin is reflected, not starred. A separate Host/authority allowlist (`engine.openeng.app`, `localhost`, `127.0.0.1`, `::1`) defeats DNS rebinding — a request whose Host is an attacker domain is rejected outright even if the Origin trick would make it look same-origin. The macOS builds are older releases pending a rebuild and don't enforce the exact-origin allow-list yet; a build without it answers any website, so on macOS don't browse untrusted sites while the engine runs, or run the engine on a Linux machine until the rebuild ships.
- **Default-deny auth** — The port sits behind a default-deny interceptor. The browser pairs by fetching a `0600` handshake token the engine mints at boot from OS randomness (vended at an auth-exempt `GET /openeng/handshake` to the app's origin and to any local program that sends no `Origin`, so other users' programs on a shared machine can fetch it too); a world-readable token is refused. Private Network Access preflights are answered so the public app can legitimately reach the loopback engine.
- **Local-only persistence** — Imported kubeconfigs (`~/.openeng/kubernetes/kubeconfigs/`) and connectors (`~/.openeng/kubernetes/connectors/`) are written `0600` and never logged. Generic UI preferences and SWR caches are engine-persisted (`GetClientState`/`SetClientState`, `CacheGet`/`CacheSet`) so every browser on the machine shares the same last-known state — never as a credential carrier.

### Mandatory engine auto-update

The engine self-updates at boot and cannot be skipped. While it is serving, a newer stable build is downloaded, verified, and *staged* in the background; `CreateSession` and `GetUpdateStatus` report the running version plus any `staged_version` awaiting a restart. The UI surfaces a "restart to update" banner that calls `RestartEngine` — the engine re-execs in place into the staged binary, carrying the live session internally so you never re-pair, and the UI re-probes and reconnects to the new version. The one exception, from the release notes: restarting this way when updating from Kubernetes 0.0.4 asks for a fresh one-time key once; updating at the next start needs nothing. No credential rides either update RPC; they carry only version strings.
