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
This commit is contained in:
@@ -27,6 +27,9 @@
|
|||||||
"@spoon/backend": "workspace:*",
|
"@spoon/backend": "workspace:*",
|
||||||
"@spoon/ui": "workspace:*",
|
"@spoon/ui": "workspace:*",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@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",
|
"convex": "catalog:convex",
|
||||||
"monaco-editor": "latest",
|
"monaco-editor": "latest",
|
||||||
"monaco-vim": "latest",
|
"monaco-vim": "latest",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,7 +3,13 @@
|
|||||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery } from 'convex/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 { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
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 { FileTree } from './file-tree';
|
||||||
import { JobStatusBar } from './job-status-bar';
|
import { JobStatusBar } from './job-status-bar';
|
||||||
import { WorkspaceActions } from './workspace-actions';
|
import { WorkspaceActions } from './workspace-actions';
|
||||||
|
import { WorkspaceTerminal } from './workspace-terminal';
|
||||||
|
|
||||||
|
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
|
||||||
|
|
||||||
type OpenFileState = {
|
type OpenFileState = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -81,9 +90,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
|
const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
|
||||||
const [workspaceError, setWorkspaceError] = useState<string>();
|
const [workspaceError, setWorkspaceError] = useState<string>();
|
||||||
const [agentTurnActive, setAgentTurnActive] = useState(false);
|
const [agentTurnActive, setAgentTurnActive] = useState(false);
|
||||||
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<
|
const [activeWorkspaceTab, setActiveWorkspaceTab] =
|
||||||
'editor' | 'diff' | 'thread'
|
useState<WorkspaceTab>('editor');
|
||||||
>('editor');
|
|
||||||
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
||||||
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
||||||
|
|
||||||
@@ -575,7 +583,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
<Tabs
|
<Tabs
|
||||||
value={activeWorkspaceTab}
|
value={activeWorkspaceTab}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setActiveWorkspaceTab(value as 'editor' | 'diff' | 'thread')
|
setActiveWorkspaceTab(value as WorkspaceTab)
|
||||||
}
|
}
|
||||||
className='flex min-h-0 flex-1 flex-col'
|
className='flex min-h-0 flex-1 flex-col'
|
||||||
>
|
>
|
||||||
@@ -594,6 +602,13 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
<GitCompare className='size-4' />
|
<GitCompare className='size-4' />
|
||||||
Diff viewer
|
Diff viewer
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value='terminal'
|
||||||
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
||||||
|
>
|
||||||
|
<SquareTerminal className='size-4' />
|
||||||
|
Terminal
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value='thread'
|
value='thread'
|
||||||
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
|
||||||
@@ -642,6 +657,15 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value='terminal'
|
||||||
|
className='m-0 min-h-0 flex-1 overflow-hidden'
|
||||||
|
>
|
||||||
|
<WorkspaceTerminal
|
||||||
|
jobId={jobId}
|
||||||
|
active={activeWorkspaceTab === 'terminal' && workspaceReady}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||||
<DiffViewer
|
<DiffViewer
|
||||||
diff={diff}
|
diff={diff}
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ITheme, Terminal } from '@xterm/xterm';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
|
const TERMINAL_FONT =
|
||||||
|
"var(--font-victor-mono), 'Geist Mono', ui-monospace, monospace";
|
||||||
|
|
||||||
|
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
|
||||||
|
|
||||||
|
const darkTheme: ITheme = {
|
||||||
|
background: '#080e14',
|
||||||
|
foreground: '#eef3f5',
|
||||||
|
cursor: '#1fb895',
|
||||||
|
cursorAccent: '#080e14',
|
||||||
|
selectionBackground: '#1fb89544',
|
||||||
|
black: '#10171e',
|
||||||
|
red: '#f3625d',
|
||||||
|
green: '#8fd6b4',
|
||||||
|
yellow: '#e3b341',
|
||||||
|
blue: '#6aa6ff',
|
||||||
|
magenta: '#b692e8',
|
||||||
|
cyan: '#5fd0e0',
|
||||||
|
white: '#cdd6dc',
|
||||||
|
brightBlack: '#93a1a9',
|
||||||
|
brightRed: '#f3625d',
|
||||||
|
brightGreen: '#8fd6b4',
|
||||||
|
brightYellow: '#e3b341',
|
||||||
|
brightBlue: '#6aa6ff',
|
||||||
|
brightMagenta: '#b692e8',
|
||||||
|
brightCyan: '#5fd0e0',
|
||||||
|
brightWhite: '#eef3f5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const lightTheme: ITheme = {
|
||||||
|
background: '#f7fbfa',
|
||||||
|
foreground: '#0d1218',
|
||||||
|
cursor: '#007560',
|
||||||
|
cursorAccent: '#f7fbfa',
|
||||||
|
selectionBackground: '#00756033',
|
||||||
|
black: '#0d1218',
|
||||||
|
red: '#d73337',
|
||||||
|
green: '#2f8f6e',
|
||||||
|
yellow: '#9a6b00',
|
||||||
|
blue: '#2f6bd8',
|
||||||
|
magenta: '#7c4dd1',
|
||||||
|
cyan: '#0f7d92',
|
||||||
|
white: '#26323c',
|
||||||
|
brightBlack: '#555f68',
|
||||||
|
brightRed: '#d73337',
|
||||||
|
brightGreen: '#2f8f6e',
|
||||||
|
brightYellow: '#9a6b00',
|
||||||
|
brightBlue: '#2f6bd8',
|
||||||
|
brightMagenta: '#7c4dd1',
|
||||||
|
brightCyan: '#0f7d92',
|
||||||
|
brightWhite: '#0d1218',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceTerminal = ({
|
||||||
|
jobId,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
jobId: string;
|
||||||
|
active: boolean;
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const termRef = useRef<Terminal | null>(null);
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const themeIsLight = resolvedTheme === 'light';
|
||||||
|
const [status, setStatus] = useState<Status>('connecting');
|
||||||
|
const [errorText, setErrorText] = useState<string>();
|
||||||
|
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<ArrayBuffer | string>) => {
|
||||||
|
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 (
|
||||||
|
<div className='relative flex h-full min-h-0 flex-col'>
|
||||||
|
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{status === 'connected'
|
||||||
|
? 'Connected · workspace shell'
|
||||||
|
: status === 'connecting'
|
||||||
|
? 'Connecting…'
|
||||||
|
: status === 'closed'
|
||||||
|
? 'Session ended'
|
||||||
|
: status === 'unconfigured'
|
||||||
|
? 'Terminal not configured'
|
||||||
|
: 'Connection error'}
|
||||||
|
</p>
|
||||||
|
{status === 'closed' || status === 'error' ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setReconnectKey((key) => key + 1)}
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{status === 'unconfigured' ? (
|
||||||
|
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
||||||
|
The terminal is not enabled on this deployment.
|
||||||
|
<br />
|
||||||
|
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
|
||||||
|
<div ref={containerRef} className='h-full w-full' />
|
||||||
|
{errorText ? (
|
||||||
|
<p className='text-destructive mt-2 px-1 text-xs break-all'>
|
||||||
|
{errorText}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,6 +22,9 @@ export const env = createEnv({
|
|||||||
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
|
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
|
||||||
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
|
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
|
||||||
SPOON_WORKER_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_URL: z.string(),
|
||||||
NEXT_PUBLIC_SENTRY_ORG: z.string(),
|
NEXT_PUBLIC_SENTRY_ORG: z.string(),
|
||||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: 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.
|
* 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:
|
SPOON_AGENT_WORKER_INTERNAL_TOKEN:
|
||||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
|
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
|
||||||
SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_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_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||||
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
|
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { env } from '@/env';
|
import { env } from '@/env';
|
||||||
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
|
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 type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.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 = {
|
type RouteContext = {
|
||||||
params: Promise<{ jobId: string }> | { jobId: string };
|
params: Promise<{ jobId: string }> | { jobId: string };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,6 +107,9 @@
|
|||||||
"@spoon/backend": "workspace:*",
|
"@spoon/backend": "workspace:*",
|
||||||
"@spoon/ui": "workspace:*",
|
"@spoon/ui": "workspace:*",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@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",
|
"convex": "catalog:convex",
|
||||||
"monaco-editor": "latest",
|
"monaco-editor": "latest",
|
||||||
"monaco-vim": "latest",
|
"monaco-vim": "latest",
|
||||||
@@ -1689,6 +1692,12 @@
|
|||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
"@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/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
||||||
|
|
||||||
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user