@chatpanel/gateway 0.6.71 → 0.6.73

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.
@@ -3,6 +3,9 @@
3
3
  //
4
4
  // chatpanel-gateway start the gateway (foreground)
5
5
  // chatpanel-gateway mcp stdio MCP server exposing warm history as tools
6
+ // chatpanel-gateway tools list the same tools, from a shell — names and one-liners
7
+ // chatpanel-gateway tools schema <tool> one tool's full schema
8
+ // chatpanel-gateway call <tool> '<json>' run one tool (exit 0 ok · 1 tool error · 2 usage)
6
9
  // chatpanel-gateway local show the local runtime — bridge + gateway, one view
7
10
  // chatpanel-gateway connect point your CLI agents (Codex, Claude Code, …) at this server
8
11
  // chatpanel-gateway --install register login auto-start + start now
@@ -21,6 +24,11 @@ try {
21
24
  // server.js (which would open a second handle on the warm SQLite store).
22
25
  const { runMcpServer } = await import('../src/mcp.js');
23
26
  await runMcpServer();
27
+ } else if (arg === 'tools' || arg === 'call') {
28
+ // MCP2CLI: the MCP tools as shell verbs, for agents that only have a shell. Same
29
+ // in-process dispatcher as `mcp`, same no-server.js rule.
30
+ const { runMcpCli } = await import('../src/mcp-cli.js');
31
+ process.exit(await runMcpCli(process.argv.slice(2)));
24
32
  } else if (arg === 'local') {
25
33
  // Read-only unified view of both services. No server.js import — just HTTP probes.
26
34
  const { localStatus, formatLocalStatus } = await import('../src/local-status.js');
@@ -52,7 +60,7 @@ try {
52
60
  start();
53
61
  break;
54
62
  default:
55
- console.error(`unknown option: ${arg}\nUsage: chatpanel-gateway [mcp|--install|--uninstall|--status|--version]`);
63
+ console.error(`unknown option: ${arg}\nUsage: chatpanel-gateway [mcp|tools list|tools schema <tool>|call <tool> '<json>'|local|connect|--install|--uninstall|--status|--version]`);
56
64
  process.exit(2);
57
65
  }
58
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.71",
3
+ "version": "0.6.73",
4
4
  "description": "Local privacy gateway \u2014 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/mcp-cli.js ADDED
@@ -0,0 +1,113 @@
1
+ // `chatpanel-gateway tools list | tools schema <tool> | call <tool> '<json>'` — the same
2
+ // tools the MCP server offers, reached from a shell.
3
+ //
4
+ // An MCP connection is a standing cost: every tool's schema sits in the agent's context on
5
+ // every turn, called or not. A shell verb costs nothing until it is used — `tools list`
6
+ // prints names and one-liners, `tools schema` pulls ONE schema at the moment of calling,
7
+ // `call` runs the tool. So an agent that has only a shell (a skill's `scripts/`, a CI job,
8
+ // a CLI with no MCP support) reaches history, memory and briefs at a cost that scales with
9
+ // use rather than with how many tools exist. The daemon's secrets never move: the CLI
10
+ // talks to the gateway exactly as the MCP server does, over loopback with the gateway
11
+ // token, and prints results.
12
+ //
13
+ // Exit codes, so a script can tell them apart: 0 the tool answered; 1 the tool itself
14
+ // reported an error (a real tool-level failure — the record was not found); 2 a usage or
15
+ // transport failure (bad JSON, unknown verb, gateway unreachable).
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import { handleRpc } from './mcp.js';
19
+
20
+ const USAGE = [
21
+ 'Usage:',
22
+ ' chatpanel-gateway tools list names and one-liners (no schemas)',
23
+ ' chatpanel-gateway tools schema <tool> one tool\'s full input schema',
24
+ ' chatpanel-gateway call <tool> [\'<json>\'] run a tool; arguments as a JSON object',
25
+ ' chatpanel-gateway call <tool> --file <path> arguments from a JSON file (no shell quoting)',
26
+ '',
27
+ 'Exit codes: 0 ok · 1 the tool reported an error · 2 usage or transport failure',
28
+ ].join('\n');
29
+
30
+ /** First sentence, whitespace collapsed, capped — enough to choose a tool, not to call it. */
31
+ export function oneLiner(description, max = 100) {
32
+ const s = String(description || '').replace(/\s+/g, ' ').trim();
33
+ const cut = s.search(/[.!?]\s/);
34
+ const first = cut > 20 ? s.slice(0, cut + 1) : s;
35
+ return first.length > max ? `${first.slice(0, max - 1).trimEnd()}…` : first;
36
+ }
37
+
38
+ export function formatToolsList(tools) {
39
+ const width = Math.min(24, Math.max(...tools.map((t) => t.name.length), 4));
40
+ return tools.map((t) => {
41
+ const req = Array.isArray(t.inputSchema?.required) && t.inputSchema.required.length ? `(${t.inputSchema.required.join(', ')})` : '()';
42
+ return `${t.name.padEnd(width)} ${req.padEnd(22)} ${oneLiner(t.description)}`;
43
+ }).join('\n');
44
+ }
45
+
46
+ function parseArgs(argv) {
47
+ const rest = [...argv];
48
+ let file = null;
49
+ const i = rest.indexOf('--file');
50
+ if (i >= 0) { file = rest[i + 1]; rest.splice(i, 2); }
51
+ return { rest, file };
52
+ }
53
+
54
+ /**
55
+ * Run one CLI invocation. Returns the exit code; writes through `out`/`err` so a test can
56
+ * capture without a process. `rpc` is the MCP dispatcher — `handleRpc` in production.
57
+ */
58
+ export async function runMcpCli(argv, { out = (s) => process.stdout.write(s), err = (s) => process.stderr.write(s), rpc = handleRpc } = {}) {
59
+ const [verb, ...more] = argv;
60
+ const list = async () => (await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }))?.result?.tools || [];
61
+
62
+ if (verb === 'tools') {
63
+ const [sub, name] = more;
64
+ if (sub === 'list') {
65
+ out(`${formatToolsList(await list())}\n`);
66
+ return 0;
67
+ }
68
+ if (sub === 'schema') {
69
+ if (!name) { err(`tools schema: which tool?\n${USAGE}\n`); return 2; }
70
+ const tool = (await list()).find((t) => t.name === name);
71
+ if (!tool) { err(`no tool named "${name}". Run: chatpanel-gateway tools list\n`); return 2; }
72
+ out(`${JSON.stringify({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }, null, 2)}\n`);
73
+ return 0;
74
+ }
75
+ err(`${USAGE}\n`);
76
+ return 2;
77
+ }
78
+
79
+ if (verb === 'call') {
80
+ const { rest, file } = parseArgs(more);
81
+ const [name, inline] = rest;
82
+ if (!name) { err(`call: which tool?\n${USAGE}\n`); return 2; }
83
+ let args = {};
84
+ try {
85
+ const text = file ? readFileSync(file, 'utf8') : (inline ?? '{}');
86
+ args = JSON.parse(text);
87
+ } catch (e) {
88
+ err(`call: arguments must be a JSON object (${e.message})\n`);
89
+ return 2;
90
+ }
91
+ if (!args || typeof args !== 'object' || Array.isArray(args)) { err('call: arguments must be a JSON object\n'); return 2; }
92
+ let reply;
93
+ try {
94
+ reply = await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
95
+ } catch (e) {
96
+ err(`call: ${e.message}\n`);
97
+ return 2;
98
+ }
99
+ if (reply?.error) { err(`call: ${reply.error.message}\n`); return 2; }
100
+ const text = (reply?.result?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
101
+ if (reply?.result?.isError) {
102
+ // The gateway being down looks like a tool error from the MCP layer; it is transport.
103
+ const transport = /fetch failed|ECONNREFUSED|not running|unreachable/i.test(text);
104
+ err(`${text}\n`);
105
+ return transport ? 2 : 1;
106
+ }
107
+ out(`${text}\n`);
108
+ return 0;
109
+ }
110
+
111
+ err(`${USAGE}\n`);
112
+ return 2;
113
+ }
package/src/ner.js CHANGED
@@ -8,6 +8,10 @@
8
8
  // deterministic-only redaction. redact.js consults the engine directly, so we do
