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

@@ -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<boolea
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) => {
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<boolea
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
const shareMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/share$/);
if (shareMatch && req.method === 'POST') {
@@ -271,6 +305,36 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
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
const executeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/execute$/);
if (executeMatch && req.method === 'POST') {

View File

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

View File

@@ -22,6 +22,20 @@ function createTokenValue(): string {
export class CliSessionShareManager {
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: {
sessionKey: string;
projectRoot: string;
@@ -72,9 +86,20 @@ export class CliSessionShareManager {
}
let singleton: CliSessionShareManager | null = null;
let cleanupTimer: ReturnType<typeof setInterval> | 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;
}