feat: add CLI session sharing functionality

- Implemented share token creation and revocation for CLI sessions.
- Added a new page for viewing shared CLI sessions with SSE support.
- Introduced hooks for fetching and managing CLI session shares.
- Enhanced the IssueTerminalTab component to handle share tokens and display active shares.
- Updated API routes to support fetching and revoking share tokens.
- Added unit tests for the CLI session share manager and rate limiter.
- Updated localization files to include new strings for sharing functionality.
This commit is contained in:
catlog22
2026-02-09 22:57:05 +08:00
parent 362f354f1c
commit d0cdee2e68
18 changed files with 748 additions and 23 deletions

View File

@@ -17,6 +17,8 @@ import {
closeCliSession, closeCliSession,
createCliSession, createCliSession,
createCliSessionShareToken, createCliSessionShareToken,
fetchCliSessionShares,
revokeCliSessionShareToken,
executeInCliSession, executeInCliSession,
fetchCliSessionBuffer, fetchCliSessionBuffer,
fetchCliSessions, fetchCliSessions,
@@ -55,6 +57,11 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [isExecuting, setIsExecuting] = useState(false); const [isExecuting, setIsExecuting] = useState(false);
const [shareUrl, setShareUrl] = useState<string>(''); const [shareUrl, setShareUrl] = useState<string>('');
const [shareToken, setShareToken] = useState<string>('');
const [shareExpiresAt, setShareExpiresAt] = useState<string>('');
const [shareRecords, setShareRecords] = useState<Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }>>([]);
const [isLoadingShares, setIsLoadingShares] = useState(false);
const [isRevokingShare, setIsRevokingShare] = useState(false);
const terminalHostRef = useRef<HTMLDivElement | null>(null); const terminalHostRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTerm | null>(null); const xtermRef = useRef<XTerm | null>(null);
@@ -103,6 +110,39 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? ''); setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
}, [sessions, selectedSessionKey]); }, [sessions, selectedSessionKey]);
const buildShareLink = (sessionKey: string, token: string): string => {
const url = new URL(window.location.href);
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
url.pathname = `${base}/cli-sessions/share`;
url.search = `sessionKey=${encodeURIComponent(sessionKey)}&shareToken=${encodeURIComponent(token)}`;
return url.toString();
};
const refreshShares = async (sessionKey: string) => {
if (!sessionKey) {
setShareRecords([]);
return;
}
setIsLoadingShares(true);
try {
const r = await fetchCliSessionShares(sessionKey, projectPath || undefined);
setShareRecords(r.shares || []);
} catch {
setShareRecords([]);
} finally {
setIsLoadingShares(false);
}
};
// Refresh share tokens when session changes
useEffect(() => {
setShareUrl('');
setShareToken('');
setShareExpiresAt('');
void refreshShares(selectedSessionKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionKey, projectPath]);
// Init xterm // Init xterm
useEffect(() => { useEffect(() => {
if (!terminalHostRef.current) return; if (!terminalHostRef.current) return;
@@ -282,18 +322,38 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
if (!selectedSessionKey) return; if (!selectedSessionKey) return;
setError(null); setError(null);
setShareUrl(''); setShareUrl('');
setShareToken('');
setShareExpiresAt('');
try { try {
const r = await createCliSessionShareToken(selectedSessionKey, { mode: 'read' }, projectPath || undefined); const r = await createCliSessionShareToken(selectedSessionKey, { mode: 'read' }, projectPath || undefined);
const url = new URL(window.location.href); setShareUrl(buildShareLink(selectedSessionKey, r.shareToken));
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''); setShareToken(r.shareToken);
url.pathname = `${base}/cli-sessions/share`; setShareExpiresAt(r.expiresAt);
url.search = `sessionKey=${encodeURIComponent(selectedSessionKey)}&shareToken=${encodeURIComponent(r.shareToken)}`; void refreshShares(selectedSessionKey);
setShareUrl(url.toString());
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} }
}; };
const handleRevokeShareLink = async (token: string) => {
if (!selectedSessionKey || !token) return;
setIsRevokingShare(true);
setError(null);
try {
await revokeCliSessionShareToken(selectedSessionKey, { shareToken: token }, projectPath || undefined);
if (token === shareToken) {
setShareUrl('');
setShareToken('');
setShareExpiresAt('');
}
void refreshShares(selectedSessionKey);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsRevokingShare(false);
}
};
const handleCopyShareLink = async () => { const handleCopyShareLink = async () => {
if (!shareUrl) return; if (!shareUrl) return;
try { try {
@@ -352,12 +412,67 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
</div> </div>
{shareUrl && ( {shareUrl && (
<div className="flex items-center gap-2"> <div className="space-y-2">
<Input value={shareUrl} readOnly /> <div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCopyShareLink}> <Input value={shareUrl} readOnly />
<Copy className="w-4 h-4 mr-2" /> <Button variant="outline" onClick={handleCopyShareLink}>
{formatMessage({ id: 'common.actions.copy' })} <Copy className="w-4 h-4 mr-2" />
</Button> {formatMessage({ id: 'common.actions.copy' })}
</Button>
<Button
variant="outline"
onClick={() => handleRevokeShareLink(shareToken)}
disabled={isRevokingShare || !shareToken}
>
{formatMessage({ id: 'issues.terminal.session.revokeShare' })}
</Button>
</div>
{shareExpiresAt && (
<div className="text-xs text-muted-foreground font-mono">
{formatMessage({ id: 'issues.terminal.session.expiresAt' })}: {shareExpiresAt}
</div>
)}
</div>
)}
{selectedSessionKey && shareRecords.length > 0 && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
{formatMessage({ id: 'issues.terminal.session.activeShares' })}
{isLoadingShares ? '…' : ''}
</div>
<div className="space-y-1">
{shareRecords.map((s) => (
<div key={s.shareToken} className="flex items-center gap-2">
<div className="text-xs font-mono truncate flex-1 min-w-0">
{s.shareToken.slice(0, 6)}{s.shareToken.slice(-6)}
</div>
<div className="text-xs text-muted-foreground font-mono">{s.mode}</div>
<div className="text-xs text-muted-foreground font-mono truncate max-w-[220px]">{s.expiresAt}</div>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(buildShareLink(selectedSessionKey, s.shareToken));
} catch {
// ignore
}
}}
>
{formatMessage({ id: 'common.actions.copy' })}
</Button>
<Button
variant="outline"
size="sm"
disabled={isRevokingShare}
onClick={() => handleRevokeShareLink(s.shareToken)}
>
{formatMessage({ id: 'issues.terminal.session.revokeShare' })}
</Button>
</div>
))}
</div>
</div> </div>
)} )}

View File

@@ -19,7 +19,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { QueueCard } from '@/components/issue/queue/QueueCard'; import { QueueCard } from '@/components/issue/queue/QueueCard';
import { QueueBoard } from '@/components/issue/queue/QueueBoard'; import { QueueBoard } from '@/components/issue/queue/QueueBoard';
import { SolutionDrawer } from '@/components/issue/queue/SolutionDrawer'; import { SolutionDrawer } from '@/components/issue/queue/SolutionDrawer';
import { useIssueQueue, useQueueHistory, useQueueMutations } from '@/hooks'; import { useIssueQueue, useIssueQueueById, useQueueHistory, useQueueMutations } from '@/hooks';
import type { QueueItem } from '@/lib/api'; import type { QueueItem } from '@/lib/api';
// ========== Loading Skeleton ========== // ========== Loading Skeleton ==========
@@ -74,9 +74,11 @@ function QueueEmptyState() {
export function QueuePanel() { export function QueuePanel() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [selectedItem, setSelectedItem] = useState<QueueItem | null>(null); const [selectedItem, setSelectedItem] = useState<QueueItem | null>(null);
const [selectedQueueId, setSelectedQueueId] = useState<string>('');
const { data: queueData, isLoading, error } = useIssueQueue(); const activeQueueQuery = useIssueQueue();
const { data: historyIndex } = useQueueHistory(); const { data: historyIndex } = useQueueHistory();
const selectedQueueQuery = useIssueQueueById(selectedQueueId);
const { const {
activateQueue, activateQueue,
deactivateQueue, deactivateQueue,
@@ -90,8 +92,12 @@ export function QueuePanel() {
isSplitting, isSplitting,
} = useQueueMutations(); } = useQueueMutations();
// Get queue data with proper type const queue = selectedQueueId && selectedQueueQuery.data ? selectedQueueQuery.data : activeQueueQuery.data;
const queue = queueData; const isLoading =
activeQueueQuery.isLoading ||
(selectedQueueId ? selectedQueueQuery.isLoading && !selectedQueueQuery.data : false);
const error = activeQueueQuery.error || selectedQueueQuery.error;
const taskCount = queue?.tasks?.length || 0; const taskCount = queue?.tasks?.length || 0;
const solutionCount = queue?.solutions?.length || 0; const solutionCount = queue?.solutions?.length || 0;
const conflictCount = queue?.conflicts?.length || 0; const conflictCount = queue?.conflicts?.length || 0;
@@ -100,13 +106,13 @@ export function QueuePanel() {
const activeQueueId = historyIndex?.active_queue_id || null; const activeQueueId = historyIndex?.active_queue_id || null;
const activeQueueIds = historyIndex?.active_queue_ids || []; const activeQueueIds = historyIndex?.active_queue_ids || [];
const queueId = queue?.id; const queueId = queue?.id;
const [selectedQueueId, setSelectedQueueId] = useState<string>('');
// Keep selector in sync with active queue id // Keep selector in sync with active queue id
useEffect(() => { useEffect(() => {
if (selectedQueueId) return;
if (activeQueueId) setSelectedQueueId(activeQueueId); if (activeQueueId) setSelectedQueueId(activeQueueId);
else if (queueId) setSelectedQueueId(queueId); else if (queueId) setSelectedQueueId(queueId);
}, [activeQueueId, queueId]); }, [activeQueueId, queueId, selectedQueueId]);
const handleActivate = async (queueId: string) => { const handleActivate = async (queueId: string) => {
try { try {

View File

@@ -130,7 +130,9 @@ export function QueueActions({
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
onClick={() => onActivate(queueId)} onClick={() => {
if (queueId) onActivate(queueId);
}}
disabled={isActivating || !queueId} disabled={isActivating || !queueId}
title={formatMessage({ id: 'issues.queue.actions.activate' })} title={formatMessage({ id: 'issues.queue.actions.activate' })}
> >

View File

@@ -22,6 +22,7 @@ import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/Dropdown'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/Dropdown';
import type { DraggableProvidedDragHandleProps, DraggableProvidedDraggableProps } from '@hello-pangea/dnd';
import type { Issue } from '@/lib/api'; import type { Issue } from '@/lib/api';
// ========== Types ========== // ========== Types ==========
@@ -35,8 +36,8 @@ export interface IssueCardProps {
className?: string; className?: string;
compact?: boolean; compact?: boolean;
showActions?: boolean; showActions?: boolean;
draggableProps?: Record<string, unknown>; draggableProps?: DraggableProvidedDraggableProps;
dragHandleProps?: Record<string, unknown>; dragHandleProps?: DraggableProvidedDragHandleProps | null;
innerRef?: React.Ref<HTMLDivElement>; innerRef?: React.Ref<HTMLDivElement>;
} }

View File

@@ -69,6 +69,7 @@ export type {
export { export {
useIssues, useIssues,
useIssueQueue, useIssueQueue,
useIssueQueueById,
useQueueHistory, useQueueHistory,
useCreateIssue, useCreateIssue,
useUpdateIssue, useUpdateIssue,

View File

@@ -8,6 +8,7 @@ import {
fetchIssues, fetchIssues,
fetchIssueHistory, fetchIssueHistory,
fetchIssueQueue, fetchIssueQueue,
fetchQueueById,
fetchQueueHistory, fetchQueueHistory,
createIssue, createIssue,
updateIssue, updateIssue,
@@ -208,6 +209,23 @@ export function useIssueQueue(): UseQueryResult<IssueQueue> {
}); });
} }
/**
* Hook for fetching a specific queue by ID
*/
export function useIssueQueueById(queueId?: string): UseQueryResult<IssueQueue> {
const projectPath = useWorkflowStore(selectProjectPath);
return useQuery<IssueQueue>({
queryKey:
projectPath && queueId
? workspaceQueryKeys.issueQueueById(projectPath, queueId)
: ['issueQueueById', projectPath ?? 'no-project', queueId ?? 'no-queue'],
queryFn: () => fetchQueueById(queueId!, projectPath),
staleTime: STALE_TIME,
enabled: !!projectPath && !!queueId,
retry: 2,
});
}
// ========== Mutations ========== // ========== Mutations ==========
export interface UseCreateIssueReturn { export interface UseCreateIssueReturn {
@@ -346,6 +364,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
mutationFn: (queueId: string) => activateQueue(queueId, projectPath), mutationFn: (queueId: string) => activateQueue(queueId, projectPath),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
}, },
}); });
@@ -354,6 +373,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
mutationFn: () => deactivateQueue(projectPath), mutationFn: () => deactivateQueue(projectPath),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
}, },
}); });
@@ -362,6 +382,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
mutationFn: (queueId: string) => deleteQueueApi(queueId, projectPath), mutationFn: (queueId: string) => deleteQueueApi(queueId, projectPath),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
}, },
}); });
@@ -371,6 +392,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
mergeQueuesApi(sourceId, targetId, projectPath), mergeQueuesApi(sourceId, targetId, projectPath),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
}, },
}); });
@@ -380,6 +402,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
splitQueueApi(sourceQueueId, itemIds, projectPath), splitQueueApi(sourceQueueId, itemIds, projectPath),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
}, },
}); });
@@ -389,6 +412,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
reorderQueueGroupApi(projectPath, { groupId, newOrder }), reorderQueueGroupApi(projectPath, { groupId, newOrder }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
}, },
}); });
@@ -397,6 +421,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
moveQueueItemApi(projectPath, { itemId, toGroupId, toIndex }), moveQueueItemApi(projectPath, { itemId, toGroupId, toIndex }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
}, },
}); });

