@chatpanel/gateway 0.4.2 → 0.5.0

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.2",
3
+ "version": "0.5.0",
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": {
@@ -30,7 +30,7 @@
30
30
  "node": ">=18"
31
31
  },
32
32
  "dependencies": {
33
- "@chatpanel/pii": "^0.2.1"
33
+ "@chatpanel/pii": "^0.2.3"
34
34
  },
35
35
  "homepage": "https://chatpanel.net",
36
36
  "repository": {
@@ -48,6 +48,7 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
48
48
  autoNarrow: cfg.tools?.autoNarrow !== false,
49
49
  maxPerTurn: Number(cfg.tools?.maxPerTurn) > 0 ? Number(cfg.tools.maxPerTurn) : 8,
50
50
  narrowAll: !!cfg.tools?.narrowAll,
51
+ toolData: cfg.tools?.toolData === 'redactRemote' ? 'redactRemote' : 'real',
51
52
  },
52
53
  };
53
54
  }
@@ -105,6 +106,7 @@ export function applyConfigPatch(cfg, patch = {}) {
105
106
  cfg.tools = cfg.tools || {};
106
107
  if ('autoNarrow' in patch.tools) cfg.tools.autoNarrow = !!patch.tools.autoNarrow;
107
108
  if ('narrowAll' in patch.tools) cfg.tools.narrowAll = !!patch.tools.narrowAll;
109
+ if (patch.tools.toolData === 'real' || patch.tools.toolData === 'redactRemote') cfg.tools.toolData = patch.tools.toolData;
108
110
  const cap = Number(patch.tools.maxPerTurn);
109
111
  if (Number.isFinite(cap) && cap >= 1) cfg.tools.maxPerTurn = Math.floor(cap);
110
112
  }
package/src/openai.js CHANGED
@@ -55,16 +55,19 @@ export function extractLatestToolResult(body) {
55
55
  }
56
56
 
57
57
  // Restore a buffered (non-streaming) response: assistant text + tool-call args.
58
- export function restoreResponse(json, vault) {
58
+ export function restoreResponse(json, vault, harness = null) {
59
59
  for (const choice of json?.choices || []) {
60
60
  const msg = choice?.message;
61
61
  if (!msg) continue;
62
- // Visible text keeps the pseudonym (restoreText); tool-call args get the REAL
63
- // value (restoreDeepAliases) so the client runs the tool on real data.
62
+ // Visible text keeps the pseudonym (restoreText); tool-call args go through the
63
+ // shared harness — real values so the client runs the tool on real data, or the
64
+ // redacted token kept for remote MCP tools under "redact remote".
64
65
  if (typeof msg.content === 'string') msg.content = restoreText(msg.content, vault);
65
66
  for (const tc of msg.tool_calls || []) {
66
67
  if (tc?.function && typeof tc.function.arguments === 'string') {
67
- tc.function.arguments = restoreDeepAliases(tc.function.arguments, vault);
68
+ tc.function.arguments = harness
69
+ ? harness.toTool(tc.function.name, tc.function.arguments)
70
+ : restoreDeepAliases(tc.function.arguments, vault);
68
71
  }
69
72
  }
70
73
  }
package/src/server.js CHANGED
@@ -21,7 +21,7 @@ import { createServer } from 'node:http';
21
21
  import { loadConfig } from './config.js';
22
22
  import { redactSegments } from './redact.js';
23
23
  import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
24
- import { restoreText, effectiveTier, gatedDictionary, narrowSpecs } from '@chatpanel/pii';
24
+ import { restoreText, effectiveTier, gatedDictionary, narrowSpecs, makeToolHarness } from '@chatpanel/pii';
25
25
  import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
26
26
  import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
27
27
  import { shaperFor } from './shape.js';
@@ -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.2';
36
+ export const VERSION = '0.5.0';
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)) {
@@ -179,12 +201,12 @@ async function pumpRelay(res, s, shaper) {
179
201
  }
180
202
 
181
203
  // New tool-enabled turn: open the bridge with the client's tools as MCP specs.
182
- async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools) {
204
+ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools, harness = null) {
183
205
  const { messages, system } = adapter.toTurn(body);
184
206
  const token = readBridgeToken(cfg.bridge.token);
185
207
  const shaper = shaperFor(kind, body?.model || agent);
186
208
  const redactOpts = { tier: effectiveTier({ tier: cfg.redaction.tier }, isPro), dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
187
- const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token });
209
+ const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token, harness });
188
210
  const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
189
211
  let resp;
190
212
  try {
@@ -206,7 +228,7 @@ async function resumeRelay(res, s, toolContent, model) {
206
228
  return pumpRelay(res, s, shaper);
207
229
  }
208
230
 
209
- async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride }, body, vault, cfg, isPro) {
231
+ async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness }, body, vault, cfg, isPro) {
210
232
  if (!redactable) {
211
233
  return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
212
234
  }
@@ -222,7 +244,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
222
244
  }
