diff --git a/docs/ARCHITECTURE-PROXY.md b/docs/ARCHITECTURE-PROXY.md index a7242b0f5..caa669f80 100644 --- a/docs/ARCHITECTURE-PROXY.md +++ b/docs/ARCHITECTURE-PROXY.md @@ -1,7 +1,7 @@ # CodeMie Proxy Architecture -**Version**: 1.0 -**Date**: 2025-12-11 +**Version**: 2.0 +**Date**: 2026-09-10 **Status**: Production --- @@ -27,22 +27,25 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between AI coding agents and their target API endpoints. It enables: -- **SSO Authentication**: Automatic cookie injection for enterprise SSO +- **SSO / JWT Authentication**: Automatic cookie or bearer-token injection for enterprise auth +- **Local Gateway Auth**: Static bearer key validation for daemon-mode clients (e.g. Claude Desktop, VS Code BYOK) - **MCP Authorization**: OAuth proxy for remote MCP servers with SSRF protection +- **Request Normalization & Sanitization**: Per-agent body fixes (Claude thinking params, Kimi token caps, Codex model mapping, encrypted reasoning-state replay/retry, VS Code user-id constraints) - **Header Management**: CodeMie-specific header injection for traceability - **Observability**: Detailed logging and metrics collection -- **Metrics Sync**: Background sync of session metrics to CodeMie API +- **Session Sync**: Background sync of session metrics _and_ conversations to the CodeMie API - **Desktop Telemetry**: Local Claude Desktop 3P transcript discovery and conversation sync when daemon mode is enabled - **VS Code BYOK**: Profile-configured OpenAI-compatible custom endpoints with transparent forwarding +- **Self-Healing Daemon**: Background watcher that detects a dead/unhealthy proxy and restarts it in-process on the same pinned port - **Extensibility**: Plugin architecture for future features ### 1.2 Key Design Principles -* ✅ **KISS (Keep It Simple)**: Core does ONE thing - forwards HTTP with streaming -* ✅ **SOLID**: Single Responsibility, Open/Closed via plugins, Dependency Injection -* ✅ **Zero Buffering**: True HTTP streaming with no body buffering -* ✅ **Plugin-Based**: Core is stable, features added via plugins -* ✅ **Fail-Safe**: Plugin failures don't break proxy flow +- ✅ **KISS (Keep It Simple)**: Core does ONE thing - forwards HTTP with streaming +- ✅ **SOLID**: Single Responsibility, Open/Closed via plugins, Dependency Injection +- ✅ **Zero Buffering**: True HTTP streaming with no body buffering (buffering is an opt-in exception for a few auth/reasoning-state hooks, see §6.5 and §6.9) +- ✅ **Plugin-Based**: Core is stable, features added via plugins +- ✅ **Fail-Safe**: Plugin failures don't break proxy flow --- @@ -53,7 +56,7 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A ``` ┌─────────────────────────────────────────────────────────────────┐ │ AI Coding Agent │ -│ (claude, gemini, etc.) │ +│ (claude, codex, gemini, kimi, vscode-byok, ...) │ └────────────────────────────┬────────────────────────────────────┘ │ HTTP Request ▼ @@ -63,19 +66,28 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A │ ┌────────────────────────────────────────────────────────────┐ │ │ │ Plugin System (Priority-Based) │ │ │ │ │ │ -│ │ [3] MCP Auth Plugin → MCP OAuth proxy & URL rewrite│ │ -│ │ [10] SSO Auth Plugin → Inject cookies │ │ -│ │ [20] Header Injection → Add X-CodeMie headers │ │ -│ │ [50] Logging Plugin → Log requests/responses │ │ -│ │ [100] Metrics Sync Plugin → Background metrics sync │ │ +│ │ [3] MCP Auth → MCP OAuth proxy & URL rewrite│ +│ │ [5] Endpoint Blocker → block unwanted endpoints early│ +│ │ [7] Gateway Key → validate local daemon bearer key│ +│ │ [10] SSO Auth / JWT Auth → Inject cookies / bearer token│ +│ │ [14] Claude/Kimi/Codex Normalizers → per-agent body fixes │ +│ │ [15] Request Sanitizer → strip unsupported reasoning params│ +│ │ [16] Codex/Copilot Encrypted-Content Sanitizers → reasoning replay retry│ +│ │ [17] VS Code Request Normalizer → constrain user identifiers│ +│ │ [20] Header Injection → Add X-CodeMie headers │ +│ │ [50] Logging → Log requests/responses │ +│ │ [100] SSO Session Sync → Background metrics + conversation sync│ │ └────────────────────────────────────────────────────────────┘ │ │ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ HTTP Streaming Core │ │ │ │ • Build context │ │ +│ │ • /health, /healthz liveness probe (pre-auth, pre-hooks) │ │ +│ │ • Run handleRequest hooks (full-bypass, e.g. MCP relay) │ │ │ │ • Run onRequest hooks │ │ │ │ • Forward to upstream (no buffering) │ │ -│ │ • Run onResponseHeaders hooks │ │ +│ │ • Run onUpstreamResponse hooks (optional buffered retry) │ │ +│ │ • Run onResponseHeaders hooks │ │ │ │ • Stream response chunks (with optional transform) │ │ │ │ • Run onResponseComplete hooks │ │ │ └────────────────────────────────────────────────────────────┘ │ @@ -116,7 +128,7 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A │ Layer 4: HTTP Forwarding (ProxyHTTPClient) │ │ • Upstream connection management │ │ • True HTTP streaming (no buffering) │ -│ • SSL/TLS handling │ +│ • SSL/TLS handling │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -124,83 +136,99 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A ## 3. Core Components +> All proxy source lives under `src/providers/plugins/sso/proxy/` (proxy core + plugins are shipped as part of the `sso` provider plugin, not a standalone top-level module). + ### 3.1 CodeMieProxy -**Location**: `src/utils/codemie-proxy.ts` +**Location**: `src/providers/plugins/sso/proxy/sso.proxy.ts` **Responsibilities**: + - HTTP server lifecycle (start/stop) - Request routing to plugins - Error handling and recovery -- Port management (dynamic allocation) +- Port management (dynamic allocation, or pinned-port EADDRINUSE retry for daemon restarts) +- `/health` and `/healthz` liveness probe, answered before authentication and before any plugin hook **Key Operations**: + - Initialize plugins and start server -- Graceful shutdown with plugin cleanup -- Main request handler -- Streaming response handler - -**Configuration Parameters**: -- Target API URL (upstream endpoint) -- Local port (0 = dynamic allocation) -- Client type (agent identifier) -- Timeout duration -- Profile name, provider, model -- Session ID +- Graceful shutdown with plugin cleanup (force-drains keep-alive sockets before closing, so a persistent client like Claude Desktop can't hang an in-process restart) +- Main request handler (`handleRequest`) +- Streaming response handler (`streamResponse`) + +**Configuration Parameters** (`ProxyConfig`, `src/providers/plugins/sso/proxy/proxy-types.ts`): + +- Target API URL (upstream endpoint), host/port (0 = dynamic allocation), `pinnedPort` (retry same port on EADDRINUSE instead of falling back to random) +- Client type (agent identifier), timeout duration +- Profile name, provider, model, integration ID +- Session ID, CLI version +- `authMethod` (`'sso' | 'jwt'`), `jwtToken` +- `repository` / `branch` / `project` (header injection), `gatewayKey` (local daemon auth) +- `syncApiUrl` / `syncCodeMieUrl` (session sync target and credential lookup, independent of the main upstream) +- `telemetryMode` (`'none' | 'claude-desktop'`) and related poll/inactivity timeouts ### 3.2 PluginRegistry -**Location**: `src/proxy/plugins/registry.ts` +**Location**: `src/providers/plugins/sso/proxy/plugins/registry.ts` **Responsibilities**: + - Plugin registration and storage - Dependency resolution - Priority-based sorting (0-1000) - Lifecycle hook invocation **Key Operations**: + - Register plugins at startup -- Initialize plugins with context +- Initialize plugins with context (`getPluginRegistry()` / `resetPluginRegistry()`) - Enable/disable plugins at runtime - Retrieve plugin configurations -**Plugin Priority Levels**: +**Plugin Priority Levels** (current, see §6 for the full plugin list): + - **0-3**: MCP protocol handling (MCP Auth: 3) -- **4-10**: Authentication and security (SSO Auth: 10) -- **11-50**: Header manipulation (Header Injection: 20) -- **51-100**: Observability (Logging: 50, Metrics Sync: 100) -- **101-500**: Business logic (rate limiting, caching) -- **501-1000**: Post-processing (analytics, reporting) +- **4-10**: Endpoint blocking, gateway/local auth, and upstream authentication (Endpoint Blocker: 5, Gateway Key: 7, SSO Auth: 10, JWT Auth: 10) +- **11-19**: Per-agent request normalization and sanitization (Claude/Kimi/Codex Normalizers: 14, Request Sanitizer: 15, Codex/Copilot Encrypted-Content Sanitizers: 16, VS Code Request Normalizer: 17) +- **20-50**: Header manipulation and observability (Header Injection: 20, Logging: 50) +- **100**: Session sync (SSO Session Sync: 100) + +Note: priorities 14-17 do request-_body_ normalization/sanitization, not header work — despite falling inside what an older version of this doc called the "header manipulation" band. Priority bands are a loose grouping, not a hard contract; always check the actual `priority` field and registration comments in `plugins/index.ts` before assuming a band's purpose. ### 3.3 ProxyHTTPClient -**Location**: `src/proxy/http-client.ts` +**Location**: `src/providers/plugins/sso/proxy/proxy-http-client.ts` **Responsibilities**: + - HTTP/HTTPS forwarding with streaming - Connection pooling - Timeout management - SSL/TLS certificate handling **Features**: + - Zero buffering (streams directly) - Async iteration over response chunks -- Custom SSL/TLS options (self-signed certs) +- Custom SSL/TLS options (self-signed certs; `rejectUnauthorized: false` by default) - Configurable timeouts +- `readResponseBody()` helper used by plugins that need to buffer-and-retry (see `onUpstreamResponse` in §4.1) ### 3.4 ProxyContext -**Location**: `src/proxy/types.ts` +**Location**: `src/providers/plugins/sso/proxy/proxy-types.ts` **Purpose**: Shared state across all plugin hooks for a single request **Context Attributes**: -- **Identity**: Request ID, Session ID, Agent name + +- **Identity**: Request ID, Session ID, Agent name (`agentName`) - **Traceability**: Profile, Provider, Model -- **Request Details**: Method, URL, Headers, Body +- **Request Details**: Method, URL, Headers, Body (`Buffer`, to preserve byte integrity for multi-byte UTF-8) - **Timing**: Request start time - **Upstream**: Target URL -- **Extensibility**: Metadata dictionary for plugin-specific data +- **Extensibility**: Metadata dictionary for plugin-specific data (also used for the `blocked` / `blockedResponseBody` short-circuit, and `gatewayKeyValidated` flag) --- @@ -210,21 +238,28 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A **Design Pattern**: Chain of Responsibility + Observer -**Plugin Interface**: +**Plugin Interface** (`ProxyPlugin`, `src/providers/plugins/sso/proxy/plugins/types.ts`): + - **Metadata**: ID, name, version, priority, dependencies -- **Factory Method**: Creates interceptor instance with context +- **Factory Method**: `createInterceptor()` — creates interceptor instance with context - **Lifecycle Hooks**: Install, uninstall, enable, disable -**Interceptor Interface**: -- **Proxy Lifecycle**: onProxyStart, onProxyStop -- **Request Lifecycle**: onRequest, onResponseHeaders, onResponseChunk, onResponseComplete, onError +**Interceptor Interface** (`ProxyInterceptor`): + +- **Proxy Lifecycle**: `onProxyStart`, `onProxyStop` +- **Full Request Bypass**: `handleRequest` — optional hook checked in priority order _before_ the standard pipeline; if a plugin returns `true`, it has fully handled the request (response written) and the entire onRequest → forward → onResponseHeaders → stream → onResponseComplete pipeline is skipped. Used for traffic that doesn't go to the configured `targetApiUrl` at all (e.g. MCP Auth's `/mcp_auth` and `/mcp_relay/...` routes to arbitrary MCP servers). The handling plugin owns all security guarantees (SSRF checks etc.) for its own traffic. +- **Request Lifecycle**: `onRequest`, `onUpstreamResponse`, `onResponseHeaders`, `onResponseChunk`, `onResponseComplete`, `onError` + +`onUpstreamResponse(context, upstreamResponse, tools)` runs immediately after the upstream call and before header/streaming hooks. `tools` gives a plugin `readBody()` (buffer the full response), `retry()` (re-issue the same request, e.g. after stripping now-rejected state), and `fromBuffer()` (turn a buffered `Buffer` back into a stream-shaped `IncomingMessage`). Only the Copilot encrypted-content sanitizer uses this hook (see §6.8) — the Codex one instead watches `onResponseChunk` and flips a permanent `onRequest`-time flag; see §6.8 for why these two plugins differ. + +**`handleRequest` is not limited to non-upstream traffic.** In addition to MCP Auth's full off-upstream relay (§6.1), the Gateway Key Plugin (§6.3) also implements `handleRequest` — it runs _before_ `onRequest`, validating the local daemon bearer key and returning `true` with a 401 body on failure, or `false` to let the same upstream-bound request continue into the normal `onRequest` pipeline. So `handleRequest` is used both for "this traffic never reaches `targetApiUrl`" (MCP Auth) and "reject this upstream-bound request early, before auth injection" (Gateway Key) — check each plugin's own hook rather than assuming from the hook name alone. ### 4.2 Plugin Lifecycle ``` ┌──────────────────────────────────────────────────────────────┐ │ Application Startup │ -│ ├─ Import: src/proxy/plugins/index.ts │ +│ ├─ Import: src/providers/plugins/sso/proxy/plugins/index.ts │ │ ├─ Auto-register: registerCorePlugins() │ │ └─ Plugins registered in PluginRegistry │ └──────────────────────────────────────────────────────────────┘ @@ -233,21 +268,27 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A ┌──────────────────────────────────────────────────────────────┐ │ Proxy Start (per session) │ │ ├─ CodeMieProxy.start() called │ -│ ├─ Build PluginContext (config, credentials) │ +│ ├─ Build PluginContext (config, credentials, profileConfig) │ │ ├─ PluginRegistry.initialize(context) │ -│ │ ├─ Filter enabled plugins │ +│ │ ├─ Filter enabled plugins (createInterceptor may throw │ +│ │ │ ConfigurationError to opt itself out silently) │ │ │ ├─ Sort by priority │ │ │ ├─ Call createInterceptor() for each │ │ │ └─ Return sorted interceptor list │ -│ └─ Call onProxyStart() on all interceptors │ +│ ├─ Bind server (dynamic port, or pinned-port EADDRINUSE retry)│ +│ └─ Call onProxyStart() on all interceptors, then resolve │ +│ (only once the final bound port is known) │ └──────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────┐ │ Request Handling (per request) │ +│ ├─ /health, /healthz short-circuit (no context, no hooks) │ │ ├─ Build ProxyContext │ -│ ├─ onRequest() hooks (all interceptors) │ +│ ├─ handleRequest() hooks (full bypass, priority order) │ +│ ├─ onRequest() hooks (all interceptors, early-exit if blocked)│ │ ├─ Forward to upstream │ +│ ├─ onUpstreamResponse() hooks (optional buffer/retry) │ │ ├─ onResponseHeaders() hooks │ │ ├─ Stream response with onResponseChunk() hooks │ │ └─ onResponseComplete() hooks │ @@ -258,32 +299,35 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A │ Proxy Stop (per session) │ │ ├─ CodeMieProxy.stop() called │ │ ├─ Call onProxyStop() on all interceptors │ -│ └─ Cleanup resources │ +│ └─ Cleanup resources │ └──────────────────────────────────────────────────────────────┘ ``` ### 4.3 Plugin Registration Pattern **Auto-Registration**: -- Plugins register themselves on module import -- Core plugins registered in central index file -- Registry maintains plugin instances and configurations -**Manual Registration**: -- Runtime registration for conditional plugins -- Used for feature flags or environment-specific plugins +- Plugins register themselves on module import via `registerCorePlugins()`, called at the bottom of `plugins/index.ts` +- Any consumer that imports `plugins/index.ts` (the proxy core itself, or `bin/proxy-daemon.ts`) triggers registration as a side effect + +**Opt-Out via createInterceptor()**: + +- A plugin opts itself out for a given session by throwing `ConfigurationError` from `createInterceptor()` (e.g. SSO Session Sync throws if there's no session ID, credentials aren't SSO, or sync is disabled by config/env var) rather than through a separate enable/disable flag ### 4.4 Error Handling **Fail-Safe Design**: Plugin errors don't break proxy flow **Error Handling Strategy**: -- Try-catch wrapper around all plugin hooks + +- Try-catch wrapper around all plugin hooks (`runHook()` in `sso.proxy.ts`) - Errors logged for debugging - Execution continues with remaining interceptors - Graceful degradation ensures proxy availability +- Exception: `handleRequest` hook errors are routed through the normal error pipeline (`onError` interceptors run) instead of being swallowed, since a full-bypass plugin failing usually means the request truly cannot be served **Benefits**: + - One misbehaving plugin doesn't crash the proxy - Full error context captured in logs - System remains operational under failure conditions @@ -301,15 +345,31 @@ Detailed Flow: 1. Agent sends HTTP request to localhost:PORT 2. Proxy receives request - ├─ Build ProxyContext (requestId, sessionId, headers, body) - ├─ Run onRequest() hooks (priority order) - │ ├─ SSOAuthPlugin: Inject cookies - │ ├─ HeaderInjectionPlugin: Add X-CodeMie headers - │ └─ LoggingPlugin: Log request + ├─ Short-circuit /health, /healthz (before context/auth/hooks) + ├─ Build ProxyContext (requestId, sessionId, agentName, headers, body) + ├─ Run handleRequest() hooks in priority order — a plugin here can either + │ fully handle the request (MCPAuthPlugin relaying to an MCP server, + │ short-circuiting the rest of this flow entirely) or reject it early + │ (GatewayKeyPlugin returning a 401 before auth injection runs); any + │ plugin returning false here falls through to onRequest() below + ├─ Run onRequest() hooks (priority order), e.g.: + │ ├─ EndpointBlockerPlugin: reject/short-circuit unwanted endpoints + │ ├─ SSOAuthPlugin / JWTAuthPlugin: inject cookies / bearer token + │ ├─ Claude/Kimi/Codex request normalizers: fix up model-specific body fields + │ ├─ RequestSanitizerPlugin: strip unsupported reasoning params + │ ├─ Codex/Copilot encrypted-content sanitizers: forward reasoning state as-is + │ ├─ VsCodeRequestNormalizerPlugin: constrain user identifiers + │ ├─ HeaderInjectionPlugin: add X-CodeMie headers + │ └─ LoggingPlugin: log request + ├─ Check context.metadata.blocked — if set by any onRequest hook, respond + │ immediately (200 + configured body) without contacting upstream ├─ Build target URL (targetApiUrl + request path) └─ Forward to upstream via ProxyHTTPClient 3. Upstream responds + ├─ Run onUpstreamResponse() hooks — most plugins pass the stream through + │ untouched; the encrypted-content sanitizers may buffer, detect a + │ reasoning-replay rejection, strip the offending state, and retry here ├─ Receive response headers ├─ Run onResponseHeaders() hooks │ └─ LoggingPlugin: Log response headers @@ -324,7 +384,7 @@ Detailed Flow: 5. Response complete ├─ Run onResponseComplete() hooks │ ├─ LoggingPlugin: Log final stats - │ └─ MetricsSyncPlugin: No-op (runs on timer) + │ └─ SSOSessionSyncPlugin: No-op (runs on timer) └─ Close connection ``` @@ -334,7 +394,7 @@ Detailed Flow: Client → Proxy → Error → Proxy → Client Error Handling: -1. Error occurs (network, timeout, upstream error) +1. Error occurs (network, timeout, upstream error, or a handleRequest hook throwing) 2. Proxy catches error ├─ Check if client disconnected (abort error) @@ -363,66 +423,228 @@ Streaming Strategy: - Optional transformation via plugin hooks - Immediate write to client (no accumulation) - Constant memory footprint +- Downstream disconnect is detected mid-stream and iteration stops early Benefits: - ~90% less memory usage (no buffering) - Constant memory regardless of response size - True streaming for SSE and long responses - Real-time data delivery + +Exceptions (deliberate, narrow buffering): +- MCP Auth buffers small JSON auth-metadata responses to rewrite embedded URLs (§6.5) +- Codex/Copilot encrypted-content sanitizers buffer a response only after detecting a + reasoning-state replay rejection, to retry with state stripped (§6.9) ``` --- ## 6. Plugin Implementations -### 6.1 SSO Auth Plugin +All plugin files below live under `src/providers/plugins/sso/proxy/plugins/`. Registration order and priority comments are the source of truth in `plugins/index.ts`. + +### 6.1 MCP Auth Plugin + +**Priority**: 3 (runs before all other plugins) +**File**: `mcp-auth.plugin.ts` + +**Purpose**: Proxy MCP OAuth authorization flows through the CodeMie proxy so that all auth traffic is routed centrally and `client_name` can be overridden via the `MCP_CLIENT_NAME` environment variable. + +#### 6.1.1 URL Scheme + +The plugin intercepts two URL patterns via its `handleRequest` hook (full bypass of the standard pipeline): + +| Route | Pattern | Purpose | +| ----------- | ------------------------------------------ | ------------------------------------------- | +| **Initial** | `/mcp_auth?original=` | First MCP connection — starts an OAuth flow | +| **Relay** | `/mcp_relay///` | Subsequent requests routed through proxy | -**Priority**: 10 (must run first) -**File**: `src/proxy/plugins/sso-auth.plugin.ts` +- `root_b64`: Base64url-encoded root MCP server origin (for per-flow isolation) +- `relay_b64`: Base64url-encoded actual target origin (may differ when auth server is on a separate host) + +#### 6.1.2 Request Handling + +**`/mcp_auth` route:** + +1. Extract `original` query parameter (the real MCP server URL) +2. Validate URL (SSRF check) +3. Forward request to the target MCP server +4. Buffer the JSON response and rewrite all discovered URLs to proxy relay URLs +5. Return the rewritten response to the MCP client + +**`/mcp_relay` route:** + +1. Decode `root_b64` and `relay_b64` to recover target origin +2. Validate root-relay association (per-flow origin scoping) +3. Reconstruct the full target URL from relay origin + path + query +4. Forward request to the real target +5. Buffer JSON auth metadata responses and rewrite URLs; stream all other responses + +#### 6.1.3 Response URL Rewriting + +The plugin buffers JSON responses (auth metadata, client registration, etc.) and rewrites all absolute HTTP(S) URLs found in JSON values to proxy relay URLs. This ensures the MCP client routes all subsequent requests through the proxy. + +**Exceptions**: Token audience identifiers (e.g., `resource` field) are not rewritten — they are logical identifiers, not URLs to access. + +**Browser endpoints** (e.g., `authorization_endpoint`) are left as-is so the user's browser navigates directly to the auth server. + +#### 6.1.4 Security + +**SSRF Protection:** + +- Private/loopback IP addresses are rejected (both literal hostname check and DNS resolution) +- Only `http:` and `https:` schemes are allowed + +**Per-Flow Origin Scoping:** + +- Discovered origins (from auth metadata) are tagged with their root MCP server origin +- Relay requests validate that the relay origin is associated with the claimed root origin +- Prevents cross-flow origin confusion + +**Buffering Policy:** + +- Only auth metadata responses are buffered (for URL rewriting) +- Post-auth MCP traffic streams through without buffering + +#### 6.1.5 Companion Components + +The MCP Auth Plugin works in conjunction with the stdio-to-HTTP bridge: + +| Component | File | Purpose | +| ----------------- | ------------------------------------ | ------------------------------------------------------------- | +| Stdio-HTTP Bridge | `src/mcp/stdio-http-bridge.ts` | Bridges stdio JSON-RPC to streamable HTTP transport | +| OAuth Provider | `src/mcp/auth/mcp-oauth-provider.ts` | Implements `OAuthClientProvider` for browser-based OAuth flow | +| Callback Server | `src/mcp/auth/callback-server.ts` | Ephemeral localhost server for receiving OAuth callbacks | +| Proxy Logger | `src/mcp/proxy-logger.ts` | File-based logger for proxy operations | +| Constants | `src/mcp/constants.ts` | `MCP_CLIENT_NAME` default and accessor | + +#### 6.1.6 Configuration + +**Environment Variables:** + +- `MCP_CLIENT_NAME`: Client name for OAuth Dynamic Client Registration (default: `CodeMie CLI`) +- `MCP_PROXY_DEBUG`: Enable verbose proxy logging +- `CODEMIE_PROXY_PORT`: Fixed proxy port (for stable MCP auth URLs across restarts) + +**Log Location**: `~/.codemie/logs/mcp-proxy.log` + +### 6.2 Endpoint Blocker Plugin + +**Priority**: 5 +**File**: `endpoint-blocker.plugin.ts` + +**Purpose**: Blocks unwanted upstream endpoints early, before any auth or normalization work runs, by short-circuiting via `context.metadata.blocked` (see §5.1 step 2). + +### 6.3 Gateway Key Plugin + +**Priority**: 7 +**File**: `gateway-key.plugin.ts` + +**Purpose**: Validates a static local bearer key (`gatewayKey` on `ProxyConfig`) for daemon-mode clients (Claude Desktop, VS Code BYOK) so they authenticate to the local proxy without ever seeing real SSO/JWT credentials. Strips the header before the request is forwarded upstream. + +**Hook used**: `handleRequest`, not `onRequest` — this plugin runs in the earlier full-bypass phase (§4.1) so an invalid key is rejected (401) before any auth-injection or normalization plugin sees the request. On a valid key it returns `false` and the request falls through to the normal `onRequest` pipeline. + +### 6.4 SSO Auth Plugin + +**Priority**: 10 (must run early, alongside JWT Auth) +**File**: `sso-auth.plugin.ts` **Purpose**: Inject SSO cookies into requests for enterprise authentication **Behavior**: + - Reads cookies from PluginContext credentials - Builds Cookie header from key-value pairs - Only runs when SSO credentials present - Executes in onRequest() hook **Architecture**: + - Single responsibility: Cookie injection - No state maintained between requests - Fails if credentials missing -### 6.2 Header Injection Plugin +### 6.5 JWT Auth Plugin + +**Priority**: 10 (alternative to SSO Auth — mutually exclusive per `ProxyConfig.authMethod`) +**File**: `jwt-auth.plugin.ts` + +**Purpose**: Injects a Bearer `Authorization` header from a JWT token (CLI arg, `CODEMIE_JWT_TOKEN` env var, or credential store) instead of SSO cookies, for the `authMethod: 'jwt'` path. + +### 6.6 Per-Agent Request Normalizers + +**Priority**: 14 +**Files**: `claude-request-normalizer.plugin.ts`, `kimi-request-normalizer.plugin.ts`, `codex-request-normalizer.plugin.ts` + +**Purpose**: Fix up agent-specific request body quirks before the request reaches upstream: + +- **Claude**: normalizes `thinking` params for Claude models +- **Kimi**: caps Kimi output-token requests to stay within upstream limits +- **Codex**: maps the Codex app's undated model names onto dated CodeMie deployments + +### 6.7 Request Sanitizer Plugin + +**Priority**: 15 +**File**: `request-sanitizer.plugin.ts` + +**Purpose**: Strips reasoning parameters the target upstream doesn't support, independent of any single agent's normalizer. + +### 6.8 Codex / Copilot Encrypted-Content Sanitizer Plugins + +**Priority**: 16 +**Files**: `codex-encrypted-content-sanitizer.plugin.ts`, `copilot-encrypted-content-sanitizer.plugin.ts` + +**Shared purpose**: Forward Responses-API encrypted reasoning state untouched by default (preserving cross-turn reasoning continuity), and self-heal once the upstream signals the state is no longer replayable. **The two plugins implement this with genuinely different mechanisms — do not assume one describes the other:** + +- **Codex** (`codex-encrypted-content-sanitizer.plugin.ts`): watches for the rejection marker in **`onResponseChunk`** (streaming inspection of the SSE body), not `onUpstreamResponse`. On the first sighting it sets a permanent `reasoningStateUnusable` flag (comment: "never cleared for this proxy") that causes its `onRequest` hook to strip reasoning state from every _future_ request. It does **not** retry the failing turn — that turn's response streams through unchanged and its error surfaces to the client; only the _next_ request onward gets the stripped-state treatment. +- **Copilot** (`copilot-encrypted-content-sanitizer.plugin.ts`): uses **`onUpstreamResponse`** with `tools.readBody`/`tools.retry` and retries the _same_ failing request once, inline, after stripping the offending state (`return tools.retry(sanitizedRequest.body)`). It keeps **no persistent latch** — every subsequent request is re-checked independently rather than being pre-stripped based on prior failures. + +In short: Codex = detect-in-stream, no retry, permanent latch for later requests. Copilot = detect-post-response, immediate retry of the same request, no latch. Both achieve "the session keeps working instead of repeatedly replaying unusable state," but via opposite trade-offs (Copilot fixes the current turn but re-pays the detection cost every time; Codex pays once and then avoids the cost, at the price of always failing the turn that first triggers it). See §6.13 for the VS Code BYOK context this was built for; the same class of self-healing applies uniformly to `codemie-codex`, `codemie-code`, `codemie-opencode`, `codemie-pi`, and `vscode-byok`. + +### 6.9 VS Code Request Normalizer Plugin + +**Priority**: 17 +**File**: `vscode-request-normalizer.plugin.ts` + +**Purpose**: Constrains the Responses `user` identifier for `vscode-byok` traffic (bounded compatibility normalization only — see §6.14). + +### 6.10 Header Injection Plugin **Priority**: 20 -**File**: `src/proxy/plugins/header-injection.plugin.ts` +**File**: `header-injection.plugin.ts` **Purpose**: Add CodeMie-specific headers for traceability -**Headers Injected**: +**Headers Injected**: only `X-CodeMie-Request-ID` and `X-CodeMie-CLI` are truly unconditional (`header-injection.plugin.ts:30-73`). Every other header — including `X-CodeMie-Session-ID` and `X-CodeMie-Client` — is conditional on the corresponding value being present. **Always Injected:** + - `X-CodeMie-CLI`: CLI wrapper and version (e.g., `codemie-cli/0.0.16`) -- `X-CodeMie-Client`: Agent identifier (e.g., `codemie-claude`, `codemie-gemini`, `codemie-code`) - `X-CodeMie-Request-ID`: Request UUID for traceability -- `X-CodeMie-Session-ID`: Session UUID for correlation **Conditionally Injected:** + +- `X-CodeMie-Session-ID`: Session UUID for correlation, only `if (context.sessionId && context.sessionId !== 'unknown')` +- `X-CodeMie-Client`: Agent identifier (e.g., `codemie-claude`, `codemie-gemini`, `codemie-code`), only `if (config.clientType)` - `X-CodeMie-Integration`: Integration ID (only when provider requires integration via `requiresIntegration` flag) -- `X-CodeMie-CLI-Model`: Model name from config (if configured) +- `X-CodeMie-CLI-Model`: Model name from config (if configured) — this fires for `vscode-byok` too whenever the active profile has a `model` set, since the daemon receives `--model` for every target (see §6.13) - `X-CodeMie-CLI-Timeout`: Timeout value from config (if configured) +- `X-CodeMie-Repository`: Repository name (parent/current format), from `ProxyConfig.repository` +- `X-CodeMie-Branch`: Git branch at proxy startup, from `ProxyConfig.branch` +- `X-CodeMie-Project`: CodeMie project name, from `ProxyConfig.project` (also used by VS Code BYOK, see §6.13) +- `x-litellm-session-id`: session ID, injected only for `codemie-codex`/`codemie-copilot` client types (`header-injection.plugin.ts:41-43`) — not a `X-CodeMie-*` header, easy to miss when scanning for the CodeMie prefix **Architecture**: + - Reads values from PluginContext and ProxyContext - Adds headers to outgoing request - Executes in onRequest() hook - Fails gracefully if values are missing (optional headers) -### 6.3 Logging Plugin +### 6.11 Logging Plugin **Priority**: 50 -**File**: `src/proxy/plugins/logging.plugin.ts` +**File**: `logging.plugin.ts` **Purpose**: Log detailed proxy activity @@ -431,62 +653,68 @@ Benefits: **Log Level**: DEBUG (file only, console when CODEMIE_DEBUG=1) **Lifecycle Hooks**: + - **onRequest**: Log request details (method, URL, headers, body size) - **onResponseHeaders**: Log response headers (content-type, encoding) -- **onResponseChunk**: Log streaming progress (every 10th chunk) +- **onResponseChunk**: Log streaming progress (1st chunk, then every 1000th chunk) - **onResponseComplete**: Log final stats (status, duration, bytes sent) - **onError**: Log error details (type, message, stack trace) **Architecture**: + - Stateless within single request - Maintains chunk counter for sampling - No impact on proxy performance (async logging) -### 6.4 Metrics Sync Plugin +### 6.12 SSO Session Sync Plugin (Unified) **Priority**: 100 -**File**: `src/proxy/plugins/metrics-sync.plugin.ts` +**File**: `sso.session-sync.plugin.ts` -**Purpose**: Background sync of session metrics to CodeMie API +**Purpose**: Unified background orchestrator that syncs session data — both metrics _and_ conversations — to the CodeMie API. This plugin replaced an earlier, metrics-only "Metrics Sync Plugin"; if you find references to `metrics-sync.plugin.ts` or `CODEMIE_METRICS_SYNC_*` env vars elsewhere (old docs, old comments), they describe the previous design and no longer match the code. -#### 6.4.1 Overview +#### 6.12.1 Overview **Design Decisions**: -- ✅ **Aggregation over Granularity**: Multiple deltas aggregated into single metric per sync -- ✅ **Single Metric Sync**: API receives one aggregated metric object, not array + +- ✅ **Unified Orchestration**: Sessions are discovered and read once, then handed to multiple pluggable processors (metrics, conversations) — zero duplicated I/O +- ✅ **Agent-Agnostic**: Adapters support Claude, Gemini, and others behind a common interface - ✅ **Session-Level Sync**: Plugin is session-scoped (syncs only current session) -- ✅ **In-Place Marking**: Sync status tracked directly in JSONL file -- ✅ **SSO-Only Operation**: Only runs when provider is `ai-run-sso` -- ✅ **Cookie Authentication**: Uses SSO cookies from proxy context +- ✅ **SSO-Only Operation**: Only runs when credentials are SSO cookies (guards against JWT-only sessions) +- ✅ **Opt-Out via createInterceptor()**: Throws `ConfigurationError` (not a separate flag) when session ID, SSO credentials, client type, or sync-enabled config is missing — the plugin simply isn't registered for that session -#### 6.4.2 Architecture +#### 6.12.2 Architecture **Lifecycle**: + ``` Proxy Start └─ onProxyStart() - ├─ Initialize MetricsApiClient - ├─ Start background timer (every 5 minutes) - └─ Log: "Starting metrics sync" - -Background Timer (every 5 minutes) - └─ syncMetrics() - ├─ Read {sessionId}_metrics.jsonl - ├─ Filter deltas with syncStatus='pending' - ├─ Load session metadata - ├─ Aggregate deltas → single metric - ├─ POST ${apiUrl}/metrics - ├─ On success: Mark deltas as 'synced' in JSONL - └─ Log: "Synced N deltas" + ├─ Initialize SessionSyncer + ├─ Start background timer (every 2 minutes by default — see §6.12.6) + └─ Log: "Starting session sync" + +Background Timer (every 2 minutes by default) + └─ sync() + ├─ Discover session files via adapter (once) + ├─ Pass parsed sessions to all processors (metrics, conversations) + ├─ Each processor aggregates/syncs its own concern + └─ Log: "Synced N sessions" Proxy Stop └─ onProxyStop() ├─ Stop background timer - ├─ Final sync (ensures all pending deltas sent) + ├─ Final sync (ensures all pending data sent) └─ Log: "Final sync completed" ``` -#### 6.4.3 Claude Desktop 3P Telemetry Runtime +**Components**: + +- `SSOSessionSyncPlugin` / `SSOSessionSyncInterceptor`: Plugin registration and timer/sync orchestration (`sso.session-sync.plugin.ts`) +- `SessionSyncer` (`src/providers/plugins/sso/session/SessionSyncer.ts`): Discovery + I/O shared across processors +- `BaseProcessor` (`src/providers/plugins/sso/session/BaseProcessor.ts`) and concrete processors under `src/providers/plugins/sso/session/processors/` (e.g. `processors/metrics/metrics-sync-processor.ts` for the metrics-aggregation logic previously described as the whole plugin) + +#### 6.12.3 Claude Desktop 3P Telemetry Runtime When the proxy daemon is started in Desktop mode, the daemon also starts a local telemetry runtime for Claude Desktop 3P: @@ -497,16 +725,9 @@ When the proxy daemon is started in Desktop mode, the daemon also starts a local - Syncs pending JSONL metrics and conversations through `SessionSyncer` - Sends session lifecycle metrics with client identity `claude-desktop` -This path is intentionally separate from the hook-based `codemie-claude` flow. Claude Desktop does not expose CodeMie-managed lifecycle hooks, so ingestion is file-discovery driven rather than event-callback driven. The shared runtime is generic; client-specific logic lives behind a Desktop adapter so future IDE or desktop clients can plug into the same sync pipeline. +This path is intentionally separate from the hook-based `codemie-claude` flow. Claude Desktop does not expose CodeMie-managed lifecycle hooks, so ingestion is file-discovery driven rather than event-callback driven. The shared runtime is generic; client-specific logic lives behind a Desktop adapter (`src/telemetry/clients/claude-desktop/ClaudeDesktopTelemetryAdapter.js`, wired into `src/telemetry/runtime/DesktopTelemetryRuntime.ts`) so future IDE or desktop clients can plug into the same sync pipeline. Started/stopped directly by `src/bin/proxy-daemon.ts` when `--telemetry-mode claude-desktop` is passed. -**Components**: -- MetricsSyncPlugin: Plugin registration and initialization -- MetricsSyncInterceptor: Interceptor with timer and sync logic -- MetricsApiClient: HTTP client for API communication -- Aggregation Logic: Combines deltas into session metric -- JSONL Utilities: Atomic file operations - -#### 6.4.3 API Contract +#### 6.12.4 API Contract **Endpoint**: `POST ${apiUrl}/metrics` **Example**: `POST https://codemie.ai/metrics` @@ -514,11 +735,13 @@ This path is intentionally separate from the hook-based `codemie-claude` flow. C **Auth**: `Cookie: session={token}` (SSO cookies) **Metric Structure**: + - **metric_name**: Always `codemie_coding_agent_usage` - **attributes**: Session-aggregated metrics - **time**: ISO timestamp **Metric Attributes**: + - **Identity**: agent, agent_version, llm_model, project, session_id - **Interaction**: total_user_prompts, total_ai_requests, total_ai_responses - **Tokens**: total_input_tokens, total_output_tokens, total_cache_read_input_tokens @@ -527,26 +750,30 @@ This path is intentionally separate from the hook-based `codemie-claude` flow. C - **Session**: session_duration_ms, exit_reason, had_errors, status, is_final, count **Response Structure**: + - success: Boolean flag - received: Number of metrics received - processed: Number successfully processed - failed: Number of failures - timestamp: Server timestamp -#### 6.4.4 Data Flow +#### 6.12.5 Data Flow **Local Metrics Storage**: `~/.codemie/sessions/` + - `{sessionId}.json`: Session metadata - `{sessionId}_metrics.jsonl`: Delta records (one per line) **Delta Lifecycle**: -1. MetricsOrchestrator writes delta with syncStatus='pending' -2. MetricsSyncPlugin reads all pending deltas periodically (every 5 minutes) + +1. Metrics orchestrator writes delta with syncStatus='pending' +2. SSOSessionSyncPlugin's metrics processor reads all pending deltas periodically (every `CODEMIE_SESSION_SYNC_INTERVAL` ms, default 120000 = 2 minutes, see §6.12.6) 3. All pending deltas aggregated into single session metric 4. Single metric sent to API as JSON object 5. On success: All aggregated deltas marked with syncStatus='synced' **Sync Algorithm**: + 1. Read all deltas from JSONL file 2. Filter for syncStatus='pending' only 3. Load session metadata @@ -555,49 +782,55 @@ This path is intentionally separate from the hook-based `codemie-claude` flow. C 6. On success: Mark all aggregated deltas as 'synced' with atomic JSONL rewrite 7. On failure: Retry with exponential backoff, keep deltas as 'pending' -#### 6.4.5 Configuration +#### 6.12.6 Configuration **Priority**: Environment Variables > Profile Config > Default (true) **Environment Variables**: -- `CODEMIE_METRICS_SYNC_ENABLED`: Enable/disable sync (default: true for SSO) -- `CODEMIE_METRICS_SYNC_INTERVAL`: Sync interval in milliseconds (default: 300000) -- `CODEMIE_METRICS_MAX_RETRIES`: Max retry attempts (default: 3) + +- `CODEMIE_SESSION_SYNC_ENABLED`: Enable/disable sync (default: true for SSO) +- `CODEMIE_SESSION_DRY_RUN`: Run sync logic without actually sending data (default: false) +- `CODEMIE_SESSION_SYNC_INTERVAL`: Background sync interval in milliseconds (default: `120000` = 2 minutes). Not gated by profile config — env var only. **Profile Configuration**: + - Location: `~/.codemie/codemie-cli.config.json` -- Path: `profiles[name].metrics.sync` -- Properties: enabled, interval, maxRetries +- Path: `profiles[name].session.sync` (properties: `enabled`, `dryRun`) **Opt-Out Options**: + - Single session: Set env var to false - Profile-wide: Disable in profile config -#### 6.4.6 Error Handling +#### 6.12.7 Error Handling **Retryable Errors** (exponential backoff: 1s → 2s → 5s): + - Network timeouts - 5xx server errors - 429 Rate limiting - Connection refused **Non-Retryable Errors** (fail immediately): + - 401 Unauthorized (SSO session expired) - 403 Forbidden (insufficient permissions) - 400 Bad Request (invalid payload) **Failure Strategy**: + - On retry exhaustion: Keep deltas as 'pending' - Next sync cycle retries automatically - Errors logged at ERROR level - Plugin failure doesn't break proxy **Concurrency Protection**: + - isSyncing flag prevents concurrent syncs - Timer skips if sync already in progress - Serial processing guaranteed -#### 6.4.7 Performance +#### 6.12.8 Performance **Memory**: ~5MB for plugin (within proxy process) **Disk I/O**: O(1) - single session file read/write @@ -605,114 +838,38 @@ This path is intentionally separate from the hook-based `codemie-claude` flow. C **CPU**: Minimal (simple arithmetic aggregation) **Scalability**: + - Session-scoped: Only syncs current session - No cross-session interference - Timer-based: Predictable resource usage -#### 6.4.8 Monitoring +#### 6.12.9 Monitoring **Log Location**: `~/.codemie/logs/debug-YYYY-MM-DD.log` **Log Events**: -- Starting metrics sync (with interval) + +- Starting session sync (with interval) - Syncing N pending deltas - Successfully synced N deltas -- Stopping metrics sync +- Stopping session sync - Final sync completed - Sync failures with error details **Troubleshooting**: + - Check syncStatus in JSONL file (pending/synced) - Verify SSO cookies valid - Check network connectivity to API - Enable debug logging -### 6.5 MCP Auth Plugin - -**Priority**: 3 (runs before all other plugins) -**File**: `src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts` - -**Purpose**: Proxy MCP OAuth authorization flows through the CodeMie proxy so that all auth traffic is routed centrally and `client_name` can be overridden via the `MCP_CLIENT_NAME` environment variable. - -#### 6.5.1 URL Scheme +### 6.13 VS Code BYOK Profile Configuration -The plugin intercepts two URL patterns: +`codemie proxy connect --vscode` resolves one effective CodeMie profile before configuring the client. It does **not** write the profile's single `model` into VS Code's config as one entry — `writeVsCodeLanguageModelsConfig()` (`connectors/vscode.ts`, `vscode-models.ts`) writes the **entire fixed `VS_CODE_SUPPORTED_MODELS` catalog** (~20 entries, including the GPT-5.5/5.6 Responses-API entries described below) into a single managed provider in `chatLanguageModels.json`. The profile's `model` field is not read by this write path at all. The profile's `codeMieProject` is passed independently to the daemon for `X-CodeMie-Project` header injection. -| Route | Pattern | Purpose | -|-------|---------|---------| -| **Initial** | `/mcp_auth?original=` | First MCP connection — starts an OAuth flow | -| **Relay** | `/mcp_relay///` | Subsequent requests routed through proxy | +The persistent daemon **does** receive a configured model: `spawnDaemon()` passes `--model ` whenever the active profile has a `model` set, and this is shared across every `proxy connect` target (`--vscode`, `--claude-desktop`, `--codex-desktop`), not restricted to Codex. When present, the daemon injects `X-CodeMie-CLI-Model` (§6.10) for `vscode-byok` traffic same as any other client. What the daemon does _not_ do for VS Code traffic is rewrite the **request body's** model field — the Codex request normalizer (§6.6), which does that kind of body rewrite, explicitly excludes `vscode-byok` from its allowed client list. It validates the local gateway key (via the Gateway Key Plugin, §6.3), injects SSO authentication and CodeMie context headers, then forwards request and response bodies through the existing streaming path byte-for-byte. -- `root_b64`: Base64url-encoded root MCP server origin (for per-flow isolation) -- `relay_b64`: Base64url-encoded actual target origin (may differ when auth server is on a separate host) - -#### 6.5.2 Request Handling - -**`/mcp_auth` route:** -1. Extract `original` query parameter (the real MCP server URL) -2. Validate URL (SSRF check) -3. Forward request to the target MCP server -4. Buffer the JSON response and rewrite all discovered URLs to proxy relay URLs -5. Return the rewritten response to the MCP client - -**`/mcp_relay` route:** -1. Decode `root_b64` and `relay_b64` to recover target origin -2. Validate root-relay association (per-flow origin scoping) -3. Reconstruct the full target URL from relay origin + path + query -4. Forward request to the real target -5. Buffer JSON auth metadata responses and rewrite URLs; stream all other responses - -#### 6.5.3 Response URL Rewriting - -The plugin buffers JSON responses (auth metadata, client registration, etc.) and rewrites all absolute HTTP(S) URLs found in JSON values to proxy relay URLs. This ensures the MCP client routes all subsequent requests through the proxy. - -**Exceptions**: Token audience identifiers (e.g., `resource` field) are not rewritten — they are logical identifiers, not URLs to access. - -**Browser endpoints** (e.g., `authorization_endpoint`) are left as-is so the user's browser navigates directly to the auth server. - -#### 6.5.4 Security - -**SSRF Protection:** -- Private/loopback IP addresses are rejected (both literal hostname check and DNS resolution) -- Only `http:` and `https:` schemes are allowed - -**Per-Flow Origin Scoping:** -- Discovered origins (from auth metadata) are tagged with their root MCP server origin -- Relay requests validate that the relay origin is associated with the claimed root origin -- Prevents cross-flow origin confusion - -**Buffering Policy:** -- Only auth metadata responses are buffered (for URL rewriting) -- Post-auth MCP traffic streams through without buffering - -#### 6.5.5 Companion Components - -The MCP Auth Plugin works in conjunction with the stdio-to-HTTP bridge: - -| Component | File | Purpose | -|-----------|------|---------| -| Stdio-HTTP Bridge | `src/mcp/stdio-http-bridge.ts` | Bridges stdio JSON-RPC to streamable HTTP transport | -| OAuth Provider | `src/mcp/auth/mcp-oauth-provider.ts` | Implements `OAuthClientProvider` for browser-based OAuth flow | -| Callback Server | `src/mcp/auth/callback-server.ts` | Ephemeral localhost server for receiving OAuth callbacks | -| Proxy Logger | `src/mcp/proxy-logger.ts` | File-based logger for proxy operations | -| Constants | `src/mcp/constants.ts` | `MCP_CLIENT_NAME` default and accessor | - -#### 6.5.6 Configuration - -**Environment Variables:** -- `MCP_CLIENT_NAME`: Client name for OAuth Dynamic Client Registration (default: `CodeMie CLI`) -- `MCP_PROXY_DEBUG`: Enable verbose proxy logging -- `CODEMIE_PROXY_PORT`: Fixed proxy port (for stable MCP auth URLs across restarts) - -**Log Location**: `~/.codemie/logs/mcp-proxy.log` - -### 6.6 VS Code BYOK Profile Configuration - -`codemie proxy connect vscode` resolves one effective CodeMie profile before configuring the client. The selected profile's `model` is written directly to VS Code's `models[].id`, which is the identifier VS Code sends in inference requests. The profile's `codeMieProject` is passed independently to the daemon for `X-CodeMie-Project` header injection. - -The persistent daemon never receives a configured model and does not rewrite request bodies. It validates the local gateway key, injects SSO authentication and CodeMie context headers, then forwards request and response bodies through the existing streaming path byte-for-byte. - -The command reuses a healthy daemon when its profile, project, provider, target URL, and `vscode-byok` client type match. Model changes only rewrite VS Code configuration; they do not restart the daemon. +The command reuses a healthy daemon when its profile, project, provider, target URL, `vscode-byok` client type, **and configured model** all match (`daemonMatchesRequest()` in `connect-orchestrator.ts` also compares model when the request specifies one — a mismatch spawns a fresh daemon rather than silently reusing a stale one). The connector merges one managed model into VS Code's `chatLanguageModels.json` and preserves unrelated models plus an existing `${input:chat.lm.secret.*}` reference as `apiKey`. If no valid reference exists, it omits `apiKey` rather than generating a placeholder and directs the user to open `Chat: Manage Language Models`, right-click **CodeMie Profile Model**, and choose **Update API Key**. VS Code then stores the local `codemie-proxy` key in secret storage; CodeMie SSO credentials never enter VS Code configuration. @@ -723,7 +880,7 @@ Code owns the complete conversation history for these entries and sends stateles function-call-output items. This keeps LiteLLM free to load-balance each request across Azure deployments. -The proxy performs only bounded compatibility normalization for `vscode-byok`: it constrains the +The proxy performs only bounded compatibility normalization for `vscode-byok` (§6.9): it constrains the Responses `user` identifier and forwards reasoning state untouched. It preserves the selected `reasoning.effort`, visible messages, assistant phases, tools, call IDs, and tool outputs. The proxy does not persist conversation content, add session affinity, buffer Responses events, or @@ -733,7 +890,7 @@ Deployment-bound reasoning state is now handled server-side: the gateway runs Li `encrypted_content_affinity`, which routes a follow-up carrying encrypted content back to the deployment that produced it. Follow-ups therefore keep full cross-turn reasoning continuity. -The proxy retains a self-healing fallback for the case where an affinity pin has expired +The proxy retains a self-healing fallback (§6.8) for the case where an affinity pin has expired (`deployment_affinity_ttl_seconds`). It watches upstream responses for `invalid_encrypted_content` and for bare-reasoning-id rejections; on the first sighting it strips reasoning state from every later request for the life of that proxy. The failing turn surfaces its error, then the session @@ -767,33 +924,42 @@ sequenceDiagram ### 7.1 Performance **Streaming**: Zero buffering, constant memory + - Before: ~100MB memory for 10MB response (buffered) - After: ~10MB memory for 10MB response (streamed) **Throughput**: No artificial limits + - Limited only by network and upstream API - No CPU-intensive operations in hot path **Latency**: Minimal overhead + - Plugin hooks: ~1-5ms per request - Streaming: No added latency (pass-through) **Concurrency**: Multi-request support + - Node.js event loop handles concurrent requests - No blocking operations in request path ### 7.2 Reliability **Fail-Safe**: Plugin failures don't break proxy + - Try-catch around all plugin hooks - Log errors and continue **Graceful Shutdown**: Clean resource cleanup + - Call onProxyStop() on all plugins - Final sync operations complete -- HTTP server closes gracefully +- Force-drain keep-alive sockets, then close the HTTP server + +**Self-Healing (daemon mode)**: `ProxyWatcher` (`src/cli/commands/proxy/watcher.ts`) deep-checks the daemon every 30s via `/health`; on failure it restarts the proxy in-process on the same pinned port (up to 3 attempts) before giving up and recording `health: 'unhealthy'` in the daemon state file. See §9.1. **Error Recovery**: Structured error responses + - Normalized error types - Actionable error messages - Full error context in logs @@ -801,6 +967,7 @@ sequenceDiagram ### 7.3 Maintainability **SOLID Principles**: + - **Single Responsibility**: Core = forward HTTP, Plugins = features - **Open/Closed**: Add features via plugins without modifying core - **Liskov Substitution**: All plugins implement same interface @@ -808,37 +975,61 @@ sequenceDiagram - **Dependency Inversion**: Core depends on plugin abstractions **Code Organization**: + ``` -src/proxy/ -├── errors.ts # Error types -├── http-client.ts # HTTP forwarding -├── types.ts # Core types +src/providers/plugins/sso/proxy/ +├── proxy-errors.ts # Error types +├── proxy-http-client.ts # HTTP forwarding +├── proxy-types.ts # Core types +├── sso.proxy.ts # CodeMieProxy core └── plugins/ - ├── index.ts # Plugin registration - ├── registry.ts # Plugin management - ├── types.ts # Plugin interfaces - ├── sso-auth.plugin.ts - ├── header-injection.plugin.ts - ├── logging.plugin.ts - └── metrics-sync.plugin.ts + ├── index.ts # Plugin registration + ├── registry.ts # Plugin management + ├── types.ts # Plugin interfaces + ├── mcp-auth.plugin.ts # priority 3 + ├── endpoint-blocker.plugin.ts # priority 5 + ├── gateway-key.plugin.ts # priority 7 + ├── sso-auth.plugin.ts # priority 10 + ├── jwt-auth.plugin.ts # priority 10 + ├── claude-request-normalizer.plugin.ts # priority 14 + ├── kimi-request-normalizer.plugin.ts # priority 14 + ├── codex-request-normalizer.plugin.ts # priority 14 + ├── request-sanitizer.plugin.ts # priority 15 + ├── codex-encrypted-content-sanitizer.plugin.ts # priority 16 + ├── copilot-encrypted-content-sanitizer.plugin.ts # priority 16 + ├── vscode-request-normalizer.plugin.ts # priority 17 + ├── header-injection.plugin.ts # priority 20 + ├── logging.plugin.ts # priority 50 + └── sso.session-sync.plugin.ts # priority 100 + +src/providers/plugins/sso/session/ +├── SessionSyncer.ts # discovery + I/O shared by processors +├── BaseProcessor.ts +└── processors/ + ├── metrics/metrics-sync-processor.ts + └── ... # conversation and other processors ``` ### 7.4 Security -**Authentication**: SSO cookie handling -- Cookies never logged (sanitized) +**Authentication**: SSO cookie / JWT bearer / local gateway key handling + +- Credentials never logged (sanitized) - Secure credential storage (CredentialStore) - Encrypted at rest **TLS/SSL**: Support for self-signed certs -- rejectUnauthorized option configurable + +- `rejectUnauthorized` option configurable (defaults to `false`) - Allows enterprise CA certificates **Input Validation**: Header sanitization + - Remove Host and Connection headers - Validate proxy configuration **Audit Trail**: Full request logging + - Request ID for tracing - Session ID for correlation - Detailed logs for forensics @@ -848,14 +1039,18 @@ src/proxy/ **Plugin System**: Add features without core changes **Extension Points**: + - **onProxyStart**: Initialization tasks, background services +- **handleRequest**: Full request bypass for traffic that doesn't target the main upstream (rare — use only when the standard pipeline genuinely doesn't apply) - **onRequest**: Request modification, authentication, validation +- **onUpstreamResponse**: Inspect/replace the raw upstream response before header/streaming hooks run (buffer-and-retry patterns) - **onResponseHeaders**: Header inspection, caching decisions - **onResponseChunk**: Streaming transformation, filtering - **onResponseComplete**: Analytics, logging, cleanup - **onError**: Error handling, alerting, recovery **Future Plugin Examples**: + - Rate Limiting: Per-session request throttling - Caching: LRU cache with TTL expiration - Request Replay: Store/retry failed requests @@ -908,26 +1103,40 @@ src/proxy/ ### 9.1 Startup Flow ``` -1. Agent CLI starts - └─ codemie-claude "implement feature" --provider ai-run-sso +1. Agent CLI starts, or `codemie proxy start` / `codemie proxy connect ...` spawns the daemon + └─ e.g. codemie-claude "implement feature" --provider ai-run-sso -2. Agent detects SSO provider +2. Agent/CLI detects SSO provider (or daemon mode is requested) └─ Checks if proxy is needed -3. Agent spawns proxy +3a. In-process usage: agent spawns proxy directly ├─ Create ProxyConfig (targetApiUrl, sessionId, etc.) ├─ new CodeMieProxy(config) └─ await proxy.start() - ├─ Load SSO credentials + ├─ Load SSO/JWT credentials ├─ Initialize plugins ├─ Call onProxyStart() hooks └─ Bind to dynamic port +3b. Daemon usage (src/bin/proxy-daemon.ts, detached process, e.g. for Claude + Desktop or VS Code BYOK, spawned via src/cli/commands/proxy/daemon-manager.ts): + ├─ Parse CLI args (--target-url, --state-file, --gateway-key, --port, ...) + ├─ new CodeMieProxy(config) and await proxy.start() + ├─ Optionally start DesktopTelemetryRuntime (--telemetry-mode claude-desktop) + ├─ Persist state file atomically (pid, port, url, health, timestamps) + ├─ Start ProxyWatcher: deep-checks /health every 30s, restarts the proxy + │ in-process on the SAME pinned port (up to 3 attempts) on failure, + │ records health: 'unhealthy' in the state file if it gives up + └─ Register SIGTERM/SIGINT handlers that stop the watcher, telemetry + runtime, and proxy, then delete the state file + 4. Proxy returns URL - └─ http://localhost:54321 + └─ http://localhost:PORT or http://127.0.0.1:PORT (daemon binds 127.0.0.1 + explicitly — Claude Desktop's gateway URL validator requires the literal + loopback IP; 'localhost' can resolve to IPv6 ::1 only on macOS) 5. Agent uses proxy URL - └─ Set environment variable: ANTHROPIC_BASE_URL=http://localhost:54321 + └─ Set environment variable: ANTHROPIC_BASE_URL=http://localhost:PORT 6. Agent runs normally └─ All API requests go through proxy @@ -936,51 +1145,65 @@ src/proxy/ ### 9.2 Shutdown Flow ``` -1. User exits agent (Ctrl+C or normal exit) +1. User exits agent (Ctrl+C or normal exit), or sends SIGTERM/SIGINT to the daemon -2. Agent cleanup - ├─ Signal proxy to stop +2. Cleanup + ├─ Stop the ProxyWatcher (daemon mode only) + ├─ Stop the Desktop telemetry runtime, if running └─ await proxy.stop() ├─ Call onProxyStop() hooks - │ └─ MetricsSyncPlugin: Final sync - ├─ Close HTTP server + │ └─ SSOSessionSyncPlugin: Final sync + ├─ Force-drain keep-alive sockets, then close HTTP server └─ Cleanup HTTP client -3. Agent exits +3. Daemon mode also deletes the state file + +4. Process exits ``` ### 9.3 Configuration **Programmatic Configuration**: + - Target API URL -- Port (0 = dynamic) +- Port (0 = dynamic), pinned-port retry flag - Client type - Session ID - Profile, provider, model +- Auth method (`sso` | `jwt`), gateway key +- Telemetry mode and intervals **Environment Variables** (plugin-specific): -- CODEMIE_METRICS_SYNC_ENABLED -- CODEMIE_METRICS_SYNC_INTERVAL -- CODEMIE_DEBUG + +- `CODEMIE_SESSION_SYNC_ENABLED` +- `CODEMIE_SESSION_DRY_RUN` +- `CODEMIE_DEBUG` +- `MCP_PROXY_DEBUG` +- `MCP_CLIENT_NAME` +- `CODEMIE_PROXY_PORT` +- `CODEMIE_JWT_TOKEN` **Profile Configuration** (plugin-specific): + - Location: ~/.codemie/codemie-cli.config.json - Provider-specific settings -- Metrics sync configuration +- Session sync configuration (`profiles[name].session.sync`) **Priority**: Environment variables > Profile config > Defaults ### 9.4 Monitoring -**Log Files**: `~/.codemie/logs/debug-YYYY-MM-DD.log` +**Log Files**: `~/.codemie/logs/debug-YYYY-MM-DD.log`, `~/.codemie/logs/mcp-proxy.log` **Log Levels**: + - ERROR: Plugin failures, network errors - WARN: Retry attempts, deprecated features - INFO: Plugin initialization, sync operations - DEBUG: All proxy activity (file only) -**Metrics** (via Metrics Sync Plugin): +**Metrics** (via SSO Session Sync Plugin): + - Request count per session - Token usage - Tool calls @@ -988,7 +1211,9 @@ src/proxy/ - Session duration **Health Indicators**: -- Proxy responds to requests + +- `/health` / `/healthz` responds (used by CLI commands and the in-daemon watcher) +- Daemon state file `health` field (`ok` / `unhealthy`) - Plugins loaded successfully - No critical errors in logs @@ -996,15 +1221,19 @@ src/proxy/ **Problem**: Proxy not starting **Diagnosis**: Check logs for port binding errors -**Solution**: Use dynamic port (port: 0) +**Solution**: Use dynamic port (port: 0), or check for a stale pinned-port daemon holding the port **Problem**: SSO auth failing **Diagnosis**: Check if credentials exist **Solution**: Re-authenticate with auth command -**Problem**: Metrics not syncing -**Diagnosis**: Check plugin enabled in config -**Solution**: Enable in profile or env var +**Problem**: Session sync not working +**Diagnosis**: Check plugin enabled in config/env; confirm credentials are SSO (not JWT) +**Solution**: Enable via `CODEMIE_SESSION_SYNC_ENABLED` or `profiles[name].session.sync.enabled` + +**Problem**: Daemon restarts repeatedly / marked unhealthy +**Diagnosis**: Check `ProxyWatcher` log lines and the daemon state file's `healthReason` +**Solution**: After 3 failed restarts the watcher gives up rather than looping forever — investigate the underlying upstream/auth failure before forcing another daemon start **Problem**: Plugin error breaking proxy **Diagnosis**: Check logs for plugin name @@ -1016,41 +1245,52 @@ src/proxy/ ### 10.1 Planned Features -**Request Caching** (Priority: 90): +> These were speculative when originally written. Priorities 15-17 are now occupied by shipped +> request-sanitization plugins (§6.7-6.9), so any new plugin in this range should pick an unused +> priority rather than reusing the numbers below verbatim. + +**Request Caching**: + - LRU cache for identical requests - TTL-based expiration - Cache invalidation API -**Rate Limiting** (Priority: 15): +**Rate Limiting**: + - Per-session rate limits - Token bucket algorithm - Configurable limits per profile -**Request Replay** (Priority: 110): +**Request Replay**: + - Store failed requests - Automatic retry on recovery - Persistence across restarts -**Request/Response Transformation** (Priority: 25): +**Request/Response Transformation**: + - Modify request body (add system prompts) - Filter response content (PII redaction) - Format transformation (OpenAI → Anthropic) ### 10.2 Scalability Considerations -**Current Limitation**: Single process, single session +**Current Limitation**: Single process, single session (per daemon instance) **Future Enhancement**: Multi-session support + - Session routing via header - Per-session plugin context - Shared cache across sessions **Load Balancing**: Multiple upstream targets + - Round-robin routing - Health checks - Failover logic **Distributed Tracing**: OpenTelemetry integration + - Trace ID propagation - Span creation for plugin hooks - Export to observability platforms @@ -1060,12 +1300,14 @@ src/proxy/ **Vision**: Community-contributed plugins **Requirements**: + - Plugin validation (security, performance) - Versioning and compatibility checks - Documentation standards - Distribution via npm **Example Third-Party Plugins**: + - Advanced analytics with custom metrics - Record/replay for debugging - Security scanning for vulnerabilities diff --git a/src/agents/__tests__/registry.test.ts b/src/agents/__tests__/registry.test.ts index baf860714..b23950028 100644 --- a/src/agents/__tests__/registry.test.ts +++ b/src/agents/__tests__/registry.test.ts @@ -19,6 +19,7 @@ describe('AgentRegistry', () => { 'codemie-code', 'claude', 'claude-acp', + 'cursor-ide', 'gemini', 'opencode', 'codex', diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index d38c5b743..a48a6e430 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -664,6 +664,35 @@ export interface AgentHookConfig { * } */ eventNameMapping?: Record; + + + /** + * When true, the `codemie hook` CLI path never exits non-zero for this + * agent: JSON-parse failures and `validateHookEvent` failures degrade to a + * non-blocking failure (thrown internally, caught, and turned into a + * successful exit) instead of `process.exit(2)`. Set by agents whose host + * treats a non-zero exit as a hard block on the user's action. + */ + neverBlockingExit?: boolean; + + /** + * Optional per-agent stdout response contract, invoked with the + * agent-native event name (`event.hook_event_name`, unmutated by + * `eventNameMapping`) after the hook has been routed. Lets a host that + * inspects stdout for a synchronous response (e.g. Cursor's + * `{"permission":"allow"}`/`{"continue":true}`) get one without adding an + * agent-name literal to `hook.ts` — set only by agents whose host reads a + * response from stdout. + */ + writeStdoutResponse?: (nativeEventName: string) => void; + + + /** + * When true, the hook action forwards every event as-is to the local proxy + * daemon (see forwardOtlpEvent) and returns immediately, before the shared + * transform/validate/route pipeline and its legacy analytics handlers run. + */ + otlpIngestion?: boolean; } /** diff --git a/src/agents/plugins/cursor-ide/cursor-ide.constants.ts b/src/agents/plugins/cursor-ide/cursor-ide.constants.ts new file mode 100644 index 000000000..7bdb0b7ee --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.constants.ts @@ -0,0 +1,15 @@ +/** + * Shared Cursor IDE identifiers. + * + * Kept apart from `cursor-ide.plugin.ts` so other modules (the connector, the + * hook transformer) can import identifiers without pulling in the full plugin. + */ + +/** Internal agent key. Canonical everywhere: the `--cursor-ide` flag, `--agent cursor-ide`, `metadata.name`. */ +export const CURSOR_IDE_AGENT_NAME = 'cursor-ide'; + +/** User-facing label shown in analytics output. */ +export const CURSOR_IDE_DISPLAY_NAME = 'Cursor IDE'; + +/** Runtime client type recorded on ingested analytics events. */ +export const CURSOR_IDE_CLIENT_TYPE = 'codemie-cursor-ide'; diff --git a/src/agents/plugins/cursor-ide/cursor-ide.otlp-forwarder.ts b/src/agents/plugins/cursor-ide/cursor-ide.otlp-forwarder.ts new file mode 100644 index 000000000..9c804bf65 --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.otlp-forwarder.ts @@ -0,0 +1,64 @@ +import { readState, isProcessAlive } from '../../../cli/commands/proxy/daemon-manager.js'; +import { logger } from '../../../utils/logger.js'; + +interface OtlpEventPayload { + agentName: string; + timestamp: string; + raw: string; +} + +/** + * Fire-and-forget forward of a raw cursor-ide hook event to the local proxy daemon. + * + * - Calls readState() to get daemon URL and gateway key + * - If no daemon or process is dead, logs at debug and returns (no error) + * - POSTs { agentName, timestamp, raw: rawInput } to daemon's /v1/otlp/hook-events route + * - Uses 1500ms timeout and swallows all errors (network, non-2xx status) + * - Never throws, never blocks cursor's exit, never affects hook's exit code + */ +export async function forwardOtlpEvent(rawInput: string, agentName: string): Promise { + try { + const state = await readState(); + if (!state) { + logger.debug('forwardOtlpEvent: no daemon state found'); + return; + } + + if (!isProcessAlive(state.pid)) { + logger.debug(`forwardOtlpEvent: daemon pid ${state.pid} not alive`); + return; + } + + const payload: OtlpEventPayload = { + agentName, + timestamp: new Date().toISOString(), + raw: rawInput, + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1500); + + try { + const response = await fetch(`${state.url}/v1/otlp/hook-events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${state.gatewayKey}`, + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + logger.debug(`forwardOtlpEvent: received status ${response.status}`); + } + } finally { + clearTimeout(timeout); + } + } catch (err) { + // Swallow all errors: network failure, timeout, JSON errors, etc. + // Never let a POST failure affect cursor's exit or the hook's behavior + const msg = err instanceof Error ? err.message : String(err); + logger.debug(`forwardOtlpEvent: ${msg}`); + } +} diff --git a/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts b/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts new file mode 100644 index 000000000..6155a46bf --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts @@ -0,0 +1,69 @@ +import type { AgentMetadata } from '../../core/types.js'; +import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; +import { writeCursorResponse } from './cursor-ide.response.js'; +import { + CURSOR_IDE_AGENT_NAME, + CURSOR_IDE_CLIENT_TYPE, + CURSOR_IDE_DISPLAY_NAME, +} from './cursor-ide.constants.js'; + +export { + CURSOR_IDE_AGENT_NAME, + CURSOR_IDE_CLIENT_TYPE, + CURSOR_IDE_DISPLAY_NAME, +} from './cursor-ide.constants.js'; + +export const CursorIdePluginMetadata: AgentMetadata = { + name: CURSOR_IDE_AGENT_NAME, + displayName: CURSOR_IDE_DISPLAY_NAME, + description: 'Cursor IDE - analytics-only hook ingestion, never installed or launched by CodeMie', + + // Analytics-only: CodeMie never installs, updates, or launches Cursor. + npmPackage: null, + cliCommand: null, + + envMapping: { + baseUrl: [], + apiKey: [], + model: [], + }, + supportedProviders: [], + + // The sole gate excluding this agent from `codemie install/list/uninstall/update` + // (src/agents/registry.ts:getManageableAgents, types.ts:analyticsOnly). + analyticsOnly: true, + + ssoConfig: { + enabled: false, + clientType: CURSOR_IDE_CLIENT_TYPE, + }, + + hookConfig: { + // Exit code 2 is equivalent to `permission: "deny"` in Cursor and blocks + // the user's action - analytics ingestion must never be capable of that. + neverBlockingExit: true, + // Cursor reads a JSON response off stdout for a subset of its events + // (see cursor-ide.response.ts) - this is the sole gate that calls it, + // set only for this agent. + writeStdoutResponse: writeCursorResponse, + // Fire-and-forget forward raw events to the local proxy daemon's + // /v1/otlp/hook-events route, bypassing the shared transform/validate/route + // pipeline and its legacy analytics handlers. + otlpIngestion: true, + }, +}; + +export class CursorIdePlugin extends BaseAgentAdapter { + constructor(metadata: AgentMetadata = CursorIdePluginMetadata) { + super(metadata); + } + + override async isInstalled(): Promise { + // Analytics-only: Cursor's installation state is irrelevant to CodeMie. + return true; + } + + override async getVersion(): Promise { + return null; + } +} diff --git a/src/agents/plugins/cursor-ide/cursor-ide.response.ts b/src/agents/plugins/cursor-ide/cursor-ide.response.ts new file mode 100644 index 000000000..66b59e299 --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.response.ts @@ -0,0 +1,48 @@ +/** + * Cursor IDE stdout response contract. + * + * Cursor reads a JSON object off the hook process's stdout to decide how to + * proceed for a subset of its events - `{"permission":"allow"}` for + * tool-permission events, `{"continue":true}` for `beforeSubmitPrompt`. + * Every other event reads nothing from stdout, so this writer emits nothing + * for them: an unexpected stdout payload is exactly as unsafe here as a + * missing one. + * + * This module never decides *whether* to run - it is wired in declaratively + * via `AgentHookConfig.writeStdoutResponse` (see cursor-ide.plugin.ts), so + * `hook.ts` never needs an `if (agentName === 'cursor-ide')` branch to call + * it only for this agent. + * + * See: https://cursor.com/docs/hooks + */ + +const ALLOW_RESPONSE = JSON.stringify({ permission: 'allow' }); +const CONTINUE_RESPONSE = JSON.stringify({ continue: true }); + +/** + * Cursor-native event names (not the internal names they map onto) that + * carry a response contract. + */ +const CURSOR_STDOUT_RESPONSES: Readonly> = { + preToolUse: ALLOW_RESPONSE, + beforeShellExecution: ALLOW_RESPONSE, + beforeMCPExecution: ALLOW_RESPONSE, + beforeReadFile: ALLOW_RESPONSE, + beforeTabFileRead: ALLOW_RESPONSE, + subagentStart: ALLOW_RESPONSE, + beforeSubmitPrompt: CONTINUE_RESPONSE, +}; + +/** + * Writes Cursor's expected stdout response for `nativeEventName`, if any. + * A no-op for every event outside the response matrix above. + * + * @param nativeEventName - The Cursor-native event name (`hook_event_name` + * on the raw/transformed payload - never the internal name it maps onto). + */ +export function writeCursorResponse(nativeEventName: string): void { + const response = CURSOR_STDOUT_RESPONSES[nativeEventName]; + if (response) { + process.stdout.write(`${response}\n`); + } +} diff --git a/src/agents/registry.ts b/src/agents/registry.ts index d0a655de4..7948c8376 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -9,6 +9,7 @@ import { KimiPlugin } from './plugins/kimi/kimi.plugin.js'; import { KimiAcpPlugin } from './plugins/kimi/kimi-acp.plugin.js'; import { OpenWikiPlugin } from './plugins/openwiki/openwiki.plugin.js'; import { CopilotCliPlugin } from './plugins/copilot-cli/index.js'; +import { CursorIdePlugin } from './plugins/cursor-ide/cursor-ide.plugin.js'; import { AgentAdapter, AgentAnalyticsAdapter } from './core/types.js'; // Re-export for backwards compatibility @@ -43,6 +44,7 @@ export class AgentRegistry { AgentRegistry.registerPlugin(new KimiAcpPlugin()); AgentRegistry.registerPlugin(new OpenWikiPlugin()); AgentRegistry.registerPlugin(new CopilotCliPlugin()); + AgentRegistry.registerPlugin(new CursorIdePlugin()); AgentRegistry.initialized = true; } diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index 5e91b9fed..b4eb564b0 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -5,6 +5,7 @@ import { getSessionPath, getSessionMetricsPath, getSessionConversationPath } fro import { SESSION_ORIGIN, SESSION_ORIGIN_ENV_KEY } from '@/agents/core/session/types.js'; import type { BaseHookEvent, HookTransformer, MCPConfigSummary, ExtensionsScanSummary } from '@/agents/core/types.js'; import type { ProcessingContext } from '@/agents/core/session/BaseProcessor.js'; +import { forwardOtlpEvent } from '@/agents/plugins/cursor-ide/cursor-ide.otlp-forwarder.js'; /** * Hook event handlers for agent lifecycle events @@ -130,8 +131,16 @@ function getConfigValue(envKey: string, config?: HookProcessingConfig): string | * @returns The CodeMie session ID from environment * @throws Error if required environment variables are missing */ -function initializeLoggerContext(): string { - const agentName = process.env.CODEMIE_AGENT; +/** + * Resolve the agent name for this hook invocation. + * + * Precedence: explicit `--agent ` flag beats `CODEMIE_AGENT` env; when + * neither is present the current throwing behavior is unchanged (a hook + * process CodeMie did not spawn, e.g. Cursor's `hooks.json` has no `env` key + * to inherit `CODEMIE_AGENT` through, so it must self-identify via the flag). + */ +function resolveAgentName(agentFlag?: string): string { + const agentName = agentFlag || process.env.CODEMIE_AGENT; if (!agentName) { // Debug: Log all environment variables that start with CODEMIE_ const codemieEnvVars = Object.keys(process.env) @@ -141,9 +150,27 @@ function initializeLoggerContext(): string { console.error(`[hook:debug] CODEMIE_AGENT missing. Available CODEMIE_* vars: ${codemieEnvVars || 'none'}`); throw new Error('CODEMIE_AGENT environment variable is required'); } + return agentName; +} - // Use CODEMIE_SESSION_ID from environment - const sessionId = process.env.CODEMIE_SESSION_ID; +/** + * Initialize logger context using CODEMIE_SESSION_ID + * + * Uses CODEMIE_SESSION_ID from environment for: + * - Logging (logger.setSessionId) + * - Session files (~/.codemie/sessions/{sessionId}.json) + * - Metrics files (~/.codemie/sessions/{sessionId}_metrics.jsonl) + * - Conversation files (~/.codemie/sessions/{sessionId}_conversation.jsonl) + * + * @param agentName - Resolved agent name (flag, then CODEMIE_AGENT env) + * @returns The CodeMie session ID from environment + * @throws Error if required environment variables are missing + */ +function initializeLoggerContext(agentName: string, fallbackSessionId?: string): string { + // Use CODEMIE_SESSION_ID from environment, falling back to a payload-derived + // session id (e.g. cursor-ide's transformed conversation_id) when a hook + // process CodeMie did not spawn has no CODEMIE_SESSION_ID to inherit. + const sessionId = process.env.CODEMIE_SESSION_ID || fallbackSessionId; if (!sessionId) { throw new Error('CODEMIE_SESSION_ID environment variable is required'); } @@ -495,9 +522,9 @@ async function accumulateActiveDuration(sessionId: string): Promise { * Handle UserPromptSubmit event * Starts activity tracking to measure active session time */ -async function handleUserPromptSubmit(event: BaseHookEvent, sessionId: string, config?: HookProcessingConfig): Promise { +async function handleUserPromptSubmit(event: BaseHookEvent, sessionId: string, config?: HookProcessingConfig, agentName?: string): Promise { logger.info(`[hook:UserPromptSubmit] ${JSON.stringify(event)}`); - await enforceAnalyticsAuthGate(config); + await enforceAnalyticsAuthGate(config, agentName); await startActivityTracking(sessionId); } @@ -514,7 +541,7 @@ async function handleUserPromptSubmit(event: BaseHookEvent, sessionId: string, c * disappear from metrics. The marker is cleared by `codemie profile login` * and by any successful metrics send. */ -async function enforceAnalyticsAuthGate(config?: HookProcessingConfig): Promise { +async function enforceAnalyticsAuthGate(config?: HookProcessingConfig, agentName?: string): Promise { try { const provider = getConfigValue('CODEMIE_PROVIDER', config); const ssoUrl = getConfigValue('CODEMIE_URL', config); @@ -559,9 +586,10 @@ async function enforceAnalyticsAuthGate(config?: HookProcessingConfig): Promise< logger.warn(`[hook:UserPromptSubmit] Blocking prompt: ${reason}`); - if (config) { - // Programmatic mode (e.g. VSCode extension): let the host decide how to - // surface the failure instead of exiting its process + if (config || agentNeverBlocks(agentName)) { + // Programmatic mode (e.g. VSCode extension), or an agent that declares + // `hookConfig.neverBlockingExit`: let the caller decide how to surface + // the failure instead of exiting the process with a blocking code. throw new Error(message); } @@ -569,7 +597,7 @@ async function enforceAnalyticsAuthGate(config?: HookProcessingConfig): Promise< console.error(message); process.exit(2); // Blocking: stderr is fed back to the agent } catch (error) { - if (config) { + if (config || agentNeverBlocks(agentName)) { throw error; } // The gate itself must never break the prompt flow @@ -671,7 +699,7 @@ async function routeHookEvent(event: BaseHookEvent, rawInput: string, sessionId: const normalizedEventName = normalizeEventName(originalEventName, agentName); logger.info(`[hook:router] Normalized event name: "${normalizedEventName}"`); - switch (normalizedEventName) { +switch (normalizedEventName) { case 'SessionStart': logger.info(`[hook:router] Calling handleSessionStart`); await handleSessionStart(event as SessionStartEvent, rawInput, sessionId, config); @@ -690,7 +718,7 @@ async function routeHookEvent(event: BaseHookEvent, rawInput: string, sessionId: break; case 'UserPromptSubmit': logger.info(`[hook:router] Calling handleUserPromptSubmit`); - await handleUserPromptSubmit(event, sessionId, config); + await handleUserPromptSubmit(event, sessionId, config, agentName); break; case 'SubagentStop': logger.info(`[hook:router] Calling handleSubagentStop`); @@ -700,7 +728,7 @@ async function routeHookEvent(event: BaseHookEvent, rawInput: string, sessionId: logger.info(`[hook:router] Calling handlePreCompact`); await handlePreCompact(event); break; - default: +default: logger.info(`[hook:router] Unsupported event: ${normalizedEventName} (silently ignored)`); return; } @@ -1298,32 +1326,114 @@ async function sendSessionEndMetrics(event: SessionEndEvent, sessionId: string, } } +/** + * Look up whether an agent has declared `neverBlockingExit` on its + * `metadata.hookConfig` — a fully declarative gate (no agent-name literal) + * that lets a hook path degrade to non-blocking failure instead of exiting + * non-zero. Set only by agents (e.g. cursor-ide) whose host never tolerates + * a blocking exit code. + */ +function agentNeverBlocks(agentName?: string): boolean { + if (!agentName) { + return false; + } + try { + const agent = AgentRegistry.getAgent(agentName); + return Boolean(agent?.metadata?.hookConfig?.neverBlockingExit); + } catch { + return false; + } +} + +/** + * Look up whether an agent has declared `otlpIngestion` on its + * `metadata.hookConfig` — lets the hook action forward OTLP events + * directly to the proxy daemon and bypass the shared pipeline. + */ +function agentOtlpIngestion(agentName?: string): boolean { + if (!agentName) { + return false; + } + try { + const agent = AgentRegistry.getAgent(agentName); + return Boolean(agent?.metadata?.hookConfig?.otlpIngestion); + } catch { + return false; + } +} + +/** + * Whether an agent has declared a stdout response contract + * (`metadata.hookConfig.writeStdoutResponse`) - a fully declarative check + * (no agent-name literal) used to decide whether this logger's own + * CODEMIE_DEBUG console output must stay off stdout for the rest of this + * process (see `writeAgentStdoutResponse` below). + */ +function agentHasStdoutResponseContract(agentName?: string): boolean { + if (!agentName) { + return false; + } + try { + const agent = AgentRegistry.getAgent(agentName); + return typeof agent?.metadata?.hookConfig?.writeStdoutResponse === 'function'; + } catch { + return false; + } +} + +/** + * Write an agent's declarative stdout response contract, if it has one + * (`metadata.hookConfig.writeStdoutResponse` - see cursor-ide.response.ts). + * A no-op for every agent that doesn't declare one. Never throws: a + * response-writer failure must not turn a successful hook into a failed one. + * + * @param agentName - Resolved agent name + * @param nativeEventName - The agent-native event name (`event.hook_event_name`) + */ +function writeAgentStdoutResponse(agentName: string | undefined, nativeEventName: string): void { + if (!agentName) { + return; + } + try { + const agent = AgentRegistry.getAgent(agentName); + const writer = agent?.metadata?.hookConfig?.writeStdoutResponse; + if (typeof writer === 'function') { + writer(nativeEventName); + } + } catch (error) { + logger.debug('[hook] Failed to write agent stdout response (non-blocking):', error); + } +} + /** * Validate hook event required fields * @param event - Hook event to validate * @param config - Optional configuration object (if provided, throws errors; otherwise sets exitCode) - * @throws Error if validation fails and config is provided + * @param agentName - Resolved agent name (CLI mode only); used to look up the declarative + * `neverBlockingExit` hook-config flag via AgentRegistry + * @throws Error if validation fails and config is provided, or the resolved agent + * declares `neverBlockingExit` */ -function validateHookEvent(event: BaseHookEvent, config?: HookProcessingConfig): void { - if (!event.session_id) { - const error = new Error('Missing required field: session_id'); - if (config) { +function validateHookEvent(event: BaseHookEvent, config?: HookProcessingConfig, agentName?: string): void { + const neverBlocks = agentNeverBlocks(agentName); + + const fail = (message: string): void => { + const error = new Error(message); + if (config || neverBlocks) { throw error; } - logger.error('[hook] Missing required field: session_id'); + logger.error(`[hook] ${message}`); logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); process.exitCode = 2; + }; + + if (!event.session_id) { + fail('Missing required field: session_id'); return; } if (!event.hook_event_name) { - const error = new Error('Missing required field: hook_event_name'); - if (config) { - throw error; - } - logger.error('[hook] Missing required field: hook_event_name'); - logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); - process.exitCode = 2; + fail('Missing required field: hook_event_name'); return; } @@ -1333,23 +1443,21 @@ function validateHookEvent(event: BaseHookEvent, config?: HookProcessingConfig): const transcriptOptionalEvents = ['SessionStart', 'SessionEnd']; const hasTranscriptPath = Boolean(event.transcript_path) || (event.transcript_paths && event.transcript_paths.length > 0); if (!hasTranscriptPath && !transcriptOptionalEvents.includes(event.hook_event_name)) { - const error = new Error('Missing required field: transcript_path'); - if (config) { - throw error; - } - logger.error('[hook] Missing required field: transcript_path'); - logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); - process.exitCode = 2; - return; + fail('Missing required field: transcript_path'); } } /** * Initialize hook context (logger and session/agent info) * @param config - Optional configuration object (if not provided, reads from environment variables) + * @param agentFlag - Optional `--agent ` CLI flag value (CLI mode only); beats `CODEMIE_AGENT` env * @returns Object with sessionId and agentName */ -function initializeHookContext(config?: HookProcessingConfig): { sessionId: string; agentName: string } { +function initializeHookContext( + config?: HookProcessingConfig, + agentFlag?: string, + fallbackSessionId?: string +): { sessionId: string; agentName: string } { let sessionId: string; let agentName: string; @@ -1365,9 +1473,9 @@ function initializeHookContext(config?: HookProcessingConfig): { sessionId: stri logger.setProfileName(config.profileName); } } else { - // Use environment variables (CLI mode) - sessionId = initializeLoggerContext(); - agentName = process.env.CODEMIE_AGENT || 'unknown'; + // Use environment variables (CLI mode), with the --agent flag taking precedence + agentName = resolveAgentName(agentFlag); + sessionId = initializeLoggerContext(agentName, fallbackSessionId); } return { sessionId, agentName }; @@ -1427,7 +1535,7 @@ function normalizeAndLogEvent(event: BaseHookEvent, sessionId: string, agentName */ export async function processEvent(event: BaseHookEvent, config?: HookProcessingConfig): Promise { // Validate required fields - validateHookEvent(event, config); + validateHookEvent(event, config, config?.agentName); if (process.exitCode === 2) { return; // Validation failed in CLI mode } @@ -1454,13 +1562,36 @@ export async function processEvent(event: BaseHookEvent, config?: HookProcessing export function createHookCommand(): Command { return new Command('hook') .description('Unified hook event handler (called by agent plugins)') - .action(async () => { + .option('--agent ', 'Agent name for hook attribution (overrides CODEMIE_AGENT)') + .action(async (opts: { agent?: string }) => { const hookStartTime = Date.now(); let event: BaseHookEvent | null = null; + // Hoisted so the catch block can also resolve declarative agent gating + // (agentNeverBlocks/writeAgentStdoutResponse) after a failure. + let agentName: string | undefined; try { + // Resolve the agent name up front (flag beats CODEMIE_AGENT env) so + // agent-specific gating (e.g. non-blocking exit) is known even before + // stdin is read/parsed. + agentName = resolveAgentName(opts.agent); + + // An agent that declares a stdout response contract (e.g. cursor-ide) + // needs stdout to carry only that response - suppress this logger's + // own CODEMIE_DEBUG console mirror before the very first AgentRegistry + // lookup below (which lazily initializes every plugin and would + // otherwise log its own bootstrap to stdout ahead of the check's + // answer), then restore normal behavior once we know it wasn't needed. + logger.setStdoutSuppressed(true); + if (!agentHasStdoutResponseContract(agentName)) { + logger.setStdoutSuppressed(false); + } + // Read JSON from stdin - const input = await readStdin(); + const rawInput = await readStdin(); + // Strip UTF-8 BOM (U+FEFF) that Windows processes may prepend. + // JSON.parse rejects BOM; stripping here fixes the issue on Windows. + const input = rawInput.charCodeAt(0) === 0xFEFF ? rawInput.slice(1) : rawInput; // Log raw input at debug level (may contain sensitive data) logger.debug(`[hook] Received input (${input.length} bytes)`); @@ -1472,44 +1603,50 @@ export function createHookCommand(): Command { const parseMsg = parseError instanceof Error ? parseError.message : String(parseError); logger.error(`[hook] Failed to parse JSON input: ${parseMsg}`); logger.debug(`[hook] Invalid JSON: ${input.substring(0, 200)}...`); + if (agentNeverBlocks(agentName)) { + return; // Non-blocking agent: fail without exiting 2 + } process.exit(2); // Blocking error } - // Validate required fields from hook input schema - if (!event.session_id) { - logger.error('[hook] Missing required field: session_id'); - logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); - process.exit(2); // Blocking error - } - - if (!event.hook_event_name) { - logger.error('[hook] Missing required field: hook_event_name'); - logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); - process.exit(2); // Blocking error + // OTLP ingestion bypass: if agent declares otlpIngestion, forward the + // raw event to the local proxy daemon and exit immediately, bypassing + // the shared transform/validate/route pipeline and its legacy analytics. + if (agentOtlpIngestion(agentName)) { + await forwardOtlpEvent(input, agentName); + writeAgentStdoutResponse(agentName, event.hook_event_name); + await logger.close(); + process.exitCode = 0; + return; } - // Initialize logger context using CODEMIE_SESSION_ID from environment - // This ensures consistent session ID across all hooks - const { sessionId, agentName } = initializeHookContext(); - - // Apply hook transformation if agent provides a transformer. - // Some agents (e.g. Kimi) do not emit a transcript_path in their raw - // hook payload; the transformer computes it from agent-specific session - // layout before we validate the internal event shape. + // Apply hook transformation if agent provides a transformer, before + // initializing logger/session context. Some agents (e.g. Kimi) do + // not emit a transcript_path in their raw hook payload; others (e.g. + // cursor-ide) send conversation_id instead of session_id. The + // transformer computes/maps these fields before we resolve the + // CodeMie session id or validate the internal event shape. const transformedEvent = applyHookTransformation(event, agentName); - // Validate required fields after transformation so agent-specific - // transformers can populate fields such as transcript_path. - validateHookEvent(transformedEvent); + // Initialize logger context using CODEMIE_SESSION_ID from environment, + // falling back to the transform-derived session id when a hook + // process CodeMie did not spawn has nothing to inherit it from. + const { sessionId, agentName: resolvedAgentName } = initializeHookContext( + undefined, + agentName, + transformedEvent.session_id + ); + + validateHookEvent(transformedEvent, undefined, resolvedAgentName); if (process.exitCode === 2) { return; // Validation failed } // Normalize event name and log processing info - normalizeAndLogEvent(transformedEvent, sessionId, agentName); + normalizeAndLogEvent(transformedEvent, sessionId, resolvedAgentName); // Route to appropriate handler with transformed event and session ID - await routeHookEvent(transformedEvent, input, sessionId, agentName); + await routeHookEvent(transformedEvent, input, sessionId, resolvedAgentName); // Log successful completion const totalDuration = Date.now() - hookStartTime; @@ -1517,6 +1654,12 @@ export function createHookCommand(): Command { `[hook] Completed ${event.hook_event_name} event successfully (${totalDuration}ms)` ); + // Declarative per-agent stdout response contract (e.g. cursor-ide's + // {"permission":"allow"}/{"continue":true}) - a no-op for every + // agent that doesn't declare one. Uses the agent-native event name, + // which transformers leave unmutated on the transformed event. + writeAgentStdoutResponse(resolvedAgentName, transformedEvent.hook_event_name); + // Flush logger before exit to ensure write completes await logger.close(); // Use process.exitCode instead of process.exit() to allow graceful shutdown @@ -1544,9 +1687,19 @@ export function createHookCommand(): Command { // Flush logger before exit await logger.close(); - // Use process.exitCode instead of process.exit() to allow graceful shutdown - // This prevents Windows libuv UV_HANDLE_CLOSING assertion failures - process.exitCode = 1; + + // An agent that declares `hookConfig.neverBlockingExit` (e.g. + // cursor-ide) must never see a non-zero exit, even from an internal + // failure - still write its stdout response contract so the host + // doesn't stall waiting on a response that will never arrive. + if (agentNeverBlocks(agentName)) { + writeAgentStdoutResponse(agentName, event?.hook_event_name || ''); + process.exitCode = 0; + } else { + // Use process.exitCode instead of process.exit() to allow graceful shutdown + // This prevents Windows libuv UV_HANDLE_CLOSING assertion failures + process.exitCode = 1; + } } }); } diff --git a/src/cli/commands/proxy/__tests__/connect-wiring.test.ts b/src/cli/commands/proxy/__tests__/connect-wiring.test.ts index cc890c03e..09712ff4b 100644 --- a/src/cli/commands/proxy/__tests__/connect-wiring.test.ts +++ b/src/cli/commands/proxy/__tests__/connect-wiring.test.ts @@ -44,7 +44,7 @@ describe('proxy connect — unified command and deprecated aliases', () => { expect(connectTargets).toHaveBeenCalledWith( expect.objectContaining({ - targets: { claudeDesktop: true, vscode: true, vscodeClaudeCode: true, codexDesktop: false }, + targets: { claudeDesktop: true, vscode: true, vscodeClaudeCode: true, codexDesktop: false, cursorIde: false }, }) ); }); @@ -175,7 +175,7 @@ describe('deprecated aliases forward their flags under real CLI nesting', () => expect(connectTargets).toHaveBeenCalledWith( expect.objectContaining({ - targets: { claudeDesktop: false, vscode: true, vscodeClaudeCode: false, codexDesktop: false }, + targets: { claudeDesktop: false, vscode: true, vscodeClaudeCode: false, codexDesktop: false, cursorIde: false }, profile: 'p', }) ); @@ -208,6 +208,6 @@ describe('proxy connect --codex-desktop and proxy disconnect', () => { await createProxyCommand().parseAsync(['disconnect', '--codex-desktop'], { from: 'user' }); - expect(disconnectTargets).toHaveBeenCalledWith({ targets: { codexDesktop: true } }); + expect(disconnectTargets).toHaveBeenCalledWith({ targets: { codexDesktop: true, cursorIde: false } }); }); }); diff --git a/src/cli/commands/proxy/__tests__/index.test.ts b/src/cli/commands/proxy/__tests__/index.test.ts index c21ac3cd3..01da3759a 100644 --- a/src/cli/commands/proxy/__tests__/index.test.ts +++ b/src/cli/commands/proxy/__tests__/index.test.ts @@ -281,7 +281,7 @@ describe('proxy connect desktop', () => { await createProxyCommand().parseAsync(['connect', 'desktop'], { from: 'user' }); - expect(ConfigLoader.load).toHaveBeenCalledWith(process.cwd()); + expect(ConfigLoader.load).toHaveBeenCalledWith(process.cwd(), undefined); expect(spawnDaemon).toHaveBeenCalledWith(expect.objectContaining({ profile: 'selected-profile', })); diff --git a/src/cli/commands/proxy/connect-orchestrator.ts b/src/cli/commands/proxy/connect-orchestrator.ts index fe5392cb7..5f35c9365 100644 --- a/src/cli/commands/proxy/connect-orchestrator.ts +++ b/src/cli/commands/proxy/connect-orchestrator.ts @@ -18,6 +18,7 @@ import { import { logger } from '../../../utils/logger.js'; import { sanitizeLogArgs } from '../../../utils/security.js'; import { syncRegisteredSkills } from '../skills/setup/sync.js'; +import { ensureApiBase } from '../../../providers/core/codemie-auth-helpers.js'; import { syncPluginSkills } from '../skills/setup/sync-plugin.js'; import { checkStatus, @@ -47,6 +48,7 @@ import { selectCodexModel, writeCodexDesktopConfig, } from './connectors/codex-desktop.js'; +import { writeCursorIdeHooksConfig } from './connectors/cursor-ide.js'; export const DEFAULT_DAEMON_PORT = 4001; @@ -58,6 +60,7 @@ export interface ConnectTargets { vscode?: boolean; vscodeClaudeCode?: boolean; codexDesktop?: boolean; + cursorIde?: boolean; } /** Options for a unified `connect` run (built by the command/alias wrappers). */ @@ -69,10 +72,12 @@ export interface ConnectOptions { verbose?: boolean; /** Pin a specific model for the Codex desktop target. */ model?: string; + /** Gate for the cursor-ide target — analytics-only hook ingestion. */ + analytics?: boolean; } /** Effective client type used by `daemonMatchesRequest`. */ -export type EffectiveClientType = 'claude-desktop' | 'vscode-byok' | 'codex-desktop'; +export type EffectiveClientType = 'claude-desktop' | 'vscode-byok' | 'codex-desktop' | 'cursor-ide'; /** * The daemon identity for a target set. `spawnOptions` is byte-identical to the @@ -86,7 +91,8 @@ export interface DaemonIdentity { spawnOptions: | { telemetryMode: 'claude-desktop' } | { clientType: 'vscode-byok' } - | { clientType: 'codex-desktop' }; + | { clientType: 'codex-desktop' } + | { clientType: 'cursor-ide' }; } /** @@ -102,6 +108,9 @@ export function deriveDaemonIdentity(targets: ConnectTargets): DaemonIdentity { if (targets.codexDesktop) { return { clientType: 'codex-desktop', spawnOptions: { clientType: 'codex-desktop' } }; } + if (targets.cursorIde) { + return { clientType: 'cursor-ide', spawnOptions: { clientType: 'cursor-ide' } }; + } return { clientType: 'vscode-byok', spawnOptions: { clientType: 'vscode-byok' } }; } @@ -204,13 +213,19 @@ export async function resolveSsoProxyConfig( }; } - const activeConfig = await ConfigLoader.load(process.cwd()); + // Resolve the active profile by name first so ConfigLoader.load()'s + // profile-protection branch engages - otherwise env vars like + // CODEMIE_BASE_URL silently clobber the profile's baseUrl below. + const activeProfileName = await ConfigLoader.getActiveProfileName(process.cwd()); + const activeConfig = await ConfigLoader.load( + process.cwd(), + activeProfileName ? { name: activeProfileName } : undefined + ); const activeProvider = ProviderRegistry.getProvider(activeConfig.provider ?? ''); if (activeProvider?.authType === 'sso') { return { config: activeConfig, profileSource: 'active' }; } - const activeProfileName = await ConfigLoader.getActiveProfileName(process.cwd()); const available = await listCodeMieProfiles(); const providerName = activeConfig.provider ?? 'unknown'; const details = available.length > 0 @@ -269,18 +284,20 @@ const TARGET_LIST = [ ' --vscode VS Code Copilot Chat models (BYOK)', ' --vscode-claude-code VS Code Claude Code extension', ' --codex-desktop Codex desktop app (writes ~/.codex/config.toml)', + ' --cursor-ide Cursor IDE — writes .cursor/hooks.json (requires --analytics)', '', 'Examples:', ' codemie proxy connect --claude-desktop', ' codemie proxy connect --codex-desktop', ' codemie proxy connect --vscode --vscode-claude-code', ' codemie proxy connect --claude-desktop --vscode --insiders', + ' codemie proxy connect --cursor-ide --analytics', '', "Run 'codemie proxy connect --help' for all options.", ].join('\n'); function hasAnyTarget(t: ConnectTargets): boolean { - return Boolean(t.claudeDesktop || t.vscode || t.vscodeClaudeCode || t.codexDesktop); + return Boolean(t.claudeDesktop || t.vscode || t.vscodeClaudeCode || t.codexDesktop || t.cursorIde); } /** A human label and the base command to echo in remediation messages. */ @@ -291,6 +308,7 @@ function describeTargets(t: ConnectTargets): { label: string; commandExample: st if (t.vscode) { flags.push('--vscode'); labels.push('VS Code'); } if (t.vscodeClaudeCode) { flags.push('--vscode-claude-code'); labels.push('VS Code Claude Code'); } if (t.codexDesktop) { flags.push('--codex-desktop'); labels.push('Codex Desktop'); } + if (t.cursorIde) { flags.push('--cursor-ide'); labels.push('Cursor IDE'); } const label = labels.length === 1 ? labels[0] : 'CodeMie'; return { label, commandExample: `codemie proxy connect ${flags.join(' ')}` }; } @@ -360,7 +378,10 @@ async function ensureDaemon( port: DEFAULT_DAEMON_PORT, project: config.codeMieProject, ...identity.spawnOptions, - syncApiUrl: config.ssoConfig?.apiUrl, + // config.ssoConfig is never populated anywhere in this codebase - it's a + // dead field. Fall back to codeMieUrl/baseUrl, the same convention + // sso.models.ts uses to resolve the CodeMie backend API URL. + syncApiUrl: config.codeMieUrl ? ensureApiBase(config.codeMieUrl) : config.baseUrl, syncCodeMieUrl: config.codeMieUrl, }); startedInThisRun = true; @@ -587,13 +608,69 @@ async function runCodexDesktop( /** Test seam \u2014 the runner is otherwise only reachable through `connectTargets`. */ export const runCodexDesktopForTest = runCodexDesktop; +interface CursorIdeRunOptions { + force?: boolean; +} + +/** + * Writes/merges `.cursor/hooks.json`. cursor-ide now goes through the same + * daemon lifecycle as every other target (its hooks forward OTLP events to + * the daemon's `/v1/otlp/hook-events` route) \u2014 this just writes the hooks + * config file itself, dispatched alongside the other per-target runners + * after the daemon is ensured. + */ +async function runCursorIde(options: CursorIdeRunOptions): Promise { + const label = 'Cursor IDE'; + try { + const result = await writeCursorIdeHooksConfig({ force: options.force }); + console.log(chalk.green(`\u2713 Cursor IDE hooks configured (${result.path})`)); + console.log(chalk.dim(` ${result.events.length} event(s) wired to codemie hook --agent cursor-ide`)); + if (result.backupPath) { + console.log(chalk.dim(` Backup written: ${result.backupPath}`)); + } + console.log(chalk.yellow(' Workspace must be trusted for project hooks to run in Cursor.')); + console.log(chalk.dim(' Cursor hot-reloads hooks.json; restart Cursor if hooks do not pick up.')); + console.log(chalk.dim(' Enable transcripts in Cursor for transcript_path to be populated.')); + console.log(chalk.dim( + ' Cloud agents skip sessionStart/sessionEnd, the MCP hooks, the Tab hooks, and workspaceOpen.' + )); + return { label, ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn('[proxy] Failed to configure Cursor IDE hooks', ...sanitizeLogArgs({ error: message })); + console.log(chalk.yellow(` Could not configure Cursor IDE hooks: ${message}`)); + return { label, ok: false, error: message }; + } +} + +/** Test seam \u2014 the runner is otherwise only reachable through `connectTargets`. */ +export const runCursorIdeForTest = runCursorIde; + export async function connectTargets(opts: ConnectOptions): Promise { const { targets } = opts; + const analytics = Boolean(opts.analytics); + + // --analytics carries no target flag of its own, so it must be checked + // ahead of hasAnyTarget — otherwise "--analytics" alone (or with unrelated + // flags but no target) silently falls through to the generic target list + // instead of explaining that --analytics only applies to --cursor-ide. + if (analytics && !targets.cursorIde) { + console.log(chalk.yellow('Note: --analytics has no effect without --cursor-ide.')); + return; + } + if (!hasAnyTarget(targets)) { console.log(TARGET_LIST); return; } + if (targets.cursorIde && !analytics) { + console.log(chalk.yellow( + 'Note: --cursor-ide requires --analytics. Re-run with --cursor-ide --analytics.' + )); + return; + } + const verbose = Boolean(opts.verbose); const insiders = Boolean(opts.insiders); @@ -690,6 +767,7 @@ export async function connectTargets(opts: ConnectOptions): Promise { verbose, })); } + if (targets.cursorIde) results.push(await runCursorIde({ force: Boolean(opts.force) })); const anyFailed = results.some((r) => !r.ok); const allFailed = results.every((r) => !r.ok); diff --git a/src/cli/commands/proxy/connectors/cursor-ide.ts b/src/cli/commands/proxy/connectors/cursor-ide.ts new file mode 100644 index 000000000..0e05fd317 --- /dev/null +++ b/src/cli/commands/proxy/connectors/cursor-ide.ts @@ -0,0 +1,331 @@ +/** + * Writes and merges `.cursor/hooks.json` at the project root, wiring Cursor's + * full native hook surface (21 events) onto `codemie hook --agent cursor-ide`. + * + * Mirrors the read-merge-write-atomically shape of `vscode-claude-code.ts` + * (`{written, path}` result, a `...AtPath` test seam) and the backup-on-first- + * modification shape of `codex-desktop.ts`. Unlike those two, `.cursor/hooks.json` + * is merged additively by Cursor from every config source, so this connector + * must upsert - never clobber a user's existing file or foreign hook entries. + */ + +import { existsSync } from 'node:fs'; +import { copyFile, readFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ConfigurationError } from '@/utils/errors.js'; +import { logger } from '@/utils/logger.js'; +import { sanitizeLogArgs } from '@/utils/security.js'; +import { resolveProjectRoot } from '@/utils/project-root.js'; +import { writeAtomically } from './vscode.js'; + +export const CURSOR_IDE_HOOKS_BACKUP_SUFFIX = '.codemie-backup'; + +/** + * Cursor's full native hook surface (21 events). This list is used to wire + * all events into `.cursor/hooks.json` pointing to `codemie hook --agent cursor-ide`. + */ +export const CURSOR_IDE_HOOK_EVENTS: readonly string[] = [ + 'sessionStart', + 'sessionEnd', + 'beforeSubmitPrompt', + 'stop', + 'preCompact', + 'subagentStart', + 'subagentStop', + 'preToolUse', + 'beforeShellExecution', + 'beforeMCPExecution', + 'beforeReadFile', + 'beforeTabFileRead', + 'postToolUse', + 'afterShellExecution', + 'afterMCPExecution', + 'afterFileEdit', + 'afterTabFileEdit', + 'postToolUseFailure', + 'afterAgentResponse', + 'afterAgentThought', + 'workspaceOpen', +]; + +// Identifies a CodeMie-authored entry so re-runs are idempotent even after +// the resolved binary path changes (absolute paths vary by machine/install). +const CODEMIE_COMMAND_MARKER = 'hook --agent cursor-ide'; + +interface CursorHookEntry { + [key: string]: unknown; + command?: unknown; + timeout?: unknown; + failClosed?: unknown; +} + +interface CursorHooksConfig { + [key: string]: unknown; + version?: unknown; + hooks?: Record; +} + +export interface WriteCursorIdeHooksResult { + written: boolean; + path: string; + backupPath: string | null; + events: string[]; +} + +function isCodemieEntry(entry: unknown): boolean { + return ( + typeof entry === 'object' && + entry !== null && + typeof (entry as CursorHookEntry).command === 'string' && + (entry as CursorHookEntry).command!.toString().includes(CODEMIE_COMMAND_MARKER) + ); +} + +async function readHooksConfig(configPath: string): Promise { + if (!existsSync(configPath)) return {}; + + let raw: string; + try { + raw = await readFile(configPath, 'utf-8'); + } catch (error) { + throw new ConfigurationError( + `Failed to read Cursor hooks config at ${configPath}: ` + + `${error instanceof Error ? error.message : String(error)}` + ); + } + + if (raw.trim().length === 0) return {}; + + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new ConfigurationError(`Cursor hooks config must contain a JSON object: ${configPath}`); + } + return parsed as CursorHooksConfig; + } catch (error) { + if (error instanceof ConfigurationError) throw error; + throw new ConfigurationError( + `Cursor hooks config at ${configPath} is not valid JSON and was not changed.` + ); + } +} + +/** + * Backup on first modification only - keyed on whether a codemie entry is + * already present, mirroring `codex-desktop.ts`'s `backupIfUnmanaged`. A + * config that already carries our entries has its true pre-CodeMie state in + * the existing backup (if any); a fresh backup at that point would enshrine + * our own entries as "the original". + */ +async function backupIfUnmanaged(configPath: string, config: CursorHooksConfig): Promise { + if (!existsSync(configPath)) return null; + + const backupPath = `${configPath}${CURSOR_IDE_HOOKS_BACKUP_SUFFIX}`; + const hooks = config.hooks ?? {}; + const alreadyManaged = Object.values(hooks).some((entries) => + Array.isArray(entries) && entries.some(isCodemieEntry) + ); + + if (alreadyManaged) { + if (existsSync(backupPath)) { + return backupPath; + } + // Managed but the backup is gone - nothing safe to reconstruct here + // (unlike Codex's TOML, we cannot cheaply strip just our entries out of + // an arbitrary hooks.json without already having done the merge), so + // skip rather than risk enshrining our own entries as the "original". + return null; + } + + await copyFile(configPath, backupPath); + return backupPath; +} + +/** + * Upsert exactly one CodeMie entry per event key, preserving every foreign + * entry and every foreign top-level key. Never deletes a user entry. + */ +function mergeHooksConfig( + existing: CursorHooksConfig, + command: string +): { config: CursorHooksConfig; events: string[] } { + const hooks: Record = { ...(existing.hooks ?? {}) }; + const events: string[] = []; + + for (const eventName of CURSOR_IDE_HOOK_EVENTS) { + const existingEntries: unknown[] = Array.isArray(hooks[eventName]) ? (hooks[eventName] as unknown[]) : []; + const foreignEntries = existingEntries.filter((entry) => !isCodemieEntry(entry)); + const codemieEntry: CursorHookEntry = { + command, + timeout: 10, + failClosed: false, + }; + hooks[eventName] = [...foreignEntries, codemieEntry]; + events.push(eventName); + } + + return { + config: { + ...existing, + version: existing.version ?? 1, + hooks, + }, + events, + }; +} + +/** + * Write/merge `.cursor/hooks.json` at an explicit path. Test seam mirroring + * `writeVsCodeClaudeCodeConfigAtPath` - the resolving wrapper below is the + * one every real caller uses. + */ +export async function writeCursorIdeHooksConfigAtPath( + configPath: string +): Promise { + const existing = await readHooksConfig(configPath); + const backupPath = await backupIfUnmanaged(configPath, existing); + + const command = 'codemie hook --agent cursor-ide'; + const { config: merged, events } = mergeHooksConfig(existing, command); + + try { + await writeAtomically(configPath, `${JSON.stringify(merged, null, 2)}\n`); + } catch (error) { + throw new ConfigurationError( + `Failed to write Cursor hooks config at ${configPath}: ` + + `${error instanceof Error ? error.message : String(error)}` + ); + } + + logger.info( + '[proxy] Configured Cursor IDE hooks', + ...sanitizeLogArgs({ configPath, backupPath, eventCount: events.length }) + ); + + return { written: true, path: configPath, backupPath, events }; +} + +export interface WriteCursorIdeHooksOptions { + /** Project root to resolve `.cursor/hooks.json` under. Defaults to `resolveProjectRoot()`. */ + projectRoot?: string; + /** + * Accepted for signature parity with the other connectors' `{force}` option + * (e.g. `writeCodexDesktopConfig`) and with `connectTargets`'s per-target + * dispatch, which passes `opts.force` uniformly. The merge here is always + * additive/idempotent and never refuses to write, so this is currently a + * no-op - there is no unsafe state for `--force` to override. + */ + force?: boolean; +} + +/** + * Write/merge `.cursor/hooks.json` at `/.cursor/hooks.json`, + * where `projectRoot` resolves via the same shared `resolveProjectRoot()` + * Task 6's event log uses, so the two locations can never drift apart. + */ +export async function writeCursorIdeHooksConfig( + options: WriteCursorIdeHooksOptions = {} +): Promise { + const projectRoot = options.projectRoot ?? resolveProjectRoot(); + const configPath = join(projectRoot, '.cursor', 'hooks.json'); + return writeCursorIdeHooksConfigAtPath(configPath); +} + +export interface RemoveCursorIdeHooksResult { + removed: boolean; + usedBackup: boolean; + path: string | null; +} + +/** + * Everything that remains once codemie's own entries are gone. `hooks` is + * "empty" once every event key it held has been dropped, and the config is + * empty once no foreign top-level key survives either - `version` doesn't + * count, since `mergeHooksConfig` sets it unconditionally (defaulting to 1) + * even for a file we created from scratch. + */ +function isConfigEmpty(config: CursorHooksConfig): boolean { + const hooks = config.hooks ?? {}; + const hasRemainingHookEntries = Object.values(hooks).some( + (entries) => Array.isArray(entries) && entries.length > 0 + ); + if (hasRemainingHookEntries) return false; + + const otherKeys = Object.keys(config).filter((key) => key !== 'version' && key !== 'hooks'); + return otherKeys.length === 0; +} + +/** + * Remove only codemie-authored entries at an explicit path - the disconnect + * counterpart to `writeCursorIdeHooksConfigAtPath`. Test seam mirroring the + * write side; the resolving wrapper below is the one every real caller uses. + */ +export async function removeCursorIdeHooksConfigAtPath( + configPath: string +): Promise { + if (!existsSync(configPath)) { + return { removed: false, usedBackup: false, path: null }; + } + + const existing = await readHooksConfig(configPath); + const hooks = existing.hooks ?? {}; + const hadCodemieEntry = Object.values(hooks).some( + (entries) => Array.isArray(entries) && entries.some(isCodemieEntry) + ); + + if (!hadCodemieEntry) { + return { removed: false, usedBackup: false, path: configPath }; + } + + // Strip our entries, dropping an event key entirely once its list is empty + // rather than leaving `"eventName": []` behind. + const nextHooks: Record = {}; + for (const [eventName, entries] of Object.entries(hooks)) { + if (!Array.isArray(entries)) { + nextHooks[eventName] = entries; + continue; + } + const remaining = entries.filter((entry) => !isCodemieEntry(entry)); + if (remaining.length > 0) { + nextHooks[eventName] = remaining; + } + } + + const stripped: CursorHooksConfig = { ...existing, hooks: nextHooks }; + const backupPath = `${configPath}${CURSOR_IDE_HOOKS_BACKUP_SUFFIX}`; + let usedBackup = false; + + if (isConfigEmpty(stripped)) { + if (existsSync(backupPath)) { + // Codemie's entries were the file's only content - restore the true + // pre-connect original rather than writing a near-empty shell. + const backupContent = await readFile(backupPath, 'utf-8'); + await writeAtomically(configPath, backupContent); + usedBackup = true; + } else { + // No pre-connect backup exists, so we created this file ourselves; + // nothing else to preserve, so remove it entirely. + await unlink(configPath); + } + } else { + await writeAtomically(configPath, `${JSON.stringify(stripped, null, 2)}\n`); + } + + logger.info( + '[proxy] Removed Cursor IDE hooks', + ...sanitizeLogArgs({ configPath, usedBackup }) + ); + + return { removed: true, usedBackup, path: configPath }; +} + +/** + * Remove/merge `.cursor/hooks.json` at `/.cursor/hooks.json`, + * resolved the same way `writeCursorIdeHooksConfig` does. + */ +export async function removeCursorIdeHooksConfig( + projectRoot: string = resolveProjectRoot() +): Promise { + const configPath = join(projectRoot, '.cursor', 'hooks.json'); + return removeCursorIdeHooksConfigAtPath(configPath); +} diff --git a/src/cli/commands/proxy/disconnect-orchestrator.ts b/src/cli/commands/proxy/disconnect-orchestrator.ts index 5f1d9808f..86c5ea0c3 100644 --- a/src/cli/commands/proxy/disconnect-orchestrator.ts +++ b/src/cli/commands/proxy/disconnect-orchestrator.ts @@ -10,9 +10,11 @@ import { logger } from '@/utils/logger.js'; import { sanitizeLogArgs } from '@/utils/security.js'; import { removeCodexDesktopConfig } from './connectors/codex-desktop.js'; +import { removeCursorIdeHooksConfig } from './connectors/cursor-ide.js'; export interface DisconnectTargets { codexDesktop?: boolean; + cursorIde?: boolean; } export interface DisconnectOptions { @@ -23,17 +25,13 @@ const DISCONNECT_TARGET_LIST = [ 'Select at least one target to disconnect:', '', ' --codex-desktop Codex desktop app (removes the CodeMie block from ~/.codex/config.toml)', + ' --cursor-ide Cursor IDE (removes codemie-authored entries from .cursor/hooks.json)', '', 'Example:', ' codemie proxy disconnect --codex-desktop', ].join('\n'); -export async function disconnectTargets(opts: DisconnectOptions): Promise { - if (!opts.targets.codexDesktop) { - console.log(DISCONNECT_TARGET_LIST); - return; - } - +async function disconnectCodexDesktop(): Promise { try { const result = await removeCodexDesktopConfig(); @@ -56,3 +54,41 @@ export async function disconnectTargets(opts: DisconnectOptions): Promise process.exitCode = 1; } } + +async function disconnectCursorIde(): Promise { + try { + const result = await removeCursorIdeHooksConfig(); + + if (!result.removed) { + console.log(chalk.dim('Cursor IDE: nothing to disconnect.')); + return; + } + + console.log(chalk.green(`✓ Cursor IDE hooks removed (${result.path})`)); + if (result.usedBackup) { + console.log(chalk.yellow( + "⚠ Restored the pre-connect backup because CodeMie's entries were the file's only content." + )); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn('[proxy] Cursor IDE disconnect failed', ...sanitizeLogArgs({ error: message })); + console.error(chalk.red(`✗ Cursor IDE — ${message}`)); + process.exitCode = 1; + } +} + +export async function disconnectTargets(opts: DisconnectOptions): Promise { + if (!opts.targets.codexDesktop && !opts.targets.cursorIde) { + console.log(DISCONNECT_TARGET_LIST); + return; + } + + if (opts.targets.codexDesktop) { + await disconnectCodexDesktop(); + } + + if (opts.targets.cursorIde) { + await disconnectCursorIde(); + } +} diff --git a/src/cli/commands/proxy/index.ts b/src/cli/commands/proxy/index.ts index 7236e5643..be69f3879 100644 --- a/src/cli/commands/proxy/index.ts +++ b/src/cli/commands/proxy/index.ts @@ -35,6 +35,8 @@ interface UnifiedConnectOptions { vscode?: boolean; vscodeClaudeCode?: boolean; codexDesktop?: boolean; + cursorIde?: boolean; + analytics?: boolean; profile?: string; force?: boolean; verbose?: boolean; @@ -292,6 +294,8 @@ export function createProxyCommand(): Command { .option('--vscode', 'Configure VS Code Copilot Chat models — BYOK (writes chatLanguageModels.json)') .option('--vscode-claude-code', 'Configure the VS Code Claude Code extension (writes settings.json: ANTHROPIC_BASE_URL/token)') .option('--codex-desktop', 'Configure the Codex desktop app (writes ~/.codex/config.toml)') + .option('--cursor-ide', 'Configure Cursor IDE — writes .cursor/hooks.json (requires --analytics)') + .option('--analytics', 'Enable analytics-only hook ingestion (applies to --cursor-ide)') .option('--model ', 'Pin a specific model for --codex-desktop (default: best available)') .option('--profile ', 'Profile whose credentials to use') .option('--force', 'Stop any existing proxy and start a fresh one, even if it looks healthy') @@ -304,12 +308,14 @@ export function createProxyCommand(): Command { vscode: Boolean(opts.vscode), vscodeClaudeCode: Boolean(opts.vscodeClaudeCode), codexDesktop: Boolean(opts.codexDesktop), + cursorIde: Boolean(opts.cursorIde), }, profile: opts.profile, insiders: Boolean(opts.insiders), force: Boolean(opts.force), verbose: Boolean(opts.verbose), model: opts.model, + analytics: Boolean(opts.analytics), }); }); @@ -317,8 +323,14 @@ export function createProxyCommand(): Command { .command('disconnect') .description('Remove CodeMie proxy configuration from a client') .option('--codex-desktop', 'Remove the CodeMie block from ~/.codex/config.toml') - .action(async (opts: { codexDesktop?: boolean }) => { - await disconnectTargets({ targets: { codexDesktop: Boolean(opts.codexDesktop) } }); + .option('--cursor-ide', 'Remove codemie-authored entries from .cursor/hooks.json') + .action(async (opts: { codexDesktop?: boolean; cursorIde?: boolean }) => { + await disconnectTargets({ + targets: { + codexDesktop: Boolean(opts.codexDesktop), + cursorIde: Boolean(opts.cursorIde), + }, + }); }); // Deprecated aliases — kept working, mapped onto the unified target flags. diff --git a/src/providers/plugins/sso/proxy/plugins/index.ts b/src/providers/plugins/sso/proxy/plugins/index.ts index f1ab3a4a3..84806374f 100644 --- a/src/providers/plugins/sso/proxy/plugins/index.ts +++ b/src/providers/plugins/sso/proxy/plugins/index.ts @@ -21,6 +21,7 @@ import { CopilotEncryptedContentSanitizerPlugin } from './copilot-encrypted-cont import { VsCodeRequestNormalizerPlugin } from './vscode-request-normalizer.plugin.js'; import { LoggingPlugin } from './logging.plugin.js'; import { SSOSessionSyncPlugin } from './sso.session-sync.plugin.js'; +import { OtlpIngestPlugin } from './otlp-ingest.plugin.js'; /** * Register core plugins @@ -45,6 +46,7 @@ export function registerCorePlugins(): void { registry.register(new HeaderInjectionPlugin()); registry.register(new LoggingPlugin()); // Always enabled - logs to log files at INFO level registry.register(new SSOSessionSyncPlugin()); // Priority 100 - syncs sessions via multiple processors + registry.register(new OtlpIngestPlugin()); // Priority 10 - OTLP hook event ingestion } // Auto-register on import @@ -68,5 +70,6 @@ export { LoggingPlugin, }; export { SSOSessionSyncPlugin } from './sso.session-sync.plugin.js'; +export { OtlpIngestPlugin } from './otlp-ingest.plugin.js'; export { getPluginRegistry, resetPluginRegistry } from './registry.js'; export * from './types.js'; diff --git a/src/providers/plugins/sso/proxy/plugins/otlp-dispatcher.ts b/src/providers/plugins/sso/proxy/plugins/otlp-dispatcher.ts new file mode 100644 index 000000000..cab3e7d6e --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/otlp-dispatcher.ts @@ -0,0 +1,449 @@ +import { createHash } from 'node:crypto'; +import { logger } from '../../../../../utils/logger.js'; +import { sanitizeLogArgs } from '../../../../../utils/security.js'; +import type { SSOCredentials, JWTCredentials } from '../../../../core/types.js'; +import { isSSOCredentials, isJWTCredentials } from '../../../../core/types.js'; +import { buildAuthHeaders } from '../../../../core/codemie-auth-helpers.js'; +import { CODEMIE_ENDPOINTS } from '../../sso.http-client.js'; + +export interface OtlpEventPayload { + agentName: string; + timestamp: string; + raw: string; +} + +type OtlpAttr = { key: string; value: { stringValue: string } }; + +const SERVICE_NAME = 'cursor-agent'; +const OTLP_POST_TIMEOUT_MS = 1500; +const OTLP_SEVERITY_NUMBER = 9; +const OTLP_SEVERITY_TEXT = 'INFO'; +const SPAN_NAME_TOOL = 'cursor.tool'; +const SPAN_NAME_INTERACTION = 'cursor.interaction'; +const SPAN_NAME_SUBAGENT = 'cursor.subagent'; +const METRIC_NAME_LINES = 'cursor.lines_of_code.count'; + +const EVENT_TYPE_MAP: Record = { + sessionStart: 'agent.session.start', + sessionEnd: 'agent.session.end', + stop: 'agent.session.stop', + preToolUse: 'agent.tool.start', + postToolUse: 'agent.tool.end', + postToolUseFailure: 'agent.tool.error', + beforeSubmitPrompt: 'agent.prompt.submit', + subagentStart: 'agent.subagent.start', + subagentStop: 'agent.subagent.stop', + preCompact: 'agent.session.compact', +}; + +export class OtlpDispatcher { + constructor( + private readonly credentials?: SSOCredentials | JWTCredentials, + private readonly baseUrl?: string, + private readonly projectName?: string + ) { } + + async dispatch(payload: OtlpEventPayload): Promise { + let event: Record; + try { + const parsed = JSON.parse(payload.raw) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return; + event = parsed as Record; + } catch { + return; + } + const hookName = String(event['hook_event_name'] ?? ''); + const tsNs = this.nowNs(); + + let gitBranch = ''; + let repoRemote = ''; + const cwd = this.extractCwd(event); + if (cwd) { + try { + const { detectGitBranch, detectGitRemoteRepo } = await import('../../../../../utils/processes.js'); + [gitBranch, repoRemote] = await Promise.all([ + detectGitBranch(cwd).then(v => v ?? ''), + detectGitRemoteRepo(cwd).then(v => v ?? ''), + ]); + } catch { + // ignore — git info is best-effort + } + } + + if (hookName === 'postToolUse') { + await Promise.all([ + this.pushLogs(this.wrapLogs([this.buildLogRecord(event, hookName, tsNs, gitBranch, repoRemote)])), + this.pushTraces(this.wrapTraces([this.buildToolSpan(event, tsNs)])), + ]); + return; + } + if (hookName === 'beforeSubmitPrompt') { + await Promise.all([ + this.pushLogs(this.wrapLogs([this.buildLogRecord(event, hookName, tsNs, gitBranch, repoRemote)])), + this.pushTraces(this.wrapTraces([this.buildInteractionSpan(event, tsNs)])), + ]); + return; + } + if (hookName === 'subagentStop') { + await Promise.all([ + this.pushLogs(this.wrapLogs([this.buildLogRecord(event, hookName, tsNs, gitBranch, repoRemote)])), + this.pushTraces(this.wrapTraces([this.buildSubagentSpan(event, tsNs)])), + ]); + return; + } + if (hookName === 'afterFileEdit') { + const metric = this.buildLinesMetric(event, tsNs); + if (metric) await this.pushMetrics(this.wrapMetrics([metric])); + return; + } + if (hookName === 'stop') { + await this.pushLogs(this.wrapLogs([ + this.buildLogRecord(event, hookName, tsNs, gitBranch, repoRemote), + this.buildApiRequestRecord(event, tsNs), + ])); + return; + } + await this.pushLogs(this.wrapLogs([this.buildLogRecord(event, hookName, tsNs, gitBranch, repoRemote)])); + } + + private nowNs(): string { + return (BigInt(Date.now()) * 1_000_000n).toString(); + } + + private toTraceId(sessionId: string): string { + return createHash('sha256').update(String(sessionId || '')).digest('hex').slice(0, 32); + } + + private toSpanId(id: string): string { + return createHash('sha256').update(String(id || '')).digest('hex').slice(0, 16); + } + + private extractCwd(event: Record): string { + const roots = event['workspace_roots']; + const raw = Array.isArray(roots) && roots.length > 0 ? String(roots[0]) : String(event['cwd'] || ''); + // Cursor sends MINGW-style paths on Windows: /C:/foo → C:/foo + return raw.replace(/^\/([A-Za-z]):\//, '$1:/'); + } + + private extractPromptBody(event: Record): string { + if (typeof event['prompt'] === 'string') return event['prompt']; + if (typeof event['message'] === 'string') return event['message']; + const messages = event['messages']; + if (Array.isArray(messages) && messages.length > 0) { + const lastUser = [...messages].reverse().find((m: unknown) => { + return typeof m === 'object' && m !== null && + (m as Record)['role'] === 'user'; + }); + if (lastUser) { + const content = (lastUser as Record)['content']; + if (typeof content === 'string') return content; + } + } + return ''; + } + + private extractFilePath(_toolName: string, toolInput: unknown): string { + let input = toolInput; + if (typeof input === 'string') { + try { input = JSON.parse(input) as unknown; } catch { return ''; } + } + if (!input || typeof input !== 'object' || Array.isArray(input)) return ''; + const inp = input as Record; + return String(inp['file_path'] ?? inp['path'] ?? inp['notebook_path'] ?? ''); + } + + private skillNameFromPath(filePath: string): string { + if (!filePath) return ''; + const SKILL_RE = /(?:^|[/\\])skills[/\\]|SKILL\.md$/i; + if (!SKILL_RE.test(filePath)) return ''; + const parts = filePath.split(/[/\\]/); + const idx = parts.findIndex(p => p.toLowerCase() === 'skills'); + if (idx >= 0 && parts[idx + 1]) return parts[idx + 1]; + const last = parts[parts.length - 1]; + return last ? last.replace(/\.md$/i, '') : ''; + } + + private decodeJwtClaims(token: string): Record { + const parts = token.split('.'); + if (parts.length < 2) return {}; + return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as Record; + } + + private resolveUserEmail(event: Record): string { + if (this.credentials && isJWTCredentials(this.credentials)) { + try { + const claims = this.decodeJwtClaims(this.credentials.token); + if (typeof claims['email'] === 'string' && claims['email']) { + return claims['email']; + } + } catch { /* ignore decode failures */ } + } + if (this.credentials && isSSOCredentials(this.credentials)) { + const accessToken = this.credentials.cookies['codemie_access_token']; + if (accessToken) { + try { + const claims = this.decodeJwtClaims(accessToken); + const email = claims['email'] ?? claims['preferred_username']; + if (typeof email === 'string' && email) return email; + } catch { /* ignore decode failures */ } + } + } + if (typeof event['user_email'] === 'string' && event['user_email']) { + return event['user_email']; + } + return ''; + } + + private toStringField(value: unknown): string { + if (typeof value === 'string') return value; + if (value != null) return JSON.stringify(value); + return ''; + } + + private wrapSignal(resourceKey: string, scopeKey: string, recordKey: string, records: object[]): object { + return { + [resourceKey]: [{ + resource: { attributes: [{ key: 'service.name', value: { stringValue: SERVICE_NAME } }] }, + [scopeKey]: [{ scope: {}, [recordKey]: records }], + }], + }; + } + + private wrapLogs(records: object[]): object { + return this.wrapSignal('resourceLogs', 'scopeLogs', 'logRecords', records); + } + + private wrapTraces(spans: object[]): object { + return this.wrapSignal('resourceSpans', 'scopeSpans', 'spans', spans); + } + + private wrapMetrics(metrics: object[]): object { + return this.wrapSignal('resourceMetrics', 'scopeMetrics', 'metrics', metrics); + } + + private buildLogRecord(event: Record, hookName: string, tsNs: string, gitBranch = '', repoRemote = ''): object { + const eventType = EVENT_TYPE_MAP[hookName] ?? hookName; + const toolUseId = String(event['tool_use_id'] ?? '').replace(/\n/g, '_'); + const userEmail = this.resolveUserEmail(event); + const attrs: OtlpAttr[] = [ + { key: 'event_type', value: { stringValue: eventType } }, + { key: 'session_id', value: { stringValue: String(event['session_id'] ?? '') } }, + { key: 'developer_name', value: { stringValue: userEmail } }, + { key: 'user.email', value: { stringValue: userEmail } }, + { key: 'cwd', value: { stringValue: this.extractCwd(event) } }, + { key: 'git_branch', value: { stringValue: gitBranch } }, + { key: 'repo_remote', value: { stringValue: repoRemote } }, + { key: 'tool_name', value: { stringValue: String(event['tool_name'] ?? '') } }, + { key: 'tool_use_id', value: { stringValue: toolUseId } }, + { key: 'tool_input', value: { stringValue: event['tool_input'] ? JSON.stringify(event['tool_input']) : '' } }, + { key: 'tool_output', value: { stringValue: this.toStringField(event['tool_output']) } }, + { key: 'codemie_project_name', value: { stringValue: this.projectName ?? '' } }, + { key: 'prompt_body', value: { stringValue: hookName === 'beforeSubmitPrompt' ? this.extractPromptBody(event) : '' } }, + { key: 'slash_command', value: { stringValue: '' } }, + { key: 'agent_type', value: { stringValue: String(event['subagent_type'] ?? '') } }, + ]; + return { + timeUnixNano: tsNs, + observedTimeUnixNano: tsNs, + severityNumber: OTLP_SEVERITY_NUMBER, + severityText: OTLP_SEVERITY_TEXT, + body: { stringValue: '' }, + attributes: attrs, + }; + } + + private buildToolSpan(event: Record, tsNs: string): object { + const sessionId = String(event['session_id'] ?? ''); + const toolUseId = String(event['tool_use_id'] ?? '').replace(/\n/g, '_'); + const startNs = this.startNsFromDurationMs(tsNs, event['duration']); + const filePath = this.extractFilePath(String(event['tool_name'] ?? ''), event['tool_input']); + return { + traceId: this.toTraceId(sessionId), + spanId: this.toSpanId(toolUseId || (sessionId + tsNs)), + name: SPAN_NAME_TOOL, + kind: 1, + startTimeUnixNano: startNs, + endTimeUnixNano: tsNs, + status: { code: 1 }, + attributes: [ + { key: 'session.id', value: { stringValue: sessionId } }, + { key: 'tool_name', value: { stringValue: String(event['tool_name'] ?? '') } }, + { key: 'tool_use_id', value: { stringValue: toolUseId } }, + { key: 'file_path', value: { stringValue: filePath } }, + { key: 'subagent_type', value: { stringValue: String(event['subagent_type'] ?? '') } }, + { key: 'skill_name', value: { stringValue: this.skillNameFromPath(filePath) } }, + ], + }; + } + + private buildInteractionSpan(event: Record, tsNs: string): object { + const sessionId = String(event['session_id'] ?? ''); + const genId = String(event['generation_id'] ?? ''); + return { + traceId: this.toTraceId(sessionId), + spanId: this.toSpanId(genId || (sessionId + tsNs)), + name: SPAN_NAME_INTERACTION, + kind: 1, + startTimeUnixNano: tsNs, + endTimeUnixNano: tsNs, + status: { code: 1 }, + attributes: [ + { key: 'session.id', value: { stringValue: sessionId } }, + ], + }; + } + + private buildSubagentSpan(event: Record, tsNs: string): object { + const sessionId = String(event['session_id'] ?? ''); + const subagentId = String(event['subagent_id'] ?? ''); + const startNs = this.startNsFromDurationMs(tsNs, event['duration_ms']); + return { + traceId: this.toTraceId(sessionId), + spanId: this.toSpanId(subagentId || (sessionId + tsNs)), + name: SPAN_NAME_SUBAGENT, + kind: 1, + startTimeUnixNano: startNs, + endTimeUnixNano: tsNs, + status: { code: event['status'] === 'error' ? 2 : 1 }, + attributes: [ + { key: 'session.id', value: { stringValue: sessionId } }, + { key: 'subagent_id', value: { stringValue: subagentId } }, + { key: 'subagent_type', value: { stringValue: String(event['subagent_type'] ?? '') } }, + { key: 'status', value: { stringValue: String(event['status'] ?? '') } }, + { key: 'duration_ms', value: { stringValue: String(Number(event['duration_ms'] ?? 0)) } }, + { key: 'tool_call_count', value: { stringValue: String(Number(event['tool_call_count'] ?? 0)) } }, + { key: 'message_count', value: { stringValue: String(Number(event['message_count'] ?? 0)) } }, + ], + }; + } + + private startNsFromDurationMs(tsNs: string, rawDur: unknown): string { + const durationNs = BigInt(Math.round(Number.isFinite(Number(rawDur)) ? Math.max(0, Number(rawDur)) : 0) * 1_000_000); + const endNs = BigInt(tsNs); + return endNs > durationNs ? (endNs - durationNs).toString() : '0'; + } + + private countLines(str: unknown): number { + if (!str || typeof str !== 'string') return 0; + const lines = str.split('\n'); + if (lines[lines.length - 1] === '') lines.pop(); + return lines.length; + } + + private buildLinesMetric(event: Record, tsNs: string): object | null { + const edits = Array.isArray(event['edits']) + ? (event['edits'] as Record[]) + : []; + let linesAdded = 0; + let linesRemoved = 0; + for (const edit of edits) { + if (!edit || typeof edit !== 'object' || Array.isArray(edit)) continue; + linesAdded += this.countLines(edit['new_string']); + linesRemoved += this.countLines(edit['old_string']); + } + if (linesAdded === 0 && linesRemoved === 0) return null; + const sessionId = String(event['session_id'] ?? ''); + const userEmail = this.resolveUserEmail(event); + const commonAttrs = [ + { key: 'session.id', value: { stringValue: sessionId } }, + { key: 'user.email', value: { stringValue: userEmail } }, + ]; + const dataPoints: object[] = []; + if (linesAdded > 0) { + dataPoints.push({ + attributes: [...commonAttrs, { key: 'type', value: { stringValue: 'added' } }], + startTimeUnixNano: tsNs, + timeUnixNano: tsNs, + asInt: String(linesAdded), + }); + } + if (linesRemoved > 0) { + dataPoints.push({ + attributes: [...commonAttrs, { key: 'type', value: { stringValue: 'removed' } }], + startTimeUnixNano: tsNs, + timeUnixNano: tsNs, + asInt: String(linesRemoved), + }); + } + return { + name: METRIC_NAME_LINES, + sum: { dataPoints, aggregationTemporality: 1, isMonotonic: true }, + }; + } + + private buildApiRequestRecord(event: Record, tsNs: string): object { + const sessionId = String(event['session_id'] ?? ''); + const userEmail = this.resolveUserEmail(event); + const model = String(event['model'] ?? ''); + const toInt = (v: unknown): number => (Number.isFinite(Number(v)) ? Number(v) : 0); + return { + timeUnixNano: tsNs, + observedTimeUnixNano: tsNs, + severityNumber: OTLP_SEVERITY_NUMBER, + severityText: OTLP_SEVERITY_TEXT, + body: { stringValue: '' }, + attributes: [ + { key: 'event.name', value: { stringValue: 'api_request' } }, + { key: 'session_id', value: { stringValue: sessionId } }, + { key: 'user.email', value: { stringValue: userEmail } }, + { key: 'model', value: { stringValue: model } }, + { key: 'input_tokens', value: { intValue: toInt(event['input_tokens']) } }, + { key: 'output_tokens', value: { intValue: toInt(event['output_tokens']) } }, + { key: 'cache_read_tokens', value: { intValue: toInt(event['cache_read_input_tokens']) } }, + { key: 'cache_creation_tokens', value: { intValue: toInt(event['cache_creation_input_tokens']) } }, + ], + }; + } + + private async postOtlp(url: string, payload: unknown): Promise { + try { + if (!this.credentials || !this.baseUrl) return; + let headers: Record; + if (isSSOCredentials(this.credentials)) { + headers = buildAuthHeaders(this.credentials.cookies); + } else if (isJWTCredentials(this.credentials)) { + headers = buildAuthHeaders(this.credentials.token); + } else { + return; + } + headers['Content-Type'] = 'application/json'; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OTLP_POST_TIMEOUT_MS); + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + if (!response.ok) { + const bodyText = await response.text().catch(() => ''); + logger.info( + `[otlp-ingest] postOtlp: status ${response.status}`, + ...sanitizeLogArgs({ url, body: bodyText.slice(0, 500) }) + ); + } else { + logger.info(`[otlp-ingest] postOtlp: ok ${response.status}`, ...sanitizeLogArgs({ url })); + await response.body?.cancel().catch(() => {}); + } + } finally { + clearTimeout(timeout); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.info(`[otlp-ingest] postOtlp: ${msg}`, ...sanitizeLogArgs({ url })); + } + } + + private async pushLogs(payload: unknown): Promise { + return this.postOtlp(`${this.baseUrl}${CODEMIE_ENDPOINTS.CLI_ANALYTICS_LOGS}`, payload); + } + + private async pushTraces(payload: unknown): Promise { + return this.postOtlp(`${this.baseUrl}${CODEMIE_ENDPOINTS.CLI_ANALYTICS_TRACES}`, payload); + } + + private async pushMetrics(payload: unknown): Promise { + return this.postOtlp(`${this.baseUrl}${CODEMIE_ENDPOINTS.CLI_ANALYTICS_METRICS}`, payload); + } +} diff --git a/src/providers/plugins/sso/proxy/plugins/otlp-ingest.plugin.ts b/src/providers/plugins/sso/proxy/plugins/otlp-ingest.plugin.ts new file mode 100644 index 000000000..9ee7963de --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/otlp-ingest.plugin.ts @@ -0,0 +1,107 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import type { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; +import type { ProxyContext } from '../proxy-types.js'; +import type { ProxyHTTPClient } from '../proxy-http-client.js'; +import { logger } from '../../../../../utils/logger.js'; +import { sanitizeLogArgs } from '../../../../../utils/security.js'; +import type { SSOCredentials, JWTCredentials } from '../../../../core/types.js'; +import { OtlpDispatcher } from './otlp-dispatcher.js'; +import type { OtlpEventPayload } from './otlp-dispatcher.js'; + +export class OtlpIngestPlugin implements ProxyPlugin { + id = '@codemie/proxy-otlp-ingest'; + name = 'OTLP Ingestion'; + version = '1.0.0'; + priority = 10; // After gateway-key (priority 7) + + createInterceptor(context: PluginContext): ProxyInterceptor { + return new OtlpIngestInterceptor( + context.syncCredentials || context.credentials, + context.config.syncApiUrl ?? context.config.targetApiUrl, + context.config.project + ); + } +} + +class OtlpIngestInterceptor implements ProxyInterceptor { + name = 'otlp-ingest'; + private readonly dispatcher: OtlpDispatcher; + + constructor(credentials?: SSOCredentials | JWTCredentials, baseUrl?: string, projectName?: string) { + this.dispatcher = new OtlpDispatcher(credentials, baseUrl, projectName); + } + + async handleRequest( + ctx: ProxyContext, + _req: IncomingMessage, + res: ServerResponse, + _httpClient: ProxyHTTPClient + ): Promise { + if (ctx.method !== 'POST' || ctx.url !== '/v1/otlp/hook-events') return false; + if (!ctx.metadata.gatewayKeyValidated) { + logger.warn('[otlp-ingest] Rejected request: gateway key not validated', ...sanitizeLogArgs({ url: ctx.url })); + return this.sendError(res, 401, 'authentication_error', 'Unauthorized'); + } + try { + const payload = await this.parseBody(ctx, res); + if (!payload) return true; + void this.dispatcher.dispatch(payload).catch(err => { + const msg = err instanceof Error ? err.message : String(err); + logger.info('[otlp-ingest] dispatch error', ...sanitizeLogArgs({ err: msg })); + }); + res.statusCode = 202; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ accepted: true })); + return true; + } catch (error) { + logger.error('[otlp-ingest] Unexpected error', ...sanitizeLogArgs({ + error: error instanceof Error ? error.message : String(error), + })); + return this.sendError(res, 500, 'internal_server_error', 'Internal server error'); + } + } + + private sendError(res: ServerResponse, status: number, type: string, message: string): true { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ type: 'error', error: { type, message } })); + return true; + } + + private async parseBody(ctx: ProxyContext, res: ServerResponse): Promise { + if (!ctx.requestBody) { + logger.warn('[otlp-ingest] Received request with empty body'); + this.sendError(res, 400, 'invalid_request_error', 'Empty body'); + return null; + } + let payload: OtlpEventPayload; + try { + payload = JSON.parse(ctx.requestBody.toString('utf-8')) as OtlpEventPayload; + } catch (parseError) { + logger.warn('[otlp-ingest] Failed to parse JSON body', ...sanitizeLogArgs({ + parseError: parseError instanceof Error ? parseError.message : String(parseError), + })); + this.sendError(res, 400, 'invalid_request_error', 'Invalid JSON'); + return null; + } + if (!payload.agentName || !payload.timestamp || !payload.raw) { + logger.warn('[otlp-ingest] Missing required fields', ...sanitizeLogArgs({ + hasAgentName: Boolean(payload.agentName), + hasTimestamp: Boolean(payload.timestamp), + hasRaw: Boolean(payload.raw), + })); + this.sendError(res, 400, 'invalid_request_error', 'Missing required fields'); + return null; + } + // Dynamic import avoids a circular dependency: + // AgentRegistry -> BaseAgentAdapter -> sso/index -> sso.proxy -> plugins/index -> this file + const { AgentRegistry } = await import('../../../../../agents/registry.js'); + if (!AgentRegistry.getAgentNames().includes(payload.agentName)) { + logger.warn('[otlp-ingest] Rejected request: unrecognized agentName', ...sanitizeLogArgs({ agentName: payload.agentName })); + this.sendError(res, 400, 'invalid_request_error', 'Unrecognized agentName'); + return null; + } + return payload; + } + +} diff --git a/src/providers/plugins/sso/sso.http-client.ts b/src/providers/plugins/sso/sso.http-client.ts index 2c947497e..a263f2899 100644 --- a/src/providers/plugins/sso/sso.http-client.ts +++ b/src/providers/plugins/sso/sso.http-client.ts @@ -22,7 +22,11 @@ export const CODEMIE_ENDPOINTS = { USER: '/v1/user', ADMIN_APPLICATIONS: '/v1/admin/applications', METRICS: '/v1/metrics', - AUTH_LOGIN: '/v1/auth/login' + AUTH_LOGIN: '/v1/auth/login', + CLI_ANALYTICS_EVENT_HOOKS: '/v1/analytics/cli-analytics/event-hooks', + CLI_ANALYTICS_METRICS: '/v1/analytics/cli-analytics/metrics', + CLI_ANALYTICS_LOGS: '/v1/analytics/cli-analytics/logs', + CLI_ANALYTICS_TRACES: '/v1/analytics/cli-analytics/traces' } as const; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index ef5ba8def..9c59f5893 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -28,6 +28,7 @@ class Logger { private writeStream: fs.WriteStream | null = null; private currentLogDate: string | null = null; private isRotating: boolean = false; + private stdoutSuppressed = false; constructor() {} @@ -38,6 +39,18 @@ class Logger { this.agentName = name; } + /** + * Suppress this logger's own stdout writes (debug()'s CODEMIE_DEBUG console + * output, success()'s console output) by redirecting them to stderr + * instead. For callers whose process stdout is a data channel a caller + * parses (e.g. `codemie hook`'s per-agent stdout response contract) - + * mixing log lines into that channel corrupts it. Never suppresses the log + * file, only the console mirror. + */ + setStdoutSuppressed(suppressed: boolean): void { + this.stdoutSuppressed = suppressed; + } + /** * Get agent name */ @@ -305,7 +318,8 @@ class Logger { prefix += ` [${this.profileName}]`; } - console.log(chalk.dim(`${prefix} ${message}`), ...args); + const write = this.stdoutSuppressed ? console.error : console.log; + write(chalk.dim(`${prefix} ${message}`), ...args); } } @@ -315,7 +329,8 @@ class Logger { } success(message: string, ...args: unknown[]): void { - console.log(chalk.green(`✓ ${message}`), ...args); + const write = this.stdoutSuppressed ? console.error : console.log; + write(chalk.green(`✓ ${message}`), ...args); } warn(message: string, ...args: unknown[]): void { diff --git a/src/utils/processes.ts b/src/utils/processes.ts index 41fc1fda5..79d46a84a 100644 --- a/src/utils/processes.ts +++ b/src/utils/processes.ts @@ -396,7 +396,7 @@ export async function npxRun( */ export async function detectGitRemoteRepo(cwd: string): Promise { try { - const { stdout } = await execAsync('git remote get-url origin', { cwd, timeout: 5000 }); + const { stdout } = await execAsync('git remote get-url origin', { cwd, timeout: 5000, windowsHide: true }); const remoteUrl = stdout.trim(); const match = remoteUrl.match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/); if (match) return `${match[1]}/${match[2]}`; @@ -416,7 +416,8 @@ export async function detectGitBranch(cwd: string): Promise try { const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd, - timeout: 5000 // 5 second timeout + timeout: 5000, + windowsHide: true, }); const branch = stdout.trim(); diff --git a/src/utils/project-root.ts b/src/utils/project-root.ts new file mode 100644 index 000000000..b69d4b3e4 --- /dev/null +++ b/src/utils/project-root.ts @@ -0,0 +1,43 @@ +/** + * Project root resolution shared by any feature that must agree on "the + * project", independent of the current working directory a hook or command + * happens to run from. + * + * `resolveLocalTargetPath('.codemie')` (`src/utils/paths.ts:106`) is purely + * CWD-relative (`path.join(process.cwd(), baseTargetDir)`). That is fine for + * commands invoked from the project root, but Cursor sets `cwd` per-event and + * it need not be the workspace root for every hook (e.g. a monorepo + * subpackage) - two features that each derive "project root" from `cwd()` + * independently could silently diverge on where that is. This module is the + * single shared resolver both the event-log writer (Task 6) and the + * `.cursor/hooks.json` connector (Task 8) call, so their file locations can + * never drift apart. + */ + +import { existsSync } from 'fs'; +import { dirname, join } from 'path'; + +/** + * Resolve the project root by walking up from `startDir` looking for a + * `.git` entry (directory for a normal checkout, file for a git worktree or + * submodule). Falls back to `startDir` itself if no `.git` is found before + * reaching the filesystem root. + * + * @param startDir - Directory to start the walk from. Defaults to `process.cwd()`. + */ +export function resolveProjectRoot(startDir: string = process.cwd()): string { + let current = startDir; + + while (true) { + if (existsSync(join(current, '.git'))) { + return current; + } + + const parent = dirname(current); + if (parent === current) { + // Reached the filesystem root without finding `.git`. + return startDir; + } + current = parent; + } +}