View File

@@ -842,6 +842,13 @@ export async function fetchQueueHistory(projectPath: string): Promise<QueueHisto
return fetchApi<QueueHistoryIndex>(`/api/queue/history?path=${encodeURIComponent(projectPath)}`); return fetchApi<QueueHistoryIndex>(`/api/queue/history?path=${encodeURIComponent(projectPath)}`);
} }
/**
* Fetch a specific queue by ID
*/
export async function fetchQueueById(queueId: string, projectPath: string): Promise<IssueQueue> {
return fetchApi<IssueQueue>(`/api/queue/${encodeURIComponent(queueId)}?path=${encodeURIComponent(projectPath)}`);
}
/** /**
* Activate a queue * Activate a queue
*/ */
@@ -5797,3 +5804,23 @@ export async function createCliSessionShareToken(
{ method: 'POST', body: JSON.stringify(input) } { method: 'POST', body: JSON.stringify(input) }
); );
} }
export async function fetchCliSessionShares(
sessionKey: string,
projectPath?: string
): Promise<{ shares: Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }> }> {
return fetchApi<{ shares: Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }> }>(
withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/shares`, projectPath)
);
}
export async function revokeCliSessionShareToken(
sessionKey: string,
input: { shareToken: string },
projectPath?: string
): Promise<{ success: boolean; revoked: boolean }> {
return fetchApi<{ success: boolean; revoked: boolean }>(
withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/share/revoke`, projectPath),
{ method: 'POST', body: JSON.stringify(input) }
);
}