223
245
  const tools = adapter.extractTools(body);
224
246
  if (tools.length && body?.stream === true) {
225
- return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools);
247
+ return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness);
226
248
  }
227
249
  }
228
250
 
@@ -266,7 +288,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
266
288
 
267
289
  // ---- backend: api ----------------------------------------------------------
268
290
 
269
- async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol }, outBody, vault) {
291
+ async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness }, outBody, vault) {
270
292
  let upstream;
271
293
  try {
272
294
  const headers = forwardHeaders(req.headers, base);
@@ -291,16 +313,17 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
291
313
 
292
314
  if (ct.includes('text/event-stream') && upstream.body) {
293
315
  res.writeHead(upstream.status, resHeaders);
294
- // OpenAI streaming: restore tool-call args with real values (aliases undone)
295
- // while keeping visible text pseudonymized. Other protocols: generic restore.
296
- const pipe = kind === 'openai' ? pipeRestoredOpenAIStream : pipeRestoredStream;
297
- return pipe(upstream.body, res, vault);
316
+ // OpenAI streaming: restore tool-call args via the harness (real, or kept
317
+ // redacted for remote MCP under redactRemote) while keeping visible text
318
+ // pseudonymized. Other protocols: generic restore.
319
+ if (kind === 'openai') return pipeRestoredOpenAIStream(upstream.body, res, vault, harness);
320
+ return pipeRestoredStream(upstream.body, res, vault);
298
321
  }
299
322
 
300
323
  const buf = Buffer.from(await upstream.arrayBuffer());
301
324
  if (vault && ct.includes('application/json')) {
302
325
  try {
303
- const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault);
326
+ const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault, harness);
304
327
  res.writeHead(upstream.status, { ...resHeaders, 'content-type': 'application/json' });
305
328
  return res.end(Buffer.from(JSON.stringify(json), 'utf8'));
306
329
  } catch { /* fall through */ }
@@ -330,14 +353,41 @@ export function createGateway(cfg = loadConfig()) {
330
353
  // --- Config API (the extension's "Gateway" tab is a client of these) ---
331
354
  if (pathname === '/status' && req.method === 'GET') {
332
355
  const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
333
- const nerOn = cfg.redaction?.detection?.backend && cfg.redaction.detection.backend !== 'off';
356
+ const health = await probeNerHealth(cfg); // live GET /health on the detector
334
357
  return sendJson(res, 200, {
335
358
  ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
336
- ner: { autostart: !!cfg.ner?.autostart, ready: !!nerOn },
359
+ ner: {
360
+ autostart: !!cfg.ner?.autostart,
361
+ configured: health.configured,
362
+ ready: health.ok, // the detector actually answered /health
363
+ model: health.model, // e.g. "en_core_web_sm"
364
+ url: health.url,
365
+ },
337
366
  pro: { unlocked: proUnlocked }, usage: usage(cfg),
338
367
  uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
339
368
  });
340
369
  }
370
+ // Proxy the detector so it's checkable on the gateway's own port (the NER runs
371
+ // on its own port, e.g. 9009 — hitting :4320/ner used to 404). GET → health.
372
+ if (pathname === '/ner') {
373
+ const url = nerBaseUrl(cfg);
374
+ if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
375
+ if (req.method === 'GET') {
376
+ const health = await probeNerHealth(cfg);
377
+ return sendJson(res, health.ok ? 200 : 503, health);
378
+ }
379
+ if (req.method === 'POST') {
380
+ try {
381
+ const body = await readBody(req, cfg.maxBodyBytes);
382
+ const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(8000) });
383
+ const text = await r.text();
384
+ res.writeHead(r.status, { 'content-type': 'application/json' });
385
+ return res.end(text);
386
+ } catch (e) {
387
+ return sendJson(res, 502, { error: { message: `NER unreachable: ${e.message}`, type: 'ner_unreachable' } });
388
+ }
389
+ }
390
+ }
341
391
  if (pathname === '/logs' && req.method === 'GET') {
342
392
  return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
343
393
  }
@@ -412,6 +462,12 @@ export function createGateway(cfg = loadConfig()) {
412
462
  }
413
463
  }
414
464
 
465
+ // THE shared tool harness — same one the extension uses. The gateway only needs
466
+ // ② (tool args): restore to real for the client to run, or keep the redacted
467
+ // token for remote MCP tools when tools.toolData is "redactRemote". Results are
468
+ // re-redacted by the NEXT request's normal redaction, so ③ isn't needed here.
469
+ const harness = makeToolHarness({ vault, toolData: cfg.tools?.toolData });
470
+
415
471
  // Route by the requested model → a destination (agent via the bridge, or an
416
472
  // API we forward to). Falls back to the legacy backend when none configured.
417
473
  const dest = resolveDestination(body?.model, cfg, r.kind);
