smartcontext-proxy 0.1.0 → 0.2.1

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.
Files changed (64) hide show
  1. package/PLAN-v2.md +390 -0
  2. package/dist/src/context/ab-test.d.ts +32 -0
  3. package/dist/src/context/ab-test.js +133 -0
  4. package/dist/src/index.js +99 -78
  5. package/dist/src/proxy/classifier.d.ts +14 -0
  6. package/dist/src/proxy/classifier.js +63 -0
  7. package/dist/src/proxy/connect-proxy.d.ts +37 -0
  8. package/dist/src/proxy/connect-proxy.js +234 -0
  9. package/dist/src/proxy/server.js +10 -1
  10. package/dist/src/proxy/tls-interceptor.d.ts +23 -0
  11. package/dist/src/proxy/tls-interceptor.js +211 -0
  12. package/dist/src/proxy/transparent-listener.d.ts +31 -0
  13. package/dist/src/proxy/transparent-listener.js +285 -0
  14. package/dist/src/proxy/tunnel.d.ts +7 -0
  15. package/dist/src/proxy/tunnel.js +33 -0
  16. package/dist/src/system/dns-redirect.d.ts +28 -0
  17. package/dist/src/system/dns-redirect.js +141 -0
  18. package/dist/src/system/installer.d.ts +25 -0
  19. package/dist/src/system/installer.js +180 -0
  20. package/dist/src/system/linux.d.ts +11 -0
  21. package/dist/src/system/linux.js +60 -0
  22. package/dist/src/system/macos.d.ts +24 -0
  23. package/dist/src/system/macos.js +98 -0
  24. package/dist/src/system/pf-redirect.d.ts +25 -0
  25. package/dist/src/system/pf-redirect.js +177 -0
  26. package/dist/src/system/watchdog.d.ts +7 -0
  27. package/dist/src/system/watchdog.js +115 -0
  28. package/dist/src/test/connect-proxy.test.d.ts +1 -0
  29. package/dist/src/test/connect-proxy.test.js +147 -0
  30. package/dist/src/test/dashboard.test.js +1 -0
  31. package/dist/src/tls/ca-manager.d.ts +9 -0
  32. package/dist/src/tls/ca-manager.js +117 -0
  33. package/dist/src/tls/trust-store.d.ts +11 -0
  34. package/dist/src/tls/trust-store.js +121 -0
  35. package/dist/src/tray/bridge.d.ts +8 -0
  36. package/dist/src/tray/bridge.js +66 -0
  37. package/dist/src/ui/dashboard.d.ts +10 -1
  38. package/dist/src/ui/dashboard.js +119 -34
  39. package/dist/src/ui/ws-feed.d.ts +8 -0
  40. package/dist/src/ui/ws-feed.js +30 -0
  41. package/native/macos/SmartContextTray/Package.swift +13 -0
  42. package/native/macos/SmartContextTray/Sources/main.swift +206 -0
  43. package/package.json +6 -2
  44. package/src/context/ab-test.ts +172 -0
  45. package/src/index.ts +104 -74
  46. package/src/proxy/classifier.ts +71 -0
  47. package/src/proxy/connect-proxy.ts +251 -0
  48. package/src/proxy/server.ts +11 -2
  49. package/src/proxy/tls-interceptor.ts +261 -0
  50. package/src/proxy/transparent-listener.ts +328 -0
  51. package/src/proxy/tunnel.ts +32 -0
  52. package/src/system/dns-redirect.ts +144 -0
  53. package/src/system/installer.ts +148 -0
  54. package/src/system/linux.ts +57 -0
  55. package/src/system/macos.ts +89 -0
  56. package/src/system/pf-redirect.ts +175 -0
  57. package/src/system/watchdog.ts +76 -0
  58. package/src/test/connect-proxy.test.ts +170 -0
  59. package/src/test/dashboard.test.ts +1 -0
  60. package/src/tls/ca-manager.ts +140 -0
  61. package/src/tls/trust-store.ts +123 -0
  62. package/src/tray/bridge.ts +61 -0
  63. package/src/ui/dashboard.ts +129 -35
  64. package/src/ui/ws-feed.ts +32 -0
