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,57 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+
4
+ /** Configure Linux system proxy */
5
+ export function setSystemProxy(host: string, port: number): { success: boolean; message: string } {
6
+ const proxyUrl = `http://${host}:${port}`;
7
+
8
+ try {
9
+ // Try GNOME settings
10
+ try {
11
+ execFileSync('gsettings', ['set', 'org.gnome.system.proxy', 'mode', "'manual'"], { stdio: 'pipe' });
12
+ execFileSync('gsettings', ['set', 'org.gnome.system.proxy.http', 'host', `'${host}'`], { stdio: 'pipe' });
13
+ execFileSync('gsettings', ['set', 'org.gnome.system.proxy.http', 'port', String(port)], { stdio: 'pipe' });
14
+ execFileSync('gsettings', ['set', 'org.gnome.system.proxy.https', 'host', `'${host}'`], { stdio: 'pipe' });
15
+ execFileSync('gsettings', ['set', 'org.gnome.system.proxy.https', 'port', String(port)], { stdio: 'pipe' });
16
+ } catch {}
17
+
18
+ // Write environment file for shell sessions
19
+ const envFile = '/etc/profile.d/smartcontext-proxy.sh';
20
+ const content = `# SmartContext Proxy - auto-generated
21
+ export http_proxy="${proxyUrl}"
22
+ export https_proxy="${proxyUrl}"
23
+ export HTTP_PROXY="${proxyUrl}"
24
+ export HTTPS_PROXY="${proxyUrl}"
25
+ export no_proxy="localhost,127.0.0.1,::1"
26
+ `;
27
+ try {
28
+ execFileSync('sudo', ['tee', envFile], { input: content, stdio: ['pipe', 'pipe', 'pipe'] });
29
+ } catch {}
30
+
31
+ return { success: true, message: `System proxy set to ${proxyUrl}` };
32
+ } catch (err) {
33
+ return { success: false, message: `Failed: ${err}` };
34
+ }
35
+ }
36
+
37
+ /** Clear Linux system proxy */
38
+ export function clearSystemProxy(): { success: boolean; message: string } {
39
+ try {
40
+ try {
41
+ execFileSync('gsettings', ['set', 'org.gnome.system.proxy', 'mode', "'none'"], { stdio: 'pipe' });
42
+ } catch {}
43
+
44
+ try {
45
+ execFileSync('sudo', ['rm', '-f', '/etc/profile.d/smartcontext-proxy.sh'], { stdio: 'pipe' });
46
+ } catch {}
47
+
48
+ return { success: true, message: 'System proxy cleared' };
49
+ } catch (err) {
50
+ return { success: false, message: `Failed: ${err}` };
51
+ }
52
+ }
53
+
54
+ export function isProxyConfigured(port: number): boolean {
55
+ const proxyEnv = process.env['https_proxy'] || process.env['HTTPS_PROXY'] || '';
56
+ return proxyEnv.includes(String(port));
57
+ }
@@ -0,0 +1,89 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ /** Get active network interface name (e.g., "Wi-Fi", "Ethernet") */
4
+ export function getActiveInterface(): string {
5
+ try {
6
+ const route = execFileSync('route', ['-n', 'get', 'default'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
7
+ const ifMatch = route.match(/interface:\s*(\S+)/);
8
+ if (!ifMatch) return 'Wi-Fi';
9
+
10
+ const ifName = ifMatch[1];
11
+ // Map interface ID to service name
12
+ const services = execFileSync('networksetup', ['-listallhardwareports'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
13
+ const lines = services.split('\n');
14
+ for (let i = 0; i < lines.length; i++) {
15
+ if (lines[i].includes(`Device: ${ifName}`)) {
16
+ const nameLine = lines[i - 1];
17
+ const nameMatch = nameLine?.match(/Hardware Port:\s*(.+)/);
18
+ if (nameMatch) return nameMatch[1];
19
+ }
20
+ }
21
+ return 'Wi-Fi';
22
+ } catch {
23
+ return 'Wi-Fi';
24
+ }
25
+ }
26
+
27
+ /** Configure macOS system proxy to use SmartContext */
28
+ export function setSystemProxy(host: string, port: number): { success: boolean; message: string } {
29
+ const iface = getActiveInterface();
30
+ try {
31
+ // Set HTTPS proxy
32
+ execFileSync('networksetup', ['-setsecurewebproxy', iface, host, String(port)], { stdio: 'pipe' });
33
+ // Set HTTP proxy
34
+ execFileSync('networksetup', ['-setwebproxy', iface, host, String(port)], { stdio: 'pipe' });
35
+ // Enable them
36
+ execFileSync('networksetup', ['-setsecurewebproxystate', iface, 'on'], { stdio: 'pipe' });
37
+ execFileSync('networksetup', ['-setwebproxystate', iface, 'on'], { stdio: 'pipe' });
38
+
39
+ return { success: true, message: `System proxy set to ${host}:${port} on ${iface}` };
40
+ } catch (err) {
41
+ return { success: false, message: `Failed to set proxy on ${iface}: ${err}` };
42
+ }
43
+ }
44
+
45
+ /** Remove system proxy configuration */
46
+ export function clearSystemProxy(): { success: boolean; message: string } {
47
+ const iface = getActiveInterface();
48
+ try {
49
+ execFileSync('networksetup', ['-setsecurewebproxystate', iface, 'off'], { stdio: 'pipe' });
50
+ execFileSync('networksetup', ['-setwebproxystate', iface, 'off'], { stdio: 'pipe' });
51
+ return { success: true, message: `System proxy cleared on ${iface}` };
52
+ } catch (err) {
53
+ return { success: false, message: `Failed to clear proxy on ${iface}: ${err}` };
54
+ }
55
+ }
56
+
57
+ /** Set PAC URL as auto-proxy configuration */
58
+ export function setAutoproxyURL(pacUrl: string): { success: boolean; message: string } {
59
+ const iface = getActiveInterface();
60
+ try {
61
+ execFileSync('networksetup', ['-setautoproxyurl', iface, pacUrl], { stdio: 'pipe' });
62
+ execFileSync('networksetup', ['-setautoproxystate', iface, 'on'], { stdio: 'pipe' });
63
+ return { success: true, message: `Auto-proxy URL set to ${pacUrl} on ${iface}` };
64
+ } catch (err) {
65
+ return { success: false, message: `Failed: ${err}` };
66
+ }
67
+ }
68
+
69
+ /** Clear auto-proxy configuration */
70
+ export function clearAutoproxyURL(): { success: boolean; message: string } {
71
+ const iface = getActiveInterface();
72
+ try {
73
+ execFileSync('networksetup', ['-setautoproxystate', iface, 'off'], { stdio: 'pipe' });
74
+ return { success: true, message: `Auto-proxy cleared on ${iface}` };
75
+ } catch (err) {
76
+ return { success: false, message: `Failed: ${err}` };
77
+ }
78
+ }
79
+
80
+ /** Check if system proxy is currently set to SmartContext */
81
+ export function isProxyConfigured(port: number): boolean {
82
+ const iface = getActiveInterface();
83
+ try {
84
+ const info = execFileSync('networksetup', ['-getsecurewebproxy', iface], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
85
+ return info.includes(`Port: ${port}`) && info.includes('Enabled: Yes');
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
@@ -0,0 +1,175 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import dns from 'node:dns';
4
+ import path from 'node:path';
5
+
6
+ const PF_ANCHOR = 'com.smartcontext';
7
+ const PF_CONF_PATH = path.join(process.env['HOME'] || '.', '.smartcontext', 'pf-smartcontext.conf');
8
+
9
+ /** Known LLM provider hostnames to intercept */
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
+ /**
24
+ * Resolve hostnames to IPs for pf rules.
25
+ * pf works at IP level, not DNS.
26
+ */
27
+ async function resolveHosts(): Promise<Map<string, string[]>> {
28
+ const results = new Map<string, string[]>();
29
+
30
+ for (const host of LLM_HOSTS) {
31
+ try {
32
+ const addrs = await new Promise<string[]>((resolve, reject) => {
33
+ dns.resolve4(host, (err, addresses) => {
34
+ if (err) reject(err);
35
+ else resolve(addresses);
36
+ });
37
+ });
38
+ results.set(host, addrs);
39
+ } catch {
40
+ // Host might not resolve — skip
41
+ }
42
+ }
43
+
44
+ return results;
45
+ }
46
+
47
+ /**
48
+ * Generate pf redirect rules.
49
+ * Redirects outgoing HTTPS (port 443) traffic to LLM provider IPs
50
+ * to our local proxy port.
51
+ */
52
+ function generatePFConf(hostIPs: Map<string, string[]>, proxyPort: number): string {
53
+ const lines: string[] = [
54
+ `# SmartContext Proxy — auto-generated pf rules`,
55
+ `# Redirects LLM API traffic to local proxy`,
56
+ ``,
57
+ ];
58
+
59
+ // Collect all IPs into a pf table
60
+ const allIPs: string[] = [];
61
+ for (const [host, ips] of hostIPs) {
62
+ lines.push(`# ${host}: ${ips.join(', ')}`);
63
+ allIPs.push(...ips);
64
+ }
65
+
66
+ if (allIPs.length === 0) {
67
+ lines.push('# No IPs resolved — no rules');
68
+ return lines.join('\n');
69
+ }
70
+
71
+ lines.push('');
72
+ lines.push(`table <llm_providers> { ${allIPs.join(', ')} }`);
73
+ lines.push('');
74
+ // Redirect outgoing HTTPS to LLM providers → local proxy
75
+ // rdr-to changes destination to localhost:proxyPort
76
+ lines.push(`rdr pass on lo0 proto tcp from any to <llm_providers> port 443 -> 127.0.0.1 port ${proxyPort}`);
77
+ lines.push('');
78
+ // Route the traffic through loopback so rdr applies
79
+ lines.push(`pass out on en0 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
80
+ lines.push(`pass out on en1 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
81
+ lines.push('');
82
+
83
+ return lines.join('\n');
84
+ }
85
+
86
+ /**
87
+ * Install pf redirect rules.
88
+ * Requires sudo.
89
+ */
90
+ export async function enablePFRedirect(proxyPort: number): Promise<{ success: boolean; message: string; ips: number }> {
91
+ try {
92
+ const hostIPs = await resolveHosts();
93
+
94
+ let totalIPs = 0;
95
+ for (const ips of hostIPs.values()) totalIPs += ips.length;
96
+
97
+ if (totalIPs === 0) {
98
+ return { success: false, message: 'No LLM provider IPs resolved', ips: 0 };
99
+ }
100
+
101
+ const conf = generatePFConf(hostIPs, proxyPort);
102
+ fs.mkdirSync(path.dirname(PF_CONF_PATH), { recursive: true });
103
+ fs.writeFileSync(PF_CONF_PATH, conf);
104
+
105
+ // Load anchor into pf
106
+ try {
107
+ execFileSync('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
108
+ } catch (err) {
109
+ return { success: false, message: `pfctl load failed: ${err}`, ips: totalIPs };
110
+ }
111
+
112
+ // Enable pf if not already enabled
113
+ try {
114
+ execFileSync('sudo', ['pfctl', '-e'], { stdio: 'pipe' });
115
+ } catch {
116
+ // Already enabled — ok
117
+ }
118
+
119
+ return { success: true, message: `pf redirect active: ${totalIPs} IPs from ${hostIPs.size} hosts → :${proxyPort}`, ips: totalIPs };
120
+ } catch (err) {
121
+ return { success: false, message: `Failed: ${err}`, ips: 0 };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Remove pf redirect rules.
127
+ */
128
+ export function disablePFRedirect(): { success: boolean; message: string } {
129
+ try {
130
+ // Flush anchor rules
131
+ try {
132
+ execFileSync('sudo', ['pfctl', '-a', PF_ANCHOR, '-F', 'all'], { stdio: 'pipe' });
133
+ } catch {}
134
+
135
+ // Remove conf file
136
+ try { fs.unlinkSync(PF_CONF_PATH); } catch {}
137
+
138
+ return { success: true, message: 'pf redirect removed' };
139
+ } catch (err) {
140
+ return { success: false, message: `Failed: ${err}` };
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Check if pf redirect is active.
146
+ */
147
+ export function isPFRedirectActive(): boolean {
148
+ try {
149
+ const result = execFileSync('sudo', ['pfctl', '-a', PF_ANCHOR, '-sr'], {
150
+ encoding: 'utf-8',
151
+ stdio: ['pipe', 'pipe', 'pipe'],
152
+ });
153
+ return result.includes('rdr') || result.includes('route-to');
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Refresh IP addresses (IPs can change due to DNS).
161
+ * Call periodically to keep rules current.
162
+ */
163
+ export async function refreshPFRules(proxyPort: number): Promise<void> {
164
+ const hostIPs = await resolveHosts();
165
+ let totalIPs = 0;
166
+ for (const ips of hostIPs.values()) totalIPs += ips.length;
167
+ if (totalIPs === 0) return;
168
+
169
+ const conf = generatePFConf(hostIPs, proxyPort);
170
+ fs.writeFileSync(PF_CONF_PATH, conf);
171
+
172
+ try {
173
+ execFileSync('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
174
+ } catch {}
175
+ }
@@ -0,0 +1,76 @@
1
+ import http from 'node:http';
2
+ import * as macos from './macos.js';
3
+ import * as linux from './linux.js';
4
+
5
+ const CHECK_INTERVAL = 10_000; // 10 seconds
6
+ let watchdogTimer: NodeJS.Timeout | null = null;
7
+ let port = 4800;
8
+
9
+ /**
10
+ * Start watchdog that monitors proxy health.
11
+ * If proxy becomes unreachable, auto-removes system proxy config
12
+ * to prevent breaking the user's internet.
13
+ */
14
+ export function startWatchdog(proxyPort: number): void {
15
+ port = proxyPort;
16
+
17
+ watchdogTimer = setInterval(() => {
18
+ checkHealth().catch(() => {
19
+ console.log('[watchdog] Proxy unreachable — clearing system proxy');
20
+ emergencyClearProxy();
21
+ stopWatchdog();
22
+ });
23
+ }, CHECK_INTERVAL);
24
+
25
+ // Also clear proxy on process exit
26
+ process.on('exit', emergencyClearProxy);
27
+ process.on('uncaughtException', (err) => {
28
+ console.error('[watchdog] Uncaught exception:', err);
29
+ emergencyClearProxy();
30
+ process.exit(1);
31
+ });
32
+ process.on('unhandledRejection', (err) => {
33
+ console.error('[watchdog] Unhandled rejection:', err);
34
+ });
35
+ }
36
+
37
+ export function stopWatchdog(): void {
38
+ if (watchdogTimer) {
39
+ clearInterval(watchdogTimer);
40
+ watchdogTimer = null;
41
+ }
42
+ }
43
+
44
+ function checkHealth(): Promise<void> {
45
+ return new Promise((resolve, reject) => {
46
+ const req = http.get(`http://127.0.0.1:${port}/health`, { timeout: 5000 }, (res) => {
47
+ let data = '';
48
+ res.on('data', (chunk) => (data += chunk));
49
+ res.on('end', () => {
50
+ try {
51
+ const parsed = JSON.parse(data);
52
+ if (parsed.ok) resolve();
53
+ else reject(new Error('Health check failed'));
54
+ } catch {
55
+ reject(new Error('Invalid health response'));
56
+ }
57
+ });
58
+ });
59
+ req.on('error', reject);
60
+ req.on('timeout', () => {
61
+ req.destroy();
62
+ reject(new Error('Health check timeout'));
63
+ });
64
+ });
65
+ }
66
+
67
+ function emergencyClearProxy(): void {
68
+ try {
69
+ if (process.platform === 'darwin') {
70
+ macos.clearAutoproxyURL();
71
+ macos.clearSystemProxy();
72
+ } else {
73
+ linux.clearSystemProxy();
74
+ }
75
+ } catch {}
76
+ }
@@ -0,0 +1,170 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import http from 'node:http';
4
+ import https from 'node:https';
5
+ import net from 'node:net';
6
+ import tls from 'node:tls';
7
+ import fs from 'node:fs';
8
+ import { ConnectProxy } from '../proxy/connect-proxy.js';
9
+ import { buildConfig } from '../config/auto-detect.js';
10
+ import { ensureCA, getCACertPath } from '../tls/ca-manager.js';
11
+ import { classifyHost } from '../proxy/classifier.js';
12
+
13
+ function httpRequest(
14
+ url: string,
15
+ options: http.RequestOptions,
16
+ ): Promise<{ status: number; body: string }> {
17
+ return new Promise((resolve, reject) => {
18
+ const req = http.request(url, options, (res) => {
19
+ let data = '';
20
+ res.on('data', (chunk) => (data += chunk));
21
+ res.on('end', () => resolve({ status: res.statusCode || 0, body: data }));
22
+ });
23
+ req.on('error', reject);
24
+ req.end();
25
+ });
26
+ }
27
+
28
+ describe('Traffic Classifier', () => {
29
+ it('identifies Anthropic as LLM', () => {
30
+ const match = classifyHost('api.anthropic.com', 443);
31
+ assert.ok(match);
32
+ assert.strictEqual(match.provider, 'anthropic');
33
+ });
34
+
35
+ it('identifies OpenAI as LLM', () => {
36
+ const match = classifyHost('api.openai.com', 443);
37
+ assert.ok(match);
38
+ assert.strictEqual(match.provider, 'openai');
39
+ });
40
+
41
+ it('identifies Ollama local as LLM', () => {
42
+ const match = classifyHost('localhost', 11434);
43
+ assert.ok(match);
44
+ assert.strictEqual(match.provider, 'ollama');
45
+ });
46
+
47
+ it('returns null for non-LLM hosts', () => {
48
+ assert.strictEqual(classifyHost('google.com', 443), null);
49
+ assert.strictEqual(classifyHost('github.com', 443), null);
50
+ assert.strictEqual(classifyHost('localhost', 3000), null);
51
+ });
52
+
53
+ it('identifies all known providers', () => {
54
+ const providers = [
55
+ ['api.anthropic.com', 'anthropic'],
56
+ ['api.openai.com', 'openai'],
57
+ ['generativelanguage.googleapis.com', 'google'],
58
+ ['openrouter.ai', 'openrouter'],
59
+ ['api.groq.com', 'groq'],
60
+ ['api.deepseek.com', 'deepseek'],
61
+ ] as const;
62
+
63
+ for (const [host, expected] of providers) {
64
+ const match = classifyHost(host, 443);
65
+ assert.ok(match, `Should match ${host}`);
66
+ assert.strictEqual(match.provider, expected);
67
+ }
68
+ });
69
+ });
70
+
71
+ describe('CA Manager', () => {
72
+ it('generates CA cert on first call', () => {
73
+ const result = ensureCA();
74
+ assert.ok(result.cert.includes('BEGIN CERTIFICATE'));
75
+ assert.ok(result.key.includes('BEGIN RSA PRIVATE KEY'));
76
+ });
77
+
78
+ it('returns same cert on subsequent calls', () => {
79
+ const a = ensureCA();
80
+ const b = ensureCA();
81
+ assert.strictEqual(a.cert, b.cert);
82
+ });
83
+
84
+ it('CA cert file exists on disk', () => {
85
+ assert.ok(fs.existsSync(getCACertPath()));
86
+ });
87
+ });
88
+
89
+ describe('CONNECT Proxy', () => {
90
+ let proxy: ConnectProxy;
91
+ const PROXY_PORT = 14820;
92
+
93
+ before(async () => {
94
+ ensureCA();
95
+ const config = buildConfig({ proxy: { port: PROXY_PORT, host: '127.0.0.1' } });
96
+ config.logging.level = 'error';
97
+ proxy = new ConnectProxy(config);
98
+ await proxy.start();
99
+ });
100
+
101
+ after(async () => {
102
+ await proxy.stop();
103
+ });
104
+
105
+ it('serves dashboard at root', async () => {
106
+ const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/`, { method: 'GET' });
107
+ assert.strictEqual(res.status, 200);
108
+ assert.ok(res.body.includes('SmartContext'));
109
+ });
110
+
111
+ it('serves health endpoint', async () => {
112
+ const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/health`, { method: 'GET' });
113
+ assert.strictEqual(res.status, 200);
114
+ const data = JSON.parse(res.body);
115
+ assert.strictEqual(data.ok, true);
116
+ assert.strictEqual(data.type, 'connect-proxy');
117
+ });
118
+
119
+ it('serves PAC file', async () => {
120
+ const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/proxy.pac`, { method: 'GET' });
121
+ assert.strictEqual(res.status, 200);
122
+ assert.ok(res.body.includes('FindProxyForURL'));
123
+ assert.ok(res.body.includes('api.anthropic.com'));
124
+ assert.ok(res.body.includes('api.openai.com'));
125
+ });
126
+
127
+ it('API endpoints work', async () => {
128
+ const status = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/_sc/status`, { method: 'GET' });
129
+ assert.strictEqual(JSON.parse(status.body).state, 'running');
130
+
131
+ const stats = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/_sc/stats`, { method: 'GET' });
132
+ assert.strictEqual(JSON.parse(stats.body).totalRequests, 0);
133
+ });
134
+
135
+ it('handles CONNECT to non-LLM host (tunnel)', async () => {
136
+ // Create a simple target server
137
+ const targetServer = http.createServer((req, res) => {
138
+ res.writeHead(200);
139
+ res.end('target-ok');
140
+ });
141
+ await new Promise<void>((r) => targetServer.listen(14821, '127.0.0.1', r));
142
+
143
+ // Send CONNECT through proxy
144
+ const tunnelOk = await new Promise<boolean>((resolve) => {
145
+ const req = http.request({
146
+ host: '127.0.0.1',
147
+ port: PROXY_PORT,
148
+ method: 'CONNECT',
149
+ path: '127.0.0.1:14821',
150
+ });
151
+
152
+ req.on('connect', (res, socket) => {
153
+ // Send HTTP request through tunnel
154
+ socket.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n');
155
+ let data = '';
156
+ socket.on('data', (chunk) => { data += chunk; });
157
+ socket.on('end', () => {
158
+ socket.destroy();
159
+ resolve(data.includes('target-ok'));
160
+ });
161
+ });
162
+
163
+ req.on('error', () => resolve(false));
164
+ req.end();
165
+ });
166
+
167
+ targetServer.close();
168
+ assert.ok(tunnelOk, 'Should tunnel non-LLM traffic');
169
+ });
170
+ });
@@ -43,6 +43,7 @@ describe('Dashboard & API', () => {
43
43
  assert.ok(res.headers['content-type']?.includes('text/html'));
44
44
  assert.ok(res.body.includes('SmartContext Proxy'));
45
45
  assert.ok(res.body.includes('Total Requests'));
46
+ assert.ok(res.body.includes('Settings'));
46
47
  });
47
48
 
48
49
  it('returns status via /_sc/status', async () => {