Skip to content
Merged
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,5 @@ SUPERSET_MCP_API_KEY=
# Relay service URL (the v2 tunnel proxy that forwards cloud API calls
# to host-service instances on user devices). Local dev: http://localhost:4734
RELAY_URL=
# Browser-exposed relay URL — the web app's host-service tRPC + terminal WS.
NEXT_PUBLIC_RELAY_URL=
2 changes: 2 additions & 0 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ jobs:
NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }}
NEXT_PUBLIC_ADMIN_URL: https://${{ env.ADMIN_ALIAS }}
NEXT_PUBLIC_DOCS_URL: https://${{ env.DOCS_ALIAS }}
NEXT_PUBLIC_RELAY_URL: ${{ secrets.RELAY_URL }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }}
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
Expand Down Expand Up @@ -326,6 +327,7 @@ jobs:
--env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \
--env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
--env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \
--env NEXT_PUBLIC_RELAY_URL=$NEXT_PUBLIC_RELAY_URL \
--env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \
--env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \
--env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ jobs:
NEXT_PUBLIC_MARKETING_URL: ${{ secrets.NEXT_PUBLIC_MARKETING_URL }}
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }}
NEXT_PUBLIC_RELAY_URL: ${{ secrets.RELAY_URL }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }}
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
Expand Down Expand Up @@ -247,6 +248,7 @@ jobs:
--env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \
--env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
--env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \
--env NEXT_PUBLIC_RELAY_URL=$NEXT_PUBLIC_RELAY_URL \
--env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \
--env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \
--env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \
Expand Down
16 changes: 11 additions & 5 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.

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-src when RELAY_URL and NEXT_PUBLIC_RELAY_URL diverge.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/next.config.ts, line 29:

<comment>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-src` when `RELAY_URL` and `NEXT_PUBLIC_RELAY_URL` diverge.</comment>

<file context>
@@ -16,16 +16,21 @@ const isProduction = process.env.NODE_ENV === "production";
 	: isProduction
 		? "wss://relay.superset.sh"
 		: null;
+const relayHttpOrigin = process.env.RELAY_URL
+	? new URL(process.env.RELAY_URL).origin
+	: isProduction
</file context>

? new URL(process.env.RELAY_URL).origin
: isProduction
? "https://relay.superset.sh"
: null;
Comment on lines 24 to +33
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use NEXT_PUBLIC_RELAY_URL as the CSP relay source-of-truth.

connect-src relay origins are derived from RELAY_URL, while browser calls use NEXT_PUBLIC_RELAY_URL. If these differ (or only the public var is set), relay requests can be blocked by CSP.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/next.config.ts` around lines 24 - 33, The CSP origins are being
derived from process.env.RELAY_URL while the browser uses
process.env.NEXT_PUBLIC_RELAY_URL; update the logic that computes relayWsOrigin
and relayHttpOrigin (and the other occurrence around the same block referenced
as the second occurrence) to prefer process.env.NEXT_PUBLIC_RELAY_URL as the
source-of-truth (use NEXT_PUBLIC_RELAY_URL if present, fall back to RELAY_URL if
not), ensure ws/http scheme conversion is applied to the chosen value, and keep
the same production default fallbacks ("wss://relay.superset.sh" /
"https://relay.superset.sh") and null behavior when neither is set.


const contentSecurityPolicy = [
"default-src 'self'",
Expand All @@ -34,6 +39,7 @@ const contentSecurityPolicy = [
"connect-src 'self'",
apiOrigin,
relayWsOrigin,
relayHttpOrigin,
"https://*.ingest.sentry.io",
"https://*.sentry.io",
"https://us.i.posthog.com",
Expand Down
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 security 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.

Prompt To Fix With AI
This 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
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Set connection state on WebSocket error events.

onerror sets only the message; state can remain "connecting" longer than needed.

Suggested fix
 socket.onerror = () => {
   setErrorMessage("WebSocket connection failed.");
+  setState("error");
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
socket.onerror = () => {
setErrorMessage("WebSocket connection failed.");
};
socket.onerror = () => {
setErrorMessage("WebSocket connection failed.");
setState("error");
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/app/workspaces/`[workspaceId]/components/WebTerminal/WebTerminal.tsx
around lines 202 - 204, The WebSocket onerror handler currently only calls
setErrorMessage which leaves the connection state stuck at "connecting"; update
the onerror handler (socket.onerror) to also update the connection state by
calling the state setter used in this component (e.g.,
setConnectionState("failed") or setConnectionState("disconnected")), so the UI
no longer shows "connecting" after an error; keep the existing setErrorMessage
call and add the connection-state update in the same handler.


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

Prompt To Fix With AI
This 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";
Loading
Loading