@chatpanel/gateway 0.4.1 → 0.4.3

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": "@chatpanel/gateway",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,9 @@
14
14
  "start": "node bin/chatpanel-gateway.js",
15
15
  "test": "node --test",
16
16
  "typecheck": "tsc -p tsconfig.json",
17
- "build:bin": "bash scripts/build-binaries.sh"
17
+ "build:bin": "bash scripts/build-binaries.sh",
18
+ "gen:ner": "node scripts/gen-ner-assets.mjs",
19
+ "prepublishOnly": "node scripts/gen-ner-assets.mjs"
18
20
  },
19
21
  "files": [
20
22
  "src",
@@ -0,0 +1,8 @@
1
+ // GENERATED by scripts/gen-ner-assets.mjs — do NOT edit. Edit ner/* and re-run.
2
+ // Base64 of the bundled spaCy NER server, embedded so a standalone binary can
3
+ // write a runnable ner/ directory to disk (the binary has no ner/ on the FS).
4
+ export const NER_ASSETS = {
5
+ "run.sh": "IyEvdXNyL2Jpbi9lbnYgYmFzaAojIE9uZS1zaG90OiBjcmVhdGUgdGhlIHZlbnYgKGlmIG5lZWRlZCksIGluc3RhbGwgZGVwcyArIHRoZSBzcGFDeSBtb2RlbCwgYW5kIHNlcnZlLgojIFVzYWdlOiAgLi9ydW4uc2ggICAgICAgIChkZWZhdWx0cyB0byBwb3J0IDkwMDkpCiMgICAgICAgICBQT1JUPTkxMDAgLi9ydW4uc2gKc2V0IC1ldW8gcGlwZWZhaWwKY2QgIiQoZGlybmFtZSAiJDAiKSIKCmlmIFsgISAtZCAudmVudiBdOyB0aGVuCiAgZWNobyAi4oaSIGNyZWF0aW5nIHZpcnR1YWwgZW52ICgudmVudinigKYiCiAgcHl0aG9uMyAtbSB2ZW52IC52ZW52CmZpCiMgc2hlbGxjaGVjayBkaXNhYmxlPVNDMTA5MQpzb3VyY2UgLnZlbnYvYmluL2FjdGl2YXRlCgplY2hvICLihpIgaW5zdGFsbGluZyBkZXBlbmRlbmNpZXPigKYiCnBpcCBpbnN0YWxsIC1xIC0tdXBncmFkZSBwaXAKcGlwIGluc3RhbGwgLXEgLXIgcmVxdWlyZW1lbnRzLnR4dApweXRob24gLWMgImltcG9ydCBlbl9jb3JlX3dlYl9zbSIgPi9kZXYvbnVsbCAyPiYxIHx8IHB5dGhvbiAtbSBzcGFjeSBkb3dubG9hZCBlbl9jb3JlX3dlYl9zbQoKZWNobyAi4oaSIHNlcnZpbmcgb24gaHR0cDovLzEyNy4wLjAuMToke1BPUlQ6LTkwMDl9L25lciAgKEN0cmwtQyB0byBzdG9wKSIKZXhlYyB1dmljb3JuIHNlcnZlcjphcHAgLS1wb3J0ICIke1BPUlQ6LTkwMDl9Igo=",
6
+ "server.py": "IiIiCkNoYXRQYW5lbCBsb2NhbCBORVIgaGVscGVyIOKAlCBhIHRpbnkgb24tZGV2aWNlIGVudGl0eSBkZXRlY3RvciBmb3IgUElJIHJlZGFjdGlvbi4KCkNoYXRQYW5lbCdzIFByaXZhY3kg4oaSICJBSSBkZXRlY3Rpb24iIGNhbiBwb2ludCBhdCBhbnkgTE9DQUwgc2VydmljZSB0aGF0IGFjY2VwdHMKICAgIFBPU1QgeyJ0ZXh0IjogIi4uLiJ9CmFuZCByZXR1cm5zCiAgICB7ImVudGl0aWVzIjogW3sidmFsdWUiOiAiLi4uIiwgInR5cGUiOiAiUEVSU09OfE9SR3xHUEV8RU1BSUx8UEhPTkV8Li4uIn1dfQoKVGhpcyB3cmFwcyBzcGFDeSAocGVvcGxlIC8gb3JnYW5pemF0aW9ucyAvIGxvY2F0aW9ucykgQU5EIGFkZHMgYSByZWdleCBwYXNzIGZvcgp0aGUgc3RydWN0dXJlZCBpZGVudGlmaWVycyBzcGFDeSBkb2Vzbid0IGVtaXQgKGVtYWlscywgcGhvbmUgbnVtYmVycywgU1NOcywgY2FyZHMsCklQcyksIHNvIHRoZSBkZXRlY3RvciBpcyBjb21wcmVoZW5zaXZlIG9uIGl0cyBvd24uIE9ubHkgdGhlIHJlZGFjdGVkIHBsYWNlaG9sZGVycwooZS5nLiBbW1BFUlNPTl8xXV0pIGV2ZXIgcmVhY2ggdGhlIGNoYXQgbW9kZWwg4oCUIHRoZSByYXcgdGV4dCBuZXZlciBsZWF2ZXMgeW91ciBib3guCgotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQpTZXR1cCAgKG1vc3QgbWFjaGluZXMgYmxvY2sgZ2xvYmFsIHBpcCBpbnN0YWxscywgc28gdXNlIGEgdmlydHVhbCBlbnYpCgogICAgcHl0aG9uMyAtbSB2ZW52IC52ZW52CiAgICBzb3VyY2UgLnZlbnYvYmluL2FjdGl2YXRlICAgICAgICAgICMgV2luZG93czogLnZlbnZcXFNjcmlwdHNcXGFjdGl2YXRlCiAgICBwaXAgaW5zdGFsbCAtciByZXF1aXJlbWVudHMudHh0CiAgICBweXRob24gLW0gc3BhY3kgZG93bmxvYWQgZW5fY29yZV93ZWJfc20KClJ1biAgKHRoZSBmaWxlIGlzIHNlcnZlci5weSwgc28gdGhlIHV2aWNvcm4gaW1wb3J0IHBhdGggaXMgInNlcnZlcjphcHAiKQoKICAgIHV2aWNvcm4gc2VydmVyOmFwcCAtLXBvcnQgOTAwOQoKVGhlbiBpbiBDaGF0UGFuZWwg4oaSIFNldHRpbmdzIOKGkiBQcml2YWN5OgogICAgUmVkYWN0aW9uIDogT24g4oCUICsgQUkgZGV0ZWN0aW9uCiAgICBEZXRlY3RvciAgOiBMb2NhbCBORVIgc2VydmljZSAoc3BhQ3kgLyBQcmVzaWRpbykKICAgIFVSTCAgICAgICA6IGh0dHA6Ly8xMjcuMC4wLjE6OTAwOS9uZXIKClRpcDogZW5fY29yZV93ZWJfc20gaXMgc21hbGwgKyBmYXN0LiBGb3IgYmV0dGVyIGFjY3VyYWN5IHVzZSBlbl9jb3JlX3dlYl9tZCBvcgplbl9jb3JlX3dlYl90cmYgKGRvd25sb2FkIHRoZSBzYW1lIHdheSwgdGhlbiBjaGFuZ2UgdGhlIGxvYWQgYmVsb3cpLgotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoiIiIKZnJvbSBmYXN0YXBpIGltcG9ydCBGYXN0QVBJCgppbXBvcnQgcmUKaW1wb3J0IHNwYWN5CgpNT0RFTCA9ICJlbl9jb3JlX3dlYl9zbSIKbmxwID0gc3BhY3kubG9hZChNT0RFTCkKCmFwcCA9IEZhc3RBUEkodGl0bGU9IkNoYXRQYW5lbCBORVIgaGVscGVyIikKCiMgc3BhQ3kncyBORVIgZW1pdHMgbmFtZXMgLyBvcmdzIC8gbG9jYXRpb25zIGJ1dCBOT1Qgc3RydWN0dXJlZCBpZGVudGlmaWVycywgc28gYWRkCiMgYSByZWdleCBwYXNzIGZvciB0aG9zZS4gKENoYXRQYW5lbCBhbHNvIGNhdGNoZXMgdGhlc2Ugb24tZGV2aWNlLCBidXQgZW1pdHRpbmcgdGhlbQojIGhlcmUga2VlcHMgdGhlIGRldGVjdG9yIHNlbGYtY29udGFpbmVkIOKAlCB3aGF0IHlvdSB0ZXN0IGlzIHdoYXQgeW91IGdldC4pIE9yZGVyCiMgbWF0dGVyczogbW9yZSBzcGVjaWZpYyBwYXR0ZXJucyBydW4gZmlyc3Qgc28gYSBjYXJkIC8gU1NOIGlzbid0IHJlLW1hdGNoZWQgYXMgYQojIHBob25lIG51bWJlci4KX1BBVFRFUk5TID0gWwogICAgKCJFTUFJTCIsIHJlLmNvbXBpbGUociJbQS1aYS16MC05Ll8lK1wtXStAW0EtWmEtejAtOS5cLV0rXC5bQS1aYS16XXsyLH0iKSksCiAgICAoIlNTTiIsIHJlLmNvbXBpbGUociJcYlxkezN9LVxkezJ9LVxkezR9XGIiKSksCiAgICAoIkNSRURJVF9DQVJEIiwgcmUuY29tcGlsZShyIlxiKD86XGRbIC1dPyl7MTMsMTl9XGIiKSksCiAgICAoIklQIiwgcmUuY29tcGlsZShyIlxiKD86XGR7MSwzfVwuKXszfVxkezEsM31cYiIpKSwKICAgICgiUEhPTkUiLCByZS5jb21waWxlKHIiKD88IVxkKSg/OlwrP1xkezEsMn1bIC5cLV0/KT9cKD9cZHszfVwpP1sgLlwtXT9cZHszfVsgLlwtXT9cZHs0fSg/IVxkKSIpKSwKXQoKCmRlZiBfcmVnZXhfZW50aXRpZXModGV4dCk6CiAgICBvdXQsIHRha2VuID0gW10sIFtdCiAgICBmb3IgbGFiZWwsIHJ4IGluIF9QQVRURVJOUzoKICAgICAgICBmb3IgbSBpbiByeC5maW5kaXRlcih0ZXh0KToKICAgICAgICAgICAgc3RhcnQsIGVuZCA9IG0uc3RhcnQoKSwgbS5lbmQoKQogICAgICAgICAgICBpZiBhbnkoc3RhcnQgPCB0ZSBhbmQgdHMgPCBlbmQgZm9yIHRzLCB0ZSBpbiB0YWtlbik6CiAgICAgICAgICAgICAgICBjb250aW51ZSAgIyBzcGFuIGFscmVhZHkgY2xhaW1lZCBieSBhIG1vcmUgc3BlY2lmaWMgcGF0dGVybgogICAgICAgICAgICB0YWtlbi5hcHBlbmQoKHN0YXJ0LCBlbmQpKQogICAgICAgICAgICBvdXQuYXBwZW5kKHsidmFsdWUiOiBtLmdyb3VwKDApLnN0cmlwKCksICJ0eXBlIjogbGFiZWx9KQogICAgcmV0dXJuIG91dAoKCkBhcHAuZ2V0KCIvaGVhbHRoIikKZGVmIGhlYWx0aCgpOgogICAgcmV0dXJuIHsib2siOiBUcnVlLCAibW9kZWwiOiBNT0RFTH0KCgpAYXBwLnBvc3QoIi9uZXIiKQpkZWYgbmVyKHBheWxvYWQ6IGRpY3QpOgogICAgIiIiUmV0dXJuIHNwYUN5IGVudGl0aWVzICsgcmVnZXggaWRlbnRpZmllcnMgaW4gQ2hhdFBhbmVsJ3MgZXhwZWN0ZWQgc2hhcGUuCgogICAgQ2hhdFBhbmVsIG1hcHMgY29tbW9uIGxhYmVscyBpdHNlbGYgKFBFUuKGklBFUlNPTiwgR1BF4oaSTE9DQVRJT04sIOKApikgYW5kIGtlZXBzCiAgICBvbmx5IHRoZSBjYXRlZ29yaWVzIHlvdSBlbmFibGUgaW4gdGhlIFByaXZhY3kgdGFiLCBzbyBpdCdzIGZpbmUgdG8gcmV0dXJuIGFsbAogICAgb2YgdGhlbSBoZXJlLgogICAgIiIiCiAgICB0ZXh0ID0gKHBheWxvYWQgb3Ige30pLmdldCgidGV4dCIsICIiKSBvciAiIgogICAgZG9jID0gbmxwKHRleHQpCiAgICBlbnRzID0gW3sidmFsdWUiOiBlbnQudGV4dCwgInR5cGUiOiBlbnQubGFiZWxffSBmb3IgZW50IGluIGRvYy5lbnRzXQogICAgZW50cy5leHRlbmQoX3JlZ2V4X2VudGl0aWVzKHRleHQpKQogICAgIyBEZS1kdXAgaWRlbnRpY2FsIHZhbHVlK3R5cGUgKHNwYUN5IGFuZCBhIHJlZ2V4IGNhbiBib3RoIHN1cmZhY2UgdGhlIHNhbWUgc3BhbikuCiAgICBzZWVuLCBvdXQgPSBzZXQoKSwgW10KICAgIGZvciBlIGluIGVudHM6CiAgICAgICAga2V5ID0gKGVbInR5cGUiXSwgZVsidmFsdWUiXS5sb3dlcigpKQogICAgICAgIGlmIGVbInZhbHVlIl0gYW5kIGtleSBub3QgaW4gc2VlbjoKICAgICAgICAgICAgc2Vlbi5hZGQoa2V5KQogICAgICAgICAgICBvdXQuYXBwZW5kKGUpCiAgICByZXR1cm4geyJlbnRpdGllcyI6IG91dH0K",
7
+ "requirements.txt": "ZmFzdGFwaQp1dmljb3JuW3N0YW5kYXJkXQpzcGFjeQo=",
8
+ };
package/src/ner.js CHANGED
@@ -9,10 +9,32 @@
9
9
  import { spawn } from 'node:child_process';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { dirname, join } from 'node:path';
12
- import { existsSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
13
13
  import os from 'node:os';
14
+ import { NER_ASSETS } from './ner-assets.js';
14
15
 
15
- const NER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'ner');
16
+ // Where the NER server lives ON DISK. We always run from a real, writable user dir
17
+ // (~/.chatpanel/ner) rather than the package's ner/ — because a STANDALONE BINARY
18
+ // has no ner/ on the filesystem at all (it's inside the SEA snapshot), and a global
19
+ // npm dir may be read-only. The venv + installed deps persist here across restarts.
20
+ function materializeNerDir() {
21
+ const dir = join(os.homedir(), '.chatpanel', 'ner');
22
+ try {
23
+ mkdirSync(dir, { recursive: true });
24
+ for (const [name, b64] of Object.entries(NER_ASSETS)) {
25
+ const target = join(dir, name);
26
+ const want = Buffer.from(b64, 'base64');
27
+ // Refresh the scripts if changed (pick up updates), but never touch .venv.
28
+ const have = existsSync(target) ? readFileSync(target) : null;
29
+ if (!have || !have.equals(want)) writeFileSync(target, want);
30
+ if (name.endsWith('.sh')) { try { chmodSync(target, 0o755); } catch { /* best effort */ } }
31
+ }
32
+ return dir;
33
+ } catch (e) {
34
+ console.log(`[ner] could not materialize NER dir at ${dir} (${e.message})`);
35
+ return null;
36
+ }
37
+ }
16
38
 
17
39
  // A login service (LaunchAgent / systemd) inherits a MINIMAL PATH — not your
18
40
  // shell's — so `bash` and a pyenv/homebrew `python3` aren't found. Resolve bash
@@ -86,10 +108,14 @@ export function startNer(cfg) {
86
108
  // 1) Adopt an existing NER already serving on the port (e.g. the user's own).
87
109
  if (await nerReachable(port, ac.signal)) { wire('using existing NER'); return; }
88
110
 
89
- // 2) Otherwise launch the bundled spaCy server.
111
+ // 2) Otherwise launch the bundled spaCy server — materializing run.sh +
112
+ // server.py + requirements.txt to a real on-disk dir first (the binary has
113
+ // no ner/ on the FS). run.sh then creates the venv, installs deps, and runs.
114
+ const nerDir = materializeNerDir();
115
+ if (!nerDir) { console.log('[ner] no writable NER dir — deterministic redaction only'); return; }
90
116
  try {
91
117
  child = spawn(resolveBash(), ['run.sh'], {
92
- cwd: NER_DIR,
118
+ cwd: nerDir,
93
119
  env: {
94
120
  ...process.env,
95
121
  PORT: String(port),
@@ -129,7 +155,7 @@ export function startNer(cfg) {
129
155
  if (await nerReachable(port, ac.signal)) { wire('ready'); return; }
130
156
  await sleep(1000);
131
157
  }
132
- if (!stopped) console.log('[ner] not ready after 300s — continuing deterministic-only (run ./ner/run.sh manually to debug).');
158
+ if (!stopped) console.log(`[ner] not ready after 300s — continuing deterministic-only (run ${join(os.homedir(), '.chatpanel', 'ner', 'run.sh')} manually to debug).`);
133
159
  })();
134
160
 
135
161
  const stop = () => {
package/src/server.js CHANGED
@@ -33,7 +33,7 @@ import * as openai from './openai.js';
33
33
  import * as responses from './responses.js';
34
34
  import * as anthropic from './anthropic.js';
35
35
 
36
- export const VERSION = '0.4.1';
36
+ export const VERSION = '0.4.3';
37
37
 
38
38
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
39
39
 
@@ -150,6 +150,28 @@ function sendJson(res, status, obj) {
150
150
  res.end(JSON.stringify(obj));
151
151
  }
152
152
 
153
+ // The detector URL (…:9009/ner) and its sibling /health. Returns null when no
154
+ // detector is wired (deterministic-only).
155
+ function nerBaseUrl(cfg) {
156
+ const url = cfg.redaction?.detection?.url;
157
+ if (!url || cfg.redaction?.detection?.backend === 'off') return null;
158
+ return url;
159
+ }
160
+
161
+ // Live health probe of the bundled/configured NER (GET /health → { ok, model }).
162
+ async function probeNerHealth(cfg) {
163
+ const url = nerBaseUrl(cfg);
164
+ if (!url) return { configured: false, ok: false, url: null, model: null };
165
+ try {
166
+ const r = await fetch(url.replace(/\/ner\/?$/, '') + '/health', { signal: AbortSignal.timeout(2000) });
167
+ if (!r.ok) return { configured: true, ok: false, url, model: null };
168
+ const j = await r.json().catch(() => ({}));
169
+ return { configured: true, ok: true, url, model: j.model || null };
170
+ } catch {
171
+ return { configured: true, ok: false, url, model: null };
172
+ }
173
+ }
174
+
153
175
  function forwardHeaders(headers, base) {
154
176
  const out = {};
155
177
  for (const [k, v] of Object.entries(headers)) {
@@ -330,14 +352,41 @@ export function createGateway(cfg = loadConfig()) {
330
352
  // --- Config API (the extension's "Gateway" tab is a client of these) ---
331
353
  if (pathname === '/status' && req.method === 'GET') {
332
354
  const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
333
- const nerOn = cfg.redaction?.detection?.backend && cfg.redaction.detection.backend !== 'off';
355
+ const health = await probeNerHealth(cfg); // live GET /health on the detector
334
356
  return sendJson(res, 200, {
335
357
  ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
336
- ner: { autostart: !!cfg.ner?.autostart, ready: !!nerOn },
358
+ ner: {
359
+ autostart: !!cfg.ner?.autostart,
360
+ configured: health.configured,
361
+ ready: health.ok, // the detector actually answered /health
362
+ model: health.model, // e.g. "en_core_web_sm"
363
+ url: health.url,
364
+ },
337
365
  pro: { unlocked: proUnlocked }, usage: usage(cfg),
338
366
  uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
339
367
  });
340
368
  }
369
+ // Proxy the detector so it's checkable on the gateway's own port (the NER runs
370
+ // on its own port, e.g. 9009 — hitting :4320/ner used to 404). GET → health.
371
+ if (pathname === '/ner') {
372
+ const url = nerBaseUrl(cfg);
373
+ if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
374
+ if (req.method === 'GET') {
375
+ const health = await probeNerHealth(cfg);
376
+ return sendJson(res, health.ok ? 200 : 503, health);
377
+ }
378
+ if (req.method === 'POST') {
379
+ try {
380
+ const body = await readBody(req, cfg.maxBodyBytes);
381
+ const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(8000) });
382
+ const text = await r.text();
383
+ res.writeHead(r.status, { 'content-type': 'application/json' });
384
+ return res.end(text);
385
+ } catch (e) {
386
+ return sendJson(res, 502, { error: { message: `NER unreachable: ${e.message}`, type: 'ner_unreachable' } });
387
+ }
388
+ }
389
+ }
341
390
  if (pathname === '/logs' && req.method === 'GET') {
342
391
  return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
343
392
  }