9
9
  // NOT mutate cfg.redaction.detection here (that field is reserved for a user's own
10
10
  // external detector, which takes precedence — see below).
11
+ //
12
+ // `ensureNer` (bottom) is the same thing on demand, for the case autostart cannot cover:
13
+ // a config that says autostart:false, weights already on disk, and a client asking for
14
+ // detection right now.
11
15
 
12
16
  import * as engine from './ner-engine.js';
13
17
  import { persistConfig, configPath } from './configstore.js';
@@ -54,3 +58,56 @@ export function startNer(cfg) {
54
58
  // Nothing to kill (no child process); just stop logging after shutdown.
55
59
  return { stop() { stopped = true; } };
56
60
  }
61
+
62
+ /**
63
+ * START THE BUNDLED DETECTOR BECAUSE SOMEONE ASKED FOR DETECTION.
64
+ *
65
+ * `startNer` only runs at boot, and only when `ner.autostart` is on. Everything after that
66
+ * assumed the engine was either running or deliberately not wanted — so a config carrying
67
+ * `autostart:false` (older gateways wrote it, and it survives every upgrade) left POST /ner
68
+ * answering 503 forever, with the weights sitting on disk the whole time. The extension's
69
+ * composer showed "name detection is not answering", and every real turn quietly fell back
70
+ * to deterministic-only redaction: names, organisations and places went to the model in full.
71
+ *
72
+ * A request for entity detection IS the intent to detect, so this starts the engine on that
73
+ * request instead of waiting for a restart the user has no reason to perform.
74
+ *
75
+ * Two limits keep it from being a surprise. It never DOWNLOADS: weights arrive through the
76
+ * model manager, which shows progress, so a preview keystroke can never kick off a hundred
77
+ * megabytes. And it defers to a user's own external detector exactly as `startNer` does —
78
+ * that field means "use mine", not "use mine if it happens to be up".
79
+ *
80
+ * Returns the engine state after the attempt; never throws.
81
+ */
82
+ export async function ensureNer(cfg, { log = (m) => console.log(m) } = {}) {
83
+ const det = cfg?.redaction?.detection;
84
+ if (det && det.backend && det.backend !== 'off') return 'external';
85
+
86
+ const st = engine.state();
87
+ if (st === 'ready') return st;
88
+ // A load already in flight (or one that already failed) — join it rather than starting a
89
+ // second one. engine.init() is single-flight, so this is just the wait.
90
+ if (st === 'loading' || st === 'downloading' || st === 'error') {
91
+ try { await engine.init(); } catch { /* fail-open: deterministic-only */ }
92
+ return engine.state();
93
+ }
94
+
95
+ const n = cfg?.ner || {};
96
+ const model = n.model || undefined;
97
+ if (!engine.modelOnDisk(model)) return 'not-downloaded';
98
+
99
+ try {
100
+ await engine.init({ model, allowDownload: false, onLog: log });
101
+ } catch (e) {
102
+ log(`[ner] on-demand load failed (${e.message}) — deterministic-only`);
103
+ return engine.state();
104
+ }
105
+ if (engine.isReady()) {
106
+ log(`[ner] started on demand — model ${engine.health().model} — entity detection active`);
107
+ if (n.enableFullTier !== false && cfg.redaction && cfg.redaction.tier !== 'full') {
108
+ cfg.redaction.tier = 'full';
109
+ log('[ner] full tier on — name/org redaction active');
110
+ }
111
+ }
112
+ return engine.state();
113
+ }
package/src/server.js CHANGED
@@ -29,7 +29,7 @@ import { secureFetch } from './secure-fetch.js';
29
29
  import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
