diff --git a/README.md b/README.md index 134bbb0..fc7115b 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ The setup script configures these variables: ## How It Works -1. **Proxy** intercepts HTTPS traffic to `api.anthropic.com` and `api.claude.ai` +1. **Proxy** intercepts HTTPS traffic to `api.anthropic.com` or your local llm API endpoint 2. **Interceptor** parses Claude API request/response format 3. **SSE Parser** handles streaming responses in real-time 4. **WebSocket** broadcasts events to connected dashboard clients diff --git a/packages/proxy/src/endpoint-detector.ts b/packages/proxy/src/endpoint-detector.ts new file mode 100644 index 0000000..45975ce --- /dev/null +++ b/packages/proxy/src/endpoint-detector.ts @@ -0,0 +1,163 @@ +import { readFileSync, existsSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +/** + * Endpoint detection configuration + * Reads Claude Code configuration to determine which endpoint to use + */ + +/** + * Endpoint information returned by detectEndpoint() + */ +export interface EndpointInfo { + url: string; + source: 'env_var' | 'claude_settings' | 'default'; + isLocalLlm: boolean; +} + +/** + * Reads ANTHROPIC_BASE_URL environment variable + * @returns The URL if set, null otherwise + */ +export function readEnvVar(): string | null { + const url = process.env.ANTHROPIC_BASE_URL; + if (url && url.trim() !== '') { + return url.trim(); + } + return null; +} + +/** + * Reads ~/.claude/settings.json and extracts ANTHROPIC_BASE_URL field + * @returns The URL if found, null otherwise + */ +export function readClaudeSettings(): string | null { + try { + const settingsPath = join(homedir(), '.claude', 'settings.json'); + if (!existsSync(settingsPath)) { + return null; + } + + const content = readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + + if (settings.env && settings.env.ANTHROPIC_BASE_URL) { + const url = String(settings.env.ANTHROPIC_BASE_URL); + if (url && url.trim() !== '') { + return url.trim(); + } + } + } catch (error) { + // If settings.json is malformed or unreadable, return null + console.debug('Failed to read or parse Claude settings:', error); + } + + return null; +} + +/** + * Determines the endpoint URL based on configuration sources + * Priority: + * 1. ANTHROPIC_BASE_URL environment variable + * 2. ~/.claude/settings.json _ANTHROPIC_BASE_URL field + * 3. Default to https://api.anthropic.com/v1/messages + * + * @returns EndpointInfo with URL, source, and isLocalLlm flag + */ +export function determineEndpoint(): EndpointInfo { + // Check environment variable first + const envUrl = readEnvVar(); + if (envUrl) { + return { + url: envUrl, + source: 'env_var', + isLocalLlm: isLocalLlmUrl(envUrl), + }; + } + + // Check Claude settings file + const settingsUrl = readClaudeSettings(); + if (settingsUrl) { + return { + url: settingsUrl, + source: 'claude_settings', + isLocalLlm: isLocalLlmUrl(settingsUrl), + }; + } + + // Default to Claude API + return { + url: 'https://api.anthropic.com/v1/messages', + source: 'default', + isLocalLlm: false, + }; +} + +/** + * Alias for determineEndpoint() for compatibility + * @returns EndpointInfo + */ +export function detectEndpoint(): EndpointInfo { + return determineEndpoint(); +} + +/** + * Evaluates whether a URL points to the local machine or an address within a local network. + * @param url The URL to check + * @returns true if the URL belongs to localhost or a local network, false otherwise + */ +function isLocalLlmUrl(url: string): boolean { + try { + // Add "http://" if no protocol is specified to prevent the URL parser from failing + const urlString = url.startsWith('http') ? url : `http://${url}`; + const parsed = new URL(urlString); + const hostname = parsed.hostname; + + // 1. Check the local machine itself (localhost and loopback addresses) + if (hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.')) { + return true; + } + + // 2. Check common local and corporate domain endings + // .local (mDNS), .lan, .corp, .internal, .home, .test (RFC 2606) + if ( + hostname.endsWith('.local') || + hostname.endsWith('.lan') || + hostname.endsWith('.corp') || + hostname.endsWith('.internal') || + hostname.endsWith('.home') || + hostname.endsWith('.test') + ) { + return true; + } + + // 3. Check private local network IP addresses (according to RFC 1918 standard) + + // 10.x.x.x subnet + if (hostname.startsWith('10.')) { + return true; + } + + // 192.168.x.x subnet + if (hostname.startsWith('192.168.')) { + return true; + } + + // Subnet from 172.16.x.x to 172.31.x.x + if (hostname.startsWith('172.')) { + const parts = hostname.split('.'); + const secondOctet = parseInt(parts[1], 10); + // Check that the second number in the IP address is between 16 and 31 + if (secondOctet >= 16 && secondOctet <= 31) { + return true; + } + } + + // If no conditions are met, it is considered an external address + return false; + } catch { + // If the URL is so malformed that the parser fails, assume it's external + return false; + } +} \ No newline at end of file diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts index cc71d40..15a1b4b 100644 --- a/packages/proxy/src/index.ts +++ b/packages/proxy/src/index.ts @@ -2,10 +2,11 @@ import { Command } from "commander"; import chalk from "chalk"; import open from "open"; import { loadOrGenerateCA, getCAPath } from "./ca.js"; -import { createProxy } from "./proxy.js"; +import { createProxy, type ProxyServer } from "./proxy.js"; import { WiretapWebSocketServer } from "./websocket.js"; import { createSetupServer, getSetupCommand } from "./setup-server.js"; import { createUIServer } from "./ui-server.js"; +import { detectEndpoint, type EndpointInfo } from "./endpoint-detector.js"; const VERSION = "1.0.10"; @@ -51,8 +52,13 @@ async function main() { // Load or generate CA certificate const ca = await loadOrGenerateCA(); + // Detect endpoint (env var > settings.json > default) + const endpointInfo: EndpointInfo = detectEndpoint(); + console.log(chalk.gray("Endpoint:"), endpointInfo.source, chalk.cyan(endpointInfo.url)); + // Start WebSocket server const wsServer = new WiretapWebSocketServer({ port: wsPort }); + wsServer.setEndpointInfo(endpointInfo); console.log( chalk.green("✓"), `WebSocket server started on port ${chalk.cyan(wsPort)}`, @@ -63,10 +69,11 @@ async function main() { port: proxyPort, ca, wsServer, - }); + endpointInfo, + }) as ProxyServer; // Start setup server (for terminal eval command) - const setupServer = createSetupServer(proxyPort); + const setupServer = createSetupServer(proxyPort, endpointInfo); // Start UI server (serves bundled dashboard) const uiServer = createUIServer({ port: uiPort }); diff --git a/packages/proxy/src/interceptor.ts b/packages/proxy/src/interceptor.ts index 54664bf..f3be637 100644 --- a/packages/proxy/src/interceptor.ts +++ b/packages/proxy/src/interceptor.ts @@ -5,6 +5,7 @@ import type { ClaudeRequest, ClaudeResponse, InterceptedRequest, + EndpointInfo, } from './types.js'; import { SSEStreamParser, reconstructResponseFromEvents } from './parser.js'; import type { WiretapWebSocketServer } from './websocket.js'; @@ -18,24 +19,27 @@ const CLAUDE_MESSAGES_PATH = '/v1/messages'; export class ClaudeInterceptor { private wsServer: WiretapWebSocketServer; + private endpointInfo: EndpointInfo; private activeRequests: Map = new Map(); - constructor(wsServer: WiretapWebSocketServer) { + constructor(wsServer: WiretapWebSocketServer, endpointInfo?: EndpointInfo) { this.wsServer = wsServer; + this.endpointInfo = endpointInfo || { + url: 'https://api.anthropic.com/v1/messages', + source: 'default', + isLocalLlm: false, + }; } isClaudeRequest(request: CompletedRequest): boolean { - const host = request.headers.host || new URL(request.url).host; const path = new URL(request.url).pathname; + const method = request.method; - return ( - CLAUDE_API_HOSTS.some((h) => host.includes(h)) && - path.includes(CLAUDE_MESSAGES_PATH) && - request.method === 'POST' - ); + // Check for Claude API path and POST method only (not host-based filtering) + return path.includes(CLAUDE_MESSAGES_PATH) && method === 'POST'; } async handleRequest(request: CompletedRequest): Promise { @@ -108,6 +112,13 @@ export class ClaudeInterceptor { hasTools ? chalk.yellow(`+ ${requestBody.tools!.length} tools`) : '', isStreaming ? chalk.magenta('streaming') : '' ); + + // Log endpoint info + console.log( + chalk.gray(' Endpoint:'), + chalk.cyan(this.endpointInfo.url), + chalk.gray(`(${this.endpointInfo.source})`) + ); } return requestId; @@ -298,4 +309,8 @@ export class ClaudeInterceptor { getActiveRequestCount(): number { return this.activeRequests.size; } + + getEndpointInfo(): EndpointInfo { + return this.endpointInfo; + } } diff --git a/packages/proxy/src/proxy.ts b/packages/proxy/src/proxy.ts index f14059a..57925fe 100644 --- a/packages/proxy/src/proxy.ts +++ b/packages/proxy/src/proxy.ts @@ -2,7 +2,8 @@ import * as mockttp from 'mockttp'; import chalk from 'chalk'; import { gunzipSync, brotliDecompressSync } from 'zlib'; import type { CAConfig } from './ca.js'; -import { ClaudeInterceptor, CLAUDE_API_HOSTS } from './interceptor.js'; +import { ClaudeInterceptor } from './interceptor.js'; +import { detectEndpoint, type EndpointInfo } from './endpoint-detector.js'; import type { WiretapWebSocketServer } from './websocket.js'; function decompressBody(buffer: Buffer, contentEncoding: string | undefined): string { @@ -22,29 +23,25 @@ function decompressBody(buffer: Buffer, contentEncoding: string | undefined): st return buffer.toString('utf-8'); } -function isAnthropicHost(url: string): boolean { - try { - const host = new URL(url).host; - return CLAUDE_API_HOSTS.some((h) => host.includes(h)); - } catch { - return false; - } -} - export interface ProxyOptions { port: number; ca: CAConfig; wsServer: WiretapWebSocketServer; + endpointInfo?: EndpointInfo; } export interface ProxyServer { server: mockttp.Mockttp; interceptor: ClaudeInterceptor; + endpointInfo: EndpointInfo; stop: () => Promise; } export async function createProxy(options: ProxyOptions): Promise { - const { port, ca, wsServer } = options; + const { port, ca, wsServer, endpointInfo: providedEndpointInfo } = options; + + // Determine endpoint if not provided + const endpointInfo = providedEndpointInfo || detectEndpoint(); const server = mockttp.getLocal({ https: { @@ -55,27 +52,28 @@ export async function createProxy(options: ProxyOptions): Promise { const interceptor = new ClaudeInterceptor(wsServer); - // Track request IDs for matching requests to responses (only for Anthropic requests) + // Track request IDs for matching requests to responses const requestIds = new Map(); - // All requests pass through, but only Anthropic API requests are intercepted + // All requests pass through - we intercept Claude API traffic based on path await server .forAnyRequest() .thenPassThrough({ beforeRequest: async (request) => { - // Quick check - skip non-Anthropic hosts immediately - if (!isAnthropicHost(request.url)) { - return {}; - } - - const requestId = await interceptor.handleRequest(request); - if (requestId) { - requestIds.set(request.id, requestId); + // Check if this is a Claude API request (based on path, not host) + const path = new URL(request.url).pathname; + const isClaudeRequest = path.includes('/v1/messages') && request.method === 'POST'; + + if (isClaudeRequest) { + const requestId = await interceptor.handleRequest(request); + if (requestId) { + requestIds.set(request.id, requestId); + } } return {}; }, beforeResponse: async (response) => { - // Only process if we have a tracked request ID (i.e., it was an Anthropic request) + // Only process if we have a tracked request ID const requestId = requestIds.get(response.id); if (!requestId) { return {}; @@ -109,12 +107,13 @@ export async function createProxy(options: ProxyOptions): Promise { await server.start(port); console.log(chalk.green('✓'), `Proxy server started on port ${chalk.cyan(port)}`); - console.log(chalk.gray(' Intercepting:'), CLAUDE_API_HOSTS.join(', ')); - console.log(chalk.gray(' All other traffic: transparent passthrough')); + console.log(chalk.gray(' Endpoint:'), endpointInfo.source, chalk.cyan(endpointInfo.url)); + console.log(chalk.gray(' All Claude API traffic: intercepted and displayed in UI')); return { server, interceptor, + endpointInfo, stop: async () => { await server.stop(); console.log(chalk.gray('○'), 'Proxy server stopped'); diff --git a/packages/proxy/src/setup-server.ts b/packages/proxy/src/setup-server.ts index 2362e0d..80c112f 100644 --- a/packages/proxy/src/setup-server.ts +++ b/packages/proxy/src/setup-server.ts @@ -1,11 +1,20 @@ import { createServer, type Server } from 'http'; import { getCAPath } from './ca.js'; import chalk from 'chalk'; +import type { EndpointInfo } from './types.js'; const SETUP_PORT = 8082; -function generateSetupScript(proxyPort: number): string { +function generateSetupScript(proxyPort: number, endpointInfo?: EndpointInfo): string { const caPath = getCAPath(); + let endpointDisplay = ''; + + if (endpointInfo) { + const endpointType = endpointInfo.isLocalLlm ? 'Local LLM' : 'Claude API'; + endpointDisplay = ` + Endpoint: ${endpointType} (${endpointInfo.source}) + URL: ${endpointInfo.url}`; + } return `#!/bin/bash # CC Wiretap - Terminal Setup Script @@ -50,7 +59,15 @@ echo "" echo " ✓ CC Wiretap proxy configured for this terminal" echo "" echo " Proxy: http://localhost:${proxyPort}" -echo " CA: ${caPath}" +echo " CA: ${caPath}${endpointDisplay}" +echo "" +echo " To trust the CA certificate (macOS):" +echo " sudo security add-trusted-cert -d -r trustRoot \\\\" +echo " -k /Library/Keychains/System.keychain \\\"${caPath}\\\"" +echo "" +echo " To trust the CA certificate (Linux - Debian/Ubuntu):" +echo " sudo cp \\\"${caPath}\\\" /usr/local/share/ca-certificates/cc-wiretap.crt" +echo " sudo update-ca-certificates" echo "" echo " All HTTP/HTTPS traffic from this terminal will be intercepted." echo " Run 'unset-wiretap' to disable." @@ -68,8 +85,16 @@ export -f unset-wiretap 2>/dev/null || true `; } -function generateFishScript(proxyPort: number): string { +function generateFishScript(proxyPort: number, endpointInfo?: EndpointInfo): string { const caPath = getCAPath(); + let endpointDisplay = ''; + + if (endpointInfo) { + const endpointType = endpointInfo.isLocalLlm ? 'Local LLM' : 'Claude API'; + endpointDisplay = ` + Endpoint: ${endpointType} (${endpointInfo.source}) + URL: ${endpointInfo.url}`; + } return `# CC Wiretap - Fish Shell Setup Script @@ -91,7 +116,7 @@ echo "" echo " ✓ CC Wiretap proxy configured for this terminal" echo "" echo " Proxy: http://localhost:${proxyPort}" -echo " CA: ${caPath}" +echo " CA: ${caPath}${endpointDisplay}" echo "" function unset-wiretap @@ -104,7 +129,7 @@ end `; } -export function createSetupServer(proxyPort: number): Server { +export function createSetupServer(proxyPort: number, endpointInfo?: EndpointInfo): Server { const server = createServer((req, res) => { const url = new URL(req.url || '/', `http://localhost:${SETUP_PORT}`); @@ -119,9 +144,9 @@ export function createSetupServer(proxyPort: number): Server { res.setHeader('Content-Type', 'text/plain; charset=utf-8'); if (shell === 'fish') { - res.end(generateFishScript(proxyPort)); + res.end(generateFishScript(proxyPort, endpointInfo)); } else { - res.end(generateSetupScript(proxyPort)); + res.end(generateSetupScript(proxyPort, endpointInfo)); } } else if (url.pathname === '/status') { res.setHeader('Content-Type', 'application/json'); @@ -129,6 +154,7 @@ export function createSetupServer(proxyPort: number): Server { active: true, proxyPort, caPath: getCAPath(), + endpointInfo, })); } else { res.statusCode = 404; diff --git a/packages/proxy/src/types.ts b/packages/proxy/src/types.ts index f473b48..3aa6d4c 100644 --- a/packages/proxy/src/types.ts +++ b/packages/proxy/src/types.ts @@ -196,13 +196,21 @@ export type WSMessage = | WSResponseComplete | WSError | WSClearAll - | WSHistorySync; + | WSHistorySync + | WSSessionStart; export interface WSHistorySync { type: 'history_sync'; requests: InterceptedRequest[]; } +export interface WSSessionStart { + type: 'session_start'; + sessionId: string; + timestamp: number; + endpointInfo: EndpointInfo; +} + export interface WSClearAll { type: 'clear_all'; } @@ -268,3 +276,9 @@ export interface InterceptedRequest { durationMs?: number; error?: string; } + +export interface EndpointInfo { + url: string; + source: 'env_var' | 'claude_settings' | 'default'; + isLocalLlm: boolean; +} diff --git a/packages/proxy/src/websocket.ts b/packages/proxy/src/websocket.ts index bf09d0f..90a1d26 100644 --- a/packages/proxy/src/websocket.ts +++ b/packages/proxy/src/websocket.ts @@ -1,6 +1,6 @@ import { WebSocketServer, WebSocket } from 'ws'; import type { Server } from 'http'; -import type { WSMessage, InterceptedRequest } from './types.js'; +import type { WSMessage, InterceptedRequest, EndpointInfo } from './types.js'; import chalk from 'chalk'; export interface WiretapWebSocketServerOptions { @@ -12,6 +12,7 @@ export class WiretapWebSocketServer { private wss: WebSocketServer; private clients: Set = new Set(); private requests: Map = new Map(); + private endpointInfo: EndpointInfo | null = null; constructor(options: WiretapWebSocketServerOptions = {}) { if (options.server) { @@ -57,7 +58,21 @@ export class WiretapWebSocketServer { }); } + setEndpointInfo(info: EndpointInfo): void { + this.endpointInfo = info; + } + private sendCurrentState(ws: WebSocket): void { + // Send session start with endpoint info first + if (this.endpointInfo) { + this.sendToClient(ws, { + type: 'session_start', + sessionId: Date.now().toString(), + timestamp: Date.now(), + endpointInfo: this.endpointInfo, + }); + } + // Send all existing requests in a single message for fast initial load if (this.requests.size > 0) { this.sendToClient(ws, { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index bab7e22..f551375 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1,12 +1,13 @@ import { useCallback } from 'react'; -import { Trash2, PanelLeftClose, PanelLeft, ChevronsDownUp, ChevronsUpDown, Keyboard, Loader2, Cpu, MessageSquare, Clock, ArrowUp, ArrowDown, BookOpen, PenLine, Database, Wifi, WifiOff, CircleAlert } from 'lucide-react'; +import { Trash2, PanelLeftClose, PanelLeft, ChevronsDownUp, ChevronsUpDown, Keyboard, Loader2, Cpu, MessageSquare, Clock, ArrowUp, ArrowDown, BookOpen, PenLine, Database, Wifi, WifiOff, CircleAlert, Server } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { HotkeysDialog } from '@/components/ui/hotkeys-dialog'; -import { useConnectionStatus, useSidebarVisible, useShowClearDialog, useShowHotkeysDialog, useAppStore, useSelectedRequest } from '@/stores/appStore'; +import { useConnectionStatus, useSidebarVisible, useShowClearDialog, useShowHotkeysDialog, useAppStore, useSelectedRequest, useEndpointInfo } from '@/stores/appStore'; import { sendWebSocketMessage, reconnectWebSocket } from '@/hooks/useWebSocket'; import { formatDuration, extractModelName, formatTokenCount } from '@/lib/utils'; import type { ConnectionStatus } from '@/lib/types'; +import { Badge } from '@/components/ui/badge'; const statusConfig: Record = { connected: { label: 'Connected', color: 'text-emerald-500', clickable: false }, @@ -26,6 +27,7 @@ export function Header() { const triggerExpandAll = useAppStore((state) => state.triggerExpandAll); const triggerCollapseAll = useAppStore((state) => state.triggerCollapseAll); const selectedRequest = useSelectedRequest(); + const endpointInfo = useEndpointInfo(); const { label, color, clickable } = statusConfig[connectionStatus]; // Extract request info @@ -164,6 +166,21 @@ export function Header() { )} + + {endpointInfo && ( + <> + | +
+ + + {endpointInfo.isLocalLlm ? 'Local LLM' : 'Claude API'} + +
+ + )} )} diff --git a/packages/ui/src/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx index 6c52b7d..81bd6b8 100644 --- a/packages/ui/src/components/ui/badge.tsx +++ b/packages/ui/src/components/ui/badge.tsx @@ -14,6 +14,8 @@ const badgeVariants = cva( success: 'border-transparent bg-green-600 text-white', warning: 'border-transparent bg-yellow-600 text-white', info: 'border-transparent bg-blue-600 text-white', + local_llm: 'border-transparent bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20', + claude_api: 'border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20', }, }, defaultVariants: { diff --git a/packages/ui/src/lib/types.ts b/packages/ui/src/lib/types.ts index 11e5464..610954d 100644 --- a/packages/ui/src/lib/types.ts +++ b/packages/ui/src/lib/types.ts @@ -202,13 +202,21 @@ export type WSMessage = | WSResponseComplete | WSError | WSClearAll - | WSHistorySync; + | WSHistorySync + | WSSessionStart; export interface WSHistorySync { type: 'history_sync'; requests: Request[]; } +export interface WSSessionStart { + type: 'session_start'; + sessionId: string; + timestamp: number; + endpointInfo: EndpointInfo; +} + export interface WSClearAll { type: 'clear_all'; } @@ -259,6 +267,13 @@ export interface WSError { // UI State Types +// Endpoint Information +export interface EndpointInfo { + url: string; + source: 'env_var' | 'claude_settings' | 'default'; + isLocalLlm: boolean; +} + export interface Request { id: string; timestamp: number; diff --git a/packages/ui/src/stores/appStore.ts b/packages/ui/src/stores/appStore.ts index 4f7ba26..ab136a0 100644 --- a/packages/ui/src/stores/appStore.ts +++ b/packages/ui/src/stores/appStore.ts @@ -4,6 +4,7 @@ import type { ConnectionStatus, WSMessage, RateLimitInfo, + EndpointInfo, } from '@/lib/types'; interface AppState { @@ -11,6 +12,10 @@ interface AppState { connectionStatus: ConnectionStatus; setConnectionStatus: (status: ConnectionStatus) => void; + // Endpoint info + endpointInfo: EndpointInfo | null; + setEndpointInfo: (info: EndpointInfo | null) => void; + // Rate limits rateLimitInfo: RateLimitInfo | null; @@ -82,6 +87,10 @@ export const useAppStore = create((set, get) => ({ connectionStatus: 'disconnected', setConnectionStatus: (status) => set({ connectionStatus: status }), + // Endpoint info + endpointInfo: null, + setEndpointInfo: (info) => set({ endpointInfo: info }), + // Rate limits rateLimitInfo: null, @@ -131,6 +140,14 @@ export const useAppStore = create((set, get) => ({ const state = get(); switch (message.type) { + case 'session_start': { + // Set endpoint info from session message + if (message.endpointInfo) { + set({ endpointInfo: message.endpointInfo }); + } + break; + } + case 'request_start': { const newRequests = new Map(state.requests); newRequests.set(message.requestId, { @@ -300,3 +317,4 @@ export const useSelectedRequest = () => { return requests.get(selectedRequestId) || null; }; export const useRateLimitInfo = () => useAppStore((state) => state.rateLimitInfo); +export const useEndpointInfo = () => useAppStore((state) => state.endpointInfo);