-
Notifications
You must be signed in to change notification settings - Fork 4
feat(terminal): instance terminal (xterm.js over WebSocket) prototype #1467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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://<operations-origin>/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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<HTMLDivElement | null>(null); | ||||||||||||||||||||||
| const [status, setStatus] = useState<Status>('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`); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
Comment on lines
+94
to
+98
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a connection error occurs,
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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); | ||||||||||||||||||||||
|
Comment on lines
+107
to
+108
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrap the |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||
| observer.disconnect(); | ||||||||||||||||||||||
| onData.dispose(); | ||||||||||||||||||||||
| onResize.dispose(); | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| ws.close(1000, 'client navigating away'); | ||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||
| // already closing | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| term.dispose(); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| }, [entityId, reconnectNonce]); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <div className="flex flex-col gap-2"> | ||||||||||||||||||||||
| <div className="flex items-center gap-2 text-sm"> | ||||||||||||||||||||||
| <StatusDot status={status} /> | ||||||||||||||||||||||
| <span className="text-muted-foreground"> | ||||||||||||||||||||||
| {labelFor(status)} | ||||||||||||||||||||||
| {detail && status !== 'open' ? ` — ${detail}` : ''} | ||||||||||||||||||||||
| </span> | ||||||||||||||||||||||
| {(status === 'closed' || status === 'error') && ( | ||||||||||||||||||||||
| <Button | ||||||||||||||||||||||
| size="sm" | ||||||||||||||||||||||
| variant="outline" | ||||||||||||||||||||||
| onClick={() => setReconnectNonce((n) => n + 1)} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| Reconnect | ||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| <div | ||||||||||||||||||||||
| ref={containerRef} | ||||||||||||||||||||||
| className="h-[70vh] w-full overflow-hidden rounded-md border bg-[#0b0f19] p-2" | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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 <span className={`inline-block size-2 rounded-full ${color}`} />; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Set
ws.binaryType = 'arraybuffer'explicitly to ensure that any binary frames sent by the server are received asArrayBufferobjects, matching theev.data instanceof ArrayBuffercheck in theonmessagehandler. By default, browsers use'blob'for binary frames, which would cause binary data to be silently ignored.