30
30
  import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
31
31
  import { shaperFor } from './shape.js';
32
- import { startNer } from './ner.js';
32
+ import { startNer, ensureNer } from './ner.js';
33
33
  import { installTimestampedConsole } from './log.js';
34
34
  import { saveBackupSecret, clearBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
35
35
  import { createMemoryStore } from './memory-store.js';
@@ -56,7 +56,7 @@ import * as openai from './openai.js';
56
56
  import * as responses from './responses.js';
57
57
  import * as anthropic from './anthropic.js';
58
58
 
59
- export const VERSION = '0.6.71';
59
+ export const VERSION = '0.6.73';
60
60
 
61
61
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
62
62
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -281,6 +281,43 @@ function nerBaseUrl(cfg) {
281
281
  return url;
282
282
  }
283
283
 
284
+ /**
285
+ * CAN THIS GATEWAY REDACT NAMES RIGHT NOW — the question every privacy UI has to answer
286
+ * before it draws a shield.
287
+ *
288
+ * Deterministic patterns (emails, phones, cards, keys) always run, so a redaction with no
289
+ * detector behind it produces text that LOOKS redacted. `coverage` names the difference:
290
+ * 'names' means people, organisations and places are covered too; 'patterns' means they are
291
+ * not, and the client is expected to say so rather than let the shield imply it.
292
+ *
293
+ * Synchronous on purpose — it reports the engine's own state and never probes the network,
294
+ * so it can sit on a per-keystroke route.
295
+ */
296
+ function detectorStatus(cfg) {
297
+ const det = cfg?.redaction?.detection;
298
+ const tier = cfg?.redaction?.tier === 'full' ? 'full' : 'basic';
299
+ if (det && det.backend && det.backend !== 'off') {
300
+ // A user's own detector: we cannot know it is up without sending it text, and a probe
301
+ // per keystroke is not a trade worth making. Report it as configured and let the turn
302
+ // itself be the test.
303
+ return { source: 'external', backend: det.backend, state: 'external', model: det.model || null, ready: true, tier, coverage: tier === 'full' ? 'names' : 'patterns' };
304
+ }
305
+ const h = nerEngine.health();
306
+ const ready = h.ok && tier === 'full';
307
+ return {
308
+ source: 'bundled',
309
+ state: h.state, // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
310
+ model: h.model || cfg?.ner?.model || null,
311
+ // Weights on disk with the engine off is the recoverable case (it starts on the next
312
+ // request); no weights is the case that needs the user to install a model.
313
+ installed: nerEngine.modelOnDisk(cfg?.ner?.model || undefined),
314
+ error: h.error || null,
315
+ ready,
316
+ tier,
317
+ coverage: ready ? 'names' : 'patterns',
318
+ };
319
+ }
320
+
284
321
  // Health of the detector for /status. The bundled IN-PROCESS engine takes
285
322
  // precedence; its public contract URL is the gateway's own /ner (no second port).
286
323
  // A user-configured external detector is probed over HTTP as before.
@@ -690,6 +727,9 @@ export function createGateway(cfg = loadConfig()) {
690
727
  model: health.model, // e.g. "en_core_web_sm"
691
728
  url: health.url,
692
729
  },
730
+ // Additive: one block that answers "are names covered right now", for clients that
731
+ // draw a privacy indicator and must not overstate it. See detectorStatus.
732
+ detector: detectorStatus(cfg),
693
733
  pro: { unlocked: proUnlocked }, usage: usage(cfg),
694
734
  uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
695
735
  });