View File

@@ -35,6 +35,8 @@ export const workspaceQueryKeys = {
issuesList: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'list'] as const, issuesList: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'list'] as const,
issuesHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'history'] as const, issuesHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'history'] as const,
issueQueue: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queue'] as const, issueQueue: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queue'] as const,
issueQueueById: (projectPath: string, queueId: string) =>
[...workspaceQueryKeys.issues(projectPath), 'queueById', queueId] as const,
issueQueueHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queueHistory'] as const, issueQueueHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queueHistory'] as const,
// ========== Discoveries ========== // ========== Discoveries ==========

View File

@@ -120,7 +120,18 @@
"refresh": "Refresh", "refresh": "Refresh",
"new": "New Session", "new": "New Session",
"close": "Close", "close": "Close",
"share": "Share (Read-only)" "share": "Share (Read-only)",
"revokeShare": "Revoke",
"expiresAt": "Expires at",
"activeShares": "Active shares"
},
"share": {
"pageTitle": "Shared CLI Session",
"missingParams": "Missing sessionKey or shareToken in URL",
"connecting": "Connecting",
"connected": "Live",
"error": "Error",
"linkLabel": "Share link"
}, },
"exec": { "exec": {
"tool": "Tool", "tool": "Tool",

View File

@@ -120,7 +120,18 @@
"refresh": "刷新", "refresh": "刷新",
"new": "新建会话", "new": "新建会话",
"close": "关闭", "close": "关闭",
"share": "分享(只读)" "share": "分享(只读)",
"revokeShare": "撤销分享",
"expiresAt": "过期时间",
"activeShares": "分享列表"
},
"share": {
"pageTitle": "共享终端会话",
"missingParams": "链接缺少 sessionKey 或 shareToken 参数",
"connecting": "连接中",
"connected": "实时",
"error": "错误",
"linkLabel": "分享链接"
}, },
"exec": { "exec": {
"tool": "工具", "tool": "工具",

View File

@@ -0,0 +1,219 @@
// ========================================
// CLI Session Share Page
// ========================================
// Read-only viewer for a PTY-backed CLI session using SSE + shareToken.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { useSearchParams } from 'react-router-dom';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { Copy, Monitor } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/utils';
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error';
function buildStreamUrl(sessionKey: string, shareToken: string): string {
const apiPath = `/api/cli-sessions/${encodeURIComponent(sessionKey)}/stream`;
const params = new URLSearchParams({
shareToken,
includeBuffer: '1',
});
return `${apiPath}?${params.toString()}`;
}
export function CliSessionSharePage() {
const { formatMessage } = useIntl();
const [searchParams] = useSearchParams();
const sessionKey = searchParams.get('sessionKey') ?? '';
const shareToken = searchParams.get('shareToken') ?? '';
const shareUrl = useMemo(() => (typeof window !== 'undefined' ? window.location.href : ''), []);
const [connection, setConnection] = useState<ConnectionState>('idle');
const [error, setError] = useState<string | null>(null);
const terminalHostRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
// Init xterm
useEffect(() => {
if (!terminalHostRef.current) return;
if (xtermRef.current) return;
const term = new XTerm({
convertEol: true,
cursorBlink: false,
disableStdin: true,
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 12,
scrollback: 10_000,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalHostRef.current);
fitAddon.fit();
xtermRef.current = term;
fitAddonRef.current = fitAddon;
return () => {
try {
term.dispose();
} finally {
xtermRef.current = null;
fitAddonRef.current = null;
}
};
}, []);
// Resize observer -> fit
useEffect(() => {
const host = terminalHostRef.current;
const fitAddon = fitAddonRef.current;
if (!host || !fitAddon) return;
const ro = new ResizeObserver(() => fitAddon.fit());
ro.observe(host);
return () => ro.disconnect();
}, []);
// Connect SSE
useEffect(() => {
const term = xtermRef.current;
if (!term) return;
if (!sessionKey || !shareToken) {
setConnection('error');
setError(formatMessage({ id: 'issues.terminal.share.missingParams' }));
return;
}
setError(null);
setConnection('connecting');
const streamUrl = buildStreamUrl(sessionKey, shareToken);
const es = new EventSource(streamUrl);
let hasConnected = false;
const onBuffer = (event: MessageEvent) => {
try {
const payload = JSON.parse(event.data) as { buffer?: string };
term.reset();
term.clear();
term.write(payload.buffer || '');
hasConnected = true;
setConnection('connected');
} catch {
// ignore parse errors
}
};
const onOutput = (event: MessageEvent) => {
try {
const payload = JSON.parse(event.data) as { data?: string };
if (typeof payload.data === 'string') {
term.write(payload.data);
}
if (!hasConnected) {
hasConnected = true;
setConnection('connected');
}
} catch {
// ignore parse errors
}
};
const onError = () => {
setConnection(hasConnected ? 'reconnecting' : 'connecting');
};
es.addEventListener('buffer', onBuffer as any);
es.addEventListener('output', onOutput as any);
es.onerror = onError;
return () => {
try {
es.close();
} catch {
// ignore
}
};
}, [formatMessage, sessionKey, shareToken]);
const connectionBadge = useMemo(() => {
switch (connection) {
case 'connected':
return <Badge variant="success">{formatMessage({ id: 'issues.terminal.share.connected' })}</Badge>;
case 'reconnecting':
case 'connecting':
return <Badge variant="secondary">{formatMessage({ id: 'issues.terminal.share.connecting' })}</Badge>;
case 'error':
return <Badge variant="destructive">{formatMessage({ id: 'issues.terminal.share.error' })}</Badge>;
default:
return <Badge variant="secondary"></Badge>;
}
}, [connection, formatMessage]);
const handleCopyLink = async () => {
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl);
} catch {
// ignore
}
};
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-5xl mx-auto space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Monitor className="w-5 h-5 text-muted-foreground" />
<h1 className="text-lg font-semibold text-foreground truncate">
{formatMessage({ id: 'issues.terminal.share.pageTitle' })}
</h1>
{connectionBadge}
</div>
<div className="mt-1 text-xs text-muted-foreground font-mono truncate">
{sessionKey || '—'}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" onClick={handleCopyLink} disabled={!shareUrl}>
<Copy className="w-4 h-4 mr-2" />
{formatMessage({ id: 'common.actions.copy' })}
</Button>
</div>
</div>
{error && (
<Card className="p-4 border-destructive/50 bg-destructive/5">
<div className="text-sm text-destructive">{error}</div>
</Card>
)}
<Card className="p-3 space-y-2">
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.share.linkLabel' })}</div>
<Input value={shareUrl} readOnly />
</Card>
<div className={cn('rounded-md border border-border bg-black/90 overflow-hidden')}>
<div ref={terminalHostRef} className="h-[70vh] w-full" />
</div>
</div>
</div>
);
}
export default CliSessionSharePage;

