squeezr-ai 1.17.6 → 1.17.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squeezr-ai",
3
- "version": "1.17.6",
3
+ "version": "1.17.7",
4
4
  "description": "AI proxy that compresses Claude Code, Codex, Aider, Gemini CLI and Ollama context windows to save thousands of tokens per session",
5
5
  "keywords": [
6
6
  "claude",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "scripts": {
30
30
  "build": "tsc",
31
+ "prepack": "node -e \"['dist/cursorMitm.js','dist/cursorMitm.d.ts','dist/__tests__/cursorMitm.test.js','dist/__tests__/cursorMitm.test.d.ts'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"",
31
32
  "dev": "tsx src/index.ts",
32
33
  "start": "node dist/index.js",
33
34
  "gain": "node dist/gain.js",
@@ -1 +0,0 @@
1
- export {};
@@ -1,313 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- // We test the exported utility functions and the ConnectRPC/proto layer.
3
- // Since cursorMitm.ts has many private functions, we use a test helper approach
4
- // by importing the module and testing the public API + running integration checks.
5
- // For testing the proto encoder/decoder, we replicate the minimal logic here
6
- // since the functions are module-private. We'll also test the full flow.
7
- // ── ConnectRPC frame tests ───────────────────────────────────────────────────
8
- function buildConnectFrame(payload, flag = 0) {
9
- const header = Buffer.alloc(5);
10
- header[0] = flag;
11
- header.writeUInt32BE(payload.length, 1);
12
- return Buffer.concat([header, payload]);
13
- }
14
- function parseConnectFrame(buf) {
15
- if (buf.length < 5)
16
- return null;
17
- const flag = buf[0];
18
- const length = buf.readUInt32BE(1);
19
- if (buf.length < 5 + length)
20
- return null;
21
- return { flag, payload: buf.subarray(5, 5 + length), total: 5 + length };
22
- }
23
- describe('ConnectRPC frame encoding', () => {
24
- it('should round-trip a simple payload', () => {
25
- const payload = Buffer.from('hello world');
26
- const frame = buildConnectFrame(payload);
27
- expect(frame.length).toBe(5 + payload.length);
28
- expect(frame[0]).toBe(0); // uncompressed flag
29
- const parsed = parseConnectFrame(frame);
30
- expect(parsed).not.toBeNull();
31
- expect(parsed.flag).toBe(0);
32
- expect(parsed.payload.toString()).toBe('hello world');
33
- expect(parsed.total).toBe(frame.length);
34
- });
35
- it('should handle gzip flag', () => {
36
- const payload = Buffer.from('compressed data');
37
- const frame = buildConnectFrame(payload, 1);
38
- const parsed = parseConnectFrame(frame);
39
- expect(parsed.flag).toBe(1);
40
- expect(parsed.payload.toString()).toBe('compressed data');
41
- });
42
- it('should return null for incomplete frames', () => {
43
- expect(parseConnectFrame(Buffer.alloc(3))).toBeNull();
44
- // Header says 100 bytes but only 10 present
45
- const incomplete = Buffer.alloc(15);
46
- incomplete.writeUInt32BE(100, 1);
47
- expect(parseConnectFrame(incomplete)).toBeNull();
48
- });
49
- it('should handle empty payload', () => {
50
- const frame = buildConnectFrame(Buffer.alloc(0));
51
- expect(frame.length).toBe(5);
52
- const parsed = parseConnectFrame(frame);
53
- expect(parsed.payload.length).toBe(0);
54
- });
55
- it('should handle large payloads', () => {
56
- const payload = Buffer.alloc(65536, 0x42);
57
- const frame = buildConnectFrame(payload);
58
- const parsed = parseConnectFrame(frame);
59
- expect(parsed.payload.length).toBe(65536);
60
- expect(parsed.payload[0]).toBe(0x42);
61
- });
62
- });
63
- // ── Protobuf varint tests ────────────────────────────────────────────────────
64
- function encodeVarint(value) {
65
- const bytes = [];
66
- while (value > 0x7F) {
67
- bytes.push((value & 0x7F) | 0x80);
68
- value >>>= 7;
69
- }
70
- bytes.push(value & 0x7F);
71
- return Buffer.from(bytes);
72
- }
73
- function decodeVarint(buf, offset) {
74
- let value = 0;
75
- let shift = 0;
76
- let bytesRead = 0;
77
- while (offset + bytesRead < buf.length) {
78
- const byte = buf[offset + bytesRead];
79
- value |= (byte & 0x7F) << shift;
80
- bytesRead++;
81
- if ((byte & 0x80) === 0)
82
- break;
83
- shift += 7;
84
- }
85
- return { value, bytesRead };
86
- }
87
- describe('Protobuf varint encoding', () => {
88
- it('should encode small numbers', () => {
89
- expect(encodeVarint(0)).toEqual(Buffer.from([0]));
90
- expect(encodeVarint(1)).toEqual(Buffer.from([1]));
91
- expect(encodeVarint(127)).toEqual(Buffer.from([127]));
92
- });
93
- it('should encode multi-byte varints', () => {
94
- expect(encodeVarint(128)).toEqual(Buffer.from([0x80, 0x01]));
95
- expect(encodeVarint(300)).toEqual(Buffer.from([0xAC, 0x02]));
96
- });
97
- it('should round-trip varints', () => {
98
- for (const val of [0, 1, 127, 128, 300, 16383, 16384, 65535]) {
99
- const buf = encodeVarint(val);
100
- const { value } = decodeVarint(buf, 0);
101
- expect(value).toBe(val);
102
- }
103
- });
104
- });
105
- // ── Protobuf message building/parsing ────────────────────────────────────────
106
- function encodeTag(fieldNumber, wireType) {
107
- return encodeVarint((fieldNumber << 3) | wireType);
108
- }
109
- function encodeLengthDelimited(fieldNumber, data) {
110
- const tag = encodeTag(fieldNumber, 2);
111
- const len = encodeVarint(data.length);
112
- return Buffer.concat([tag, len, data]);
113
- }
114
- function encodeString(fieldNumber, str) {
115
- return encodeLengthDelimited(fieldNumber, Buffer.from(str, 'utf-8'));
116
- }
117
- function encodeVarintField(fieldNumber, value) {
118
- const tag = encodeTag(fieldNumber, 0);
119
- const val = encodeVarint(value);
120
- return Buffer.concat([tag, val]);
121
- }
122
- function parseProtoFields(buf) {
123
- const fields = [];
124
- let offset = 0;
125
- while (offset < buf.length) {
126
- const tagStart = offset;
127
- const { value: tag, bytesRead: tagBytes } = decodeVarint(buf, offset);
128
- offset += tagBytes;
129
- const fieldNumber = tag >>> 3;
130
- const wireType = tag & 0x07;
131
- let fieldEnd = offset;
132
- switch (wireType) {
133
- case 0: {
134
- while (fieldEnd < buf.length && (buf[fieldEnd] & 0x80) !== 0)
135
- fieldEnd++;
136
- fieldEnd++;
137
- break;
138
- }
139
- case 1: {
140
- fieldEnd += 8;
141
- break;
142
- }
143
- case 2: {
144
- const { value: len, bytesRead: lenBytes } = decodeVarint(buf, offset);
145
- fieldEnd = offset + lenBytes + len;
146
- break;
147
- }
148
- case 5: {
149
- fieldEnd += 4;
150
- break;
151
- }
152
- default: fieldEnd = buf.length;
153
- }
154
- fields.push({ fieldNumber, wireType, data: buf.subarray(tagStart, fieldEnd) });
155
- offset = fieldEnd;
156
- }
157
- return fields;
158
- }
159
- function extractPayload(rawField) {
160
- let offset = 0;
161
- while (offset < rawField.length && (rawField[offset] & 0x80) !== 0)
162
- offset++;
163
- offset++;
164
- const { value: len, bytesRead } = decodeVarint(rawField, offset);
165
- offset += bytesRead;
166
- return rawField.subarray(offset, offset + len);
167
- }
168
- describe('Protobuf field encoding/parsing', () => {
169
- it('should encode and parse a string field', () => {
170
- const encoded = encodeString(2, 'Hello, world!');
171
- const fields = parseProtoFields(encoded);
172
- expect(fields.length).toBe(1);
173
- expect(fields[0].fieldNumber).toBe(2);
174
- expect(fields[0].wireType).toBe(2);
175
- const payload = extractPayload(fields[0].data);
176
- expect(payload.toString('utf-8')).toBe('Hello, world!');
177
- });
178
- it('should encode and parse a varint field', () => {
179
- const encoded = encodeVarintField(1, 42);
180
- const fields = parseProtoFields(encoded);
181
- expect(fields.length).toBe(1);
182
- expect(fields[0].fieldNumber).toBe(1);
183
- expect(fields[0].wireType).toBe(0);
184
- });
185
- it('should encode and parse a ConversationMessage-like structure', () => {
186
- // ConversationMessage: field 1 = role (varint), field 2 = text (string)
187
- const msgPayload = Buffer.concat([
188
- encodeVarintField(1, 1), // role = HUMAN
189
- encodeString(2, 'What is 2+2?'),
190
- ]);
191
- const conversationField = encodeLengthDelimited(2, msgPayload);
192
- const outerFields = parseProtoFields(conversationField);
193
- expect(outerFields.length).toBe(1);
194
- expect(outerFields[0].fieldNumber).toBe(2);
195
- const innerPayload = extractPayload(outerFields[0].data);
196
- const innerFields = parseProtoFields(innerPayload);
197
- expect(innerFields.length).toBe(2);
198
- expect(innerFields[0].fieldNumber).toBe(1); // role
199
- expect(innerFields[1].fieldNumber).toBe(2); // text
200
- const text = extractPayload(innerFields[1].data).toString('utf-8');
201
- expect(text).toBe('What is 2+2?');
202
- });
203
- it('should handle a full GetChatRequest with multiple conversation messages', () => {
204
- // Build a fake GetChatRequest:
205
- // field 2 (conversation) repeated, field 5 (workspace_root_path)
206
- const msg1 = encodeLengthDelimited(2, Buffer.concat([
207
- encodeVarintField(1, 1),
208
- encodeString(2, 'Please review my code'),
209
- ]));
210
- const msg2 = encodeLengthDelimited(2, Buffer.concat([
211
- encodeVarintField(1, 2),
212
- encodeString(2, 'Sure, I see several issues with your implementation...'),
213
- ]));
214
- const msg3 = encodeLengthDelimited(2, Buffer.concat([
215
- encodeVarintField(1, 1),
216
- encodeString(2, 'Can you fix them?'),
217
- ]));
218
- const workspacePath = encodeString(5, '/home/user/project');
219
- const request = Buffer.concat([msg1, msg2, msg3, workspacePath]);
220
- const fields = parseProtoFields(request);
221
- // Should have 4 fields: 3 conversation + 1 workspace
222
- expect(fields.length).toBe(4);
223
- const conversationFields = fields.filter(f => f.fieldNumber === 2);
224
- expect(conversationFields.length).toBe(3);
225
- const otherFields = fields.filter(f => f.fieldNumber !== 2);
226
- expect(otherFields.length).toBe(1);
227
- expect(otherFields[0].fieldNumber).toBe(5);
228
- });
229
- it('should preserve round-trip of unknown fields', () => {
230
- // Fields we don't know should survive parse → concat(data) unchanged
231
- const msg1 = encodeLengthDelimited(2, Buffer.concat([
232
- encodeVarintField(1, 1),
233
- encodeString(2, 'hello'),
234
- ]));
235
- const unknownField1 = encodeString(7, 'some-request-id');
236
- const unknownField2 = encodeVarintField(27, 1); // is_composer
237
- const unknownField3 = encodeString(15, 'conv-id-123');
238
- const original = Buffer.concat([msg1, unknownField1, unknownField2, unknownField3]);
239
- const fields = parseProtoFields(original);
240
- // Reconstructing from raw data should give same bytes
241
- const reconstructed = Buffer.concat(fields.map(f => f.data));
242
- expect(reconstructed).toEqual(original);
243
- });
244
- });
245
- // ── Integration: ConnectRPC + Protobuf ───────────────────────────────────────
246
- describe('ConnectRPC + Protobuf integration', () => {
247
- it('should encode a full request frame and parse it back', () => {
248
- const msg1 = encodeLengthDelimited(2, Buffer.concat([
249
- encodeVarintField(1, 1),
250
- encodeString(2, 'User question'),
251
- ]));
252
- const msg2 = encodeLengthDelimited(2, Buffer.concat([
253
- encodeVarintField(1, 2),
254
- encodeString(2, 'AI response with lots of context and details'),
255
- ]));
256
- const requestPayload = Buffer.concat([msg1, msg2]);
257
- const frame = buildConnectFrame(requestPayload);
258
- // Parse frame
259
- const parsed = parseConnectFrame(frame);
260
- expect(parsed).not.toBeNull();
261
- // Parse proto
262
- const fields = parseProtoFields(parsed.payload);
263
- expect(fields.filter(f => f.fieldNumber === 2).length).toBe(2);
264
- });
265
- });
266
- // ── Deterministic compression ────────────────────────────────────────────────
267
- function deterministicCompress(text) {
268
- let out = text;
269
- out = out.replace(/\n{3,}/g, '\n\n');
270
- out = out.replace(/[ \t]+$/gm, '');
271
- const lines = out.split('\n');
272
- const result = [];
273
- let lastLine = '';
274
- let repeatCount = 0;
275
- for (const line of lines) {
276
- if (line === lastLine && line.trim().length > 0) {
277
- repeatCount++;
278
- }
279
- else {
280
- if (repeatCount > 0) {
281
- result.push(` ... (repeated ${repeatCount}x)`);
282
- repeatCount = 0;
283
- }
284
- result.push(line);
285
- lastLine = line;
286
- }
287
- }
288
- if (repeatCount > 0)
289
- result.push(` ... (repeated ${repeatCount}x)`);
290
- return result.join('\n');
291
- }
292
- describe('Deterministic compression', () => {
293
- it('should collapse blank lines', () => {
294
- const input = 'a\n\n\n\n\nb';
295
- expect(deterministicCompress(input)).toBe('a\n\nb');
296
- });
297
- it('should strip trailing whitespace', () => {
298
- const input = 'hello \nworld\t\t';
299
- expect(deterministicCompress(input)).toBe('hello\nworld');
300
- });
301
- it('should collapse repeated lines', () => {
302
- const input = 'error: connection failed\nerror: connection failed\nerror: connection failed\nok';
303
- const result = deterministicCompress(input);
304
- expect(result).toContain('error: connection failed');
305
- expect(result).toContain('repeated 2x');
306
- expect(result).toContain('ok');
307
- });
308
- it('should not collapse empty repeated lines', () => {
309
- const input = 'a\n\n\nb';
310
- const result = deterministicCompress(input);
311
- expect(result).not.toContain('repeated');
312
- });
313
- });
@@ -1,18 +0,0 @@
1
- /**
2
- * Cursor Subscription MITM Proxy
3
- *
4
- * Intercepts Cursor IDE (Electron/Chromium) traffic to api2.cursor.sh,
5
- * compresses the conversation context using Cursor's own models (cursor-small),
6
- * and forwards the compressed request transparently.
7
- *
8
- * Protocol: ConnectRPC over HTTP/2 with binary Protobuf
9
- * Target: api2.cursor.sh/aiserver.v1.ChatService/StreamUnifiedChatWithTools
10
- */
11
- export declare function getCursorStats(): {
12
- requests: number;
13
- compressed: number;
14
- charsSaved: number;
15
- };
16
- export declare function getCursorMitmPort(): number;
17
- export declare function startCursorMitm(): Promise<void>;
18
- export declare function stopCursorMitm(): void;
@@ -1,846 +0,0 @@
1
- /**
2
- * Cursor Subscription MITM Proxy
3
- *
4
- * Intercepts Cursor IDE (Electron/Chromium) traffic to api2.cursor.sh,
5
- * compresses the conversation context using Cursor's own models (cursor-small),
6
- * and forwards the compressed request transparently.
7
- *
8
- * Protocol: ConnectRPC over HTTP/2 with binary Protobuf
9
- * Target: api2.cursor.sh/aiserver.v1.ChatService/StreamUnifiedChatWithTools
10
- */
11
- import net from 'node:net';
12
- import tls from 'node:tls';
13
- import http from 'node:http';
14
- import http2 from 'node:http2';
15
- import fs from 'node:fs';
16
- import crypto from 'node:crypto';
17
- import { homedir } from 'node:os';
18
- import { join } from 'node:path';
19
- import forge from 'node-forge';
20
- import { config } from './config.js';
21
- // ── Constants ────────────────────────────────────────────────────────────────
22
- const CURSOR_API_HOST = 'api2.cursor.sh';
23
- const CURSOR_CHAT_PATH = '/aiserver.v1.ChatService/StreamUnifiedChatWithTools';
24
- const CURSOR_COMPOSER_PATH = '/aiserver.v1.AiService/StreamComposer';
25
- const CURSOR_CHAT_HARD_PATH = '/aiserver.v1.AiService/StreamChatTryReallyHard';
26
- const INTERCEPTED_PATHS = new Set([CURSOR_CHAT_PATH, CURSOR_COMPOSER_PATH, CURSOR_CHAT_HARD_PATH]);
27
- // Minimal Protobuf field numbers for GetChatRequest (from cursor-rpc protos)
28
- // We decode only the fields we need to compress, pass-through everything else at binary level
29
- const PROTO_FIELD_CONVERSATION = 2; // repeated ConversationMessage
30
- const PROTO_FIELD_ROLE = 1; // MessageType enum in ConversationMessage
31
- const PROTO_FIELD_TEXT = 2; // string text in ConversationMessage
32
- // Protobuf wire types
33
- const WIRE_VARINT = 0;
34
- const WIRE_LENGTH_DELIMITED = 2;
35
- const COMPRESS_THRESHOLD = config.threshold ?? 800;
36
- const KEEP_RECENT = config.keepRecent ?? 3;
37
- // ── CA paths (shared with codexMitm) ─────────────────────────────────────────
38
- const CA_DIR = join(homedir(), '.squeezr', 'mitm-ca');
39
- const CA_KEY_PATH = join(CA_DIR, 'ca.key');
40
- const CA_CERT_PATH = join(CA_DIR, 'ca.crt');
41
- // ── Per-host cert (cached) ───────────────────────────────────────────────────
42
- const certCache = new Map();
43
- function getCert(hostname) {
44
- if (certCache.has(hostname))
45
- return certCache.get(hostname);
46
- const caKey = forge.pki.privateKeyFromPem(fs.readFileSync(CA_KEY_PATH, 'utf-8'));
47
- const caCert = forge.pki.certificateFromPem(fs.readFileSync(CA_CERT_PATH, 'utf-8'));
48
- const keys = forge.pki.rsa.generateKeyPair(2048);
49
- const cert = forge.pki.createCertificate();
50
- cert.publicKey = keys.publicKey;
51
- cert.serialNumber = crypto.randomBytes(8).toString('hex');
52
- cert.validity.notBefore = new Date();
53
- cert.validity.notAfter = new Date();
54
- cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
55
- cert.setSubject([{ name: 'commonName', value: hostname }]);
56
- cert.setIssuer(caCert.subject.attributes);
57
- cert.setExtensions([{ name: 'subjectAltName', altNames: [{ type: 2, value: hostname }] }]);
58
- cert.sign(caKey, forge.md.sha256.create());
59
- const result = {
60
- key: forge.pki.privateKeyToPem(keys.privateKey),
61
- cert: forge.pki.certificateToPem(cert),
62
- };
63
- certCache.set(hostname, result);
64
- return result;
65
- }
66
- // ── ConnectRPC envelope framing ──────────────────────────────────────────────
67
- // Wire format: [flag: 1 byte][length: 4 bytes big-endian][payload: N bytes]
68
- // flag 0x00 = uncompressed, 0x01 = gzip (we only handle uncompressed)
69
- function parseConnectFrame(buf) {
70
- if (buf.length < 5)
71
- return null;
72
- const flag = buf[0];
73
- const length = buf.readUInt32BE(1);
74
- if (buf.length < 5 + length)
75
- return null;
76
- return { flag, payload: buf.subarray(5, 5 + length), total: 5 + length };
77
- }
78
- function buildConnectFrame(payload, flag = 0) {
79
- const header = Buffer.alloc(5);
80
- header[0] = flag;
81
- header.writeUInt32BE(payload.length, 1);
82
- return Buffer.concat([header, payload]);
83
- }
84
- function decodeVarint(buf, offset) {
85
- let value = 0;
86
- let shift = 0;
87
- let bytesRead = 0;
88
- while (offset + bytesRead < buf.length) {
89
- const byte = buf[offset + bytesRead];
90
- value |= (byte & 0x7F) << shift;
91
- bytesRead++;
92
- if ((byte & 0x80) === 0)
93
- break;
94
- shift += 7;
95
- }
96
- return { value, bytesRead };
97
- }
98
- function encodeVarint(value) {
99
- const bytes = [];
100
- while (value > 0x7F) {
101
- bytes.push((value & 0x7F) | 0x80);
102
- value >>>= 7;
103
- }
104
- bytes.push(value & 0x7F);
105
- return Buffer.from(bytes);
106
- }
107
- function encodeTag(fieldNumber, wireType) {
108
- return encodeVarint((fieldNumber << 3) | wireType);
109
- }
110
- function encodeLengthDelimited(fieldNumber, data) {
111
- const tag = encodeTag(fieldNumber, WIRE_LENGTH_DELIMITED);
112
- const len = encodeVarint(data.length);
113
- return Buffer.concat([tag, len, data]);
114
- }
115
- function encodeString(fieldNumber, str) {
116
- const data = Buffer.from(str, 'utf-8');
117
- return encodeLengthDelimited(fieldNumber, data);
118
- }
119
- function encodeVarintField(fieldNumber, value) {
120
- const tag = encodeTag(fieldNumber, WIRE_VARINT);
121
- const val = encodeVarint(value);
122
- return Buffer.concat([tag, val]);
123
- }
124
- /** Parse a protobuf message into raw fields, preserving byte-level data for round-trip */
125
- function parseProtoFields(buf) {
126
- const fields = [];
127
- let offset = 0;
128
- while (offset < buf.length) {
129
- const tagStart = offset;
130
- const { value: tag, bytesRead: tagBytes } = decodeVarint(buf, offset);
131
- offset += tagBytes;
132
- const fieldNumber = tag >>> 3;
133
- const wireType = tag & 0x07;
134
- let fieldEnd = offset;
135
- switch (wireType) {
136
- case 0: { // varint
137
- while (fieldEnd < buf.length && (buf[fieldEnd] & 0x80) !== 0)
138
- fieldEnd++;
139
- fieldEnd++; // last byte
140
- break;
141
- }
142
- case 1: { // 64-bit
143
- fieldEnd += 8;
144
- break;
145
- }
146
- case 2: { // length-delimited
147
- const { value: len, bytesRead: lenBytes } = decodeVarint(buf, offset);
148
- fieldEnd = offset + lenBytes + len;
149
- break;
150
- }
151
- case 5: { // 32-bit
152
- fieldEnd += 4;
153
- break;
154
- }
155
- default:
156
- // Unknown wire type — skip to end to avoid infinite loop
157
- fieldEnd = buf.length;
158
- }
159
- fields.push({
160
- fieldNumber,
161
- wireType,
162
- data: buf.subarray(tagStart, fieldEnd),
163
- });
164
- offset = fieldEnd;
165
- }
166
- return fields;
167
- }
168
- /** Extract the payload of a length-delimited field (skipping tag + length prefix) */
169
- function extractLengthDelimitedPayload(rawField) {
170
- let offset = 0;
171
- // skip tag varint
172
- while (offset < rawField.length && (rawField[offset] & 0x80) !== 0)
173
- offset++;
174
- offset++; // last tag byte
175
- // read length varint
176
- const { value: len, bytesRead } = decodeVarint(rawField, offset);
177
- offset += bytesRead;
178
- return rawField.subarray(offset, offset + len);
179
- }
180
- /** Read a varint field value */
181
- function readVarintValue(rawField) {
182
- let offset = 0;
183
- while (offset < rawField.length && (rawField[offset] & 0x80) !== 0)
184
- offset++;
185
- offset++;
186
- return decodeVarint(rawField, offset).value;
187
- }
188
- function extractConversation(requestPayload) {
189
- const fields = parseProtoFields(requestPayload);
190
- const messages = [];
191
- const otherFields = [];
192
- for (const field of fields) {
193
- if (field.fieldNumber === PROTO_FIELD_CONVERSATION && field.wireType === WIRE_LENGTH_DELIMITED) {
194
- const msgPayload = extractLengthDelimitedPayload(field.data);
195
- const msgFields = parseProtoFields(msgPayload);
196
- let role = 0;
197
- let text = '';
198
- for (const mf of msgFields) {
199
- if (mf.fieldNumber === PROTO_FIELD_ROLE && mf.wireType === WIRE_VARINT) {
200
- role = readVarintValue(mf.data);
201
- }
202
- else if (mf.fieldNumber === PROTO_FIELD_TEXT && mf.wireType === WIRE_LENGTH_DELIMITED) {
203
- text = extractLengthDelimitedPayload(mf.data).toString('utf-8');
204
- }
205
- }
206
- messages.push({ role, text, originalBytes: field.data });
207
- }
208
- else {
209
- otherFields.push(field.data);
210
- }
211
- }
212
- return { messages, otherFields };
213
- }
214
- function rebuildRequest(messages, otherFields, compressed) {
215
- const parts = [];
216
- // Re-add non-conversation fields in original order
217
- // We need to interleave them, but proto doesn't care about field order
218
- for (const raw of otherFields) {
219
- parts.push(raw);
220
- }
221
- // Re-add conversation messages
222
- for (let i = 0; i < messages.length; i++) {
223
- if (compressed.has(i)) {
224
- // Build new ConversationMessage proto
225
- const msgPayload = Buffer.concat([
226
- encodeVarintField(PROTO_FIELD_ROLE, messages[i].role),
227
- encodeString(PROTO_FIELD_TEXT, compressed.get(i)),
228
- ]);
229
- parts.push(encodeLengthDelimited(PROTO_FIELD_CONVERSATION, msgPayload));
230
- }
231
- else {
232
- // Keep original bytes
233
- parts.push(messages[i].originalBytes);
234
- }
235
- }
236
- return Buffer.concat(parts);
237
- }
238
- // ── Compression via Cursor's own API ─────────────────────────────────────────
239
- let cursorStats = { requests: 0, compressed: 0, charsSaved: 0 };
240
- export function getCursorStats() {
241
- return { ...cursorStats };
242
- }
243
- async function compressViaCursor(texts, bearerToken, headers) {
244
- // Build a compression request using the same Cursor API
245
- // We ask cursor-small to summarize multiple conversation turns
246
- const combinedText = texts.map((t, i) => `[Turn ${i + 1}]:\n${t}`).join('\n\n');
247
- const prompt = `Compress this conversation history into a concise summary preserving: file paths, function names, error messages, decisions made, and key technical context. Be very concise, under ${Math.max(150, Math.floor(combinedText.length / 6))} chars.\n\n${combinedText}`;
248
- try {
249
- // Build a minimal GetChatRequest protobuf
250
- const conversationMsg = Buffer.concat([
251
- encodeVarintField(PROTO_FIELD_ROLE, 1), // HUMAN
252
- encodeString(PROTO_FIELD_TEXT, prompt),
253
- ]);
254
- const requestPayload = encodeLengthDelimited(PROTO_FIELD_CONVERSATION, conversationMsg);
255
- const frame = buildConnectFrame(requestPayload);
256
- // Make HTTP/2 request to api2.cursor.sh
257
- return await new Promise((resolve) => {
258
- const timeout = setTimeout(() => resolve(texts), 15_000);
259
- const session = http2.connect(`https://${CURSOR_API_HOST}`, {
260
- // Trust system CAs + our MITM CA
261
- rejectUnauthorized: true,
262
- });
263
- session.on('error', () => {
264
- clearTimeout(timeout);
265
- resolve(texts);
266
- });
267
- const reqHeaders = {
268
- ':method': 'POST',
269
- ':path': CURSOR_CHAT_PATH,
270
- ':authority': CURSOR_API_HOST,
271
- 'content-type': 'application/connect+proto',
272
- 'connect-protocol-version': '1',
273
- 'authorization': bearerToken,
274
- };
275
- // Forward Cursor-specific headers
276
- for (const [k, v] of Object.entries(headers)) {
277
- const lk = k.toLowerCase();
278
- if (lk.startsWith('x-cursor-') || lk === 'x-ghost-mode' || lk === 'x-session-id' || lk === 'x-client-key') {
279
- reqHeaders[lk] = v;
280
- }
281
- }
282
- const req = session.request(reqHeaders);
283
- req.write(frame);
284
- req.end();
285
- let responseBuf = Buffer.alloc(0);
286
- req.on('data', (chunk) => {
287
- responseBuf = Buffer.concat([responseBuf, chunk]);
288
- });
289
- req.on('end', () => {
290
- clearTimeout(timeout);
291
- try {
292
- // Parse response ConnectRPC frame(s)
293
- const responseFrame = parseConnectFrame(responseBuf);
294
- if (responseFrame && responseFrame.flag === 0) {
295
- // Parse the response protobuf — look for text field
296
- const respFields = parseProtoFields(responseFrame.payload);
297
- for (const f of respFields) {
298
- if (f.wireType === WIRE_LENGTH_DELIMITED) {
299
- const inner = extractLengthDelimitedPayload(f.data);
300
- const innerFields = parseProtoFields(inner);
301
- for (const inf of innerFields) {
302
- if (inf.wireType === WIRE_LENGTH_DELIMITED) {
303
- const text = extractLengthDelimitedPayload(inf.data).toString('utf-8');
304
- if (text.length > 20 && text.length < combinedText.length) {
305
- session.close();
306
- resolve([text]);
307
- return;
308
- }
309
- }
310
- }
311
- }
312
- }
313
- }
314
- session.close();
315
- resolve(texts); // fallback: return originals
316
- }
317
- catch {
318
- session.close();
319
- resolve(texts);
320
- }
321
- });
322
- req.on('error', () => {
323
- clearTimeout(timeout);
324
- session.close();
325
- resolve(texts);
326
- });
327
- });
328
- }
329
- catch {
330
- return texts;
331
- }
332
- }
333
- // ── Deterministic compression (no LLM, pattern-based) ────────────────────────
334
- function deterministicCompress(text) {
335
- let out = text;
336
- // Remove duplicate blank lines
337
- out = out.replace(/\n{3,}/g, '\n\n');
338
- // Remove trailing whitespace per line
339
- out = out.replace(/[ \t]+$/gm, '');
340
- // Collapse repeated log-like lines (keep first + count)
341
- const lines = out.split('\n');
342
- const result = [];
343
- let lastLine = '';
344
- let repeatCount = 0;
345
- for (const line of lines) {
346
- if (line === lastLine && line.trim().length > 0) {
347
- repeatCount++;
348
- }
349
- else {
350
- if (repeatCount > 0) {
351
- result.push(` ... (repeated ${repeatCount}x)`);
352
- repeatCount = 0;
353
- }
354
- result.push(line);
355
- lastLine = line;
356
- }
357
- }
358
- if (repeatCount > 0)
359
- result.push(` ... (repeated ${repeatCount}x)`);
360
- return result.join('\n');
361
- }
362
- // ── HTTP/1.1 → HTTP/2 bridge for api2.cursor.sh ─────────────────────────────
363
- // When Cursor runs with --disable-http2, it sends HTTP/1.1 but api2.cursor.sh
364
- // requires HTTP/2. We accept HTTP/1.1, parse the request, optionally compress,
365
- // and forward it over HTTP/2.
366
- function handleCursorH1Bridge(clientSocket, hostname) {
367
- // Persistent H2 session to upstream — reused across keep-alive requests
368
- let upstreamH2 = null;
369
- function getUpstream() {
370
- if (upstreamH2 && !upstreamH2.closed && !upstreamH2.destroyed)
371
- return upstreamH2;
372
- upstreamH2 = http2.connect(`https://${hostname}`, { rejectUnauthorized: true });
373
- upstreamH2.on('error', () => { upstreamH2 = null; });
374
- return upstreamH2;
375
- }
376
- const fakeServer = new http.Server({ keepAlive: true, keepAliveTimeout: 30000 });
377
- // IMPORTANT: attach listener BEFORE emitting connection to avoid race condition
378
- fakeServer.on('request', async (clientReq, clientRes) => {
379
- const reqPath = clientReq.url ?? '/';
380
- const method = clientReq.method ?? 'POST';
381
- const shouldIntercept = INTERCEPTED_PATHS.has(reqPath);
382
- console.log(`[squeezr/cursor] H1→H2: ${method} ${reqPath}${shouldIntercept ? ' [CHAT]' : ''}`);
383
- // Collect request body
384
- const chunks = [];
385
- clientReq.on('data', (chunk) => chunks.push(chunk));
386
- clientReq.on('end', async () => {
387
- let body = Buffer.concat(chunks);
388
- // Compress if this is a chat endpoint
389
- if (shouldIntercept && method === 'POST' && body.length > 0) {
390
- cursorStats.requests++;
391
- try {
392
- const frame = parseConnectFrame(body);
393
- if (frame && frame.flag === 0) {
394
- const { messages, otherFields } = extractConversation(frame.payload);
395
- if (messages.length > KEEP_RECENT + 1) {
396
- const compressibleCount = messages.length - KEEP_RECENT;
397
- const compressibleMsgs = messages.slice(0, compressibleCount);
398
- const totalChars = compressibleMsgs.reduce((sum, m) => sum + m.text.length, 0);
399
- if (totalChars >= COMPRESS_THRESHOLD) {
400
- const textsToCompress = compressibleMsgs.map(m => m.text);
401
- let compressedTexts = textsToCompress.map(deterministicCompress);
402
- const compressed = new Map();
403
- for (let i = 0; i < compressedTexts.length; i++) {
404
- if (compressedTexts[i] !== textsToCompress[i]) {
405
- compressed.set(i, compressedTexts[i]);
406
- }
407
- }
408
- if (compressed.size > 0) {
409
- const filteredMessages = messages.filter((_, i) => !compressed.has(i) || compressed.get(i) !== '');
410
- const newPayload = rebuildRequest(filteredMessages, otherFields, compressed);
411
- const newFrame = buildConnectFrame(newPayload);
412
- const charsSaved = totalChars - compressedTexts.join('').length;
413
- if (charsSaved > 0) {
414
- cursorStats.compressed++;
415
- cursorStats.charsSaved += charsSaved;
416
- console.log(`[squeezr/cursor] Compressed: -${charsSaved} chars (${messages.length} msgs → ${filteredMessages.length})`);
417
- }
418
- body = newFrame;
419
- }
420
- }
421
- }
422
- }
423
- }
424
- catch (e) {
425
- console.error(`[squeezr/cursor] H1 compression error: ${e.message}`);
426
- }
427
- }
428
- // Forward to api2.cursor.sh over HTTP/2 (reusable session)
429
- const upSession = getUpstream();
430
- const upHeaders = {
431
- ':method': method,
432
- ':path': reqPath,
433
- ':authority': hostname,
434
- ':scheme': 'https',
435
- };
436
- for (const [k, v] of Object.entries(clientReq.headers)) {
437
- if (['host', 'connection', 'transfer-encoding', 'upgrade', 'proxy-connection'].includes(k))
438
- continue;
439
- upHeaders[k] = Array.isArray(v) ? v.join(', ') : (v ?? '');
440
- }
441
- upHeaders['content-length'] = String(body.length);
442
- const upStream = upSession.request(upHeaders);
443
- upStream.write(body);
444
- upStream.end();
445
- // Buffer response to get content-length for proper HTTP/1.1 keep-alive
446
- const respChunks = [];
447
- let respStatus = 200;
448
- let respHeaders = {};
449
- upStream.on('response', (upRespHeaders) => {
450
- respStatus = upRespHeaders[':status'] ?? 200;
451
- for (const [k, v] of Object.entries(upRespHeaders)) {
452
- if (k.startsWith(':'))
453
- continue;
454
- respHeaders[k] = v;
455
- }
456
- });
457
- upStream.on('data', (chunk) => {
458
- respChunks.push(chunk);
459
- });
460
- upStream.on('end', () => {
461
- const respBody = Buffer.concat(respChunks);
462
- respHeaders['content-length'] = String(respBody.length);
463
- respHeaders['connection'] = 'keep-alive';
464
- clientRes.writeHead(respStatus, respHeaders);
465
- clientRes.end(respBody);
466
- });
467
- upStream.on('error', () => {
468
- if (!clientRes.headersSent)
469
- clientRes.writeHead(502, { 'connection': 'keep-alive' });
470
- clientRes.end();
471
- });
472
- });
473
- });
474
- fakeServer.on('error', () => { try {
475
- clientSocket.destroy();
476
- }
477
- catch { } });
478
- // Emit connection AFTER all listeners are attached
479
- fakeServer.emit('connection', clientSocket);
480
- }
481
- // ── HTTP/2 MITM handler for api2.cursor.sh ───────────────────────────────────
482
- function handleCursorH2(clientSocket, _hostname) {
483
- // Use performServerHandshake to create an HTTP/2 session on the existing TLS socket
484
- let serverSession;
485
- try {
486
- serverSession = http2.performServerHandshake(clientSocket);
487
- }
488
- catch (e) {
489
- console.log(`[squeezr/cursor] H2 handshake failed: ${e.message}`);
490
- clientSocket.destroy();
491
- return;
492
- }
493
- serverSession.on('error', (e) => {
494
- // Expected when client doesn't speak h2 — just close silently
495
- if (!e.message.includes('ECONNRESET')) {
496
- console.log(`[squeezr/cursor] H2 session error: ${e.message}`);
497
- }
498
- });
499
- serverSession.on('stream', (clientStream, clientHeaders) => {
500
- const path = clientHeaders[':path'] || '';
501
- const method = clientHeaders[':method'] || 'POST';
502
- const authority = clientHeaders[':authority'] || CURSOR_API_HOST;
503
- console.log(`[squeezr/cursor] H2 stream: ${method} ${path}`);
504
- const shouldIntercept = INTERCEPTED_PATHS.has(path);
505
- // Open upstream HTTP/2 connection to real api2.cursor.sh
506
- const upstreamSession = http2.connect(`https://${CURSOR_API_HOST}`, {
507
- rejectUnauthorized: true,
508
- });
509
- upstreamSession.on('error', (err) => {
510
- console.error(`[squeezr/cursor] upstream error: ${err.message}`);
511
- try {
512
- clientStream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
513
- }
514
- catch { }
515
- });
516
- // Forward headers (strip pseudo-headers, rebuild for upstream)
517
- const upHeaders = {
518
- ':method': method,
519
- ':path': path,
520
- ':authority': CURSOR_API_HOST,
521
- ':scheme': 'https',
522
- };
523
- for (const [k, v] of Object.entries(clientHeaders)) {
524
- if (k.startsWith(':'))
525
- continue;
526
- upHeaders[k] = v;
527
- }
528
- if (!shouldIntercept) {
529
- // Non-chat path: transparent proxy
530
- const upStream = upstreamSession.request(upHeaders);
531
- clientStream.on('data', (chunk) => {
532
- try {
533
- upStream.write(chunk);
534
- }
535
- catch { }
536
- });
537
- clientStream.on('end', () => {
538
- try {
539
- upStream.end();
540
- }
541
- catch { }
542
- });
543
- upStream.on('response', (upRespHeaders) => {
544
- const respHeaders = {};
545
- for (const [k, v] of Object.entries(upRespHeaders)) {
546
- if (k === ':status')
547
- continue;
548
- respHeaders[k] = v;
549
- }
550
- clientStream.respond({
551
- ':status': upRespHeaders[':status'] ?? 200,
552
- ...respHeaders,
553
- });
554
- });
555
- upStream.on('data', (chunk) => {
556
- try {
557
- clientStream.write(chunk);
558
- }
559
- catch { }
560
- });
561
- upStream.on('end', () => {
562
- try {
563
- clientStream.end();
564
- }
565
- catch { }
566
- });
567
- clientStream.on('error', () => { try {
568
- upstreamSession.close();
569
- }
570
- catch { } });
571
- upStream.on('error', () => { try {
572
- clientStream.close();
573
- }
574
- catch { } });
575
- return;
576
- }
577
- // ── Intercepted chat path: buffer request, compress, forward ────────────
578
- let requestBuf = Buffer.alloc(0);
579
- clientStream.on('data', (chunk) => {
580
- requestBuf = Buffer.concat([requestBuf, chunk]);
581
- });
582
- clientStream.on('end', async () => {
583
- cursorStats.requests++;
584
- try {
585
- // Parse ConnectRPC frame
586
- const frame = parseConnectFrame(requestBuf);
587
- if (!frame || frame.flag !== 0) {
588
- // Can't parse or gzip-compressed — pass through as-is
589
- forwardRaw(requestBuf, upHeaders, upstreamSession, clientStream);
590
- return;
591
- }
592
- // Extract conversation from protobuf
593
- const { messages, otherFields } = extractConversation(frame.payload);
594
- if (messages.length <= KEEP_RECENT + 1) {
595
- // Not enough messages to compress — pass through
596
- forwardRaw(requestBuf, upHeaders, upstreamSession, clientStream);
597
- return;
598
- }
599
- // Find messages to compress (all except the last KEEP_RECENT)
600
- const compressibleCount = messages.length - KEEP_RECENT;
601
- const compressibleMsgs = messages.slice(0, compressibleCount);
602
- const totalChars = compressibleMsgs.reduce((sum, m) => sum + m.text.length, 0);
603
- if (totalChars < COMPRESS_THRESHOLD) {
604
- forwardRaw(requestBuf, upHeaders, upstreamSession, clientStream);
605
- return;
606
- }
607
- // Extract bearer token and headers for compression call
608
- const bearerToken = clientHeaders['authorization'] || '';
609
- const fwdHeaders = {};
610
- for (const [k, v] of Object.entries(clientHeaders)) {
611
- if (!k.startsWith(':'))
612
- fwdHeaders[k] = v;
613
- }
614
- // Try LLM compression first, fallback to deterministic
615
- const textsToCompress = compressibleMsgs.map(m => m.text);
616
- let compressedTexts;
617
- if (bearerToken) {
618
- compressedTexts = await compressViaCursor(textsToCompress, bearerToken, fwdHeaders);
619
- // If LLM returned originals unchanged, fallback to deterministic
620
- const llmChanged = compressedTexts.length !== textsToCompress.length ||
621
- compressedTexts.some((t, i) => t !== textsToCompress[i]);
622
- if (!llmChanged) {
623
- compressedTexts = textsToCompress.map(deterministicCompress);
624
- }
625
- }
626
- else {
627
- compressedTexts = textsToCompress.map(deterministicCompress);
628
- }
629
- // Build map of compressed messages
630
- const compressed = new Map();
631
- if (compressedTexts.length === 1 && textsToCompress.length > 1) {
632
- // LLM returned a single summary — replace all compressible turns with one
633
- compressed.set(0, compressedTexts[0]);
634
- // Mark rest for removal by setting empty text
635
- for (let i = 1; i < compressibleCount; i++) {
636
- compressed.set(i, '');
637
- }
638
- }
639
- else {
640
- // Per-message compression (deterministic fallback)
641
- for (let i = 0; i < compressedTexts.length; i++) {
642
- if (compressedTexts[i] !== textsToCompress[i]) {
643
- compressed.set(i, compressedTexts[i]);
644
- }
645
- }
646
- }
647
- // Filter out empty messages
648
- const filteredMessages = messages.filter((_, i) => !compressed.has(i) || compressed.get(i) !== '');
649
- // Rebuild the protobuf
650
- const newPayload = rebuildRequest(filteredMessages, otherFields, compressed);
651
- const newFrame = buildConnectFrame(newPayload);
652
- const charsSaved = totalChars - compressedTexts.join('').length;
653
- if (charsSaved > 0) {
654
- cursorStats.compressed++;
655
- cursorStats.charsSaved += charsSaved;
656
- console.log(`[squeezr/cursor] Compressed: -${charsSaved} chars (${messages.length} msgs → ${filteredMessages.length})`);
657
- }
658
- // Forward compressed request
659
- forwardRaw(newFrame, upHeaders, upstreamSession, clientStream);
660
- }
661
- catch (err) {
662
- console.error(`[squeezr/cursor] compression error:`, err);
663
- // Fallback: forward original
664
- forwardRaw(requestBuf, upHeaders, upstreamSession, clientStream);
665
- }
666
- });
667
- });
668
- serverSession.on('error', () => { });
669
- }
670
- function forwardRaw(body, headers, upstreamSession, clientStream) {
671
- // Update content-length
672
- const upHeaders = { ...headers, 'content-length': String(body.length) };
673
- const upStream = upstreamSession.request(upHeaders);
674
- upStream.write(body);
675
- upStream.end();
676
- upStream.on('response', (upRespHeaders) => {
677
- const respHeaders = {};
678
- for (const [k, v] of Object.entries(upRespHeaders)) {
679
- if (k === ':status')
680
- continue;
681
- respHeaders[k] = v;
682
- }
683
- try {
684
- clientStream.respond({
685
- ':status': upRespHeaders[':status'] ?? 200,
686
- ...respHeaders,
687
- });
688
- }
689
- catch { }
690
- });
691
- upStream.on('data', (chunk) => {
692
- try {
693
- clientStream.write(chunk);
694
- }
695
- catch { }
696
- });
697
- upStream.on('end', () => {
698
- try {
699
- clientStream.end();
700
- }
701
- catch { }
702
- });
703
- upStream.on('error', () => { try {
704
- clientStream.close();
705
- }
706
- catch { } });
707
- clientStream.on('error', () => { try {
708
- upstreamSession.close();
709
- }
710
- catch { } });
711
- }
712
- // ── Local TLS server for MITM ────────────────────────────────────────────────
713
- const tlsServerCache = new Map();
714
- function getOrCreateTlsServer(hostname) {
715
- const cached = tlsServerCache.get(hostname);
716
- if (cached)
717
- return Promise.resolve(cached.port);
718
- return new Promise((resolve, reject) => {
719
- const { key, cert } = getCert(hostname);
720
- let tcpCount = 0;
721
- let tlsCount = 0;
722
- const server = tls.createServer({
723
- key,
724
- cert,
725
- ALPNProtocols: ['h2', 'http/1.1'],
726
- }, (socket) => {
727
- tlsCount++;
728
- const protocol = socket.alpnProtocol;
729
- console.log(`[squeezr/cursor] TLS #${tlsCount} ALPN=${protocol} (${tcpCount} tcp total)`);
730
- if (protocol === 'h2') {
731
- handleCursorH2(socket, hostname);
732
- }
733
- else {
734
- // HTTP/1.1 or no ALPN — transparent tunnel to real api2.cursor.sh (no MITM)
735
- const upstream = tls.connect(443, hostname, { servername: hostname }, () => {
736
- socket.pipe(upstream);
737
- upstream.pipe(socket);
738
- });
739
- upstream.on('error', () => { try {
740
- socket.destroy();
741
- }
742
- catch { } });
743
- socket.on('error', () => { try {
744
- upstream.destroy();
745
- }
746
- catch { } });
747
- }
748
- });
749
- server.on('connection', () => { tcpCount++; });
750
- server.on('tlsClientError', () => { }); // suppress idle connection errors
751
- server.on('error', reject);
752
- server.listen(0, '127.0.0.1', () => {
753
- const addr = server.address();
754
- tlsServerCache.set(hostname, { port: addr.port, server });
755
- resolve(addr.port);
756
- });
757
- });
758
- }
759
- // ── CONNECT handler ──────────────────────────────────────────────────────────
760
- function handleConnect(req, clientSocket, _head) {
761
- const [hostname, portStr] = (req.url ?? '').split(':');
762
- const port = parseInt(portStr) || 443;
763
- console.log(`[squeezr/cursor] CONNECT ${hostname}:${port}`);
764
- // Only MITM api2.cursor.sh — everything else transparent tunnel
765
- if (hostname !== CURSOR_API_HOST) {
766
- const upstream = net.connect(port, hostname, () => {
767
- clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
768
- upstream.pipe(clientSocket);
769
- clientSocket.pipe(upstream);
770
- });
771
- upstream.on('error', (e) => { console.log(`[squeezr/cursor] tunnel error ${hostname}: ${e.message}`); try {
772
- clientSocket.destroy();
773
- }
774
- catch { } });
775
- clientSocket.on('error', () => { try {
776
- upstream.destroy();
777
- }
778
- catch { } });
779
- return;
780
- }
781
- // MITM api2.cursor.sh — route through local TLS server
782
- getOrCreateTlsServer(hostname).then((localPort) => {
783
- clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
784
- const local = net.connect(localPort, '127.0.0.1', () => {
785
- clientSocket.pipe(local);
786
- local.pipe(clientSocket);
787
- });
788
- local.on('error', () => { try {
789
- clientSocket.destroy();
790
- }
791
- catch { } });
792
- clientSocket.on('error', () => { try {
793
- local.destroy();
794
- }
795
- catch { } });
796
- }).catch(() => {
797
- clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
798
- clientSocket.destroy();
799
- });
800
- }
801
- // ── Plain HTTP handler ───────────────────────────────────────────────────────
802
- function handleHttp(req, res) {
803
- res.writeHead(200, { 'content-type': 'application/json' });
804
- res.end(JSON.stringify({ status: 'ok', type: 'cursor-mitm-proxy', stats: getCursorStats() }));
805
- }
806
- // ── Server lifecycle ─────────────────────────────────────────────────────────
807
- let cursorServer = null;
808
- const CURSOR_MITM_PORT = config.mitmPort + 1; // default: 8082
809
- export function getCursorMitmPort() { return CURSOR_MITM_PORT; }
810
- export function startCursorMitm() {
811
- return new Promise((resolve, reject) => {
812
- // Verify CA exists (codexMitm should have created it already)
813
- if (!fs.existsSync(CA_KEY_PATH) || !fs.existsSync(CA_CERT_PATH)) {
814
- console.error('[squeezr/cursor] CA not found. Run `squeezr setup` first.');
815
- reject(new Error('CA not found'));
816
- return;
817
- }
818
- cursorServer = http.createServer(handleHttp);
819
- cursorServer.on('connect', handleConnect);
820
- cursorServer.on('error', (err) => {
821
- if (err.code === 'EADDRINUSE') {
822
- console.error(`[squeezr/cursor] Port ${CURSOR_MITM_PORT} in use`);
823
- reject(err);
824
- }
825
- else {
826
- console.error('[squeezr/cursor] error:', err.message);
827
- }
828
- });
829
- cursorServer.listen(CURSOR_MITM_PORT, () => {
830
- console.log(`[squeezr/cursor] MITM proxy on http://localhost:${CURSOR_MITM_PORT}`);
831
- console.log(`[squeezr/cursor] Intercepting: ${CURSOR_API_HOST} (chat, composer, agent)`);
832
- resolve();
833
- });
834
- });
835
- }
836
- export function stopCursorMitm() {
837
- cursorServer?.close();
838
- cursorServer = null;
839
- for (const [, entry] of tlsServerCache) {
840
- try {
841
- entry.server.close();
842
- }
843
- catch { }
844
- }
845
- tlsServerCache.clear();
846
- }