mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-03 15:43:11 +08:00
Add benchmark results and tests for LSP graph builder and staged search
- Introduced a new benchmark results file for performance comparison on 2026-02-09. - Added a test for LspGraphBuilder to ensure it does not expand nodes at maximum depth. - Created a test for the staged search pipeline to validate fallback behavior when stage 1 returns empty results.
This commit is contained in:
@@ -14,10 +14,52 @@
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
import { getCliSessionManager } from '../services/cli-session-manager.js';
|
||||
import path from 'path';
|
||||
import { getCliSessionPolicy } from '../services/cli-session-policy.js';
|
||||
import { RateLimiter } from '../services/rate-limiter.js';
|
||||
import { appendCliSessionAudit } from '../services/cli-session-audit.js';
|
||||
import { describeShareAuthFailure, getCliSessionShareManager } from '../services/cli-session-share.js';
|
||||
|
||||
function clientKey(req: RouteContext['req']): string {
|
||||
const addr = req.socket?.remoteAddress ?? 'unknown';
|
||||
const ua = Array.isArray(req.headers['user-agent']) ? req.headers['user-agent'][0] : req.headers['user-agent'];
|
||||
return `${addr}|${ua ?? ''}`;
|
||||
}
|
||||
|
||||
function clientInfo(req: RouteContext['req']): { ip?: string; userAgent?: string } {
|
||||
const ip = req.socket?.remoteAddress ?? undefined;
|
||||
const userAgent = Array.isArray(req.headers['user-agent']) ? req.headers['user-agent'][0] : req.headers['user-agent'];
|
||||
return { ip: ip || undefined, userAgent: userAgent || undefined };
|
||||
}
|
||||
|
||||
function resolveProjectRoot(ctx: RouteContext): string {
|
||||
const forced = (ctx.req as any).__cliSessionShareProjectRoot;
|
||||
if (typeof forced === 'string' && forced.trim()) return path.resolve(forced);
|
||||
const raw = ctx.url.searchParams.get('path');
|
||||
if (raw && raw.trim()) return path.resolve(raw);
|
||||
return path.resolve(ctx.initialPath || process.cwd());
|
||||
}
|
||||
|
||||
function validateWorkingDir(projectRoot: string, workingDir: string, allowOutside: boolean): string | null {
|
||||
const resolved = path.resolve(workingDir);
|
||||
if (allowOutside) return null;
|
||||
|
||||
const rel = path.relative(projectRoot, resolved);
|
||||
const isInside = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
return isInside ? null : `workingDir must be within project: ${projectRoot}`;
|
||||
}
|
||||
|
||||
const policy = getCliSessionPolicy();
|
||||
const createLimiter = new RateLimiter({ limit: policy.rateLimit.createPerMinute, windowMs: 60_000 });
|
||||
const executeLimiter = new RateLimiter({ limit: policy.rateLimit.executePerMinute, windowMs: 60_000 });
|
||||
const resizeLimiter = new RateLimiter({ limit: policy.rateLimit.resizePerMinute, windowMs: 60_000 });
|
||||
const sendBytesLimiter = new RateLimiter({ limit: policy.rateLimit.sendBytesPerMinute, windowMs: 60_000 });
|
||||
const shareManager = getCliSessionShareManager();
|
||||
|
||||
export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, handlePostRequest, initialPath } = ctx;
|
||||
const manager = getCliSessionManager(process.cwd());
|
||||
const projectRoot = resolveProjectRoot(ctx);
|
||||
const manager = getCliSessionManager(projectRoot);
|
||||
|
||||
// GET /api/cli-sessions
|
||||
if (pathname === '/api/cli-sessions' && req.method === 'GET') {
|
||||
@@ -29,6 +71,15 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
// POST /api/cli-sessions
|
||||
if (pathname === '/api/cli-sessions' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const rate = createLimiter.consume(clientKey(req), 1);
|
||||
if (!rate.ok) {
|
||||
return { error: 'Rate limited', status: 429 };
|
||||
}
|
||||
|
||||
if (policy.maxSessions > 0 && manager.listSessions().length >= policy.maxSessions) {
|
||||
return { error: `Too many sessions (max ${policy.maxSessions})`, status: 429 };
|
||||
}
|
||||
|
||||
const {
|
||||
workingDir,
|
||||
cols,
|
||||
@@ -39,16 +90,41 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
resumeKey
|
||||
} = (body || {}) as any;
|
||||
|
||||
if (tool && typeof tool === 'string') {
|
||||
const normalizedTool = tool.trim();
|
||||
if (!policy.allowedTools.includes(normalizedTool)) {
|
||||
return { error: `Tool not allowed: ${normalizedTool}`, status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
const desiredWorkingDir = workingDir || initialPath;
|
||||
if (typeof desiredWorkingDir !== 'string' || !desiredWorkingDir.trim()) {
|
||||
return { error: 'workingDir is required', status: 400 };
|
||||
}
|
||||
const wdError = validateWorkingDir(projectRoot, desiredWorkingDir, policy.allowWorkingDirOutsideProject);
|
||||
if (wdError) return { error: wdError, status: 400 };
|
||||
|
||||
const session = manager.createSession({
|
||||
workingDir: workingDir || initialPath,
|
||||
workingDir: desiredWorkingDir,
|
||||
cols: typeof cols === 'number' ? cols : undefined,
|
||||
rows: typeof rows === 'number' ? rows : undefined,
|
||||
preferredShell: preferredShell === 'pwsh' ? 'pwsh' : 'bash',
|
||||
tool,
|
||||
tool: typeof tool === 'string' ? tool.trim() : undefined,
|
||||
model,
|
||||
resumeKey
|
||||
});
|
||||
|
||||
appendCliSessionAudit({
|
||||
type: 'session_created',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey: session.sessionKey,
|
||||
tool: session.tool,
|
||||
resumeKey: session.resumeKey,
|
||||
workingDir: session.workingDir,
|
||||
...clientInfo(req),
|
||||
});
|
||||
|
||||
return { success: true, session };
|
||||
});
|
||||
return true;
|
||||
@@ -58,6 +134,17 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
const bufferMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/buffer$/);
|
||||
if (bufferMatch && req.method === 'GET') {
|
||||
const sessionKey = decodeURIComponent(bufferMatch[1]);
|
||||
|
||||
const shareToken = ctx.url.searchParams.get('shareToken');
|
||||
if (shareToken) {
|
||||
const validated = shareManager.validateToken(shareToken, sessionKey);
|
||||
if (!validated || (validated.mode !== 'read' && validated.mode !== 'write')) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: describeShareAuthFailure().error }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const session = manager.getSession(sessionKey);
|
||||
if (!session) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
@@ -69,6 +156,59 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/cli-sessions/:sessionKey/stream (SSE)
|
||||
const streamMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/stream$/);
|
||||
if (streamMatch && req.method === 'GET') {
|
||||
const sessionKey = decodeURIComponent(streamMatch[1]);
|
||||
const shareToken = ctx.url.searchParams.get('shareToken');
|
||||
if (!shareToken) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'shareToken is required' }));
|
||||
return true;
|
||||
}
|
||||
const validated = shareManager.validateToken(shareToken, sessionKey);
|
||||
if (!validated || (validated.mode !== 'read' && validated.mode !== 'write')) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: describeShareAuthFailure().error }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const session = manager.getSession(sessionKey);
|
||||
if (!session) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Session not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
const includeBuffer = ctx.url.searchParams.get('includeBuffer') !== '0';
|
||||
if (includeBuffer) {
|
||||
const buffer = manager.getBuffer(sessionKey);
|
||||
res.write(`event: buffer\ndata: ${JSON.stringify({ sessionKey, buffer })}\n\n`);
|
||||
}
|
||||
|
||||
const unsubscribe = manager.onOutput((event) => {
|
||||
if (event.sessionKey !== sessionKey) return;
|
||||
res.write(`event: output\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
unsubscribe();
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/send
|
||||
const sendMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/send$/);
|
||||
if (sendMatch && req.method === 'POST') {
|
||||
@@ -78,17 +218,69 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
if (typeof text !== 'string') {
|
||||
return { error: 'text is required', status: 400 };
|
||||
}
|
||||
|
||||
const cost = Buffer.byteLength(text, 'utf8');
|
||||
const rate = sendBytesLimiter.consume(clientKey(req), cost);
|
||||
if (!rate.ok) {
|
||||
return { error: 'Rate limited', status: 429 };
|
||||
}
|
||||
|
||||
manager.sendText(sessionKey, text, appendNewline !== false);
|
||||
appendCliSessionAudit({
|
||||
type: 'session_send',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
details: { bytes: cost, appendNewline: appendNewline !== false },
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/share
|
||||
const shareMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/share$/);
|
||||
if (shareMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(shareMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const { mode, ttlMs } = (body || {}) as any;
|
||||
const session = manager.getSession(sessionKey);
|
||||
if (!session) return { error: 'Session not found', status: 404 };
|
||||
|
||||
const shareMode = mode === 'write' ? 'write' : 'read';
|
||||
const safeTtlMs = typeof ttlMs === 'number' ? Math.min(Math.max(60_000, ttlMs), 7 * 24 * 60 * 60_000) : undefined;
|
||||
const token = shareManager.createToken({
|
||||
sessionKey,
|
||||
projectRoot,
|
||||
mode: shareMode,
|
||||
ttlMs: safeTtlMs,
|
||||
});
|
||||
|
||||
appendCliSessionAudit({
|
||||
type: 'session_share_created',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
details: { shareMode, expiresAt: token.expiresAt },
|
||||
});
|
||||
|
||||
return { success: true, shareToken: token.token, expiresAt: token.expiresAt, mode: token.mode };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/execute
|
||||
const executeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/execute$/);
|
||||
if (executeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(executeMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const rate = executeLimiter.consume(clientKey(req), 1);
|
||||
if (!rate.ok) {
|
||||
return { error: 'Rate limited', status: 429 };
|
||||
}
|
||||
|
||||
const {
|
||||
tool,
|
||||
prompt,
|
||||
@@ -106,9 +298,18 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
return { error: 'prompt is required', status: 400 };
|
||||
}
|
||||
const normalizedTool = tool.trim();
|
||||
if (!policy.allowedTools.includes(normalizedTool)) {
|
||||
return { error: `Tool not allowed: ${normalizedTool}`, status: 400 };
|
||||
}
|
||||
|
||||
if (workingDir && typeof workingDir === 'string') {
|
||||
const wdError = validateWorkingDir(projectRoot, workingDir, policy.allowWorkingDirOutsideProject);
|
||||
if (wdError) return { error: wdError, status: 400 };
|
||||
}
|
||||
|
||||
const result = manager.execute(sessionKey, {
|
||||
tool,
|
||||
tool: normalizedTool,
|
||||
prompt,
|
||||
mode,
|
||||
model,
|
||||
@@ -118,6 +319,18 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
resumeStrategy: resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume'
|
||||
});
|
||||
|
||||
appendCliSessionAudit({
|
||||
type: 'session_execute',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
tool: normalizedTool,
|
||||
resumeKey: typeof resumeKey === 'string' ? resumeKey : undefined,
|
||||
workingDir: typeof workingDir === 'string' ? workingDir : undefined,
|
||||
...clientInfo(req),
|
||||
details: { executionId: result.executionId, mode, category, resumeStrategy },
|
||||
});
|
||||
|
||||
return { success: true, ...result };
|
||||
});
|
||||
return true;
|
||||
@@ -128,11 +341,23 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
if (resizeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(resizeMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const rate = resizeLimiter.consume(clientKey(req), 1);
|
||||
if (!rate.ok) {
|
||||
return { error: 'Rate limited', status: 429 };
|
||||
}
|
||||
const { cols, rows } = (body || {}) as any;
|
||||
if (typeof cols !== 'number' || typeof rows !== 'number') {
|
||||
return { error: 'cols and rows are required', status: 400 };
|
||||
}
|
||||
manager.resize(sessionKey, cols, rows);
|
||||
appendCliSessionAudit({
|
||||
type: 'session_resize',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
details: { cols, rows },
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
return true;
|
||||
@@ -143,6 +368,13 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
if (closeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(closeMatch[1]);
|
||||
manager.close(sessionKey);
|
||||
appendCliSessionAudit({
|
||||
type: 'session_closed',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
return true;
|
||||
@@ -150,4 +382,3 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import { randomBytes } from 'crypto';
|
||||
|
||||
// Import health check service
|
||||
import { getHealthCheckService } from './services/health-check-service.js';
|
||||
import { getCliSessionShareManager } from './services/cli-session-share.js';
|
||||
|
||||
// Import status check functions for warmup
|
||||
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
|
||||
@@ -465,6 +466,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
const secretKey = tokenManager.getSecretKey();
|
||||
tokenManager.getOrCreateAuthToken();
|
||||
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
|
||||
const cliSessionShareManager = getCliSessionShareManager();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
|
||||
@@ -521,8 +523,24 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
|
||||
// Authentication middleware for all API routes
|
||||
if (pathname.startsWith('/api/')) {
|
||||
const ok = authMiddleware({ pathname, req, res, tokenManager, secretKey, unauthenticatedPaths });
|
||||
if (!ok) return;
|
||||
let shareBypass = false;
|
||||
const shareToken = url.searchParams.get('shareToken');
|
||||
if (shareToken) {
|
||||
const match = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/(buffer|stream)$/);
|
||||
if (match?.[1]) {
|
||||
const sessionKey = decodeURIComponent(match[1]);
|
||||
const validated = cliSessionShareManager.validateToken(shareToken, sessionKey);
|
||||
if (validated && (validated.mode === 'read' || validated.mode === 'write')) {
|
||||
(req as any).__cliSessionShareProjectRoot = validated.projectRoot;
|
||||
shareBypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shareBypass) {
|
||||
const ok = authMiddleware({ pathname, req, res, tokenManager, secretKey, unauthenticatedPaths });
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF validation middleware for state-changing API routes
|
||||
|
||||
39
ccw/src/core/services/cli-session-audit.ts
Normal file
39
ccw/src/core/services/cli-session-audit.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export type CliSessionAuditEventType =
|
||||
| 'session_created'
|
||||
| 'session_closed'
|
||||
| 'session_send'
|
||||
| 'session_execute'
|
||||
| 'session_resize'
|
||||
| 'session_share_created'
|
||||
| 'session_idle_reaped';
|
||||
|
||||
export interface CliSessionAuditEvent {
|
||||
type: CliSessionAuditEventType;
|
||||
timestamp: string;
|
||||
projectRoot: string;
|
||||
sessionKey?: string;
|
||||
tool?: string;
|
||||
resumeKey?: string;
|
||||
workingDir?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function auditFilePath(projectRoot: string): string {
|
||||
return path.join(projectRoot, '.workflow', 'audit', 'cli-sessions.jsonl');
|
||||
}
|
||||
|
||||
export function appendCliSessionAudit(event: CliSessionAuditEvent): void {
|
||||
try {
|
||||
const filePath = auditFilePath(event.projectRoot);
|
||||
const dir = path.dirname(filePath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(filePath, JSON.stringify(event) + '\n', { encoding: 'utf8' });
|
||||
} catch {
|
||||
// Best-effort: never fail API requests due to audit write errors.
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type CliSessionResumeStrategy
|
||||
} from './cli-session-command-builder.js';
|
||||
import { getCliSessionPolicy } from './cli-session-policy.js';
|
||||
import { appendCliSessionAudit } from './cli-session-audit.js';
|
||||
|
||||
export interface CliSession {
|
||||
sessionKey: string;
|
||||
@@ -147,10 +148,29 @@ export class CliSessionManager {
|
||||
private projectRoot: string;
|
||||
private emitter = new EventEmitter();
|
||||
private maxBufferBytes: number;
|
||||
private idleTimeoutMs: number;
|
||||
private reaperTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.projectRoot = projectRoot;
|
||||
this.maxBufferBytes = getCliSessionPolicy().maxBufferBytes;
|
||||
const policy = getCliSessionPolicy();
|
||||
this.maxBufferBytes = policy.maxBufferBytes;
|
||||
this.idleTimeoutMs = policy.idleTimeoutMs;
|
||||
|
||||
if (this.idleTimeoutMs > 0) {
|
||||
this.reaperTimer = setInterval(() => {
|
||||
const reaped = this.closeIdleSessions(this.idleTimeoutMs);
|
||||
for (const sessionKey of reaped) {
|
||||
appendCliSessionAudit({
|
||||
type: 'session_idle_reaped',
|
||||
timestamp: nowIso(),
|
||||
projectRoot: this.projectRoot,
|
||||
sessionKey,
|
||||
});
|
||||
}
|
||||
}, 60_000);
|
||||
this.reaperTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
listSessions(): CliSession[] {
|
||||
@@ -354,14 +374,14 @@ export class CliSessionManager {
|
||||
return () => this.emitter.off('output', handler);
|
||||
}
|
||||
|
||||
closeIdleSessions(idleTimeoutMs: number): number {
|
||||
if (idleTimeoutMs <= 0) return 0;
|
||||
closeIdleSessions(idleTimeoutMs: number): string[] {
|
||||
if (idleTimeoutMs <= 0) return [];
|
||||
const now = Date.now();
|
||||
let closed = 0;
|
||||
const closed: string[] = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (now - s.lastActivityAt >= idleTimeoutMs) {
|
||||
this.close(s.sessionKey);
|
||||
closed += 1;
|
||||
closed.push(s.sessionKey);
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
|
||||
83
ccw/src/core/services/cli-session-share.ts
Normal file
83
ccw/src/core/services/cli-session-share.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export type CliSessionShareMode = 'read' | 'write';
|
||||
|
||||
export interface CliSessionShareTokenRecord {
|
||||
token: string;
|
||||
sessionKey: string;
|
||||
projectRoot: string;
|
||||
mode: CliSessionShareMode;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface InternalTokenRecord extends CliSessionShareTokenRecord {
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
function createTokenValue(): string {
|
||||
// 32 bytes => 43 chars base64url (approx), safe for URLs.
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
export class CliSessionShareManager {
|
||||
private tokens = new Map<string, InternalTokenRecord>();
|
||||
|
||||
createToken(input: {
|
||||
sessionKey: string;
|
||||
projectRoot: string;
|
||||
mode: CliSessionShareMode;
|
||||
ttlMs?: number;
|
||||
}): CliSessionShareTokenRecord {
|
||||
const ttlMs = typeof input.ttlMs === 'number' ? Math.max(1_000, input.ttlMs) : 24 * 60 * 60_000;
|
||||
const expiresAtMs = Date.now() + ttlMs;
|
||||
const record: InternalTokenRecord = {
|
||||
token: createTokenValue(),
|
||||
sessionKey: input.sessionKey,
|
||||
projectRoot: input.projectRoot,
|
||||
mode: input.mode,
|
||||
expiresAt: new Date(expiresAtMs).toISOString(),
|
||||
expiresAtMs,
|
||||
};
|
||||
this.tokens.set(record.token, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
validateToken(token: string, sessionKey: string): CliSessionShareTokenRecord | null {
|
||||
const record = this.tokens.get(token);
|
||||
if (!record) return null;
|
||||
if (record.sessionKey !== sessionKey) return null;
|
||||
if (Date.now() >= record.expiresAtMs) {
|
||||
this.tokens.delete(token);
|
||||
return null;
|
||||
}
|
||||
const { expiresAtMs: _expiresAtMs, ...publicRecord } = record;
|
||||
return publicRecord;
|
||||
}
|
||||
|
||||
revokeToken(token: string): boolean {
|
||||
return this.tokens.delete(token);
|
||||
}
|
||||
|
||||
cleanupExpired(): number {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
for (const [token, record] of this.tokens) {
|
||||
if (now >= record.expiresAtMs) {
|
||||
this.tokens.delete(token);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: CliSessionShareManager | null = null;
|
||||
|
||||
export function getCliSessionShareManager(): CliSessionShareManager {
|
||||
if (!singleton) singleton = new CliSessionShareManager();
|
||||
return singleton;
|
||||
}
|
||||
|
||||
export function describeShareAuthFailure(): { error: string; status: number } {
|
||||
return { error: 'Invalid or expired share token', status: 403 };
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export class NodeRunner {
|
||||
};
|
||||
}
|
||||
|
||||
const manager = getCliSessionManager(process.cwd());
|
||||
const manager = getCliSessionManager(this.context.workingDir || process.cwd());
|
||||
const routed = manager.execute(targetSessionKey, {
|
||||
tool,
|
||||
prompt: instruction,
|
||||
|
||||
Reference in New Issue
Block a user