Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion src/features/instance/InstanceNavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -82,6 +91,12 @@ export function InstanceNavBar() {
icon: <SettingsIcon className="inline-block" />,
name: 'Config',
},
// PROTOTYPE: super_user-only interactive terminal. canManage === super_user here.
canManage && {
to: buildAbsoluteLinkToPage(params, 'terminal'),
icon: <SquareTerminalIcon className="inline-block" />,
name: 'Terminal',
},
].filter(excludeFalsy) satisfies Link[], [canManage, params, apisAvailable, statusAvailable]);
return (
<>
Expand Down
2 changes: 2 additions & 0 deletions src/features/instance/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -16,6 +17,7 @@ export function createInstanceRouteTree(mode: 'local' | 'cluster' | 'instance')
createStatusRouteTree(instanceLayoutRoute),
createConfigRouteTree(instanceLayoutRoute),
createBrowseRouteTree(instanceLayoutRoute),
createTerminalRouteTree(instanceLayoutRoute),
];

return instanceLayoutRoute.addChildren(children);
Expand Down
58 changes: 58 additions & 0 deletions src/features/instance/terminal/README.md
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.
171 changes: 171 additions & 0 deletions src/features/instance/terminal/TerminalView.tsx
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Set ws.binaryType = 'arraybuffer' explicitly to ensure that any binary frames sent by the server are received as ArrayBuffer objects, matching the ev.data instanceof ArrayBuffer check in the onmessage handler. By default, browsers use 'blob' for binary frames, which would cause binary data to be silently ignored.

			ws = new WebSocket(resolved.url, resolved.protocols);
			ws.binaryType = 'arraybuffer';

} 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When a connection error occurs, ws.onerror is fired (setting the status to 'error'), immediately followed by ws.onclose. This causes the status to be overwritten to 'closed' ('Disconnected'), hiding the error state from the user. Use a functional state update in setStatus to preserve the 'error' status if it was already set.

Suggested change
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`);
};
ws.onclose = (ev) => {
setStatus((prev) => (prev === 'error' ? 'error' : '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);
Comment on lines +107 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Wrap the safeFit() call inside the ResizeObserver callback with requestAnimationFrame to prevent potential "ResizeObserver loop limit exceeded" errors in the browser when the terminal is resized.

		const observer = new ResizeObserver(() => {
			requestAnimationFrame(() => 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 (
<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}`} />;
}
14 changes: 14 additions & 0 deletions src/features/instance/terminal/hooks/useSupportsTerminal.ts
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);
}
Loading