From 8aee96bd9c93b2cf900298268df7514bf9789bde Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 11:11:42 -0400 Subject: [PATCH 1/2] feat(terminal): super_user instance terminal (xterm.js over WebSocket) prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototype (for team review) of an interactive terminal in Studio, wired to a PTY running inside the customer's Harper instance container. Pairs with the Harper component on claude/instance-terminal-prototype. First slice of the "Claude in the container" direction; establishes the authenticated WebSocket channel the planned live-log-stream and inspector-bridge features will reuse. Design (per review): connects to the OPERATIONS API port (same origin as getOperationsUrl — one security boundary), with FIRST-MESSAGE auth (credential in the first WS frame, never in the handshake headers/subprotocol). - src/features/instance/terminal: new feature module (wire.ts, TerminalView.tsx, resolveInstanceWsConnection.ts, hooks/useSupportsTerminal.ts, index.tsx, routes.ts, README.md). - Register the route (instance routes.ts) and add a super_user-gated nav entry (InstanceNavBar.tsx). - package.json: @xterm/xterm, @xterm/addon-fit. Gated three ways: super_user, direct connection only, and the instance must run Harper with terminal.enabled: true. Co-Authored-By: Claude Fable 5 --- package.json | 2 + src/features/instance/InstanceNavBar.tsx | 17 +- src/features/instance/routes.ts | 2 + src/features/instance/terminal/README.md | 58 ++++++ .../instance/terminal/TerminalView.tsx | 171 ++++++++++++++++++ .../terminal/hooks/useSupportsTerminal.ts | 14 ++ src/features/instance/terminal/index.tsx | 66 +++++++ .../terminal/resolveInstanceWsConnection.ts | 56 ++++++ src/features/instance/terminal/routes.ts | 12 ++ src/features/instance/terminal/wire.ts | 44 +++++ 10 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 src/features/instance/terminal/README.md create mode 100644 src/features/instance/terminal/TerminalView.tsx create mode 100644 src/features/instance/terminal/hooks/useSupportsTerminal.ts create mode 100644 src/features/instance/terminal/index.tsx create mode 100644 src/features/instance/terminal/resolveInstanceWsConnection.ts create mode 100644 src/features/instance/terminal/routes.ts create mode 100644 src/features/instance/terminal/wire.ts diff --git a/package.json b/package.json index e5dd24560..857431555 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "@tanstack/react-table": "^8.21.2", "@types/node": "^24.0.0", "@typescript/ata": "^0.9.8", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "ai": "^7.0.0", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", diff --git a/src/features/instance/InstanceNavBar.tsx b/src/features/instance/InstanceNavBar.tsx index da9d2bead..79faaf483 100644 --- a/src/features/instance/InstanceNavBar.tsx +++ b/src/features/instance/InstanceNavBar.tsx @@ -18,7 +18,16 @@ import { wasAReleasedBeforeB } from '@/lib/string/wasAReleasedBeforeB'; import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage'; import { useQuery } from '@tanstack/react-query'; import { Link, useLoaderData, useParams } from '@tanstack/react-router'; -import { DatabaseIcon, GaugeIcon, Menu, NotepadTextIcon, PackageIcon, ServerIcon, SettingsIcon } from 'lucide-react'; +import { + DatabaseIcon, + GaugeIcon, + Menu, + NotepadTextIcon, + PackageIcon, + ServerIcon, + SettingsIcon, + SquareTerminalIcon, +} from 'lucide-react'; import { ReactNode, useMemo } from 'react'; interface Link { @@ -82,6 +91,12 @@ export function InstanceNavBar() { icon: , name: 'Config', }, + // PROTOTYPE: super_user-only interactive terminal. canManage === super_user here. + canManage && { + to: buildAbsoluteLinkToPage(params, 'terminal'), + icon: , + name: 'Terminal', + }, ].filter(excludeFalsy) satisfies Link[], [canManage, params, apisAvailable, statusAvailable]); return ( <> diff --git a/src/features/instance/routes.ts b/src/features/instance/routes.ts index bd451bed6..e2a81f3b1 100644 --- a/src/features/instance/routes.ts +++ b/src/features/instance/routes.ts @@ -5,6 +5,7 @@ import { createBrowseRouteTree } from '@/features/instance/databases/routes'; import { createInstanceLayoutRoute } from '@/features/instance/instanceLayoutRoute'; import { createLogRouteTree } from '@/features/instance/log/routes'; import { createStatusRouteTree } from '@/features/instance/status/routes'; +import { createTerminalRouteTree } from '@/features/instance/terminal/routes'; export function createInstanceRouteTree(mode: 'local' | 'cluster' | 'instance') { const instanceLayoutRoute = createInstanceLayoutRoute(mode); @@ -16,6 +17,7 @@ export function createInstanceRouteTree(mode: 'local' | 'cluster' | 'instance') createStatusRouteTree(instanceLayoutRoute), createConfigRouteTree(instanceLayoutRoute), createBrowseRouteTree(instanceLayoutRoute), + createTerminalRouteTree(instanceLayoutRoute), ]; return instanceLayoutRoute.addChildren(children); diff --git a/src/features/instance/terminal/README.md b/src/features/instance/terminal/README.md new file mode 100644 index 000000000..f0be39419 --- /dev/null +++ b/src/features/instance/terminal/README.md @@ -0,0 +1,58 @@ +# Instance terminal (PROTOTYPE) + +An `xterm.js` terminal in Studio, wired over a WebSocket to a PTY running +**inside the customer's Harper instance container**. super_user-only, +direct-connection-only. + +> **Status: prototype for team discussion.** Pairs with the Harper component on +> `claude/instance-terminal-prototype` (`components/terminal/`). Ships behind the +> instance's own opt-in (`terminal.enabled: true`) and Studio's super_user + +> direct-connection gates. It's the first slice of the "Claude in the container" +> direction and, more importantly, it establishes the authenticated app-port +> WebSocket channel the live-log-stream and inspector-bridge features will reuse. + +## Files + +| File | Role | +| --- | --- | +| `wire.ts` | Wire protocol — opcodes + subprotocol names. **Mirror of the Harper component.** | +| `resolveInstanceWsConnection.ts` | WS analog of `resolveInstanceConnection`: derives the `wss://` URL and the auth subprotocols. | +| `hooks/useSupportsTerminal.ts` | Direct-connection gate (mirrors `useSupportsLogSSE`). | +| `TerminalView.tsx` | xterm.js ↔ WebSocket wiring and lifecycle. | +| `index.tsx` | Page: super_user + direct-connection gates, prototype banner. | +| `routes.ts` | `terminal` route under the instance layout. | + +Also touches: `../routes.ts` (registers the route) and `../InstanceNavBar.tsx` +(super_user-gated nav entry), and `package.json` (`@xterm/xterm`, +`@xterm/addon-fit`). + +## Auth over WebSocket — first-message auth + +Browsers can't set an `Authorization` header on `new WebSocket()`, and putting a +token in the handshake subprotocol leaks it into request headers that +intermediaries may log. So the credential is sent as the **first WebSocket +frame** instead (`wire.ts`, opcode `a`): the socket connects unauthenticated, +the client immediately sends `a{"token":...}` (or `a{"username":...,"password":...}`), +and Harper validates it and checks super_user before spawning anything. Nothing +sensitive rides in the handshake. `resolveInstanceWsConnection` returns the +credential; `TerminalView` sends it on open and treats the connection as +`authenticating` until the first byte comes back. + +This also means Harper needs **no change to `security/auth.ts`** — the terminal +upgrade handler does its own first-message validation. + +## Port — operations API port + +The terminal WebSocket lives on the **operations API port** (same origin as +`authStore.getOperationsUrl`), keeping it on the same security boundary as the +rest of the operations surface rather than the application data port. Harper +serves the `/terminal` upgrade from the operations Fastify server +(`server/operationsServer.ts`). `resolveInstanceWsConnection` therefore just +derives `wss:///terminal` — no port juggling. + +## Limitations (shared with existing live features) + +- **Direct connections only.** The Fabric Connect proxy buffers streamed + responses and can't carry a WebSocket, so the page shows an explanatory notice + when the session is proxied — the same limitation as the live log/deploy tails. +- **super_user only**, gated in the nav, the page, and again on the Harper side. diff --git a/src/features/instance/terminal/TerminalView.tsx b/src/features/instance/terminal/TerminalView.tsx new file mode 100644 index 000000000..919ecff09 --- /dev/null +++ b/src/features/instance/terminal/TerminalView.tsx @@ -0,0 +1,171 @@ +import '@xterm/xterm/css/xterm.css'; +import { Button } from '@/components/ui/button'; +import { EntityIds } from '@/features/auth/store/authStore'; +import { FitAddon } from '@xterm/addon-fit'; +import { Terminal } from '@xterm/xterm'; +import { useEffect, useRef, useState } from 'react'; +import { resolveInstanceWsConnection } from './resolveInstanceWsConnection'; +import { encodeAuth, encodeInput, encodeResize } from './wire'; + +type Status = 'connecting' | 'authenticating' | 'open' | 'closed' | 'error'; + +/** + * Renders an xterm.js terminal bound to a Harper instance PTY over a WebSocket. + * Connection/auth resolution lives in {@link resolveInstanceWsConnection}; auth + * is sent as the first frame (see `wire.ts`). This component owns only the + * xterm ↔ socket wiring and lifecycle. + */ +export function TerminalView({ entityId }: { entityId: EntityIds }) { + const containerRef = useRef(null); + const [status, setStatus] = useState('connecting'); + const [detail, setDetail] = useState(''); + // Bump to force the effect to tear down and reconnect. + const [reconnectNonce, setReconnectNonce] = useState(0); + + useEffect(() => { + const container = containerRef.current; + if (!container) { return; } + + const term = new Terminal({ + cursorBlink: true, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace', + fontSize: 13, + theme: { background: '#0b0f19' }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(container); + const safeFit = () => { + try { + fit.fit(); + } catch { + // container not laid out yet; the ResizeObserver will refit + } + }; + safeFit(); + + setStatus('connecting'); + setDetail(''); + + let ws: WebSocket; + let auth; + try { + const resolved = resolveInstanceWsConnection({ id: entityId }); + auth = resolved.auth; + if (!auth) { + throw new Error('No operation token or stored credentials for this instance.'); + } + ws = new WebSocket(resolved.url, resolved.protocols); + } catch (err) { + setStatus('error'); + setDetail(err instanceof Error ? err.message : String(err)); + term.dispose(); + return; + } + + let authed = false; + const sendResize = () => { + if (ws.readyState === WebSocket.OPEN && authed) { + ws.send(encodeResize(term.cols, term.rows)); + } + }; + + ws.onopen = () => { + // First-message auth: the socket is unauthenticated until the server + // accepts this frame. We flip to 'open' when the first byte comes back. + setStatus('authenticating'); + ws.send(encodeAuth(auth)); + }; + ws.onmessage = (ev) => { + if (!authed) { + authed = true; + setStatus('open'); + sendResize(); + term.focus(); + } + if (typeof ev.data === 'string') { + term.write(ev.data); + } else if (ev.data instanceof ArrayBuffer) { + term.write(new Uint8Array(ev.data)); + } + // Server sends text frames; Blob path intentionally unhandled. + }; + ws.onerror = () => setStatus('error'); + ws.onclose = (ev) => { + setStatus('closed'); + setDetail(ev.reason ? `${ev.code} ${ev.reason}` : `code ${ev.code}`); + term.write(`\r\n\x1b[90m[connection closed${ev.reason ? `: ${ev.reason}` : ''}]\x1b[0m\r\n`); + }; + + const onData = term.onData((data) => { + if (ws.readyState === WebSocket.OPEN && authed) { + ws.send(encodeInput(data)); + } + }); + const onResize = term.onResize(() => sendResize()); + + const observer = new ResizeObserver(() => safeFit()); + observer.observe(container); + + return () => { + observer.disconnect(); + onData.dispose(); + onResize.dispose(); + try { + ws.close(1000, 'client navigating away'); + } catch { + // already closing + } + term.dispose(); + }; + }, [entityId, reconnectNonce]); + + return ( +
+
+ + + {labelFor(status)} + {detail && status !== 'open' ? ` — ${detail}` : ''} + + {(status === 'closed' || status === 'error') && ( + + )} +
+
+
+ ); +} + +function labelFor(status: Status): string { + switch (status) { + case 'open': + return 'Connected'; + case 'connecting': + return 'Connecting…'; + case 'authenticating': + return 'Authenticating…'; + case 'error': + return 'Error'; + case 'closed': + return 'Disconnected'; + } +} + +function StatusDot({ status }: { status: Status }) { + const color = status === 'open' + ? 'bg-green-500' + : status === 'connecting' || status === 'authenticating' + ? 'bg-yellow-500' + : 'bg-red-500'; + return ; +} diff --git a/src/features/instance/terminal/hooks/useSupportsTerminal.ts b/src/features/instance/terminal/hooks/useSupportsTerminal.ts new file mode 100644 index 000000000..e67a59bf1 --- /dev/null +++ b/src/features/instance/terminal/hooks/useSupportsTerminal.ts @@ -0,0 +1,14 @@ +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { authStore } from '@/features/auth/store/authStore'; + +/** + * True when a live terminal WebSocket can be attempted for this entity. + * + * Gated purely on a direct connection: the central-manager fabric-connect proxy + * buffers/does not upgrade streamed responses, so a WebSocket over it can't be + * established. Mirrors `useSupportsLogSSE`. + */ +export function useSupportsTerminal(): boolean { + const params = useInstanceClientIdParams(); + return authStore.isDirectConnection(params.entityId); +} diff --git a/src/features/instance/terminal/index.tsx b/src/features/instance/terminal/index.tsx new file mode 100644 index 000000000..7df6480aa --- /dev/null +++ b/src/features/instance/terminal/index.tsx @@ -0,0 +1,66 @@ +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { useInstanceManagePermission } from '@/hooks/usePermissions'; +import { TriangleAlertIcon } from 'lucide-react'; +import { useSupportsTerminal } from './hooks/useSupportsTerminal'; +import { TerminalView } from './TerminalView'; + +/** + * Interactive terminal page (PROTOTYPE). super_user-only, direct-connection-only. + * Pairs with the Harper component on branch claude/instance-terminal-prototype. + */ +export function InstanceTerminal() { + const { entityId } = useInstanceClientIdParams(); + const canManage = useInstanceManagePermission(); + const supportsTerminal = useSupportsTerminal(); + + if (!canManage) { + return ( + + ); + } + + if (!supportsTerminal) { + return ( + + ); + } + + return ( +
+ + +
+ ); +} + +function PrototypeBanner() { + return ( +
+ +
+
Prototype — super_user shell inside the instance container
+
+ This opens a real shell running inside your Harper instance. Sessions are audit-logged on the instance. + Requires the instance to run Harper with the terminal component enabled (terminal.enabled: true). +
+
+
+ ); +} + +function Notice({ title, body }: { title: string; body: string }) { + return ( +
+
+
{title}
+
{body}
+
+
+ ); +} diff --git a/src/features/instance/terminal/resolveInstanceWsConnection.ts b/src/features/instance/terminal/resolveInstanceWsConnection.ts new file mode 100644 index 000000000..50c7fb879 --- /dev/null +++ b/src/features/instance/terminal/resolveInstanceWsConnection.ts @@ -0,0 +1,56 @@ +import { authStore, EntityIds, OverallAppSignIn } from '@/features/auth/store/authStore'; +import { TERMINAL_SUBPROTOCOL, TerminalAuth } from './wire'; + +/** Path the Harper terminal upgrade handler listens on (operations port). */ +export const TERMINAL_WS_PATH = '/terminal'; + +export interface ResolvedTerminalWs { + /** `wss://host:port/terminal` — the operations API origin. */ + url: string; + /** `[TERMINAL_SUBPROTOCOL]` — identifies the upgrade; carries no credential. */ + protocols: string[]; + /** + * Credential to send in the first WebSocket frame (see `wire.ts`). `undefined` + * when the session has neither an operation token nor stored basic auth — the + * caller should surface that rather than open an unauthenticatable socket. + */ + auth: TerminalAuth | undefined; +} + +/** + * Resolve the terminal WebSocket target — the WS analog of + * {@link resolveInstanceConnection}. + * + * The terminal lives on the **operations API port** (same origin as + * `authStore.getOperationsUrl`), keeping it on the same security boundary as the + * rest of the operations surface rather than the application data port. Because + * browsers can't set request headers on a WebSocket, the credential is returned + * here for the caller to send as the first frame rather than as an + * `Authorization` header. + * + * Only meaningful on a DIRECT connection: the Fabric Connect proxy buffers/does + * not upgrade streamed responses. Gate on `useSupportsTerminal` + * (`authStore.isDirectConnection`) before opening the socket. + */ +export function resolveInstanceWsConnection( + { id = OverallAppSignIn }: { id?: EntityIds } = {}, +): ResolvedTerminalWs { + const operationsUrl = authStore.getOperationsUrl(id); + if (!operationsUrl) { + throw new Error(`No operations URL is known for "${id}"; cannot open a terminal WebSocket.`); + } + + const url = new URL(operationsUrl); + url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'; + url.pathname = TERMINAL_WS_PATH; + + const token = authStore.getOperationToken(id); + const basic = authStore.checkForBasicAuth(id); + const auth: TerminalAuth | undefined = token + ? { token } + : basic + ? { username: basic.username, password: basic.password } + : undefined; + + return { url: url.toString(), protocols: [TERMINAL_SUBPROTOCOL], auth }; +} diff --git a/src/features/instance/terminal/routes.ts b/src/features/instance/terminal/routes.ts new file mode 100644 index 000000000..607add11b --- /dev/null +++ b/src/features/instance/terminal/routes.ts @@ -0,0 +1,12 @@ +import { createInstanceLayoutRoute } from '@/features/instance/instanceLayoutRoute'; +import { InstanceTerminal } from '@/features/instance/terminal/index'; +import { createRoute } from '@tanstack/react-router'; + +export function createTerminalRouteTree(instanceLayoutRoute: ReturnType) { + return createRoute({ + getParentRoute: () => instanceLayoutRoute, + path: 'terminal', + head: () => ({ meta: [{ title: 'Terminal — Harper Fabric' }] }), + component: InstanceTerminal, + }); +} diff --git a/src/features/instance/terminal/wire.ts b/src/features/instance/terminal/wire.ts new file mode 100644 index 000000000..6b11272e7 --- /dev/null +++ b/src/features/instance/terminal/wire.ts @@ -0,0 +1,44 @@ +/** + * Terminal wire protocol — kept in sync with the Harper component + * `components/terminal/index.ts` (branch: claude/instance-terminal-prototype). + * + * Auth is FIRST-MESSAGE: the socket connects unauthenticated, and the client's + * first frame carries the credential. Nothing sensitive rides in the WebSocket + * handshake headers/subprotocol (which intermediaries may log). + * + * Client → server (text frames, first char is an opcode): + * 'a' auth — MUST be the first frame. JSON `{ token }` or + * `{ username, password }`. The server ignores all other frames + * until a valid super_user auth arrives. + * 'i' stdin — bytes after the opcode are written to the PTY + * 'r' resize — JSON `{ cols, rows }` + * Server → client: + * raw PTY output as text frames (no opcode) + * close(code, reason) on auth failure, shell exit, idle, or policy rejection + * + * Close codes: 4401 unauthenticated, 4403 not super_user, 4408 idle/auth timeout, + * 4429 session limit, 4500 backend error, 1000 normal shell exit. + */ + +/** Subprotocol used to identify a terminal upgrade (no credential travels here). */ +export const TERMINAL_SUBPROTOCOL = 'harper-terminal'; + +export const OPCODE_AUTH = 'a'; +export const OPCODE_INPUT = 'i'; +export const OPCODE_RESIZE = 'r'; + +export type TerminalAuth = + | { token: string } + | { username: string; password: string }; + +export function encodeAuth(auth: TerminalAuth): string { + return OPCODE_AUTH + JSON.stringify(auth); +} + +export function encodeInput(data: string): string { + return OPCODE_INPUT + data; +} + +export function encodeResize(cols: number, rows: number): string { + return OPCODE_RESIZE + JSON.stringify({ cols, rows }); +} From a33797e7e85b23b9a7e32b39b5dfadb0b5fe0394 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 15:54:03 -0400 Subject: [PATCH 2/2] fix(terminal): sync pnpm-lock.yaml for @xterm deps Adds @xterm/xterm and @xterm/addon-fit to the lockfile so pnpm's frozen-lockfile install (CI default) doesn't fail with ERR_PNPM_OUTDATED_LOCKFILE. Generated with the repo-pinned pnpm@11.9.0. Co-Authored-By: Claude Opus 4.8 --- pnpm-lock.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f20fb4f1b..fb70357e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,12 @@ importers: '@typescript/ata': specifier: ^0.9.8 version: 0.9.8(typescript@6.0.3) + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 ai: specifier: ^7.0.0 version: 7.0.14(zod@4.4.3) @@ -2757,6 +2763,14 @@ packages: '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -10173,6 +10187,12 @@ snapshots: '@workflow/serde@4.1.0': {} + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1