-
Notifications
You must be signed in to change notification settings - Fork 897
feat(web): /workspaces route with host-service terminal viewer #4647
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
Changes from all commits
882fb2d
f13e8fd
c94c027
33e6fd3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,16 +16,21 @@ const isProduction = process.env.NODE_ENV === "production"; | |
| const apiOrigin = process.env.NEXT_PUBLIC_API_URL | ||
| ? new URL(process.env.NEXT_PUBLIC_API_URL).origin | ||
| : null; | ||
| // Remote-control viewers open a WebSocket to the relay. In dev the blanket | ||
| // `ws:`/`wss:` below covers it; in prod we need to allow the relay origin | ||
| // explicitly so `connect-src` doesn't block `wss://relay…`. The hard-coded | ||
| // prod fallback keeps the header correct even if RELAY_URL isn't plumbed | ||
| // into the build env. | ||
| // The web app reaches host-services through the relay — a WebSocket for the | ||
| // terminal stream and HTTP for host tRPC. In dev the blanket `ws:`/`wss:` | ||
| // below covers the socket; prod needs the relay origins listed explicitly so | ||
| // `connect-src` blocks neither. The hard-coded prod fallback keeps the header | ||
| // correct even if RELAY_URL isn't plumbed into the build env. | ||
| const relayWsOrigin = process.env.RELAY_URL | ||
| ? new URL(process.env.RELAY_URL).origin.replace(/^http/, "ws") | ||
| : isProduction | ||
| ? "wss://relay.superset.sh" | ||
| : null; | ||
| const relayHttpOrigin = process.env.RELAY_URL | ||
| ? new URL(process.env.RELAY_URL).origin | ||
| : isProduction | ||
| ? "https://relay.superset.sh" | ||
| : null; | ||
|
Comment on lines
24
to
+33
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. Use
Suggested fix-const relayWsOrigin = process.env.RELAY_URL
- ? new URL(process.env.RELAY_URL).origin.replace(/^http/, "ws")
- : isProduction
- ? "wss://relay.superset.sh"
- : null;
-const relayHttpOrigin = process.env.RELAY_URL
- ? new URL(process.env.RELAY_URL).origin
- : isProduction
- ? "https://relay.superset.sh"
- : null;
+const relayBaseUrl =
+ process.env.NEXT_PUBLIC_RELAY_URL ?? process.env.RELAY_URL;
+const relayHttpOrigin = relayBaseUrl
+ ? new URL(relayBaseUrl).origin
+ : isProduction
+ ? "https://relay.superset.sh"
+ : null;
+const relayWsOrigin = relayHttpOrigin
+ ? relayHttpOrigin.replace(/^http/, "ws")
+ : null;Also applies to: 42-42 🤖 Prompt for AI Agents |
||
|
|
||
| const contentSecurityPolicy = [ | ||
| "default-src 'self'", | ||
|
|
@@ -34,6 +39,7 @@ const contentSecurityPolicy = [ | |
| "connect-src 'self'", | ||
| apiOrigin, | ||
| relayWsOrigin, | ||
| relayHttpOrigin, | ||
| "https://*.ingest.sentry.io", | ||
| "https://*.sentry.io", | ||
| "https://us.i.posthog.com", | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,268 @@ | ||||||||||||||||
| "use client"; | ||||||||||||||||
|
|
||||||||||||||||
| import { FitAddon } from "@xterm/addon-fit"; | ||||||||||||||||
| import type { ITheme } from "@xterm/xterm"; | ||||||||||||||||
| import { Terminal } from "@xterm/xterm"; | ||||||||||||||||
| import "@xterm/xterm/css/xterm.css"; | ||||||||||||||||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||||||||||||||||
| import { getAuthToken } from "../../../../../trpc/auth-token"; | ||||||||||||||||
| import { getRelayUrl } from "../../../../../trpc/relay-url"; | ||||||||||||||||
|
|
||||||||||||||||
| const TERMINAL_THEME: ITheme = { | ||||||||||||||||
| background: "#151110", | ||||||||||||||||
| foreground: "#eae8e6", | ||||||||||||||||
| cursor: "#e07850", | ||||||||||||||||
| cursorAccent: "#151110", | ||||||||||||||||
| selectionBackground: "rgba(224, 120, 80, 0.25)", | ||||||||||||||||
| black: "#151110", | ||||||||||||||||
| red: "#dc6b6b", | ||||||||||||||||
| green: "#7ec699", | ||||||||||||||||
| yellow: "#e5c07b", | ||||||||||||||||
| blue: "#61afef", | ||||||||||||||||
| magenta: "#c678dd", | ||||||||||||||||
| cyan: "#56b6c2", | ||||||||||||||||
| white: "#eae8e6", | ||||||||||||||||
| brightBlack: "#5c5856", | ||||||||||||||||
| brightRed: "#e88888", | ||||||||||||||||
| brightGreen: "#98d1a8", | ||||||||||||||||
| brightYellow: "#ecd08f", | ||||||||||||||||
| brightBlue: "#7ec0f5", | ||||||||||||||||
| brightMagenta: "#d494e6", | ||||||||||||||||
| brightCyan: "#73c7d3", | ||||||||||||||||
| brightWhite: "#ffffff", | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| const TERMINAL_FONT_FAMILY = | ||||||||||||||||
| '"JetBrains Mono", "MesloLGS NF", "Menlo", "Monaco", "Courier New", monospace'; | ||||||||||||||||
|
|
||||||||||||||||
| const KEY_BUTTONS: Array<{ label: string; sequence: string }> = [ | ||||||||||||||||
| { label: "Tab", sequence: "\t" }, | ||||||||||||||||
| { label: "Esc", sequence: "\x1b" }, | ||||||||||||||||
| { label: "Ctrl-C", sequence: "\x03" }, | ||||||||||||||||
| { label: "Ctrl-D", sequence: "\x04" }, | ||||||||||||||||
| { label: "↑", sequence: "\x1b[A" }, | ||||||||||||||||
| { label: "↓", sequence: "\x1b[B" }, | ||||||||||||||||
| { label: "←", sequence: "\x1b[D" }, | ||||||||||||||||
| { label: "→", sequence: "\x1b[C" }, | ||||||||||||||||
| ]; | ||||||||||||||||
|
|
||||||||||||||||
| interface WebTerminalProps { | ||||||||||||||||
| workspaceId: string; | ||||||||||||||||
| terminalId: string; | ||||||||||||||||
| routingKey: string; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| type ConnectionState = "connecting" | "open" | "error" | "exited"; | ||||||||||||||||
|
|
||||||||||||||||
| // Wire protocol mirrors the desktop's terminal transport | ||||||||||||||||
| // (apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts): binary | ||||||||||||||||
| // frames are raw PTY bytes, control messages are JSON. | ||||||||||||||||
| type TerminalServerMessage = | ||||||||||||||||
| | { type: "attached"; terminalId: string } | ||||||||||||||||
| | { type: "title"; title: string | null } | ||||||||||||||||
| | { type: "error"; message: string } | ||||||||||||||||
| | { type: "exit"; exitCode: number; signal: number }; | ||||||||||||||||
|
|
||||||||||||||||
| export function WebTerminal({ | ||||||||||||||||
| workspaceId, | ||||||||||||||||
| terminalId, | ||||||||||||||||
| routingKey, | ||||||||||||||||
| }: WebTerminalProps) { | ||||||||||||||||
| const containerRef = useRef<HTMLDivElement | null>(null); | ||||||||||||||||
| const socketRef = useRef<WebSocket | null>(null); | ||||||||||||||||
| const [state, setState] = useState<ConnectionState>("connecting"); | ||||||||||||||||
| const [errorMessage, setErrorMessage] = useState<string | null>(null); | ||||||||||||||||
|
|
||||||||||||||||
| const sendSequence = useCallback((sequence: string) => { | ||||||||||||||||
| const socket = socketRef.current; | ||||||||||||||||
| if (!socket || socket.readyState !== WebSocket.OPEN) return; | ||||||||||||||||
| socket.send(JSON.stringify({ type: "input", data: sequence })); | ||||||||||||||||
| }, []); | ||||||||||||||||
|
|
||||||||||||||||
| useEffect(() => { | ||||||||||||||||
| let cancelled = false; | ||||||||||||||||
| let terminal: Terminal | null = null; | ||||||||||||||||
| let fitAddon: FitAddon | null = null; | ||||||||||||||||
| let socket: WebSocket | null = null; | ||||||||||||||||
| let resizeObserver: ResizeObserver | null = null; | ||||||||||||||||
| let resizeTimer: ReturnType<typeof setTimeout> | null = null; | ||||||||||||||||
| const visualViewport = window.visualViewport; | ||||||||||||||||
|
|
||||||||||||||||
| const sendResize = () => { | ||||||||||||||||
| const activeSocket = socketRef.current; | ||||||||||||||||
| if ( | ||||||||||||||||
| !terminal || | ||||||||||||||||
| !activeSocket || | ||||||||||||||||
| activeSocket.readyState !== WebSocket.OPEN | ||||||||||||||||
| ) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| activeSocket.send( | ||||||||||||||||
| JSON.stringify({ | ||||||||||||||||
| type: "resize", | ||||||||||||||||
| cols: terminal.cols, | ||||||||||||||||
| rows: terminal.rows, | ||||||||||||||||
| }), | ||||||||||||||||
| ); | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| // Refit on every layout change; the visualViewport listeners are what | ||||||||||||||||
| // keep the prompt above the soft keyboard on mobile, since the keyboard | ||||||||||||||||
| // resizes the visual viewport rather than the layout viewport. | ||||||||||||||||
| const refit = () => { | ||||||||||||||||
| if (!fitAddon) return; | ||||||||||||||||
| try { | ||||||||||||||||
| fitAddon.fit(); | ||||||||||||||||
| } catch { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| if (resizeTimer !== null) clearTimeout(resizeTimer); | ||||||||||||||||
| resizeTimer = setTimeout(sendResize, 150); | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| (async () => { | ||||||||||||||||
| try { | ||||||||||||||||
| const token = await getAuthToken(); | ||||||||||||||||
| if (cancelled) return; | ||||||||||||||||
| const container = containerRef.current; | ||||||||||||||||
| if (!container) return; | ||||||||||||||||
|
|
||||||||||||||||
| terminal = new Terminal({ | ||||||||||||||||
| cursorBlink: true, | ||||||||||||||||
| cursorStyle: "block", | ||||||||||||||||
| fontFamily: TERMINAL_FONT_FAMILY, | ||||||||||||||||
| fontSize: 14, | ||||||||||||||||
| scrollback: 5000, | ||||||||||||||||
| theme: TERMINAL_THEME, | ||||||||||||||||
| allowProposedApi: true, | ||||||||||||||||
| }); | ||||||||||||||||
| fitAddon = new FitAddon(); | ||||||||||||||||
| terminal.loadAddon(fitAddon); | ||||||||||||||||
| terminal.open(container); | ||||||||||||||||
| try { | ||||||||||||||||
| fitAddon.fit(); | ||||||||||||||||
| } catch { | ||||||||||||||||
| // container may not be sized yet | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const wsBase = getRelayUrl().replace(/^http/, "ws").replace(/\/$/, ""); | ||||||||||||||||
| const url = `${wsBase}/hosts/${routingKey}/terminal/${encodeURIComponent(terminalId)}?workspaceId=${encodeURIComponent(workspaceId)}&themeType=dark&token=${encodeURIComponent(token)}`; | ||||||||||||||||
| socket = new WebSocket(url); | ||||||||||||||||
| socket.binaryType = "arraybuffer"; | ||||||||||||||||
| socketRef.current = socket; | ||||||||||||||||
|
|
||||||||||||||||
| socket.onmessage = (event) => { | ||||||||||||||||
| if (event.data instanceof ArrayBuffer) { | ||||||||||||||||
| terminal?.write(new Uint8Array(event.data)); | ||||||||||||||||
|
Comment on lines
+155
to
+156
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.
The BetterAuth JWT is appended as a query parameter in the WebSocket URL. While browsers cannot set custom headers on WebSocket upgrades (making query-param auth a common workaround), the token will appear verbatim in relay access logs, browser history, and any CDN or proxy logs that record the full request path. Consider using a dedicated short-lived one-time token for this connection to bound the exposure window. Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx
Line: 164-165
Comment:
**Auth token exposed in WebSocket URL**
The BetterAuth JWT is appended as a query parameter in the WebSocket URL. While browsers cannot set custom headers on WebSocket upgrades (making query-param auth a common workaround), the token will appear verbatim in relay access logs, browser history, and any CDN or proxy logs that record the full request path. Consider using a dedicated short-lived one-time token for this connection to bound the exposure window.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| let message: TerminalServerMessage; | ||||||||||||||||
| try { | ||||||||||||||||
| message = JSON.parse(String(event.data)) as TerminalServerMessage; | ||||||||||||||||
| } catch { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| switch (message.type) { | ||||||||||||||||
| case "attached": | ||||||||||||||||
| setState("open"); | ||||||||||||||||
| sendResize(); | ||||||||||||||||
| return; | ||||||||||||||||
| case "exit": | ||||||||||||||||
| terminal?.write( | ||||||||||||||||
| `\r\n\x1b[33m[process exited code=${message.exitCode}]\x1b[0m\r\n`, | ||||||||||||||||
| ); | ||||||||||||||||
| setState("exited"); | ||||||||||||||||
| return; | ||||||||||||||||
| case "error": | ||||||||||||||||
| setErrorMessage(message.message); | ||||||||||||||||
| setState("error"); | ||||||||||||||||
| return; | ||||||||||||||||
| default: | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| socket.onclose = () => { | ||||||||||||||||
| setState((previous) => | ||||||||||||||||
| previous === "open" || previous === "connecting" | ||||||||||||||||
| ? "error" | ||||||||||||||||
| : previous, | ||||||||||||||||
| ); | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| socket.onerror = () => { | ||||||||||||||||
| setErrorMessage("WebSocket connection failed."); | ||||||||||||||||
| }; | ||||||||||||||||
|
Comment on lines
+193
to
+195
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. Set connection state on WebSocket error events.
Suggested fix socket.onerror = () => {
setErrorMessage("WebSocket connection failed.");
+ setState("error");
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
|
|
||||||||||||||||
| terminal.onData((data) => { | ||||||||||||||||
| const activeSocket = socketRef.current; | ||||||||||||||||
| if (activeSocket?.readyState === WebSocket.OPEN) { | ||||||||||||||||
| activeSocket.send(JSON.stringify({ type: "input", data })); | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+191
to
+201
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.
Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx
Line: 200-210
Comment:
**No reconnect path after WebSocket close**
`socket.onclose` transitions the state to `"error"` and `socket.onerror` sets the message, but neither exposes a reconnect action. A transient network blip will land the user on the static error banner with no recovery path other than a full page refresh. A reconnect button that re-runs the same setup effect would be a minimal improvement for a terminal UI that explicitly targets mobile.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| resizeObserver = new ResizeObserver(refit); | ||||||||||||||||
| resizeObserver.observe(container); | ||||||||||||||||
| visualViewport?.addEventListener("resize", refit); | ||||||||||||||||
| visualViewport?.addEventListener("scroll", refit); | ||||||||||||||||
| } catch (caught) { | ||||||||||||||||
| if (cancelled) return; | ||||||||||||||||
| setErrorMessage( | ||||||||||||||||
| caught instanceof Error ? caught.message : String(caught), | ||||||||||||||||
| ); | ||||||||||||||||
| setState("error"); | ||||||||||||||||
| } | ||||||||||||||||
| })(); | ||||||||||||||||
|
|
||||||||||||||||
| return () => { | ||||||||||||||||
| cancelled = true; | ||||||||||||||||
| if (resizeTimer !== null) clearTimeout(resizeTimer); | ||||||||||||||||
| resizeObserver?.disconnect(); | ||||||||||||||||
| visualViewport?.removeEventListener("resize", refit); | ||||||||||||||||
| visualViewport?.removeEventListener("scroll", refit); | ||||||||||||||||
| try { | ||||||||||||||||
| socket?.close(); | ||||||||||||||||
| } catch { | ||||||||||||||||
| // best-effort | ||||||||||||||||
| } | ||||||||||||||||
| terminal?.dispose(); | ||||||||||||||||
| socketRef.current = null; | ||||||||||||||||
| }; | ||||||||||||||||
| }, [workspaceId, terminalId, routingKey]); | ||||||||||||||||
|
|
||||||||||||||||
| return ( | ||||||||||||||||
| <div className="flex h-full flex-col"> | ||||||||||||||||
| <div className="relative flex-1 overflow-hidden"> | ||||||||||||||||
| <div ref={containerRef} className="absolute inset-0" /> | ||||||||||||||||
| {state !== "open" && ( | ||||||||||||||||
| <div | ||||||||||||||||
| className="absolute inset-x-0 top-0 px-3 py-1 text-xs" | ||||||||||||||||
| style={{ color: "#ecd08f" }} | ||||||||||||||||
| > | ||||||||||||||||
| {state === "connecting" | ||||||||||||||||
| ? "Connecting…" | ||||||||||||||||
| : state === "exited" | ||||||||||||||||
| ? "Process exited." | ||||||||||||||||
| : (errorMessage ?? "Disconnected.")} | ||||||||||||||||
| </div> | ||||||||||||||||
| )} | ||||||||||||||||
| </div> | ||||||||||||||||
| <div | ||||||||||||||||
| className="flex flex-wrap gap-1 border-t p-1" | ||||||||||||||||
| style={{ borderColor: "#2a2827", backgroundColor: "#1a1716" }} | ||||||||||||||||
| > | ||||||||||||||||
| {KEY_BUTTONS.map((button) => ( | ||||||||||||||||
| <button | ||||||||||||||||
| key={button.label} | ||||||||||||||||
| type="button" | ||||||||||||||||
| onClick={() => sendSequence(button.sequence)} | ||||||||||||||||
| className="rounded border px-2 py-1 text-xs" | ||||||||||||||||
| style={{ borderColor: "#2a2827", color: "#eae8e6" }} | ||||||||||||||||
| > | ||||||||||||||||
| {button.label} | ||||||||||||||||
| </button> | ||||||||||||||||
| ))} | ||||||||||||||||
| </div> | ||||||||||||||||
| </div> | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { WebTerminal } from "./WebTerminal"; |
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.
P2: Derive relay CSP origins from the same public relay URL used by browser calls; otherwise terminal and host tRPC requests can be blocked by
connect-srcwhenRELAY_URLandNEXT_PUBLIC_RELAY_URLdiverge.Prompt for AI agents