From 15407e7e9c6a069aa28a189521f412914372f0e7 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Wed, 24 Jun 2026 08:23:58 -0400 Subject: [PATCH] Workspace Terminal tab: xterm front end + short-lived token route - New Terminal tab in the workspace shell, backed by xterm.js, that connects to the worker's PTY WebSocket using a short-lived token minted by /api/agent-jobs/:id/terminal-token (owner-auth'd, never exposes the worker secret to the browser) - Site-matched xterm theme (light/dark, live theme switching), Victor Mono, binary stdin + JSON resize protocol, reconnect, graceful 'not configured' state when NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL is unset - env: NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL (client), SPOON_AGENT_TERMINAL_SECRET --- apps/next/package.json | 3 + .../[jobId]/terminal-token/route.ts | 18 ++ .../agent-workspace/agent-workspace-shell.tsx | 34 ++- .../agent-workspace/workspace-terminal.tsx | 245 ++++++++++++++++++ apps/next/src/env.ts | 10 + apps/next/src/lib/agent-worker-proxy.ts | 21 ++ bun.lock | 9 + 7 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 apps/next/src/app/api/agent-jobs/[jobId]/terminal-token/route.ts create mode 100644 apps/next/src/components/agent-workspace/workspace-terminal.tsx diff --git a/apps/next/package.json b/apps/next/package.json index 726f727..9b4e6c5 100644 --- a/apps/next/package.json +++ b/apps/next/package.json @@ -27,6 +27,9 @@ "@spoon/backend": "workspace:*", "@spoon/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.11", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "convex": "catalog:convex", "monaco-editor": "latest", "monaco-vim": "latest", diff --git a/apps/next/src/app/api/agent-jobs/[jobId]/terminal-token/route.ts b/apps/next/src/app/api/agent-jobs/[jobId]/terminal-token/route.ts new file mode 100644 index 0000000..b4a6722 --- /dev/null +++ b/apps/next/src/app/api/agent-jobs/[jobId]/terminal-token/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { mintTerminalToken, withOwnedJob } from '@/lib/agent-worker-proxy'; + +export const GET = async ( + _request: Request, + context: { params: Promise<{ jobId: string }> }, +) => + await withOwnedJob(context, (jobId) => { + const minted = mintTerminalToken(jobId); + return Promise.resolve( + minted + ? NextResponse.json(minted) + : NextResponse.json( + { error: 'Terminal is not configured on this deployment.' }, + { status: 503 }, + ), + ); + }); diff --git a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx index 89bb86d..923d300 100644 --- a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx +++ b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx @@ -3,7 +3,13 @@ import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { useMutation, useQuery } from 'convex/react'; -import { FileCode, GitCompare, Loader2, MessagesSquare } from 'lucide-react'; +import { + FileCode, + GitCompare, + Loader2, + MessagesSquare, + SquareTerminal, +} from 'lucide-react'; import { toast } from 'sonner'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; @@ -34,6 +40,9 @@ import { FileTabs } from './file-tabs'; import { FileTree } from './file-tree'; import { JobStatusBar } from './job-status-bar'; import { WorkspaceActions } from './workspace-actions'; +import { WorkspaceTerminal } from './workspace-terminal'; + +type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal'; type OpenFileState = { path: string; @@ -81,9 +90,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { const [focusedDiffPath, setFocusedDiffPath] = useState(); const [workspaceError, setWorkspaceError] = useState(); const [agentTurnActive, setAgentTurnActive] = useState(false); - const [activeWorkspaceTab, setActiveWorkspaceTab] = useState< - 'editor' | 'diff' | 'thread' - >('editor'); + const [activeWorkspaceTab, setActiveWorkspaceTab] = + useState('editor'); const [pendingOverwrite, setPendingOverwrite] = useState(); const [pendingClosePath, setPendingClosePath] = useState(); @@ -575,7 +583,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { - setActiveWorkspaceTab(value as 'editor' | 'diff' | 'thread') + setActiveWorkspaceTab(value as WorkspaceTab) } className='flex min-h-0 flex-1 flex-col' > @@ -594,6 +602,13 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { Diff viewer + + + Terminal + }) => { }} /> + + + { + const containerRef = useRef(null); + const termRef = useRef(null); + const { resolvedTheme } = useTheme(); + const themeIsLight = resolvedTheme === 'light'; + const [status, setStatus] = useState('connecting'); + const [errorText, setErrorText] = useState(); + const [reconnectKey, setReconnectKey] = useState(0); + + // Update the live terminal's theme without tearing down the session. + useEffect(() => { + if (termRef.current) { + termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme; + } + }, [themeIsLight]); + + useEffect(() => { + if (!active) return; + const container = containerRef.current; + if (!container) return; + + const abortController = new AbortController(); + const signal = abortController.signal; + // Read through a function so TS doesn't narrow `aborted` to a constant after + // the first guard (it changes asynchronously, on cleanup). + const isAborted = () => signal.aborted; + let ws: WebSocket | undefined; + let resizeObserver: ResizeObserver | undefined; + const encoder = new TextEncoder(); + + const start = async () => { + const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all( + [ + import('@xterm/xterm'), + import('@xterm/addon-fit'), + import('@xterm/addon-web-links'), + ], + ); + if (isAborted()) return; + + setStatus('connecting'); + setErrorText(undefined); + let response: Response; + try { + response = await fetch(`/api/agent-jobs/${jobId}/terminal-token`, { + signal, + }); + } catch { + if (!isAborted()) setStatus('error'); + return; + } + if (isAborted()) return; + if (response.status === 503) { + setStatus('unconfigured'); + return; + } + if (!response.ok) { + setStatus('error'); + setErrorText(await response.text().catch(() => undefined)); + return; + } + const { url } = (await response.json()) as { url: string }; + + const term = new Terminal({ + fontFamily: TERMINAL_FONT, + fontSize: 13, + lineHeight: 1.2, + cursorBlink: true, + theme: themeIsLight ? lightTheme : darkTheme, + allowProposedApi: true, + scrollback: 5000, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.loadAddon(new WebLinksAddon()); + term.open(container); + fit.fit(); + termRef.current = term; + + const sendResize = () => { + if (ws?.readyState !== WebSocket.OPEN) return; + ws.send( + JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }), + ); + }; + + ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; + ws.onopen = () => { + if (isAborted()) return; + setStatus('connected'); + sendResize(); + term.focus(); + }; + ws.onmessage = (event: MessageEvent) => { + if (typeof event.data === 'string') term.write(event.data); + else term.write(new Uint8Array(event.data)); + }; + ws.onclose = () => { + if (!isAborted()) setStatus('closed'); + }; + ws.onerror = () => { + if (!isAborted()) setStatus('error'); + }; + + term.onData((data) => { + if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data)); + }); + term.onResize(() => sendResize()); + resizeObserver = new ResizeObserver(() => { + try { + fit.fit(); + } catch { + // ignore transient layout errors + } + }); + resizeObserver.observe(container); + }; + + void start(); + + return () => { + abortController.abort(); + resizeObserver?.disconnect(); + ws?.close(); + termRef.current?.dispose(); + termRef.current = null; + }; + // resolvedTheme intentionally excluded: handled by the theme effect above. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, jobId, reconnectKey]); + + return ( +
+
+

+ {status === 'connected' + ? 'Connected · workspace shell' + : status === 'connecting' + ? 'Connecting…' + : status === 'closed' + ? 'Session ended' + : status === 'unconfigured' + ? 'Terminal not configured' + : 'Connection error'} +

+ {status === 'closed' || status === 'error' ? ( + + ) : null} +
+ {status === 'unconfigured' ? ( +
+ The terminal is not enabled on this deployment. +
+ Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it. +
+ ) : ( +
+
+ {errorText ? ( +

+ {errorText} +

+ ) : null} +
+ )} +
+ ); +}; diff --git a/apps/next/src/env.ts b/apps/next/src/env.ts index 5ea3e09..a902c55 100644 --- a/apps/next/src/env.ts +++ b/apps/next/src/env.ts @@ -22,6 +22,9 @@ export const env = createEnv({ SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'), SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(), SPOON_WORKER_TOKEN: z.string().optional(), + // Secret shared with the worker for signing short-lived terminal tokens. + // Falls back (in code) to the worker internal token. + SPOON_AGENT_TERMINAL_SECRET: z.string().optional(), }, /** @@ -36,6 +39,10 @@ export const env = createEnv({ NEXT_PUBLIC_SENTRY_URL: z.string(), NEXT_PUBLIC_SENTRY_ORG: z.string(), NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(), + // Browser-facing WebSocket base URL of the agent worker, e.g. + // `wss://worker.spoon.gbrown.org` (prod) or `ws://localhost:3921` (dev). + // When unset, the workspace Terminal tab is disabled. + NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL: z.string().optional(), }, /** * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. @@ -59,6 +66,9 @@ export const env = createEnv({ SPOON_AGENT_WORKER_INTERNAL_TOKEN: process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN, SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN, + SPOON_AGENT_TERMINAL_SECRET: process.env.SPOON_AGENT_TERMINAL_SECRET, + NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL: + process.env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL, NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL, diff --git a/apps/next/src/lib/agent-worker-proxy.ts b/apps/next/src/lib/agent-worker-proxy.ts index c3e1e31..0180786 100644 --- a/apps/next/src/lib/agent-worker-proxy.ts +++ b/apps/next/src/lib/agent-worker-proxy.ts @@ -1,5 +1,6 @@ import 'server-only'; +import { createHmac } from 'node:crypto'; import { NextResponse } from 'next/server'; import { env } from '@/env'; import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server'; @@ -8,6 +9,26 @@ import { fetchQuery } from 'convex/nextjs'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import { api } from '@spoon/backend/convex/_generated/api.js'; +const terminalSecret = () => + env.SPOON_AGENT_TERMINAL_SECRET ?? + env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ?? + env.SPOON_WORKER_TOKEN; + +// Mints a short-lived, job-scoped terminal token + the worker WS URL. Returns +// null when the terminal feature is not configured. The 2-minute expiry is a +// connect window only; an established PTY session persists past it. +export const mintTerminalToken = (jobId: Id<'agentJobs'>) => { + const secret = terminalSecret(); + const base = env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL; + if (!secret || !base) return null; + const expiresAt = Date.now() + 2 * 60 * 1000; + const payload = `${expiresAt}.${jobId}`; + const signature = createHmac('sha256', secret).update(payload).digest('hex'); + const token = `${payload}.${signature}`; + const url = `${base.replace(/\/$/, '')}/jobs/${encodeURIComponent(jobId)}/terminal?token=${encodeURIComponent(token)}`; + return { url, expiresAt }; +}; + type RouteContext = { params: Promise<{ jobId: string }> | { jobId: string }; }; diff --git a/bun.lock b/bun.lock index 32e4668..4435a78 100644 --- a/bun.lock +++ b/bun.lock @@ -107,6 +107,9 @@ "@spoon/backend": "workspace:*", "@spoon/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.11", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "convex": "catalog:convex", "monaco-editor": "latest", "monaco-vim": "latest", @@ -1689,6 +1692,12 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],