@chatpanel/gateway 0.1.2 → 0.1.4

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.1.2",
3
+ "version": "0.1.4",
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": {
package/src/config.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { readFileSync, existsSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
+ import os from 'node:os';
10
11
 
11
12
  const DEFAULTS = {
12
13
  host: '127.0.0.1',
@@ -100,7 +101,9 @@ function deepMerge(base, over) {
100
101
  export function loadConfig(env = process.env) {
101
102
  let cfg = DEFAULTS;
102
103
 
103
- const path = env.CHATPANEL_GATEWAY_CONFIG || join(process.cwd(), 'gateway.config.json');
104
+ // Writable per-user location by default (NOT cwd — a login service runs with
105
+ // cwd "/", which is read-only). Same path persistConfig() writes to.
106
+ const path = env.CHATPANEL_GATEWAY_CONFIG || join(os.homedir(), '.chatpanel', 'gateway.config.json');
104
107
  if (existsSync(path)) {
105
108
  try {
106
109
  cfg = deepMerge(cfg, JSON.parse(readFileSync(path, 'utf8')));
@@ -2,15 +2,19 @@
2
2
  // configure it live over the localhost API (GET/POST /config). The gateway stays
3
3
  // authoritative — the extension is just a UI client.
4
4
 
5
- import { writeFileSync } from 'node:fs';
6
- import { join } from 'node:path';
5
+ import { writeFileSync, mkdirSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import os from 'node:os';
7
8
 
9
+ // Default to a writable per-user location, NOT process.cwd(): when the gateway
10
+ // runs as a login service its cwd is "/" (read-only → EROFS on save).
8
11
  export function configPath(env = process.env) {
9
- return env.CHATPANEL_GATEWAY_CONFIG || join(process.cwd(), 'gateway.config.json');
12
+ return env.CHATPANEL_GATEWAY_CONFIG || join(os.homedir(), '.chatpanel', 'gateway.config.json');
10
13
  }
11
14
 
12
15
  // Persist the user-editable subset (not derived runtime state).
13
16
  export function persistConfig(cfg, path = configPath()) {
17
+ mkdirSync(dirname(path), { recursive: true });
14
18
  const out = {
15
19
  host: cfg.host, port: cfg.port, backend: cfg.backend,
16
20
  bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
package/src/ner.js CHANGED
@@ -14,78 +14,83 @@ const NER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'ner');
14
14
 
15
15
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
16
 
17
- async function healthy(port, signal) {
17
+ // Probe the ACTUAL /ner contract (POST {text} -> {entities}), not /health: many
18
+ // NER servers (incl. a user's own) expose only /ner. If anything answers here, we
19
+ // can use it as the detector.
20
+ async function nerReachable(port, signal) {
18
21
  try {
19
- const res = await fetch(`http://127.0.0.1:${port}/health`, { signal });
22
+ const res = await fetch(`http://127.0.0.1:${port}/ner`, {
23
+ method: 'POST',
24
+ headers: { 'content-type': 'application/json' },
25
+ body: JSON.stringify({ text: 'ping' }),
26
+ signal,
27
+ });
20
28
  return res.ok;
21
29
  } catch {
22
30
  return false;
23
31
  }
24
32
  }
25
33
 
26
- // Launch + supervise the NER server. Returns { stop() } or null if not started.
27
- // Mutates cfg.redaction once the server answers /health.
34
+ // Launch + supervise the NER server (or adopt one already on the port). Returns
35
+ // { stop() } or null if not started. Mutates cfg.redaction once NER answers.
28
36
  export function startNer(cfg) {
29
37
  const n = cfg.ner;
30
38
  if (!n || !n.autostart) return null;
31
39
 
32
- // Respect an explicitly-configured detector — don't double-launch.
40
+ // Respect an explicitly-configured detector — don't touch it.
33
41
  const det = cfg.redaction?.detection;
34
42
  if (det && det.backend && det.backend !== 'off') {
35
- console.log(`[ner] detection already configured (${det.backend}) — not autostarting bundled server`);
43
+ console.log(`[ner] detection already configured (${det.backend}) — leaving it as-is`);
36
44
  return null;
37
45
  }
38
46
 
39
47
  const port = n.port || 9009;
40
- let child;
41
- try {
42
- child = spawn('bash', ['run.sh'], {
43
- cwd: NER_DIR,
44
- env: { ...process.env, PORT: String(port) },
45
- stdio: ['ignore', 'pipe', 'pipe'],
46
- });
47
- } catch (e) {
48
- console.log(`[ner] could not launch bundled NER (${e.message}) — deterministic redaction only`);
49
- return null;
50
- }
48
+ let stopped = false;
49
+ let child = null;
50
+ const ac = new AbortController();
51
51
 
52
- let firstRun = true;
53
- child.stdout?.on('data', (b) => {
54
- const s = b.toString();
55
- if (firstRun && /installing dependencies/i.test(s)) {
56
- firstRun = false;
57
- console.log('[ner] first run: creating venv + installing spaCy (one-time, may take a minute)…');
58
- }
59
- });
60
- child.stderr?.on('data', () => { /* uvicorn logs to stderr; swallow */ });
61
- child.on('error', (e) => {
62
- console.log(`[ner] failed to start (${e.message}). Is python3 installed? Falling back to deterministic redaction.`);
63
- });
64
- child.on('exit', (code) => {
65
- if (code && code !== 0 && !stopped) {
66
- console.log(`[ner] server exited (code ${code}); redaction continues deterministic-only.`);
67
- }
68
- });
52
+ const wire = (how) => {
53
+ cfg.redaction.detection = { backend: 'endpoint', url: `http://127.0.0.1:${port}/ner`, timeoutMs: 1500, maxChars: 8000 };
54
+ if (n.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
55
+ console.log(`[ner] ${how} on http://127.0.0.1:${port}/ner — entity detection active (tier: ${cfg.redaction.tier})`);
56
+ };
69
57
 
70
- // Poll for readiness without blocking server start; wire detection when up.
71
- // `stopped` MUST be declared before the async poller below references it (else a
72
- // temporal-dead-zone ReferenceError crashes startup when autostart is on).
73
- const ac = new AbortController();
74
- let stopped = false;
75
58
  (async () => {
59
+ // 1) Adopt an existing NER already serving on the port (e.g. the user's own).
60
+ if (await nerReachable(port, ac.signal)) { wire('using existing NER'); return; }
61
+
62
+ // 2) Otherwise launch the bundled spaCy server.
63
+ try {
64
+ child = spawn('bash', ['run.sh'], {
65
+ cwd: NER_DIR,
66
+ env: { ...process.env, PORT: String(port) },
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ });
69
+ } catch (e) {
70
+ console.log(`[ner] could not launch bundled NER (${e.message}) — deterministic redaction only`);
71
+ return;
72
+ }
73
+ let firstRun = true;
74
+ child.stdout?.on('data', (b) => {
75
+ if (firstRun && /installing dependencies/i.test(b.toString())) {
76
+ firstRun = false;
77
+ console.log('[ner] first run: creating venv + installing spaCy (one-time, may take a minute)…');
78
+ }
79
+ });
80
+ child.stderr?.on('data', () => { /* uvicorn logs to stderr; swallow */ });
81
+ child.on('error', (e) => console.log(`[ner] failed to start (${e.message}). Is python3 installed? Falling back to deterministic redaction.`));
82
+ child.on('exit', (code) => {
83
+ if (code && code !== 0 && !stopped) {
84
+ // The bundled one couldn't bind (often the port is taken by another NER).
85
+ // If SOMETHING answers /ner there, adopt it instead of giving up.
86
+ nerReachable(port, ac.signal).then((ok) => { if (ok && !stopped) wire('adopted NER'); else if (!stopped) console.log(`[ner] server exited (code ${code}); deterministic-only.`); });
87
+ }
88
+ });
89
+
90
+ // 3) Poll for readiness; wire detection when up.
76
91
  const deadline = Date.now() + 120_000; // generous: first run installs deps
77
92
  while (Date.now() < deadline && !stopped) {
78
- if (await healthy(port, ac.signal)) {
79
- cfg.redaction.detection = {
80
- backend: 'endpoint',
81
- url: `http://127.0.0.1:${port}/ner`,
82
- timeoutMs: 1500,
83
- maxChars: 8000,
84
- };
85
- if (n.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
86
- console.log(`[ner] ready on http://127.0.0.1:${port}/ner — entity redaction active (tier: ${cfg.redaction.tier})`);
87
- return;
88
- }
93
+ if (await nerReachable(port, ac.signal)) { wire('ready'); return; }
89
94
  await sleep(1000);
90
95
  }
91
96
  if (!stopped) console.log('[ner] not ready after 120s — continuing deterministic-only (run ./ner/run.sh manually to debug).');
@@ -95,7 +100,7 @@ export function startNer(cfg) {
95
100
  if (stopped) return;
96
101
  stopped = true;
97
102
  ac.abort();
98
- try { child.kill('SIGTERM'); } catch { /* ignore */ }
103
+ try { child?.kill('SIGTERM'); } catch { /* ignore */ }
99
104
  };
100
105
  return { stop };
101
106
  }
package/src/server.js CHANGED
@@ -31,7 +31,7 @@ import * as openai from './openai.js';
31
31
  import * as responses from './responses.js';
32
32
  import * as anthropic from './anthropic.js';
33
33
 
34
- export const VERSION = '0.1.2';
34
+ export const VERSION = '0.1.4';
35
35
 
36
36
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
37
37