@@ -424,9 +480,9 @@ export function createGateway(cfg = loadConfig()) {
424
480
  if (isSelfUrl(dest.baseUrl, cfg)) {
425
481
  return sendJson(res, 508, { error: { message: `destination "${dest.id}" points back at the gateway (${dest.baseUrl}) — refusing to forward (would loop).`, type: 'loop_detected' } });
426
482
  }
427
- return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol }, outBody, vault);
483
+ return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness }, outBody, vault);
428
484
  }
429
- return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent }, body, vault, cfg, isPro);
485
+ return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness }, body, vault, cfg, isPro);
430
486
  });
431
487
  }
432
488
 
package/src/stream.js CHANGED
@@ -97,12 +97,16 @@ function makeFieldRestorer(vault, restoreFn) {
97
97
  // model + user keep the pseudonym) but TOOL-CALL argument deltas with
98
98
  // restoreWithAliases (the client runs the tool on the REAL value). Passes through
99
99
  // any non-JSON event untouched.
100
- export async function pipeRestoredOpenAIStream(upstreamBody, nodeRes, vault) {
100
+ export async function pipeRestoredOpenAIStream(upstreamBody, nodeRes, vault, harness = null) {
101
101
  const reader = upstreamBody.getReader();
102
102
  const decoder = new TextDecoder();
103
103
  let buf = '';
104
104
  const contentR = makeFieldRestorer(vault, restoreText);
105
- const argRs = new Map(); // tool_call index -> field restorer (aliases)
105
+ const argRs = new Map(); // tool_call index -> field restorer
106
+ const toolNames = new Map(); // tool_call index -> name (for redact-remote decisions)
107
+ // Tool args go through the shared harness (real, or kept-redacted for remote MCP
108
+ // under redactRemote); without a harness, default to real values (aliases undone).
109
+ const argFn = (idx) => (text, v) => (harness ? harness.toTool(toolNames.get(idx) || '', text) : restoreWithAliases(text, v));
106
110
 
107
111
  const handleBlock = (block) => {
108
112
  const out = [];
@@ -123,8 +127,9 @@ export async function pipeRestoredOpenAIStream(upstreamBody, nodeRes, vault) {
123
127
  if (typeof d.content === 'string') d.content = contentR.push(d.content);
124
128
  for (const tc of d.tool_calls || []) {
125
129
  const idx = typeof tc.index === 'number' ? tc.index : 0;
130
+ if (tc.function && typeof tc.function.name === 'string' && tc.function.name) toolNames.set(idx, tc.function.name);
126
131
  if (tc.function && typeof tc.function.arguments === 'string') {
127
- if (!argRs.has(idx)) argRs.set(idx, makeFieldRestorer(vault, restoreWithAliases));
132
+ if (!argRs.has(idx)) argRs.set(idx, makeFieldRestorer(vault, argFn(idx)));
128
133
  tc.function.arguments = argRs.get(idx).push(tc.function.arguments);
129
134
  }
130
135
  }
package/src/toolrelay.js CHANGED
@@ -35,9 +35,9 @@ export function toolsToSpecs(tools) {
35
35
  .map((t) => ({ name: t.function.name, description: t.function.description || '', parameters: t.function.parameters || { type: 'object', properties: {} } }));
36
36
  }
37
37
 
38
- export function createRelaySession({ vault, redactOpts, bridgeUrl, token }) {
38
+ export function createRelaySession({ vault, redactOpts, bridgeUrl, token, harness = null }) {
39
39
  const id = randomUUID().slice(0, 8);
40
- const s = { id, reader: null, decoder: new TextDecoder(), buf: '', bridgeSessionId: null, toolId: null, vault: vault || createVault(), redactOpts: redactOpts || { tier: 'basic' }, bridgeUrl, token, done: false };
40
+ const s = { id, reader: null, decoder: new TextDecoder(), buf: '', bridgeSessionId: null, toolId: null, vault: vault || createVault(), redactOpts: redactOpts || { tier: 'basic' }, bridgeUrl, token, harness, done: false };
41
41
  sessions.set(id, s);
42
42
  return s;
43
43
  }
@@ -68,8 +68,11 @@ export async function pumpBridgeStream(s, handlers) {
68
68
  handlers.onText(evt.text);
69
69
  } else if (evt.type === 'tool_request') {
70
70
  s.bridgeSessionId = evt.session; s.toolId = evt.id;
71
- // restore placeholders so the CLIENT runs the tool on REAL values
72
- const restoredArgs = restoreDeep(evt.input ?? {}, s.vault);
71
+ // via the shared harness: real values so the CLIENT runs the tool on
72
+ // them, or the redacted token kept for remote MCP under "redact remote".
73
+ const restoredArgs = s.harness
74
+ ? s.harness.toTool(evt.name, evt.input ?? {})
75
+ : restoreDeep(evt.input ?? {}, s.vault);
73
76
  handlers.onToolRequest({ name: evt.name, restoredArgs, toolId: encodeToolCallId(s.id, evt.id) });
74
77
  return 'parked';
75
78
  } else if (evt.type === 'done') {