@@ -0,0 +1,328 @@
1
+ import tls from 'node:tls';
2
+ import net from 'node:net';
3
+ import http from 'node:http';
4
+ import https from 'node:https';
5
+ import { getCertForHost } from '../tls/ca-manager.js';
6
+ import { classifyHost } from './classifier.js';
7
+ import { getRealIP } from '../system/dns-redirect.js';
8
+ import type { ProviderAdapter } from '../providers/types.js';
9
+ import type { SmartContextConfig } from '../config/schema.js';
10
+ import { ContextOptimizer } from '../context/optimizer.js';
11
+ import { MetricsCollector } from '../metrics/collector.js';
12
+ import { estimateTokens } from '../context/chunker.js';
13
+ import { getTextContent } from '../context/canonical.js';
14
+
15
+ export interface TransparentOptions {
16
+ config: SmartContextConfig;
17
+ optimizer: ContextOptimizer | null;
18
+ metrics: MetricsCollector;
19
+ adapters: Map<string, ProviderAdapter>;
20
+ paused: boolean;
21
+ requestCounter: { value: number };
22
+ log: (level: string, message: string) => void;
23
+ }
24
+
25
+ /**
26
+ * Transparent TLS listener on port 443.
27
+ * When DNS redirects LLM traffic to 127.0.0.1, clients connect directly
28
+ * via TLS (no CONNECT). This server terminates TLS using SNI to pick
29
+ * the right cert, then handles the request.
30
+ */
31
+ export class TransparentListener {
32
+ private server: net.Server;
33
+ private options: TransparentOptions;
34
+
35
+ constructor(options: TransparentOptions) {
36
+ this.options = options;
37
+
38
+ // Raw TCP server — we need SNI from ClientHello before creating TLS context
39
+ this.server = net.createServer((socket) => this.handleConnection(socket));
40
+ }
41
+
42
+ async start(port: number = 443, host: string = '127.0.0.1'): Promise<void> {
43
+ return new Promise((resolve, reject) => {
44
+ this.server.on('error', (err: NodeJS.ErrnoException) => {
45
+ if (err.code === 'EACCES') {
46
+ this.options.log('error', `Port ${port} requires root. Transparent listener disabled.`);
47
+ resolve(); // Non-fatal
48
+ } else {
49
+ reject(err);
50
+ }
51
+ });
52
+ this.server.listen(port, host, () => {
53
+ this.options.log('info', `Transparent TLS listener on ${host}:${port}`);
54
+ resolve();
55
+ });
56
+ });
57
+ }
58
+
59
+ async stop(): Promise<void> {
60
+ return new Promise((resolve) => {
61
+ this.server.close(() => resolve());
62
+ });
63
+ }
64
+
65
+ private handleConnection(socket: net.Socket): void {
66
+ // Peek at first bytes to extract SNI from TLS ClientHello
67
+ socket.once('data', (data) => {
68
+ const hostname = extractSNI(data);
69
+ if (!hostname) {
70
+ socket.destroy();
71
+ return;
72
+ }
73
+
74
+ const match = classifyHost(hostname, 443);
75
+ if (!match) {
76
+ // Not an LLM host — shouldn't happen with DNS redirect, but just in case
77
+ socket.destroy();
78
+ return;
79
+ }
80
+
81
+ this.options.log('info', `TRANSPARENT ${hostname} (${match.provider})`);
82
+
83
+ const { cert, key } = getCertForHost(hostname);
84
+
85
+ const tlsSocket = new tls.TLSSocket(socket, {
86
+ isServer: true,
87
+ cert,
88
+ key,
89
+ });
90
+
91
+ // Unshift the peeked data back
92
+ tlsSocket.unshift(data);
93
+ // Actually we already consumed data from socket, TLSSocket wraps socket
94
+ // Need to handle this differently — push data through
95
+
96
+ this.handleTLSConnection(tlsSocket, hostname, match);
97
+ });
98
+ }
99
+
100
+ private handleTLSConnection(
101
+ tlsSocket: tls.TLSSocket,
102
+ hostname: string,
103
+ match: ReturnType<typeof classifyHost> & {},
104
+ ): void {
105
+ let requestData = Buffer.alloc(0);
106
+ let headersParsed = false;
107
+ let contentLength = 0;
108
+ let headerEnd = -1;
109
+
110
+ tlsSocket.on('data', (chunk: Buffer) => {
111
+ requestData = Buffer.concat([requestData, chunk]);
112
+
113
+ if (!headersParsed) {
114
+ headerEnd = requestData.indexOf('\r\n\r\n');
115
+ if (headerEnd === -1) return;
116
+ headersParsed = true;
117
+ const headersStr = requestData.subarray(0, headerEnd).toString();
118
+ const clMatch = headersStr.match(/content-length:\s*(\d+)/i);
119
+ contentLength = clMatch ? parseInt(clMatch[1], 10) : 0;
120
+ }
121
+
122
+ const bodyStart = headerEnd + 4;
123
+ const bodyReceived = requestData.length - bodyStart;
124
+
125
+ if (bodyReceived >= contentLength) {
126
+ tlsSocket.pause();
127
+ this.handleRequest(requestData, hostname, match, tlsSocket).catch((err) => {
128
+ this.options.log('error', `Transparent handler error: ${err}`);
129
+ try { tlsSocket.end(); } catch {}
130
+ });
131
+ }
132
+ });
133
+
134
+ tlsSocket.on('error', (err) => {
135
+ this.options.log('error', `TLS error for ${hostname}: ${err.message}`);
136
+ });
137
+ }
138
+
139
+ private async handleRequest(
140
+ rawRequest: Buffer,
141
+ hostname: string,
142
+ match: { provider: string },
143
+ clientTLS: tls.TLSSocket,
144
+ ): Promise<void> {
145
+ this.options.requestCounter.value++;
146
+ const reqId = this.options.requestCounter.value;
147
+ const startTime = Date.now();
148
+
149
+ // Parse HTTP request
150
+ const headerEnd = rawRequest.indexOf('\r\n\r\n');
151
+ const headersStr = rawRequest.subarray(0, headerEnd).toString();
152
+ const body = rawRequest.subarray(headerEnd + 4);
153
+
154
+ const [requestLine, ...headerLines] = headersStr.split('\r\n');
155
+ const [method, reqPath] = requestLine.split(' ');
156
+
157
+ const headers: Record<string, string> = {};
158
+ for (const line of headerLines) {
159
+ const colonIdx = line.indexOf(':');
160
+ if (colonIdx > 0) {
161
+ headers[line.substring(0, colonIdx).toLowerCase().trim()] = line.substring(colonIdx + 1).trim();
162
+ }
163
+ }
164
+
165
+ // Get real IP for this hostname (cached before DNS override)
166
+ const realIP = getRealIP(hostname);
167
+ if (!realIP) {
168
+ this.options.log('error', `No real IP cached for ${hostname}`);
169
+ clientTLS.write('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
170
+ clientTLS.end();
171
+ return;
172
+ }
173
+
174
+ // Optimization logic
175
+ const adapter = this.options.adapters.get(match.provider);
176
+ let forwardBody = body;
177
+ let model = 'unknown';
178
+ let originalTokens = 0;
179
+ let optimizedTokens = 0;
180
+ let savingsPercent = 0;
181
+ let passThrough = true;
182
+
183
+ if (adapter && body.length > 0) {
184
+ try {
185
+ const parsed = JSON.parse(body.toString());
186
+ model = parsed.model || 'unknown';
187
+ const canonical = adapter.parseRequest(parsed, headers);
188
+ originalTokens = estimateTokens(canonical.systemPrompt || '') +
189
+ canonical.messages.reduce((sum, m) => sum + estimateTokens(getTextContent(m)), 0);
190
+
191
+ if (this.options.optimizer && !this.options.paused) {
192
+ try {
193
+ const result = await this.options.optimizer.optimize(canonical);
194
+ passThrough = result.passThrough;
195
+ if (!result.passThrough) {
196
+ canonical.messages = result.optimizedMessages;
197
+ if (result.systemPrompt !== undefined) canonical.systemPrompt = result.systemPrompt;
198
+ optimizedTokens = result.packed.optimizedTokens;
199
+ savingsPercent = result.packed.savingsPercent;
200
+ forwardBody = Buffer.from(JSON.stringify(adapter.serializeRequest(canonical)));
201
+ } else {
202
+ optimizedTokens = originalTokens;
203
+ }
204
+ } catch {
205
+ optimizedTokens = originalTokens;
206
+ }
207
+ } else {
208
+ optimizedTokens = originalTokens;
209
+ }
210
+ } catch {
211
+ optimizedTokens = originalTokens;
212
+ }
213
+ }
214
+
215
+ const latency = Date.now() - startTime;
216
+ const savingsStr = passThrough ? 'pass' : `-${savingsPercent}%`;
217
+ this.options.log('info', `#${reqId} ${match.provider}/${model} ${originalTokens}→${optimizedTokens} ${savingsStr} ${latency}ms [transparent]`);
218
+
219
+ // Forward to real provider IP
220
+ const forwardHeaders = { ...headers };
221
+ forwardHeaders['host'] = hostname;
222
+ forwardHeaders['content-length'] = String(forwardBody.length);
223
+
224
+ const providerRes = await new Promise<http.IncomingMessage>((resolve, reject) => {
225
+ const req = https.request({
226
+ hostname: realIP,
227
+ port: 443,
228
+ path: reqPath,
229
+ method,
230
+ headers: forwardHeaders,
231
+ servername: hostname, // SNI for the real server
232
+ }, resolve);
233
+ req.on('error', reject);
234
+ req.write(forwardBody);
235
+ req.end();
236
+ });
237
+
238
+ // Stream response back
239
+ const resHeaders = [`HTTP/1.1 ${providerRes.statusCode} ${providerRes.statusMessage}`];
240
+ for (const [key, val] of Object.entries(providerRes.headers)) {
241
+ if (val) {
242
+ const values = Array.isArray(val) ? val : [val];
243
+ for (const v of values) resHeaders.push(`${key}: ${v}`);
244
+ }
245
+ }
246
+ resHeaders.push('', '');
247
+ clientTLS.write(resHeaders.join('\r\n'));
248
+
249
+ await new Promise<void>((resolve) => {
250
+ providerRes.on('data', (chunk) => clientTLS.write(chunk));
251
+ providerRes.on('end', () => { clientTLS.end(); resolve(); });
252
+ providerRes.on('error', () => { clientTLS.end(); resolve(); });
253
+ });
254
+
255
+ // Record metrics
256
+ this.options.metrics.record({
257
+ id: reqId,
258
+ timestamp: Date.now(),
259
+ provider: match.provider,
260
+ model,
261
+ streaming: headers['accept']?.includes('text/event-stream') || false,
262
+ originalTokens,
263
+ optimizedTokens,
264
+ savingsPercent,
265
+ latencyOverheadMs: latency,
266
+ chunksRetrieved: 0,
267
+ topScore: 0,
268
+ passThrough,
269
+ });
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Extract SNI hostname from TLS ClientHello.
275
+ * Returns null if not found.
276
+ */
277
+ function extractSNI(data: Buffer): string | null {
278
+ try {
279
+ // TLS record: type=22 (handshake), version, length
280
+ if (data.length < 5 || data[0] !== 0x16) return null;
281
+
282
+ // Handshake: type=1 (ClientHello)
283
+ let offset = 5;
284
+ if (data[offset] !== 0x01) return null;
285
+
286
+ // Skip handshake length (3 bytes) + client version (2) + random (32)
287
+ offset += 4 + 2 + 32;
288
+
289
+ // Session ID
290
+ const sessionIdLen = data[offset];
291
+ offset += 1 + sessionIdLen;
292
+
293
+ // Cipher suites
294
+ const cipherLen = data.readUInt16BE(offset);
295
+ offset += 2 + cipherLen;
296
+
297
+ // Compression methods
298
+ const compLen = data[offset];
299
+ offset += 1 + compLen;
300
+
301
+ // Extensions
302
+ if (offset + 2 > data.length) return null;
303
+ const extLen = data.readUInt16BE(offset);
304
+ offset += 2;
305
+
306
+ const extEnd = offset + extLen;
307
+ while (offset + 4 < extEnd && offset < data.length) {
308
+ const extType = data.readUInt16BE(offset);
309
+ const extDataLen = data.readUInt16BE(offset + 2);
310
+ offset += 4;
311
+
312
+ if (extType === 0x0000) { // SNI extension
313
+ // Skip SNI list length (2)
314
+ offset += 2;
315
+ const nameType = data[offset];
316
+ offset += 1;
317
+ if (nameType === 0x00) { // hostname
318
+ const nameLen = data.readUInt16BE(offset);
319
+ offset += 2;
320
+ return data.subarray(offset, offset + nameLen).toString('ascii');
321
+ }
322
+ }
323
+
324
+ offset += extDataLen;
325
+ }
326
+ } catch {}
327
+ return null;
328
+ }
@@ -0,0 +1,32 @@
1
+ import net from 'node:net';
2
+ import type { Socket } from 'node:net';
3
+
4
+ /**
5
+ * Create a blind TCP tunnel between client and target.
6
+ * Zero overhead — no inspection, no buffering.
7
+ * Used for non-LLM HTTPS traffic.
8
+ */
9
+ export function createTunnel(clientSocket: Socket, targetHost: string, targetPort: number): void {
10
+ const targetSocket = net.connect(targetPort, targetHost, () => {
11
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
12
+ targetSocket.pipe(clientSocket);
13
+ clientSocket.pipe(targetSocket);
14
+ });
15
+
16
+ targetSocket.on('error', (err) => {
17
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\n\r\n`);
18
+ clientSocket.end();
19
+ });
20
+
21
+ clientSocket.on('error', () => {
22
+ targetSocket.destroy();
23
+ });
24
+
25
+ clientSocket.on('close', () => {
26
+ targetSocket.destroy();
27
+ });
28
+
29
+ targetSocket.on('close', () => {
30
+ clientSocket.destroy();
31
+ });
32
+ }
@@ -0,0 +1,144 @@
1
+ import fs from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import dns from 'node:dns';
4
+
5
+ const HOSTS_FILE = '/etc/hosts';
6
+ const MARKER_START = '# SmartContext Proxy — START (auto-generated, do not edit)';
7
+ const MARKER_END = '# SmartContext Proxy — END';
8
+
9
+ /** LLM provider hostnames to redirect */
10
+ const LLM_HOSTS = [
11
+ 'api.anthropic.com',
12
+ 'api.openai.com',
13
+ 'generativelanguage.googleapis.com',
14
+ 'openrouter.ai',
15
+ 'api.together.xyz',
16
+ 'api.fireworks.ai',
17
+ 'api.mistral.ai',
18
+ 'api.cohere.com',
19
+ 'api.groq.com',
20
+ 'api.deepseek.com',
21
+ ];
22
+
23
+ /** Original IP mappings — needed to forward traffic to real providers */
24
+ const originalIPs = new Map<string, string[]>();
25
+
26
+ /**
27
+ * Resolve and cache real IPs before overriding DNS.
28
+ * MUST be called before enableDNSRedirect.
29
+ */
30
+ export async function cacheRealIPs(): Promise<Map<string, string[]>> {
31
+ for (const host of LLM_HOSTS) {
32
+ try {
33
+ const addrs = await new Promise<string[]>((resolve, reject) => {
34
+ dns.resolve4(host, (err, addresses) => {
35
+ if (err) reject(err);
36
+ else resolve(addresses);
37
+ });
38
+ });
39
+ originalIPs.set(host, addrs);
40
+ } catch {}
41
+ }
42
+ return originalIPs;
43
+ }
44
+
45
+ /** Get cached real IP for a hostname */
46
+ export function getRealIP(hostname: string): string | null {
47
+ const ips = originalIPs.get(hostname);
48
+ return ips?.[0] || null;
49
+ }
50
+
51
+ /** Get all original IP mappings */
52
+ export function getAllRealIPs(): Map<string, string[]> {
53
+ return originalIPs;
54
+ }
55
+
56
+ /**
57
+ * Add /etc/hosts entries pointing LLM hostnames to 127.0.0.1.
58
+ * Requires sudo.
59
+ * After this, all apps resolve LLM hosts to localhost → our proxy.
60
+ */
61
+ export function enableDNSRedirect(): { success: boolean; message: string; hosts: number } {
62
+ try {
63
+ const current = fs.readFileSync(HOSTS_FILE, 'utf-8');
64
+
65
+ // Don't add if already present
66
+ if (current.includes(MARKER_START)) {
67
+ return { success: true, message: 'DNS redirect already active', hosts: LLM_HOSTS.length };
68
+ }
69
+
70
+ const block = [
71
+ '',
72
+ MARKER_START,
73
+ ...LLM_HOSTS.map((h) => `127.0.0.1 ${h}`),
74
+ MARKER_END,
75
+ '',
76
+ ].join('\n');
77
+
78
+ const newContent = current + block;
79
+
80
+ // Write via sudo tee
81
+ execFileSync('sudo', ['tee', HOSTS_FILE], {
82
+ input: newContent,
83
+ stdio: ['pipe', 'pipe', 'pipe'],
84
+ });
85
+
86
+ // Flush DNS cache
87
+ flushDNS();
88
+
89
+ return { success: true, message: `DNS redirect active: ${LLM_HOSTS.length} hosts → 127.0.0.1`, hosts: LLM_HOSTS.length };
90
+ } catch (err) {
91
+ return { success: false, message: `Failed: ${err}`, hosts: 0 };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Remove /etc/hosts entries.
97
+ */
98
+ export function disableDNSRedirect(): { success: boolean; message: string } {
99
+ try {
100
+ const current = fs.readFileSync(HOSTS_FILE, 'utf-8');
101
+
102
+ if (!current.includes(MARKER_START)) {
103
+ return { success: true, message: 'DNS redirect not active' };
104
+ }
105
+
106
+ // Remove our block
107
+ const regex = new RegExp(`\\n?${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n?`, 'g');
108
+ const cleaned = current.replace(regex, '\n');
109
+
110
+ execFileSync('sudo', ['tee', HOSTS_FILE], {
111
+ input: cleaned,
112
+ stdio: ['pipe', 'pipe', 'pipe'],
113
+ });
114
+
115
+ flushDNS();
116
+
117
+ return { success: true, message: 'DNS redirect removed' };
118
+ } catch (err) {
119
+ return { success: false, message: `Failed: ${err}` };
120
+ }
121
+ }
122
+
123
+ /** Check if DNS redirect is active */
124
+ export function isDNSRedirectActive(): boolean {
125
+ try {
126
+ const content = fs.readFileSync(HOSTS_FILE, 'utf-8');
127
+ return content.includes(MARKER_START);
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ function flushDNS(): void {
134
+ try {
135
+ if (process.platform === 'darwin') {
136
+ execFileSync('dscacheutil', ['-flushcache'], { stdio: 'pipe' });
137
+ execFileSync('sudo', ['killall', '-HUP', 'mDNSResponder'], { stdio: 'pipe' });
138
+ }
139
+ } catch {}
140
+ }
141
+
142
+ function escapeRegex(s: string): string {
143
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
144
+ }
@@ -0,0 +1,148 @@
1
+ import { ensureCA, getCACertPath } from '../tls/ca-manager.js';
2
+ import { installCA, uninstallCA, isCAInstalled } from '../tls/trust-store.js';
3
+ import { installService, uninstallService } from '../daemon/service.js';
4
+ import * as macos from './macos.js';
5
+ import * as linux from './linux.js';
6
+
7
+ const platform = process.platform === 'darwin' ? macos : linux;
8
+
9
+ export interface InstallResult {
10
+ success: boolean;
11
+ steps: Array<{ step: string; success: boolean; message: string }>;
12
+ }
13
+
14
+ /**
15
+ * Full installation:
16
+ * 1. Generate CA cert
17
+ * 2. Install CA in trust store
18
+ * 3. Configure system proxy
19
+ * 4. Install system service
20
+ */
21
+ export function install(port: number): InstallResult {
22
+ const steps: InstallResult['steps'] = [];
23
+ let allOk = true;
24
+
25
+ // Step 1: Generate CA
26
+ try {
27
+ ensureCA();
28
+ steps.push({ step: 'Generate CA certificate', success: true, message: `CA cert at ${getCACertPath()}` });
29
+ } catch (err) {
30
+ steps.push({ step: 'Generate CA certificate', success: false, message: String(err) });
31
+ allOk = false;
32
+ }
33
+
34
+ // Step 2: Install CA in trust store
35
+ if (allOk) {
36
+ const result = installCA();
37
+ steps.push({ step: 'Install CA in trust store', success: result.success, message: result.message });
38
+ if (!result.success) allOk = false;
39
+ }
40
+
41
+ // Step 3: Configure system proxy (PAC URL for selective proxying)
42
+ if (allOk) {
43
+ if (process.platform === 'darwin') {
44
+ const result = macos.setAutoproxyURL(`http://127.0.0.1:${port}/proxy.pac`);
45
+ steps.push({ step: 'Configure system proxy (PAC)', success: result.success, message: result.message });
46
+ if (!result.success) allOk = false;
47
+ } else {
48
+ const result = platform.setSystemProxy('127.0.0.1', port);
49
+ steps.push({ step: 'Configure system proxy', success: result.success, message: result.message });
50
+ if (!result.success) allOk = false;
51
+ }
52
+ }
53
+
54
+ // Step 4: Install system service
55
+ if (allOk) {
56
+ try {
57
+ const servicePath = installService(port);
58
+ steps.push({ step: 'Install system service', success: true, message: `Service at ${servicePath}` });
59
+ } catch (err) {
60
+ steps.push({ step: 'Install system service', success: false, message: String(err) });
61
+ // Non-fatal: proxy can still run manually
62
+ }
63
+ }
64
+
65
+ // Rollback on failure
66
+ if (!allOk) {
67
+ rollback(steps);
68
+ }
69
+
70
+ return { success: allOk, steps };
71
+ }
72
+
73
+ /**
74
+ * Full uninstallation:
75
+ * 1. Remove system service
76
+ * 2. Clear system proxy
77
+ * 3. Remove CA from trust store
78
+ */
79
+ export function uninstall(purge: boolean = false): InstallResult {
80
+ const steps: InstallResult['steps'] = [];
81
+
82
+ // Step 1: Remove service
83
+ try {
84
+ const msg = uninstallService();
85
+ steps.push({ step: 'Remove system service', success: true, message: msg });
86
+ } catch (err) {
87
+ steps.push({ step: 'Remove system service', success: false, message: String(err) });
88
+ }
89
+
90
+ // Step 2: Clear proxy
91
+ if (process.platform === 'darwin') {
92
+ const r1 = macos.clearAutoproxyURL();
93
+ steps.push({ step: 'Clear auto-proxy', success: r1.success, message: r1.message });
94
+ const r2 = macos.clearSystemProxy();
95
+ steps.push({ step: 'Clear system proxy', success: r2.success, message: r2.message });
96
+ } else {
97
+ const r = linux.clearSystemProxy();
98
+ steps.push({ step: 'Clear system proxy', success: r.success, message: r.message });
99
+ }
100
+
101
+ // Step 3: Remove CA
102
+ const caResult = uninstallCA();
103
+ steps.push({ step: 'Remove CA from trust store', success: caResult.success, message: caResult.message });
104
+
105
+ // Step 4: Purge data (optional)
106
+ if (purge) {
107
+ try {
108
+ const fs = require('node:fs');
109
+ const path = require('node:path');
110
+ const dataDir = path.join(process.env['HOME'] || '.', '.smartcontext');
111
+ fs.rmSync(dataDir, { recursive: true, force: true });
112
+ steps.push({ step: 'Purge data directory', success: true, message: `Removed ${dataDir}` });
113
+ } catch (err) {
114
+ steps.push({ step: 'Purge data directory', success: false, message: String(err) });
115
+ }
116
+ }
117
+
118
+ const allOk = steps.every((s) => s.success);
119
+ return { success: allOk, steps };
120
+ }
121
+
122
+ /** Check installation status */
123
+ export function status(port: number): Record<string, boolean | string> {
124
+ return {
125
+ caExists: require('node:fs').existsSync(getCACertPath()),
126
+ caInstalled: isCAInstalled(),
127
+ proxyConfigured: platform.isProxyConfigured(port),
128
+ };
129
+ }
130
+
131
+ /** Rollback completed steps on failure */
132
+ function rollback(completedSteps: InstallResult['steps']): void {
133
+ for (const step of completedSteps.reverse()) {
134
+ if (!step.success) continue;
135
+ try {
136
+ if (step.step.includes('CA in trust store')) uninstallCA();
137
+ if (step.step.includes('system proxy') || step.step.includes('PAC')) {
138
+ if (process.platform === 'darwin') {
139
+ macos.clearAutoproxyURL();
140
+ macos.clearSystemProxy();
141
+ } else {
142
+ linux.clearSystemProxy();
143
+ }
144
+ }
145
+ if (step.step.includes('system service')) uninstallService();
146
+ } catch {}
147
+ }
148
+ }