View File

@@ -33,5 +33,6 @@ export { GraphExplorerPage } from './GraphExplorerPage';
export { CodexLensManagerPage } from './CodexLensManagerPage'; export { CodexLensManagerPage } from './CodexLensManagerPage';
export { ApiSettingsPage } from './ApiSettingsPage'; export { ApiSettingsPage } from './ApiSettingsPage';
export { CliViewerPage } from './CliViewerPage'; export { CliViewerPage } from './CliViewerPage';
export { CliSessionSharePage } from './CliSessionSharePage';
export { IssueManagerPage } from './IssueManagerPage'; export { IssueManagerPage } from './IssueManagerPage';
export { TeamPage } from './TeamPage'; export { TeamPage } from './TeamPage';

View File

@@ -38,6 +38,7 @@ import {
CodexLensManagerPage, CodexLensManagerPage,
ApiSettingsPage, ApiSettingsPage,
CliViewerPage, CliViewerPage,
CliSessionSharePage,
TeamPage, TeamPage,
} from '@/pages'; } from '@/pages';
@@ -46,6 +47,10 @@ import {
* All routes are wrapped in AppShell layout * All routes are wrapped in AppShell layout
*/ */
const routes: RouteObject[] = [ const routes: RouteObject[] = [
{
path: 'cli-sessions/share',
element: <CliSessionSharePage />,
},
{ {
path: '/', path: '/',
element: <AppShell />, element: <AppShell />,

View File

@@ -6,10 +6,14 @@
* - GET /api/cli-sessions * - GET /api/cli-sessions
* - POST /api/cli-sessions * - POST /api/cli-sessions
* - GET /api/cli-sessions/:sessionKey/buffer * - GET /api/cli-sessions/:sessionKey/buffer
* - GET /api/cli-sessions/:sessionKey/stream (SSE, shareToken required)
* - POST /api/cli-sessions/:sessionKey/send * - POST /api/cli-sessions/:sessionKey/send
* - POST /api/cli-sessions/:sessionKey/execute * - POST /api/cli-sessions/:sessionKey/execute
* - POST /api/cli-sessions/:sessionKey/resize * - POST /api/cli-sessions/:sessionKey/resize
* - POST /api/cli-sessions/:sessionKey/close * - POST /api/cli-sessions/:sessionKey/close
* - GET /api/cli-sessions/:sessionKey/shares
* - POST /api/cli-sessions/:sessionKey/share
* - POST /api/cli-sessions/:sessionKey/share/revoke
*/ */
import type { RouteContext } from './types.js'; import type { RouteContext } from './types.js';
@@ -192,12 +196,23 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
res.write(`event: buffer\ndata: ${JSON.stringify({ sessionKey, buffer })}\n\n`); res.write(`event: buffer\ndata: ${JSON.stringify({ sessionKey, buffer })}\n\n`);
} }
// Keep the SSE connection alive through proxies even when output is idle.
const keepAliveTimer = setInterval(() => {
try {
res.write(`: keepalive ${Date.now()}\n\n`);
} catch {
// ignore
}
}, 15_000);
keepAliveTimer.unref?.();
const unsubscribe = manager.onOutput((event) => { const unsubscribe = manager.onOutput((event) => {
if (event.sessionKey !== sessionKey) return; if (event.sessionKey !== sessionKey) return;
res.write(`event: output\ndata: ${JSON.stringify(event)}\n\n`); res.write(`event: output\ndata: ${JSON.stringify(event)}\n\n`);
}); });
req.on('close', () => { req.on('close', () => {
clearInterval(keepAliveTimer);
unsubscribe(); unsubscribe();
try { try {
res.end(); res.end();
@@ -239,6 +254,25 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
return true; return true;
} }
// GET /api/cli-sessions/:sessionKey/shares
const sharesMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/shares$/);
if (sharesMatch && req.method === 'GET') {
const sessionKey = decodeURIComponent(sharesMatch[1]);
const session = manager.getSession(sessionKey);
if (!session) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
return true;
}
const shares = shareManager
.listTokensForSession(sessionKey, projectRoot)
.map((s) => ({ shareToken: s.token, expiresAt: s.expiresAt, mode: s.mode }));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ shares }));
return true;
}
// POST /api/cli-sessions/:sessionKey/share // POST /api/cli-sessions/:sessionKey/share
const shareMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/share$/); const shareMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/share$/);
if (shareMatch && req.method === 'POST') { if (shareMatch && req.method === 'POST') {
@@ -271,6 +305,36 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
return true; return true;
} }
// POST /api/cli-sessions/:sessionKey/share/revoke
const revokeShareMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/share\/revoke$/);
if (revokeShareMatch && req.method === 'POST') {
const sessionKey = decodeURIComponent(revokeShareMatch[1]);
handlePostRequest(req, res, async (body: unknown) => {
const { shareToken } = (body || {}) as any;
if (!shareToken || typeof shareToken !== 'string') {
return { error: 'shareToken is required', status: 400 };
}
const validated = shareManager.validateToken(shareToken, sessionKey);
if (!validated || validated.projectRoot !== projectRoot) {
return { error: describeShareAuthFailure().error, status: 403 };
}
const revoked = shareManager.revokeToken(shareToken);
appendCliSessionAudit({
type: 'session_share_revoked',
timestamp: new Date().toISOString(),
projectRoot,
sessionKey,
...clientInfo(req),
details: { tokenTail: shareToken.slice(-6), revoked },
});
return { success: true, revoked };
});
return true;
}
// POST /api/cli-sessions/:sessionKey/execute // POST /api/cli-sessions/:sessionKey/execute
const executeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/execute$/); const executeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/execute$/);
if (executeMatch && req.method === 'POST') { if (executeMatch && req.method === 'POST') {

View File

@@ -8,6 +8,7 @@ export type CliSessionAuditEventType =
| 'session_execute' | 'session_execute'
| 'session_resize' | 'session_resize'
| 'session_share_created' | 'session_share_created'
| 'session_share_revoked'
| 'session_idle_reaped'; | 'session_idle_reaped';
export interface CliSessionAuditEvent { export interface CliSessionAuditEvent {

View File

@@ -22,6 +22,20 @@ function createTokenValue(): string {
export class CliSessionShareManager { export class CliSessionShareManager {
private tokens = new Map<string, InternalTokenRecord>(); private tokens = new Map<string, InternalTokenRecord>();
listTokensForSession(sessionKey: string, projectRoot?: string): CliSessionShareTokenRecord[] {
this.cleanupExpired();
const records: CliSessionShareTokenRecord[] = [];
for (const record of this.tokens.values()) {
if (record.sessionKey !== sessionKey) continue;
if (projectRoot && record.projectRoot !== projectRoot) continue;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { expiresAtMs: _expiresAtMs, ...publicRecord } = record;
records.push(publicRecord);
}
records.sort((a, b) => a.expiresAt.localeCompare(b.expiresAt));
return records;
}
createToken(input: { createToken(input: {
sessionKey: string; sessionKey: string;
projectRoot: string; projectRoot: string;
@@ -72,9 +86,20 @@ export class CliSessionShareManager {
} }
let singleton: CliSessionShareManager | null = null; let singleton: CliSessionShareManager | null = null;
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
export function getCliSessionShareManager(): CliSessionShareManager { export function getCliSessionShareManager(): CliSessionShareManager {
if (!singleton) singleton = new CliSessionShareManager(); if (!singleton) {
singleton = new CliSessionShareManager();
cleanupTimer = setInterval(() => {
try {
singleton?.cleanupExpired();
} catch {
// ignore
}
}, 60_000);
cleanupTimer.unref?.();
}
return singleton; return singleton;
} }

View File

@@ -0,0 +1,138 @@
/**
* Unit tests for CLI session share tokens
*/
import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
const shareUrl = new URL('../dist/core/services/cli-session-share.js', import.meta.url).href;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mod;
const originalNow = Date.now;
afterEach(() => {
Date.now = originalNow;
});
describe('CliSessionShareManager', async () => {
mod = await import(shareUrl);
it('creates a token record with expected fields', () => {
const mgr = new mod.CliSessionShareManager();
const record = mgr.createToken({
sessionKey: 's-1',
projectRoot: 'D:\\\\Claude_dms3',
mode: 'read',
ttlMs: 10_000,
});
assert.equal(record.sessionKey, 's-1');
assert.equal(record.projectRoot, 'D:\\\\Claude_dms3');
assert.equal(record.mode, 'read');
assert.match(record.token, /^[A-Za-z0-9_-]+$/);
assert.ok(record.expiresAt.endsWith('Z'));
});
it('validates only when sessionKey matches', () => {
const mgr = new mod.CliSessionShareManager();
const record = mgr.createToken({
sessionKey: 's-abc',
projectRoot: '/tmp/project',
mode: 'read',
ttlMs: 60_000,
});
assert.ok(mgr.validateToken(record.token, 's-abc'));
assert.equal(mgr.validateToken(record.token, 's-wrong'), null);
});
it('revokes tokens', () => {
const mgr = new mod.CliSessionShareManager();
const record = mgr.createToken({
sessionKey: 's-1',
projectRoot: '/tmp/project',
mode: 'write',
ttlMs: 60_000,
});
assert.equal(mgr.revokeToken(record.token), true);
assert.equal(mgr.revokeToken(record.token), false);
assert.equal(mgr.validateToken(record.token, record.sessionKey), null);
});
it('expires tokens and cleans up expired entries', () => {
let now = 1_700_000_000_000;
Date.now = () => now;
const mgr = new mod.CliSessionShareManager();
const recordA = mgr.createToken({
sessionKey: 's-a',
projectRoot: '/tmp/project',
mode: 'read',
ttlMs: 1_000,
});
const recordB = mgr.createToken({
sessionKey: 's-b',
projectRoot: '/tmp/project',
mode: 'read',
ttlMs: 10_000,
});
now += 1_001;
assert.equal(mgr.validateToken(recordA.token, recordA.sessionKey), null);
assert.ok(mgr.validateToken(recordB.token, recordB.sessionKey));
assert.equal(mgr.cleanupExpired(), 0);
now += 10_000;
assert.equal(mgr.cleanupExpired(), 1);
assert.equal(mgr.validateToken(recordB.token, recordB.sessionKey), null);
});
it('clamps ttlMs to a minimum of 1000ms', () => {
const fixedNow = 1_700_000_000_000;
Date.now = () => fixedNow;
const mgr = new mod.CliSessionShareManager();
const record = mgr.createToken({
sessionKey: 's-1',
projectRoot: '/tmp/project',
mode: 'read',
ttlMs: 1,
});
assert.equal(record.expiresAt, new Date(fixedNow + 1_000).toISOString());
});
it('lists tokens for a session (scoped to projectRoot) and sorts by expiresAt', () => {
let now = 1_700_000_000_000;
Date.now = () => now;
const mgr = new mod.CliSessionShareManager();
const t1 = mgr.createToken({
sessionKey: 's-1',
projectRoot: '/p1',
mode: 'read',
ttlMs: 5_000,
});
const t2 = mgr.createToken({
sessionKey: 's-1',
projectRoot: '/p1',
mode: 'write',
ttlMs: 10_000,
});
mgr.createToken({ sessionKey: 's-1', projectRoot: '/p2', mode: 'read', ttlMs: 10_000 });
mgr.createToken({ sessionKey: 's-2', projectRoot: '/p1', mode: 'read', ttlMs: 10_000 });
const list1 = mgr.listTokensForSession('s-1', '/p1');
assert.equal(list1.length, 2);
assert.equal(list1[0].token, t1.token);
assert.equal(list1[1].token, t2.token);
now += 5_001;
const list2 = mgr.listTokensForSession('s-1', '/p1');
assert.deepEqual(list2.map(r => r.token), [t2.token]);
});
});

View File

@@ -0,0 +1,71 @@
/**
* Unit tests for RateLimiter
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const limiterUrl = new URL('../dist/core/services/rate-limiter.js', import.meta.url).href;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mod;
describe('RateLimiter', async () => {
mod = await import(limiterUrl);
it('enforces limits within a window', () => {
const limiter = new mod.RateLimiter({ limit: 3, windowMs: 60_000 });
const r1 = limiter.consume('k');
const r2 = limiter.consume('k');
const r3 = limiter.consume('k');
const r4 = limiter.consume('k');
assert.equal(r1.ok, true);
assert.equal(r1.remaining, 2);
assert.equal(typeof r1.resetAt, 'number');
assert.equal(r2.ok, true);
assert.equal(r2.remaining, 1);
assert.equal(r2.resetAt, r1.resetAt);
assert.equal(r3.ok, true);
assert.equal(r3.remaining, 0);
assert.equal(r3.resetAt, r1.resetAt);
assert.equal(r4.ok, false);
assert.equal(r4.remaining, 0);
assert.equal(r4.resetAt, r1.resetAt);
});
it('handles costs and does not penalize impossible costs', () => {
const limiter = new mod.RateLimiter({ limit: 3, windowMs: 60_000 });
// Cost is floored; negative cost becomes 0
assert.equal(limiter.consume('k2', -5).ok, true);
assert.equal(limiter.consume('k2').remaining, 2);
// Cost larger than limit returns not ok but bucket remains full.
const tooMuch = limiter.consume('k3', 10);
assert.equal(tooMuch.ok, false);
assert.equal(tooMuch.remaining, 0);
assert.equal(limiter.consume('k3').remaining, 2);
});
it('resets after the window expires', async () => {
const limiter = new mod.RateLimiter({ limit: 1, windowMs: 50 });
assert.equal(limiter.consume('k').ok, true);
assert.equal(limiter.consume('k').ok, false);
await new Promise((resolve) => setTimeout(resolve, 70));
assert.equal(limiter.consume('k').ok, true);
});
it('supports a limit of 0', () => {
const limiter = new mod.RateLimiter({ limit: 0, windowMs: 1_000 });
assert.equal(limiter.consume('k').ok, false);
assert.equal(limiter.consume('k', 0).ok, true);
});
});