@@ -928,8 +968,30 @@ export function createGateway(cfg = loadConfig()) {
928
968
  return sendJson(res, health.ok ? 200 : 503, health);
929
969
  }
930
970
  if (req.method === 'POST') {
971
+ // A POST here IS the request to detect, so start the bundled engine if it is not
972
+ // running and its weights are already on disk (see ensureNer — it never downloads).
973
+ // Without this a config with `ner.autostart:false` answered 503 until a restart,
974
+ // and the client read that as "redaction is broken" while the model sat unused.
975
+ await ensureNer(cfg);
931
976
  // In-process engine path.
932
977
  if (nerEngine.state() !== 'off') {
978
+ // NOT READY IS NOT AN ANSWER. `detect()` returns [] when the pipeline is still
979
+ // loading or failed to load, and 200 {entities:[]} is indistinguishable from
980
+ // "this text contains no names" — the one lie a privacy layer must never tell.
981
+ // Say which state it is in instead, and let the caller show "starting…".
982
+ if (!nerEngine.isReady()) {
983
+ const h = nerEngine.health();
984
+ return sendJson(res, 503, {
985
+ error: {
986
+ message: h.state === 'error'
987
+ ? `NER model failed to load: ${h.error || 'unknown error'}`
988
+ : `NER model is ${h.state} — not ready yet`,
989
+ type: h.state === 'error' ? 'ner_error' : 'ner_loading',
990
+ },
991
+ state: h.state,
992
+ model: h.model,
993
+ });
994
+ }
933
995
  try {
934
996
  const body = await readBody(req, cfg.maxBodyBytes);
935
997
  let text = '';
@@ -942,7 +1004,22 @@ export function createGateway(cfg = loadConfig()) {
942
1004
  }
943
1005
  // External detector proxy (user-configured endpoint).
944
1006
  const url = nerBaseUrl(cfg);
945
- if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
1007
+ if (!url) {
1008
+ // The bundled engine is the intended detector here and it is not running. Say
1009
+ // WHY — "not configured" sent a user hunting through settings that were already
1010
+ // correct, when the real answer was that the model was never downloaded.
1011
+ const onDisk = nerEngine.modelOnDisk(cfg.ner?.model || undefined);
1012
+ return sendJson(res, 503, {
1013
+ error: {
1014
+ message: onDisk
1015
+ ? 'the bundled detector could not start — deterministic-only redaction'
1016
+ : 'no entity detector is installed — deterministic-only redaction (install one from Gateway settings)',
1017
+ type: onDisk ? 'ner_error' : 'ner_not_installed',
1018
+ },
1019
+ state: nerEngine.state(),
1020
+ model: cfg.ner?.model || null,
1021
+ });
1022
+ }
946
1023
  try {
947
1024
  const body = await readBody(req, cfg.maxBodyBytes);
948
1025
  // secureFetch: scheme/host policy + resolved-IP check before POSTing raw text to the detector.
@@ -973,10 +1050,13 @@ export function createGateway(cfg = loadConfig()) {
973
1050
  let text = '';
974
1051
  try { text = String(JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8'))?.text || ''); }
975
1052
  catch { text = ''; }
976
- if (!text) return sendJson(res, 200, { text: '', count: 0, sanitized: 0, entities: [] });
1053
+ if (!text) return sendJson(res, 200, { text: '', count: 0, sanitized: 0, entities: [], detector: detectorStatus(cfg) });
977
1054
  try {
978
1055
  let out = text;
979
1056
  const isPro = await resolvePro(cfg.pro?.entitlementToken);
1057
+ // Same start-on-demand as a real turn, for the same reason — and so the preview
1058
+ // keeps describing the request rather than a weaker version of it.
1059
+ await ensureNer(cfg);
980
1060
  const r = await redactSegments(
981
1061
  [segment(() => out, (v) => { out = v; })],
982
1062
  cfg.redaction,
@@ -988,6 +1068,11 @@ export function createGateway(cfg = loadConfig()) {
988
1068
  sanitized: r.sanitized || 0,
989
1069
  tier: cfg.redaction?.tier === 'full' ? 'full' : 'basic',
990
1070
  entities: redactionDetail(r.vault, 'types') || [],
1071
+ // WHAT THIS PREVIEW COULD NOT SEE — additive, and the whole reason a client can be
1072
+ // honest. Patterns catch emails, phones and card numbers with no detector at all,
1073
+ // so a preview with the detector down looks identical to a clean one: same text,
1074
+ // same shield, names intact. `coverage` is the difference, said out loud.
1075
+ detector: detectorStatus(cfg),
991
1076
  });
992
1077
  } catch (e) {
993
1078
  // Fail LOUD. A preview that silently returns the original text would tell the user
@@ -1672,6 +1757,11 @@ export function createGateway(cfg = loadConfig()) {
1672
1757
  // was off for this turn" rather than "0 redactions", which would read as "nothing to
1673
1758
  // redact". Anonymous callers cannot switch it off: that is what the token is for.
1674
1759
  redactionOff = String(req.headers['x-chatpanel-redaction'] || '').trim().toLowerCase() === 'off' && isAdminAuthorized(req);
1760
+ // The detector must be up BEFORE the text is redacted, not after someone notices it
1761
+ // wasn't. redactSegments consults the engine and falls open when it is not ready, so
1762
+ // a gateway whose engine never autostarted sent names through at full tier without a
1763
+ // word. Cheap after the first call: ready → a state check, absent weights → nothing.
1764
+ if (!redactionOff) await ensureNer(cfg);
1675
1765
  const segs = redactionOff ? [] : r.adapter.collectSegments(body, cfg.redaction);
1676
1766
  const ac = new AbortController();
1677
1767
  req.on('close', () => ac.abort());