From d0cdee2e6839d23463917dabed37355dbb42e5f9 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Mon, 9 Feb 2026 22:57:05 +0800 Subject: [PATCH] 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. --- .../components/issue/hub/IssueTerminalTab.tsx | 137 ++++++++++- .../src/components/issue/hub/QueuePanel.tsx | 18 +- .../components/issue/queue/QueueActions.tsx | 4 +- .../src/components/shared/IssueCard.tsx | 5 +- ccw/frontend/src/hooks/index.ts | 1 + ccw/frontend/src/hooks/useIssues.ts | 25 ++ ccw/frontend/src/lib/api.ts | 27 +++ ccw/frontend/src/lib/queryKeys.ts | 2 + ccw/frontend/src/locales/en/issues.json | 13 +- ccw/frontend/src/locales/zh/issues.json | 13 +- .../src/pages/CliSessionSharePage.tsx | 219 ++++++++++++++++++ ccw/frontend/src/pages/index.ts | 1 + ccw/frontend/src/router.tsx | 5 + ccw/src/core/routes/cli-sessions-routes.ts | 64 +++++ ccw/src/core/services/cli-session-audit.ts | 1 + ccw/src/core/services/cli-session-share.ts | 27 ++- ccw/tests/cli-session-share.test.js | 138 +++++++++++ ccw/tests/rate-limiter.test.js | 71 ++++++ 18 files changed, 748 insertions(+), 23 deletions(-) create mode 100644 ccw/frontend/src/pages/CliSessionSharePage.tsx create mode 100644 ccw/tests/cli-session-share.test.js create mode 100644 ccw/tests/rate-limiter.test.js diff --git a/ccw/frontend/src/components/issue/hub/IssueTerminalTab.tsx b/ccw/frontend/src/components/issue/hub/IssueTerminalTab.tsx index 953e08ec..c4ed3760 100644 --- a/ccw/frontend/src/components/issue/hub/IssueTerminalTab.tsx +++ b/ccw/frontend/src/components/issue/hub/IssueTerminalTab.tsx @@ -17,6 +17,8 @@ import { closeCliSession, createCliSession, createCliSessionShareToken, + fetchCliSessionShares, + revokeCliSessionShareToken, executeInCliSession, fetchCliSessionBuffer, fetchCliSessions, @@ -55,6 +57,11 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) { const [prompt, setPrompt] = useState(''); const [isExecuting, setIsExecuting] = useState(false); const [shareUrl, setShareUrl] = useState(''); + const [shareToken, setShareToken] = useState(''); + const [shareExpiresAt, setShareExpiresAt] = useState(''); + const [shareRecords, setShareRecords] = useState>([]); + const [isLoadingShares, setIsLoadingShares] = useState(false); + const [isRevokingShare, setIsRevokingShare] = useState(false); const terminalHostRef = useRef(null); const xtermRef = useRef(null); @@ -103,6 +110,39 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) { setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? ''); }, [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 useEffect(() => { if (!terminalHostRef.current) return; @@ -282,18 +322,38 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) { if (!selectedSessionKey) return; setError(null); setShareUrl(''); + setShareToken(''); + setShareExpiresAt(''); try { const r = await createCliSessionShareToken(selectedSessionKey, { mode: 'read' }, projectPath || undefined); - 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(selectedSessionKey)}&shareToken=${encodeURIComponent(r.shareToken)}`; - setShareUrl(url.toString()); + setShareUrl(buildShareLink(selectedSessionKey, r.shareToken)); + setShareToken(r.shareToken); + setShareExpiresAt(r.expiresAt); + void refreshShares(selectedSessionKey); } catch (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 () => { if (!shareUrl) return; try { @@ -352,12 +412,67 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) { {shareUrl && ( -
- - +
+
+ + + +
+ {shareExpiresAt && ( +
+ {formatMessage({ id: 'issues.terminal.session.expiresAt' })}: {shareExpiresAt} +
+ )} +
+ )} + + {selectedSessionKey && shareRecords.length > 0 && ( +
+
+ {formatMessage({ id: 'issues.terminal.session.activeShares' })} + {isLoadingShares ? '…' : ''} +
+
+ {shareRecords.map((s) => ( +
+
+ {s.shareToken.slice(0, 6)}…{s.shareToken.slice(-6)} +
+
{s.mode}
+
{s.expiresAt}
+ + +
+ ))} +
)} diff --git a/ccw/frontend/src/components/issue/hub/QueuePanel.tsx b/ccw/frontend/src/components/issue/hub/QueuePanel.tsx index 0cee4a69..eb557973 100644 --- a/ccw/frontend/src/components/issue/hub/QueuePanel.tsx +++ b/ccw/frontend/src/components/issue/hub/QueuePanel.tsx @@ -19,7 +19,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { QueueCard } from '@/components/issue/queue/QueueCard'; import { QueueBoard } from '@/components/issue/queue/QueueBoard'; 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'; // ========== Loading Skeleton ========== @@ -74,9 +74,11 @@ function QueueEmptyState() { export function QueuePanel() { const { formatMessage } = useIntl(); const [selectedItem, setSelectedItem] = useState(null); + const [selectedQueueId, setSelectedQueueId] = useState(''); - const { data: queueData, isLoading, error } = useIssueQueue(); + const activeQueueQuery = useIssueQueue(); const { data: historyIndex } = useQueueHistory(); + const selectedQueueQuery = useIssueQueueById(selectedQueueId); const { activateQueue, deactivateQueue, @@ -90,8 +92,12 @@ export function QueuePanel() { isSplitting, } = useQueueMutations(); - // Get queue data with proper type - const queue = queueData; + const queue = selectedQueueId && selectedQueueQuery.data ? selectedQueueQuery.data : activeQueueQuery.data; + const isLoading = + activeQueueQuery.isLoading || + (selectedQueueId ? selectedQueueQuery.isLoading && !selectedQueueQuery.data : false); + const error = activeQueueQuery.error || selectedQueueQuery.error; + const taskCount = queue?.tasks?.length || 0; const solutionCount = queue?.solutions?.length || 0; const conflictCount = queue?.conflicts?.length || 0; @@ -100,13 +106,13 @@ export function QueuePanel() { const activeQueueId = historyIndex?.active_queue_id || null; const activeQueueIds = historyIndex?.active_queue_ids || []; const queueId = queue?.id; - const [selectedQueueId, setSelectedQueueId] = useState(''); // Keep selector in sync with active queue id useEffect(() => { + if (selectedQueueId) return; if (activeQueueId) setSelectedQueueId(activeQueueId); else if (queueId) setSelectedQueueId(queueId); - }, [activeQueueId, queueId]); + }, [activeQueueId, queueId, selectedQueueId]); const handleActivate = async (queueId: string) => { try { diff --git a/ccw/frontend/src/components/issue/queue/QueueActions.tsx b/ccw/frontend/src/components/issue/queue/QueueActions.tsx index b2e6c65d..85dad88c 100644 --- a/ccw/frontend/src/components/issue/queue/QueueActions.tsx +++ b/ccw/frontend/src/components/issue/queue/QueueActions.tsx @@ -130,7 +130,9 @@ export function QueueActions({ variant="ghost" size="sm" className="h-8 w-8 p-0" - onClick={() => onActivate(queueId)} + onClick={() => { + if (queueId) onActivate(queueId); + }} disabled={isActivating || !queueId} title={formatMessage({ id: 'issues.queue.actions.activate' })} > diff --git a/ccw/frontend/src/components/shared/IssueCard.tsx b/ccw/frontend/src/components/shared/IssueCard.tsx index 374090c5..0502d1e1 100644 --- a/ccw/frontend/src/components/shared/IssueCard.tsx +++ b/ccw/frontend/src/components/shared/IssueCard.tsx @@ -22,6 +22,7 @@ import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/Dropdown'; +import type { DraggableProvidedDragHandleProps, DraggableProvidedDraggableProps } from '@hello-pangea/dnd'; import type { Issue } from '@/lib/api'; // ========== Types ========== @@ -35,8 +36,8 @@ export interface IssueCardProps { className?: string; compact?: boolean; showActions?: boolean; - draggableProps?: Record; - dragHandleProps?: Record; + draggableProps?: DraggableProvidedDraggableProps; + dragHandleProps?: DraggableProvidedDragHandleProps | null; innerRef?: React.Ref; } diff --git a/ccw/frontend/src/hooks/index.ts b/ccw/frontend/src/hooks/index.ts index 54605cda..4c4be9ca 100644 --- a/ccw/frontend/src/hooks/index.ts +++ b/ccw/frontend/src/hooks/index.ts @@ -69,6 +69,7 @@ export type { export { useIssues, useIssueQueue, + useIssueQueueById, useQueueHistory, useCreateIssue, useUpdateIssue, diff --git a/ccw/frontend/src/hooks/useIssues.ts b/ccw/frontend/src/hooks/useIssues.ts index 1ef2b3d3..f0b08ea5 100644 --- a/ccw/frontend/src/hooks/useIssues.ts +++ b/ccw/frontend/src/hooks/useIssues.ts @@ -8,6 +8,7 @@ import { fetchIssues, fetchIssueHistory, fetchIssueQueue, + fetchQueueById, fetchQueueHistory, createIssue, updateIssue, @@ -208,6 +209,23 @@ export function useIssueQueue(): UseQueryResult { }); } +/** + * Hook for fetching a specific queue by ID + */ +export function useIssueQueueById(queueId?: string): UseQueryResult { + const projectPath = useWorkflowStore(selectProjectPath); + return useQuery({ + 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 ========== export interface UseCreateIssueReturn { @@ -346,6 +364,7 @@ export function useQueueMutations(): UseQueueMutationsReturn { mutationFn: (queueId: string) => activateQueue(queueId, projectPath), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); }, }); @@ -354,6 +373,7 @@ export function useQueueMutations(): UseQueueMutationsReturn { mutationFn: () => deactivateQueue(projectPath), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); }, }); @@ -362,6 +382,7 @@ export function useQueueMutations(): UseQueueMutationsReturn { mutationFn: (queueId: string) => deleteQueueApi(queueId, projectPath), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); }, }); @@ -371,6 +392,7 @@ export function useQueueMutations(): UseQueueMutationsReturn { mergeQueuesApi(sourceId, targetId, projectPath), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); }, }); @@ -380,6 +402,7 @@ export function useQueueMutations(): UseQueueMutationsReturn { splitQueueApi(sourceQueueId, itemIds, projectPath), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] }); queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) }); }, }); @@ -389,6 +412,7 @@ export function useQueueMutations(): UseQueueMutationsReturn { reorderQueueGroupApi(projectPath, { groupId, newOrder }), onSuccess: () => { 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 }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] }); }, }); diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index e391abac..145d813b 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -842,6 +842,13 @@ export async function fetchQueueHistory(projectPath: string): Promise(`/api/queue/history?path=${encodeURIComponent(projectPath)}`); } +/** + * Fetch a specific queue by ID + */ +export async function fetchQueueById(queueId: string, projectPath: string): Promise { + return fetchApi(`/api/queue/${encodeURIComponent(queueId)}?path=${encodeURIComponent(projectPath)}`); +} + /** * Activate a queue */ @@ -5797,3 +5804,23 @@ export async function createCliSessionShareToken( { 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) } + ); +} diff --git a/ccw/frontend/src/lib/queryKeys.ts b/ccw/frontend/src/lib/queryKeys.ts index 2872fee5..e6081995 100644 --- a/ccw/frontend/src/lib/queryKeys.ts +++ b/ccw/frontend/src/lib/queryKeys.ts @@ -35,6 +35,8 @@ export const workspaceQueryKeys = { issuesList: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'list'] as const, issuesHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'history'] 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, // ========== Discoveries ========== diff --git a/ccw/frontend/src/locales/en/issues.json b/ccw/frontend/src/locales/en/issues.json index 6cc81437..0d3546ed 100644 --- a/ccw/frontend/src/locales/en/issues.json +++ b/ccw/frontend/src/locales/en/issues.json @@ -120,7 +120,18 @@ "refresh": "Refresh", "new": "New Session", "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": { "tool": "Tool", diff --git a/ccw/frontend/src/locales/zh/issues.json b/ccw/frontend/src/locales/zh/issues.json index d64dfa48..aa0987ce 100644 --- a/ccw/frontend/src/locales/zh/issues.json +++ b/ccw/frontend/src/locales/zh/issues.json @@ -120,7 +120,18 @@ "refresh": "刷新", "new": "新建会话", "close": "关闭", - "share": "分享(只读)" + "share": "分享(只读)", + "revokeShare": "撤销分享", + "expiresAt": "过期时间", + "activeShares": "分享列表" + }, + "share": { + "pageTitle": "共享终端会话", + "missingParams": "链接缺少 sessionKey 或 shareToken 参数", + "connecting": "连接中", + "connected": "实时", + "error": "错误", + "linkLabel": "分享链接" }, "exec": { "tool": "工具", diff --git a/ccw/frontend/src/pages/CliSessionSharePage.tsx b/ccw/frontend/src/pages/CliSessionSharePage.tsx new file mode 100644 index 00000000..a2da91ad --- /dev/null +++ b/ccw/frontend/src/pages/CliSessionSharePage.tsx @@ -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('idle'); + const [error, setError] = useState(null); + + const terminalHostRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(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 {formatMessage({ id: 'issues.terminal.share.connected' })}; + case 'reconnecting': + case 'connecting': + return {formatMessage({ id: 'issues.terminal.share.connecting' })}; + case 'error': + return {formatMessage({ id: 'issues.terminal.share.error' })}; + default: + return ; + } + }, [connection, formatMessage]); + + const handleCopyLink = async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + } catch { + // ignore + } + }; + + return ( +
+
+
+
+
+ +

+ {formatMessage({ id: 'issues.terminal.share.pageTitle' })} +

+ {connectionBadge} +
+
+ {sessionKey || '—'} +
+
+ +
+ +
+
+ + {error && ( + +
{error}
+
+ )} + + +
{formatMessage({ id: 'issues.terminal.share.linkLabel' })}
+ +
+ +
+
+
+
+
+ ); +} + +export default CliSessionSharePage; diff --git a/ccw/frontend/src/pages/index.ts b/ccw/frontend/src/pages/index.ts index 5db4f382..0ab88cdb 100644 --- a/ccw/frontend/src/pages/index.ts +++ b/ccw/frontend/src/pages/index.ts @@ -33,5 +33,6 @@ export { GraphExplorerPage } from './GraphExplorerPage'; export { CodexLensManagerPage } from './CodexLensManagerPage'; export { ApiSettingsPage } from './ApiSettingsPage'; export { CliViewerPage } from './CliViewerPage'; +export { CliSessionSharePage } from './CliSessionSharePage'; export { IssueManagerPage } from './IssueManagerPage'; export { TeamPage } from './TeamPage'; diff --git a/ccw/frontend/src/router.tsx b/ccw/frontend/src/router.tsx index 5e27c8db..7a15e6c2 100644 --- a/ccw/frontend/src/router.tsx +++ b/ccw/frontend/src/router.tsx @@ -38,6 +38,7 @@ import { CodexLensManagerPage, ApiSettingsPage, CliViewerPage, + CliSessionSharePage, TeamPage, } from '@/pages'; @@ -46,6 +47,10 @@ import { * All routes are wrapped in AppShell layout */ const routes: RouteObject[] = [ + { + path: 'cli-sessions/share', + element: , + }, { path: '/', element: , diff --git a/ccw/src/core/routes/cli-sessions-routes.ts b/ccw/src/core/routes/cli-sessions-routes.ts index d6b08733..79836ee2 100644 --- a/ccw/src/core/routes/cli-sessions-routes.ts +++ b/ccw/src/core/routes/cli-sessions-routes.ts @@ -6,10 +6,14 @@ * - GET /api/cli-sessions * - POST /api/cli-sessions * - 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/execute * - POST /api/cli-sessions/:sessionKey/resize * - 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'; @@ -192,12 +196,23 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise { + try { + res.write(`: keepalive ${Date.now()}\n\n`); + } catch { + // ignore + } + }, 15_000); + keepAliveTimer.unref?.(); + const unsubscribe = manager.onOutput((event) => { if (event.sessionKey !== sessionKey) return; res.write(`event: output\ndata: ${JSON.stringify(event)}\n\n`); }); req.on('close', () => { + clearInterval(keepAliveTimer); unsubscribe(); try { res.end(); @@ -239,6 +254,25 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise ({ 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 const shareMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/share$/); if (shareMatch && req.method === 'POST') { @@ -271,6 +305,36 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise { + 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 const executeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/execute$/); if (executeMatch && req.method === 'POST') { diff --git a/ccw/src/core/services/cli-session-audit.ts b/ccw/src/core/services/cli-session-audit.ts index 6fb50717..ab0197e7 100644 --- a/ccw/src/core/services/cli-session-audit.ts +++ b/ccw/src/core/services/cli-session-audit.ts @@ -8,6 +8,7 @@ export type CliSessionAuditEventType = | 'session_execute' | 'session_resize' | 'session_share_created' + | 'session_share_revoked' | 'session_idle_reaped'; export interface CliSessionAuditEvent { diff --git a/ccw/src/core/services/cli-session-share.ts b/ccw/src/core/services/cli-session-share.ts index c7aa5ae6..21d92be7 100644 --- a/ccw/src/core/services/cli-session-share.ts +++ b/ccw/src/core/services/cli-session-share.ts @@ -22,6 +22,20 @@ function createTokenValue(): string { export class CliSessionShareManager { private tokens = new Map(); + 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: { sessionKey: string; projectRoot: string; @@ -72,9 +86,20 @@ export class CliSessionShareManager { } let singleton: CliSessionShareManager | null = null; +let cleanupTimer: ReturnType | null = null; 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; } diff --git a/ccw/tests/cli-session-share.test.js b/ccw/tests/cli-session-share.test.js new file mode 100644 index 00000000..44a71225 --- /dev/null +++ b/ccw/tests/cli-session-share.test.js @@ -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]); + }); +}); diff --git a/ccw/tests/rate-limiter.test.js b/ccw/tests/rate-limiter.test.js new file mode 100644 index 00000000..e1fdddaa --- /dev/null +++ b/ccw/tests/rate-limiter.test.js @@ -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); + }); +});