@chatpanel/gateway 0.6.68 → 0.6.70

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.6.68",
3
+ "version": "0.6.70",
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/bridge.js CHANGED
@@ -17,6 +17,47 @@ import os from 'node:os';
17
17
 
18
18
  const DEFAULT_TOKEN_PATH = join(os.homedir(), '.chatpanel', 'bridge-token');
19
19
 
20
+ export const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:4319';
21
+ const BRIDGE_PROBE_TTL_MS = 10_000;
22
+ let bridgeResolved = { at: 0, cfgUrl: '', url: '', fell: false };
23
+
24
+ async function bridgeAnswers(url, timeoutMs) {
25
+ try {
26
+ const r = await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) });
27
+ return r.ok;
28
+ } catch { return false; }
29
+ }
30
+
31
+ /**
32
+ * Where the bridge is, checked rather than believed.
33
+ *
34
+ * The persisted bridge.url was found pointing at an ephemeral port nothing answered on,
35
+ * while the real bridge sat on 4319: every agent showed unavailable, every agent turn failed,
36
+ * and the startup line said so in a log nobody was reading. A configured address that does
37
+ * not answer while the default one does is a stale setting, not a decision, so the default
38
+ * is used and the fall-back is logged. Probed at most every ten seconds.
39
+ */
40
+ export async function resolveBridgeUrl(cfg, { fallback, timeoutMs = 1500, now = Date.now() } = {}) {
41
+ // CHATPANEL_BRIDGE_FALLBACK=off disables the fallback: the test suite sets it, because a
42
+ // test whose fake bridge has gone away would otherwise find the developer's REAL bridge on
43
+ // 4319 and send its turns to a live coding agent.
44
+ if (fallback === undefined) fallback = process.env.CHATPANEL_BRIDGE_FALLBACK === 'off' ? '' : DEFAULT_BRIDGE_URL;
45
+ const cfgUrl = String(cfg?.bridge?.url || '').replace(/\/$/, '');
46
+ if (bridgeResolved.url && bridgeResolved.cfgUrl === cfgUrl && now - bridgeResolved.at < BRIDGE_PROBE_TTL_MS) return bridgeResolved.url;
47
+ let url = cfgUrl || fallback;
48
+ let fell = false;
49
+ if (fallback && url !== fallback && !(await bridgeAnswers(url, timeoutMs)) && await bridgeAnswers(fallback, timeoutMs)) {
50
+ url = fallback;
51
+ fell = true;
52
+ if (!bridgeResolved.fell || bridgeResolved.cfgUrl !== cfgUrl) console.log(`[gateway] bridge.url ${cfgUrl} is not answering; the bridge on ${fallback} is — using it (fix the address in Settings to silence this)`);
53
+ }
54
+ bridgeResolved = { at: now, cfgUrl, url, fell };
55
+ return url;
56
+ }
57
+
58
+ /** Test seam. */
59
+ export function resetBridgeResolution() { bridgeResolved = { at: 0, cfgUrl: '', url: '', fell: false }; }
60
+
20
61
  export function readBridgeToken(cfgToken, tokenPath = DEFAULT_TOKEN_PATH) {
21
62
  if (cfgToken) return cfgToken;
22
63
  try {
@@ -83,6 +83,21 @@ export function applyNerModelSelection(cfg, id) {
83
83
  }
84
84
 
85
85
  // Merge an editable patch into the live cfg. Only known fields; ignores the rest.
86
+ /**
87
+ * Is this API destination the gateway itself? A loop: a turn routed there comes straight
88
+ * back in. It arrived once from a backup import — the extension keeps the gateway in its own
89
+ * endpoint list, and the desktop copied that list into the gateway's destinations — and its
90
+ * model ids (the agents') then shadowed the real agents. Refused at the door, with a log line.
91
+ */
92
+ export function isSelfDestination(d, cfg = {}) {
93
+ if (!d || d.type !== 'api') return false;
94
+ let u;
95
+ try { u = new URL(String(d.baseUrl || '')); } catch { return false; }
96
+ const loop = /^(127\.0\.0\.1|localhost|\[::1\]|0\.0\.0\.0)$/i.test(u.hostname);
97
+ const port = Number(u.port) || (u.protocol === 'https:' ? 443 : 80);
98
+ return loop && port === (Number(cfg.port) || 4320);
99
+ }
100
+
86
101
  export function applyConfigPatch(cfg, patch = {}) {
87
102
  if (patch.backend === 'bridge' || patch.backend === 'api') cfg.backend = patch.backend;
88
103
  if (Array.isArray(patch.destinations)) {
@@ -91,6 +106,7 @@ export function applyConfigPatch(cfg, patch = {}) {
91
106
  const prev = new Map((Array.isArray(cfg.destinations) ? cfg.destinations : []).map((d) => [d.id, d]));
92
107
  cfg.destinations = patch.destinations
93
108
  .filter((d) => d && typeof d.id === 'string' && (d.type === 'agent' || d.type === 'api'))
109
+ .filter((d) => { const self = isSelfDestination(d, cfg); if (self) console.log(`[gateway] refusing destination "${d.id}": ${d.baseUrl} is this gateway — a loop`); return !self; })
94
110
  .map((d) => {
95
111
  const out = { id: d.id, type: d.type, models: Array.isArray(d.models) ? d.models.filter((m) => typeof m === 'string' && m) : [] };
96
112
  if (d.type === 'agent') out.agent = d.agent || d.id;
package/src/router.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // }
13
13
 
14
14
  import { secureFetch } from './secure-fetch.js';
15
- import { readBridgeToken } from './bridge.js';
15
+ import { readBridgeToken, resolveBridgeUrl } from './bridge.js';
16
16
  //
17
17
  // /v1/models aggregates every destination's models so clients can discover them.
18
18
 
@@ -29,10 +29,15 @@ const KNOWN_AGENTS = ['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity'
29
29
  export function listDestinations(cfg) {
30
30
  const configured = (Array.isArray(cfg.destinations) ? cfg.destinations : []).filter(Boolean);
31
31
  const haveId = new Set(configured.map((d) => d.id));
32
- const out = [...configured];
33
- for (const a of KNOWN_AGENTS) {
34
- if (!haveId.has(a)) out.push({ id: a, type: 'agent', agent: a, models: [a] });
35
- }
32
+ // AGENTS FIRST. The model list de-duplicates by id in this order, and resolveDestination
33
+ // takes the first destination that serves a model so an API destination that happened to
34
+ // list "codex" or "claude" (a gateway pointed at itself did exactly that) used to swallow
35
+ // the agents: they vanished from the picker and a turn for one was proxied to the loop.
36
+ const out = [
37
+ ...configured.filter((d) => d.type === 'agent'),
38
+ ...KNOWN_AGENTS.filter((a) => !haveId.has(a)).map((a) => ({ id: a, type: 'agent', agent: a, models: [a] })),
39
+ ...configured.filter((d) => d.type !== 'agent'),
40
+ ];
36
41
  if (cfg.backend === 'api' && !configured.some((d) => d.type === 'api')) {
37
42
  out.push({ id: 'openai', type: 'api', protocol: 'openai', baseUrl: cfg.upstreams?.openai?.baseUrl, models: [] });
38
43
  out.push({ id: 'anthropic', type: 'api', protocol: 'anthropic', baseUrl: cfg.upstreams?.anthropic?.baseUrl, models: [] });
@@ -156,7 +161,7 @@ export function aggregateModels(cfg) {
156
161
  * spends a subprocess per agent to describe something unusable.
157
162
  */
158
163
  async function bridgeAgentModels(cfg, installed, timeoutMs) {
159
- const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
164
+ const base = await resolveBridgeUrl(cfg);
160
165
  if (!base || !installed) return new Map();
161
166
  const token = readBridgeToken(cfg.bridge?.token);
162
167
  const ids = [...installed.entries()].filter(([, ok]) => ok).map(([id]) => id);
@@ -183,7 +188,7 @@ async function bridgeAgentModels(cfg, installed, timeoutMs) {
183
188
 
184
189
  /** id → installed, from the bridge's own /health. `null` when it could not be asked. */
185
190
  async function bridgeAgentAvailability(cfg, timeoutMs) {
186
- const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
191
+ const base = await resolveBridgeUrl(cfg);
187
192
  if (!base) return null;
188
193
  try {
189
194
  const token = readBridgeToken(cfg.bridge?.token);
package/src/server.js CHANGED
@@ -24,6 +24,7 @@ import { redactSegments, segment } from './redact.js';
24
24
  import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer, restoreDeep } from './stream.js';
25
25
  import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote, assertEndpointUrl } from '@chatpanel/pii';
26
26
  import { ensureGatewayToken, isAdminAuthorized } from './gateway-token.js';
27
+ import { resolveBridgeUrl } from './bridge.js';
27
28
  import { secureFetch } from './secure-fetch.js';
28
29
  import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
29
30
  import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
@@ -55,7 +56,7 @@ import * as openai from './openai.js';
55
56
  import * as responses from './responses.js';
56
57
  import * as anthropic from './anthropic.js';
57
58
 
58
- export const VERSION = '0.6.68';
59
+ export const VERSION = '0.6.70';
59
60
 
60
61
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
61
62
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -170,7 +171,7 @@ function mkTrace(sink) {
170
171
  const entry = /** @type {any} */ ({ ...this.meta, timings });
171
172
  setImmediate(() => {
172
173
  sink(entry);
173
- console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · redacted ${entry.redacted || 0}${entry.sanitized ? ` · scrubbed ${entry.sanitized} hidden` : ''}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
174
+ console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · ${entry.redaction === 'off' ? 'redaction OFF (client request)' : `redacted ${entry.redacted || 0}`}${entry.sanitized ? ` · scrubbed ${entry.sanitized} hidden` : ''}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
174
175
  });
175
176
  },
176
177
  };
@@ -356,13 +357,14 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
356
357
  // Full tier for everyone here (the free allowance is enforced in the main
357
358
  // handler), but the custom dictionary stays capped for free.
358
359
  const redactOpts = { tier: cfg.redaction.tier === 'full' ? 'full' : 'basic', dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
359
- const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token, harness });
360
+ const bridgeUrl = await resolveBridgeUrl(cfg);
361
+ const s = createRelaySession({ vault, redactOpts, bridgeUrl, token, harness });
360
362
  const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
361
363
  // The placeholder note is already in `system` (injected into the body after
362
364
  // redaction in the main handler), so toTurn() carried it here — nothing to add.
363
365
  let resp;
364
366
  try {
365
- resp = await openBridgeChat({ bridgeUrl: cfg.bridge.url, agent, token, messages, system, specs: toolsToSpecs(tools), options: {}, signal: undefined });
367
+ resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options: {}, signal: undefined });
366
368
  } catch (e) { clearTimeout(ttl); endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
367
369
  s.reader = resp.body.getReader();
368
370
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
@@ -420,7 +422,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
420
422
  // agent id, which leaves the agent on its own default exactly as before.
421
423
  const { agentModel } = parseAgentModel(body?.model, cfg);
422
424
  const turn = {
423
- bridgeUrl: cfg.bridge.url, agent, token, messages, system, signal: ac.signal,
425
+ bridgeUrl: await resolveBridgeUrl(cfg), agent, token, messages, system, signal: ac.signal,
424
426
  ...(agentModel ? { options: { model: agentModel } } : {}),
425
427
  };
426
428
 
@@ -1541,7 +1543,7 @@ export function createGateway(cfg = loadConfig()) {
1541
1543
  // modes for one screen; and the direct-to-bridge path is the one with no policy in front
1542
1544
  // of it, so making it necessary for a feature is how it becomes the habit.
1543
1545
  if (req.method === 'GET' && pathname === '/skills') {
1544
- const base = String(cfg.bridge?.url || '').replace(/\/$/, '');
1546
+ const base = await resolveBridgeUrl(cfg);
1545
1547
  if (!base) return sendJson(res, 503, { error: { message: 'no bridge is configured', type: 'no_bridge' } });
1546
1548
  const token = readBridgeToken(cfg.bridge?.token);
1547
1549
  try {
@@ -1572,7 +1574,7 @@ export function createGateway(cfg = loadConfig()) {
1572
1574
  if (!id || id === '.' || id === '..' || id.includes('/') || id.includes('\\')) {
1573
1575
  return sendJson(res, 400, { error: { message: 'not a skill id', type: 'invalid_request' } });
1574
1576
  }
1575
- const base = String(cfg.bridge?.url || '').replace(/\/$/, '');
1577
+ const base = await resolveBridgeUrl(cfg);
1576
1578
  if (!base) return sendJson(res, 503, { error: { message: 'no bridge is configured', type: 'no_bridge' } });
1577
1579
  const token = readBridgeToken(cfg.bridge?.token);
1578
1580
  try {
@@ -1624,6 +1626,7 @@ export function createGateway(cfg = loadConfig()) {
1624
1626
  let redactedCount = 0;
1625
1627
  let sanitizedCount = 0;
1626
1628
  let narrowedTools = 0;
1629
+ let redactionOff = false;
1627
1630
  let isPro = true;
1628
1631
  // Off the hot path: only build a trace when logging is on, so it adds nothing
1629
1632
  // when off (no clock reads, no record, no console line).
@@ -1659,14 +1662,24 @@ export function createGateway(cfg = loadConfig()) {
1659
1662
  type: 'free_limit_reached',
1660
1663
  } });
1661
1664
  }
1662
- const segs = r.adapter.collectSegments(body, cfg.redaction);
1665
+ // REDACTION OFF, FOR THIS REQUEST, BECAUSE THE USER SAID SO. A note task that reads
1666
+ // "write about NVIDIA GPUs" had NVIDIA replaced with an organisation placeholder, and
1667
+ // the model — seeing only [[ORG_1]] — guessed a different company and wrote about
1668
+ // that. The policy is right for the corpus and wrong for the user's own instruction,
1669
+ // and only the user can tell which a given turn is. So an AUTHENTICATED local client
1670
+ // (the desktop, the extension — both hold the gateway token) may send
1671
+ // `X-ChatPanel-Redaction: off`; the trace records it so the ledger says "redaction
1672
+ // was off for this turn" rather than "0 redactions", which would read as "nothing to
1673
+ // redact". Anonymous callers cannot switch it off: that is what the token is for.
1674
+ redactionOff = String(req.headers['x-chatpanel-redaction'] || '').trim().toLowerCase() === 'off' && isAdminAuthorized(req);
1675
+ const segs = redactionOff ? [] : r.adapter.collectSegments(body, cfg.redaction);
1663
1676
  const ac = new AbortController();
1664
1677
  req.on('close', () => ac.abort());
1665
1678
  const rd0 = trace ? trace.clock() : 0;
1666
1679
  // Redact at the configured tier for everyone (free users get name/org
1667
1680
  // redaction within their allowance); the custom dictionary stays capped for
1668
1681
  // free (isPro decides that inside).
1669
- const { vault: v, count, sanitized } = await redactSegments(segs, cfg.redaction, {
1682
+ const { vault: v, count, sanitized } = redactionOff ? { vault: null, count: 0, sanitized: 0 } : await redactSegments(segs, cfg.redaction, {
1670
1683
  signal: ac.signal,
1671
1684
  isPro,
1672
1685
  // A detector is the only hop that sees the request BEFORE redaction. It is guarded
@@ -1695,7 +1708,7 @@ export function createGateway(cfg = loadConfig()) {
1695
1708
  // tools (so privacy-aware models USE them instead of refusing). Injected
1696
1709
  // AFTER redaction so the note isn't itself redacted. Covers BOTH the API
1697
1710
  // forward and the relay (which reads system from this same body).
1698
- if (Array.isArray(body.tools) && body.tools.length && typeof r.adapter.injectSystemNote === 'function') {
1711
+ if (!redactionOff && Array.isArray(body.tools) && body.tools.length && typeof r.adapter.injectSystemNote === 'function') {
1699
1712
  r.adapter.injectSystemNote(body, placeholderToolNote({ toolData: cfg.tools?.toolData }));
1700
1713
  }
1701
1714
  outBody = Buffer.from(JSON.stringify(body), 'utf8');
@@ -1749,7 +1762,7 @@ export function createGateway(cfg = loadConfig()) {
1749
1762
  });
1750
1763
  }
1751
1764
  if (trace) {
1752
- trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, sanitized: sanitizedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail) };
1765
+ trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, sanitized: sanitizedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail), redaction: redactionOff ? 'off' : (cfg.redaction.tier || 'basic') };
1753
1766
  }
1754
1767
  if (dest && dest.type === 'api') {
1755
1768
  if (!dest.baseUrl) { trace?.commit(); return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` }); }
package/src/stream.js CHANGED
@@ -17,23 +17,30 @@ import { restoreText, restoreWithAliases } from '@chatpanel/pii';
17
17
  // Returns a TransformStream-free chunk transformer: feed it decoded string chunks,
18
18
  // it returns the prefix that's safe to forward now and buffers a possibly-partial
19
19
  // trailing token. Call flush() at end-of-stream.
20
+ /**
21
+ * Split `buf` into what is safe to forward now and what may still be the start of a token.
22
+ *
23
+ * Two shapes are held back: an unterminated "[[" (a token mid-way), and a trailing SINGLE
24
+ * "[" — a model tokenizes "[[ORG_1]]" as "[" + "[ORG_1]]" often enough that forwarding the
25
+ * lone bracket left every restored name wearing one: "[NVIDIA designs GPUs". "[[" cannot
26
+ * legitimately appear except as a token open, and a lone "[" at a chunk edge costs nothing to
27
+ * wait one chunk for.
28
+ */
29
+ function splitSafe(buf) {
30
+ const open = buf.lastIndexOf('[[');
31
+ if (open !== -1 && !buf.slice(open).includes(']]')) return [buf.slice(0, open), buf.slice(open)];
32
+ if (buf.endsWith('[')) return [buf.slice(0, -1), '['];
33
+ return [buf, ''];
34
+ }
35
+
20
36
  export function makeTokenRestorer(vault) {
21
37
  let buf = '';
22
38
  return {
23
39
  push(chunk) {
24
40
  if (!vault) return chunk || '';
25
41
  buf += chunk || '';
26
- // If an unterminated "[[" sits in the tail, a token may still be forming —
27
- // hold from there. "[[" can't legitimately appear except as a token open.
28
- const open = buf.lastIndexOf('[[');
29
42
  let safe;
30
- if (open !== -1 && !buf.slice(open).includes(']]')) {
31
- safe = buf.slice(0, open);
32
- buf = buf.slice(open);
33
- } else {
34
- safe = buf;
35
- buf = '';
36
- }
43
+ [safe, buf] = splitSafe(buf);
37
44
  return restoreText(safe, vault);
38
45
  },
39
46
  flush() {
@@ -83,10 +90,8 @@ function makeFieldRestorer(vault, restoreFn) {
83
90
  return {
84
91
  push(chunk) {
85
92
  buf += chunk || '';
86
- const open = buf.lastIndexOf('[[');
87
93
  let safe;
88
- if (open !== -1 && !buf.slice(open).includes(']]')) { safe = buf.slice(0, open); buf = buf.slice(open); }
89
- else { safe = buf; buf = ''; }
94
+ [safe, buf] = splitSafe(buf);
90
95
  return restoreFn(safe, vault);
91
96
  },
92
97
  flush() { const out = restoreFn(buf, vault); buf = ''; return out; },