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,177 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.enablePFRedirect = enablePFRedirect;
7
+ exports.disablePFRedirect = disablePFRedirect;
8
+ exports.isPFRedirectActive = isPFRedirectActive;
9
+ exports.refreshPFRules = refreshPFRules;
10
+ const node_child_process_1 = require("node:child_process");
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_dns_1 = __importDefault(require("node:dns"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ const PF_ANCHOR = 'com.smartcontext';
15
+ const PF_CONF_PATH = node_path_1.default.join(process.env['HOME'] || '.', '.smartcontext', 'pf-smartcontext.conf');
16
+ /** Known LLM provider hostnames to intercept */
17
+ const LLM_HOSTS = [
18
+ 'api.anthropic.com',
19
+ 'api.openai.com',
20
+ 'generativelanguage.googleapis.com',
21
+ 'openrouter.ai',
22
+ 'api.together.xyz',
23
+ 'api.fireworks.ai',
24
+ 'api.mistral.ai',
25
+ 'api.cohere.com',
26
+ 'api.groq.com',
27
+ 'api.deepseek.com',
28
+ ];
29
+ /**
30
+ * Resolve hostnames to IPs for pf rules.
31
+ * pf works at IP level, not DNS.
32
+ */
33
+ async function resolveHosts() {
34
+ const results = new Map();
35
+ for (const host of LLM_HOSTS) {
36
+ try {
37
+ const addrs = await new Promise((resolve, reject) => {
38
+ node_dns_1.default.resolve4(host, (err, addresses) => {
39
+ if (err)
40
+ reject(err);
41
+ else
42
+ resolve(addresses);
43
+ });
44
+ });
45
+ results.set(host, addrs);
46
+ }
47
+ catch {
48
+ // Host might not resolve — skip
49
+ }
50
+ }
51
+ return results;
52
+ }
53
+ /**
54
+ * Generate pf redirect rules.
55
+ * Redirects outgoing HTTPS (port 443) traffic to LLM provider IPs
56
+ * to our local proxy port.
57
+ */
58
+ function generatePFConf(hostIPs, proxyPort) {
59
+ const lines = [
60
+ `# SmartContext Proxy — auto-generated pf rules`,
61
+ `# Redirects LLM API traffic to local proxy`,
62
+ ``,
63
+ ];
64
+ // Collect all IPs into a pf table
65
+ const allIPs = [];
66
+ for (const [host, ips] of hostIPs) {
67
+ lines.push(`# ${host}: ${ips.join(', ')}`);
68
+ allIPs.push(...ips);
69
+ }
70
+ if (allIPs.length === 0) {
71
+ lines.push('# No IPs resolved — no rules');
72
+ return lines.join('\n');
73
+ }
74
+ lines.push('');
75
+ lines.push(`table <llm_providers> { ${allIPs.join(', ')} }`);
76
+ lines.push('');
77
+ // Redirect outgoing HTTPS to LLM providers → local proxy
78
+ // rdr-to changes destination to localhost:proxyPort
79
+ lines.push(`rdr pass on lo0 proto tcp from any to <llm_providers> port 443 -> 127.0.0.1 port ${proxyPort}`);
80
+ lines.push('');
81
+ // Route the traffic through loopback so rdr applies
82
+ lines.push(`pass out on en0 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
83
+ lines.push(`pass out on en1 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
84
+ lines.push('');
85
+ return lines.join('\n');
86
+ }
87
+ /**
88
+ * Install pf redirect rules.
89
+ * Requires sudo.
90
+ */
91
+ async function enablePFRedirect(proxyPort) {
92
+ try {
93
+ const hostIPs = await resolveHosts();
94
+ let totalIPs = 0;
95
+ for (const ips of hostIPs.values())
96
+ totalIPs += ips.length;
97
+ if (totalIPs === 0) {
98
+ return { success: false, message: 'No LLM provider IPs resolved', ips: 0 };
99
+ }
100
+ const conf = generatePFConf(hostIPs, proxyPort);
101
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(PF_CONF_PATH), { recursive: true });
102
+ node_fs_1.default.writeFileSync(PF_CONF_PATH, conf);
103
+ // Load anchor into pf
104
+ try {
105
+ (0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
106
+ }
107
+ catch (err) {
108
+ return { success: false, message: `pfctl load failed: ${err}`, ips: totalIPs };
109
+ }
110
+ // Enable pf if not already enabled
111
+ try {
112
+ (0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-e'], { stdio: 'pipe' });
113
+ }
114
+ catch {
115
+ // Already enabled — ok
116
+ }
117
+ return { success: true, message: `pf redirect active: ${totalIPs} IPs from ${hostIPs.size} hosts → :${proxyPort}`, ips: totalIPs };
118
+ }
119
+ catch (err) {
120
+ return { success: false, message: `Failed: ${err}`, ips: 0 };
121
+ }
122
+ }
123
+ /**
124
+ * Remove pf redirect rules.
125
+ */
126
+ function disablePFRedirect() {
127
+ try {
128
+ // Flush anchor rules
129
+ try {
130
+ (0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-F', 'all'], { stdio: 'pipe' });
131
+ }
132
+ catch { }
133
+ // Remove conf file
134
+ try {
135
+ node_fs_1.default.unlinkSync(PF_CONF_PATH);
136
+ }
137
+ catch { }
138
+ return { success: true, message: 'pf redirect removed' };
139
+ }
140
+ catch (err) {
141
+ return { success: false, message: `Failed: ${err}` };
142
+ }
143
+ }
144
+ /**
145
+ * Check if pf redirect is active.
146
+ */
147
+ function isPFRedirectActive() {
148
+ try {
149
+ const result = (0, node_child_process_1.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
+ }
155
+ catch {
156
+ return false;
157
+ }
158
+ }
159
+ /**
160
+ * Refresh IP addresses (IPs can change due to DNS).
161
+ * Call periodically to keep rules current.
162
+ */
163
+ async function refreshPFRules(proxyPort) {
164
+ const hostIPs = await resolveHosts();
165
+ let totalIPs = 0;
166
+ for (const ips of hostIPs.values())
167
+ totalIPs += ips.length;
168
+ if (totalIPs === 0)
169
+ return;
170
+ const conf = generatePFConf(hostIPs, proxyPort);
171
+ node_fs_1.default.writeFileSync(PF_CONF_PATH, conf);
172
+ try {
173
+ (0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
174
+ }
175
+ catch { }
176
+ }
177
+ //# sourceMappingURL=pf-redirect.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Start watchdog that monitors proxy health.
3
+ * If proxy becomes unreachable, auto-removes system proxy config
4
+ * to prevent breaking the user's internet.
5
+ */
6
+ export declare function startWatchdog(proxyPort: number): void;
7
+ export declare function stopWatchdog(): void;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.startWatchdog = startWatchdog;
40
+ exports.stopWatchdog = stopWatchdog;
41
+ const node_http_1 = __importDefault(require("node:http"));
42
+ const macos = __importStar(require("./macos.js"));
43
+ const linux = __importStar(require("./linux.js"));
44
+ const CHECK_INTERVAL = 10_000; // 10 seconds
45
+ let watchdogTimer = null;
46
+ let port = 4800;
47
+ /**
48
+ * Start watchdog that monitors proxy health.
49
+ * If proxy becomes unreachable, auto-removes system proxy config
50
+ * to prevent breaking the user's internet.
51
+ */
52
+ function startWatchdog(proxyPort) {
53
+ port = proxyPort;
54
+ watchdogTimer = setInterval(() => {
55
+ checkHealth().catch(() => {
56
+ console.log('[watchdog] Proxy unreachable — clearing system proxy');
57
+ emergencyClearProxy();
58
+ stopWatchdog();
59
+ });
60
+ }, CHECK_INTERVAL);
61
+ // Also clear proxy on process exit
62
+ process.on('exit', emergencyClearProxy);
63
+ process.on('uncaughtException', (err) => {
64
+ console.error('[watchdog] Uncaught exception:', err);
65
+ emergencyClearProxy();
66
+ process.exit(1);
67
+ });
68
+ process.on('unhandledRejection', (err) => {
69
+ console.error('[watchdog] Unhandled rejection:', err);
70
+ });
71
+ }
72
+ function stopWatchdog() {
73
+ if (watchdogTimer) {
74
+ clearInterval(watchdogTimer);
75
+ watchdogTimer = null;
76
+ }
77
+ }
78
+ function checkHealth() {
79
+ return new Promise((resolve, reject) => {
80
+ const req = node_http_1.default.get(`http://127.0.0.1:${port}/health`, { timeout: 5000 }, (res) => {
81
+ let data = '';
82
+ res.on('data', (chunk) => (data += chunk));
83
+ res.on('end', () => {
84
+ try {
85
+ const parsed = JSON.parse(data);
86
+ if (parsed.ok)
87
+ resolve();
88
+ else
89
+ reject(new Error('Health check failed'));
90
+ }
91
+ catch {
92
+ reject(new Error('Invalid health response'));
93
+ }
94
+ });
95
+ });
96
+ req.on('error', reject);
97
+ req.on('timeout', () => {
98
+ req.destroy();
99
+ reject(new Error('Health check timeout'));
100
+ });
101
+ });
102
+ }
103
+ function emergencyClearProxy() {
104
+ try {
105
+ if (process.platform === 'darwin') {
106
+ macos.clearAutoproxyURL();
107
+ macos.clearSystemProxy();
108
+ }
109
+ else {
110
+ linux.clearSystemProxy();
111
+ }
112
+ }
113
+ catch { }
114
+ }
115
+ //# sourceMappingURL=watchdog.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_test_1 = require("node:test");
7
+ const node_assert_1 = __importDefault(require("node:assert"));
8
+ const node_http_1 = __importDefault(require("node:http"));
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const connect_proxy_js_1 = require("../proxy/connect-proxy.js");
11
+ const auto_detect_js_1 = require("../config/auto-detect.js");
12
+ const ca_manager_js_1 = require("../tls/ca-manager.js");
13
+ const classifier_js_1 = require("../proxy/classifier.js");
14
+ function httpRequest(url, options) {
15
+ return new Promise((resolve, reject) => {
16
+ const req = node_http_1.default.request(url, options, (res) => {
17
+ let data = '';
18
+ res.on('data', (chunk) => (data += chunk));
19
+ res.on('end', () => resolve({ status: res.statusCode || 0, body: data }));
20
+ });
21
+ req.on('error', reject);
22
+ req.end();
23
+ });
24
+ }
25
+ (0, node_test_1.describe)('Traffic Classifier', () => {
26
+ (0, node_test_1.it)('identifies Anthropic as LLM', () => {
27
+ const match = (0, classifier_js_1.classifyHost)('api.anthropic.com', 443);
28
+ node_assert_1.default.ok(match);
29
+ node_assert_1.default.strictEqual(match.provider, 'anthropic');
30
+ });
31
+ (0, node_test_1.it)('identifies OpenAI as LLM', () => {
32
+ const match = (0, classifier_js_1.classifyHost)('api.openai.com', 443);
33
+ node_assert_1.default.ok(match);
34
+ node_assert_1.default.strictEqual(match.provider, 'openai');
35
+ });
36
+ (0, node_test_1.it)('identifies Ollama local as LLM', () => {
37
+ const match = (0, classifier_js_1.classifyHost)('localhost', 11434);
38
+ node_assert_1.default.ok(match);
39
+ node_assert_1.default.strictEqual(match.provider, 'ollama');
40
+ });
41
+ (0, node_test_1.it)('returns null for non-LLM hosts', () => {
42
+ node_assert_1.default.strictEqual((0, classifier_js_1.classifyHost)('google.com', 443), null);
43
+ node_assert_1.default.strictEqual((0, classifier_js_1.classifyHost)('github.com', 443), null);
44
+ node_assert_1.default.strictEqual((0, classifier_js_1.classifyHost)('localhost', 3000), null);
45
+ });
46
+ (0, node_test_1.it)('identifies all known providers', () => {
47
+ const providers = [
48
+ ['api.anthropic.com', 'anthropic'],
49
+ ['api.openai.com', 'openai'],
50
+ ['generativelanguage.googleapis.com', 'google'],
51
+ ['openrouter.ai', 'openrouter'],
52
+ ['api.groq.com', 'groq'],
53
+ ['api.deepseek.com', 'deepseek'],
54
+ ];
55
+ for (const [host, expected] of providers) {
56
+ const match = (0, classifier_js_1.classifyHost)(host, 443);
57
+ node_assert_1.default.ok(match, `Should match ${host}`);
58
+ node_assert_1.default.strictEqual(match.provider, expected);
59
+ }
60
+ });
61
+ });
62
+ (0, node_test_1.describe)('CA Manager', () => {
63
+ (0, node_test_1.it)('generates CA cert on first call', () => {
64
+ const result = (0, ca_manager_js_1.ensureCA)();
65
+ node_assert_1.default.ok(result.cert.includes('BEGIN CERTIFICATE'));
66
+ node_assert_1.default.ok(result.key.includes('BEGIN RSA PRIVATE KEY'));
67
+ });
68
+ (0, node_test_1.it)('returns same cert on subsequent calls', () => {
69
+ const a = (0, ca_manager_js_1.ensureCA)();
70
+ const b = (0, ca_manager_js_1.ensureCA)();
71
+ node_assert_1.default.strictEqual(a.cert, b.cert);
72
+ });
73
+ (0, node_test_1.it)('CA cert file exists on disk', () => {
74
+ node_assert_1.default.ok(node_fs_1.default.existsSync((0, ca_manager_js_1.getCACertPath)()));
75
+ });
76
+ });
77
+ (0, node_test_1.describe)('CONNECT Proxy', () => {
78
+ let proxy;
79
+ const PROXY_PORT = 14820;
80
+ (0, node_test_1.before)(async () => {
81
+ (0, ca_manager_js_1.ensureCA)();
82
+ const config = (0, auto_detect_js_1.buildConfig)({ proxy: { port: PROXY_PORT, host: '127.0.0.1' } });
83
+ config.logging.level = 'error';
84
+ proxy = new connect_proxy_js_1.ConnectProxy(config);
85
+ await proxy.start();
86
+ });
87
+ (0, node_test_1.after)(async () => {
88
+ await proxy.stop();
89
+ });
90
+ (0, node_test_1.it)('serves dashboard at root', async () => {
91
+ const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/`, { method: 'GET' });
92
+ node_assert_1.default.strictEqual(res.status, 200);
93
+ node_assert_1.default.ok(res.body.includes('SmartContext'));
94
+ });
95
+ (0, node_test_1.it)('serves health endpoint', async () => {
96
+ const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/health`, { method: 'GET' });
97
+ node_assert_1.default.strictEqual(res.status, 200);
98
+ const data = JSON.parse(res.body);
99
+ node_assert_1.default.strictEqual(data.ok, true);
100
+ node_assert_1.default.strictEqual(data.type, 'connect-proxy');
101
+ });
102
+ (0, node_test_1.it)('serves PAC file', async () => {
103
+ const res = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/proxy.pac`, { method: 'GET' });
104
+ node_assert_1.default.strictEqual(res.status, 200);
105
+ node_assert_1.default.ok(res.body.includes('FindProxyForURL'));
106
+ node_assert_1.default.ok(res.body.includes('api.anthropic.com'));
107
+ node_assert_1.default.ok(res.body.includes('api.openai.com'));
108
+ });
109
+ (0, node_test_1.it)('API endpoints work', async () => {
110
+ const status = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/_sc/status`, { method: 'GET' });
111
+ node_assert_1.default.strictEqual(JSON.parse(status.body).state, 'running');
112
+ const stats = await httpRequest(`http://127.0.0.1:${PROXY_PORT}/_sc/stats`, { method: 'GET' });
113
+ node_assert_1.default.strictEqual(JSON.parse(stats.body).totalRequests, 0);
114
+ });
115
+ (0, node_test_1.it)('handles CONNECT to non-LLM host (tunnel)', async () => {
116
+ // Create a simple target server
117
+ const targetServer = node_http_1.default.createServer((req, res) => {
118
+ res.writeHead(200);
119
+ res.end('target-ok');
120
+ });
121
+ await new Promise((r) => targetServer.listen(14821, '127.0.0.1', r));
122
+ // Send CONNECT through proxy
123
+ const tunnelOk = await new Promise((resolve) => {
124
+ const req = node_http_1.default.request({
125
+ host: '127.0.0.1',
126
+ port: PROXY_PORT,
127
+ method: 'CONNECT',
128
+ path: '127.0.0.1:14821',
129
+ });
130
+ req.on('connect', (res, socket) => {
131
+ // Send HTTP request through tunnel
132
+ socket.write('GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n');
133
+ let data = '';
134
+ socket.on('data', (chunk) => { data += chunk; });
135
+ socket.on('end', () => {
136
+ socket.destroy();
137
+ resolve(data.includes('target-ok'));
138
+ });
139
+ });
140
+ req.on('error', () => resolve(false));
141
+ req.end();
142
+ });
143
+ targetServer.close();
144
+ node_assert_1.default.ok(tunnelOk, 'Should tunnel non-LLM traffic');
145
+ });
146
+ });
147
+ //# sourceMappingURL=connect-proxy.test.js.map
@@ -40,6 +40,7 @@ function httpRequest(url, options, body) {
40
40
  node_assert_1.default.ok(res.headers['content-type']?.includes('text/html'));
41
41
  node_assert_1.default.ok(res.body.includes('SmartContext Proxy'));
42
42
  node_assert_1.default.ok(res.body.includes('Total Requests'));
43
+ node_assert_1.default.ok(res.body.includes('Settings'));
43
44
  });
44
45
  (0, node_test_1.it)('returns status via /_sc/status', async () => {
45
46
  const res = await httpRequest(`http://127.0.0.1:${PORT}/_sc/status`, { method: 'GET' });
@@ -0,0 +1,9 @@
1
+ export interface CertPair {
2
+ cert: string;
3
+ key: string;
4
+ }
5
+ /** Ensure root CA exists, generate if not */
6
+ export declare function ensureCA(): CertPair;
7
+ /** Generate a TLS certificate for a specific hostname, signed by our CA */
8
+ export declare function getCertForHost(hostname: string): CertPair;
9
+ export declare function getCACertPath(): string;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ensureCA = ensureCA;
7
+ exports.getCertForHost = getCertForHost;
8
+ exports.getCACertPath = getCACertPath;
9
+ const node_forge_1 = __importDefault(require("node-forge"));
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const CA_DIR = node_path_1.default.join(process.env['HOME'] || '.', '.smartcontext', 'ca');
13
+ const HOSTS_DIR = node_path_1.default.join(CA_DIR, 'hosts');
14
+ const CA_CERT_PATH = node_path_1.default.join(CA_DIR, 'smartcontext-ca.crt');
15
+ const CA_KEY_PATH = node_path_1.default.join(CA_DIR, 'smartcontext-ca.key');
16
+ let cachedCA = null;
17
+ const hostCertCache = new Map();
18
+ /** Ensure root CA exists, generate if not */
19
+ function ensureCA() {
20
+ node_fs_1.default.mkdirSync(CA_DIR, { recursive: true });
21
+ node_fs_1.default.mkdirSync(HOSTS_DIR, { recursive: true });
22
+ if (node_fs_1.default.existsSync(CA_CERT_PATH) && node_fs_1.default.existsSync(CA_KEY_PATH)) {
23
+ const cert = node_fs_1.default.readFileSync(CA_CERT_PATH, 'utf-8');
24
+ const key = node_fs_1.default.readFileSync(CA_KEY_PATH, 'utf-8');
25
+ cachedCA = {
26
+ cert: node_forge_1.default.pki.certificateFromPem(cert),
27
+ key: node_forge_1.default.pki.privateKeyFromPem(key),
28
+ };
29
+ return { cert, key };
30
+ }
31
+ // Generate new root CA
32
+ const keys = node_forge_1.default.pki.rsa.generateKeyPair(2048);
33
+ const cert = node_forge_1.default.pki.createCertificate();
34
+ cert.publicKey = keys.publicKey;
35
+ cert.serialNumber = generateSerial();
36
+ cert.validity.notBefore = new Date();
37
+ cert.validity.notAfter = new Date();
38
+ cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 10);
39
+ const attrs = [
40
+ { name: 'commonName', value: 'SmartContext Proxy CA' },
41
+ { name: 'organizationName', value: 'SmartContext' },
42
+ ];
43
+ cert.setSubject(attrs);
44
+ cert.setIssuer(attrs);
45
+ cert.setExtensions([
46
+ { name: 'basicConstraints', cA: true, critical: true },
47
+ { name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
48
+ { name: 'subjectKeyIdentifier' },
49
+ ]);
50
+ cert.sign(keys.privateKey, node_forge_1.default.md.sha256.create());
51
+ const certPem = node_forge_1.default.pki.certificateToPem(cert);
52
+ const keyPem = node_forge_1.default.pki.privateKeyToPem(keys.privateKey);
53
+ node_fs_1.default.writeFileSync(CA_CERT_PATH, certPem);
54
+ node_fs_1.default.writeFileSync(CA_KEY_PATH, keyPem, { mode: 0o600 });
55
+ cachedCA = { cert, key: keys.privateKey };
56
+ return { cert: certPem, key: keyPem };
57
+ }
58
+ /** Generate a TLS certificate for a specific hostname, signed by our CA */
59
+ function getCertForHost(hostname) {
60
+ // Check memory cache
61
+ const cached = hostCertCache.get(hostname);
62
+ if (cached)
63
+ return cached;
64
+ // Check disk cache
65
+ const certPath = node_path_1.default.join(HOSTS_DIR, `${hostname}.crt`);
66
+ const keyPath = node_path_1.default.join(HOSTS_DIR, `${hostname}.key`);
67
+ if (node_fs_1.default.existsSync(certPath) && node_fs_1.default.existsSync(keyPath)) {
68
+ const pair = {
69
+ cert: node_fs_1.default.readFileSync(certPath, 'utf-8'),
70
+ key: node_fs_1.default.readFileSync(keyPath, 'utf-8'),
71
+ };
72
+ hostCertCache.set(hostname, pair);
73
+ return pair;
74
+ }
75
+ // Generate new cert
76
+ if (!cachedCA)
77
+ ensureCA();
78
+ const keys = node_forge_1.default.pki.rsa.generateKeyPair(2048);
79
+ const cert = node_forge_1.default.pki.createCertificate();
80
+ cert.publicKey = keys.publicKey;
81
+ cert.serialNumber = generateSerial();
82
+ cert.validity.notBefore = new Date();
83
+ cert.validity.notAfter = new Date();
84
+ cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
85
+ cert.setSubject([{ name: 'commonName', value: hostname }]);
86
+ cert.setIssuer(cachedCA.cert.subject.attributes);
87
+ cert.setExtensions([
88
+ { name: 'basicConstraints', cA: false },
89
+ { name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
90
+ { name: 'extKeyUsage', serverAuth: true },
91
+ {
92
+ name: 'subjectAltName',
93
+ altNames: [
94
+ { type: 2, value: hostname }, // DNS
95
+ ...(isIP(hostname) ? [{ type: 7, ip: hostname }] : []),
96
+ ],
97
+ },
98
+ ]);
99
+ cert.sign(cachedCA.key, node_forge_1.default.md.sha256.create());
100
+ const certPem = node_forge_1.default.pki.certificateToPem(cert);
101
+ const keyPem = node_forge_1.default.pki.privateKeyToPem(keys.privateKey);
102
+ node_fs_1.default.writeFileSync(certPath, certPem);
103
+ node_fs_1.default.writeFileSync(keyPath, keyPem, { mode: 0o600 });
104
+ const pair = { cert: certPem, key: keyPem };
105
+ hostCertCache.set(hostname, pair);
106
+ return pair;
107
+ }
108
+ function getCACertPath() {
109
+ return CA_CERT_PATH;
110
+ }
111
+ function generateSerial() {
112
+ return Date.now().toString(16) + Math.random().toString(16).slice(2, 10);
113
+ }
114
+ function isIP(str) {
115
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(str) || str.includes(':');
116
+ }
117
+ //# sourceMappingURL=ca-manager.js.map
@@ -0,0 +1,11 @@
1
+ export interface TrustStoreResult {
2
+ success: boolean;
3
+ message: string;
4
+ requiresSudo: boolean;
5
+ }
6
+ /** Install CA cert into system trust store */
7
+ export declare function installCA(): TrustStoreResult;
8
+ /** Remove CA cert from system trust store */
9
+ export declare function uninstallCA(): TrustStoreResult;
10
+ /** Check if CA is installed in trust store */
11
+ export declare function isCAInstalled(): boolean;