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.
|
|
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
|
-
});
|
package/dist/cursorMitm.d.ts
DELETED
|
@@ -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;
|
package/dist/cursorMitm.js
DELETED
|
@@ -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
|
-
}
|