@chatpanel/gateway 0.6.38 → 0.6.39

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.38",
3
+ "version": "0.6.39",
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": {
@@ -13,7 +13,7 @@
13
13
  // tier, not this one. The file on disk is useless without the local key.
14
14
 
15
15
  import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
16
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, unlinkSync, statSync } from 'node:fs';
17
17
  import { join, dirname } from 'node:path';
18
18
  import os from 'node:os';
19
19
  import { SearchIndex } from './search-index.js';
@@ -104,6 +104,11 @@ export class HistoryStore {
104
104
  return max || null;
105
105
  }
106
106
 
107
+ // On-disk footprint for the storage dashboard: the encrypted store file.
108
+ get bytes() {
109
+ try { return existsSync(this.storePath) ? statSync(this.storePath).size : 0; } catch { return 0; }
110
+ }
111
+
107
112
  key() {
108
113
  if (!this._key) this._key = loadOrCreateKey();
109
114
  return this._key;
package/src/mcp.js CHANGED
@@ -16,10 +16,31 @@
16
16
 
17
17
  import { loadConfig } from './config.js';
18
18
  import { readBridgeToken } from './bridge.js';
19
+ import { ensureGatewayToken } from './gateway-token.js';
19
20
 
20
21
  const PROTOCOL_VERSION = '2024-11-05';
21
22
  const SERVER = { name: 'chatpanel-history', version: '1.0.0' };
22
23
 
24
+ // The calling agent's self-reported name (from MCP `initialize` clientInfo), so the
25
+ // observability dashboard can say WHICH agent read what. Untrusted; the gateway coerces it.
26
+ let clientName = 'unknown';
27
+
28
+ // Report one tool call to the long-lived gateway service's access log. Fire-and-forget:
29
+ // telemetry must never slow, block or fail a tool call. Authorized with the gateway token
30
+ // (readable only by this same-user process), so a drive-by localhost page can't forge entries.
31
+ // Raw args are sent, but the server REDACTS them before storing (a search query is never
32
+ // kept) — and the query already crossed this same loopback on the search call itself.
33
+ function reportAccess(evt) {
34
+ try {
35
+ const token = ensureGatewayToken();
36
+ fetch(`${baseUrl()}/v1/observability/access`, {
37
+ method: 'POST',
38
+ headers: { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
39
+ body: JSON.stringify(evt),
40
+ }).catch(() => {});
41
+ } catch { /* no token or gateway down — drop the telemetry, never the call */ }
42
+ }
43
+
23
44
  function baseUrl() {
24
45
  const env = process.env.CHATPANEL_GATEWAY_URL;
25
46
  if (env) return env.replace(/\/+$/, '');
@@ -201,12 +222,20 @@ export async function handleRpc(msg) {
201
222
  try {
202
223
  switch (method) {
203
224
  case 'initialize':
225
+ clientName = params?.clientInfo?.name || clientName;
204
226
  return ok({ protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER });
205
227
  case 'tools/list':
206
228
  return ok({ tools: TOOLS });
207
229
  case 'tools/call': {
208
- const text = await callTool(params?.name, params?.arguments || {});
209
- return ok({ content: [{ type: 'text', text }] });
230
+ const started = Date.now();
231
+ try {
232
+ const text = await callTool(params?.name, params?.arguments || {});
233
+ reportAccess({ client: clientName, tool: params?.name, ok: true, ms: Date.now() - started, args: params?.arguments || {} });
234
+ return ok({ content: [{ type: 'text', text }] });
235
+ } catch (e) {
236
+ reportAccess({ client: clientName, tool: params?.name, ok: false, ms: Date.now() - started, args: params?.arguments || {}, error: e.message });
237
+ throw e;
238
+ }
210
239
  }
211
240
  case 'ping':
212
241
  return ok({});
@@ -0,0 +1,121 @@
1
+ // VENDORED from @chatpanel/events/observability.js — edit there, then copy over.
2
+ // The gateway keeps its dependency surface small; this one pure module is copied in
3
+ // rather than pulling the whole events package, the same way the bridge vendors its
4
+ // events files. Source of truth: chatpanel-events/observability.js.
5
+ //
6
+ // observability.js — the contract for "who consumed what, when, and how much is stored".
7
+ //
8
+ // ChatPanel's data is reachable by more than one agent now: the side panel, and any CLI
9
+ // (Codex, Claude Code, OpenCode…) wired to the gateway's MCP server. Once several agents
10
+ // read your history and skills, you need to SEE that — which agent touched what, and how
11
+ // much sits in each storage tier. That is one question with one answer shape, so it lives
12
+ // here, not re-derived in every client. The extension renders it; the gateway records it;
13
+ // a desktop/mobile app will do both against this same contract.
14
+ //
15
+ // Pure and dependency-free (the @chatpanel/events rule): identical code in browser ESM,
16
+ // the gateway (Node) and a mobile JS runtime. No clock, no storage, no platform APIs —
17
+ // the caller passes `ts`; the caller owns persistence.
18
+ //
19
+ // PRIVACY IS THE POINT of the redactor below. An access log that stored raw tool arguments
20
+ // would quietly become a second copy of every search query — the exact PII we redact
21
+ // everywhere else. So the note attached to each event is built from a per-tool WHITELIST of
22
+ // non-sensitive fields; a search query's TEXT is never recorded, only that a search ran.
23
+
24
+ export const ACCESS_LOG_VERSION = 1;
25
+
26
+ // Default ring size — enough to see a working session's activity without unbounded growth.
27
+ export const ACCESS_LOG_MAX = 500;
28
+
29
+ // Per-tool whitelist: which argument fields are safe to keep in the human note. Anything not
30
+ // listed here is dropped. Content-bearing fields (a search `query`) are deliberately ABSENT —
31
+ // the tool name already says "a search happened"; the words searched are not logged.
32
+ const SAFE_ARGS = {
33
+ search_history: ['limit'],
34
+ list_history: ['limit', 'offset'],
35
+ get_record: ['id'], // opaque record id, not content
36
+ open_skill: ['skill'], // skill names are catalog identifiers, not PII
37
+ read_skill_file: ['skill', 'path'],
38
+ list_skills: ['limit'],
39
+ };
40
+
41
+ /**
42
+ * A short, SAFE descriptor of a call's arguments for display. Never returns content that
43
+ * could carry PII. Unknown tools get an empty note (the tool name is the only signal).
44
+ */
45
+ export function redactAccessArgs(tool, args) {
46
+ const allow = SAFE_ARGS[tool];
47
+ if (!allow || !args || typeof args !== 'object') return '';
48
+ const parts = [];
49
+ for (const key of allow) {
50
+ const v = args[key];
51
+ if (v === undefined || v === null || v === '') continue;
52
+ // Cap any string field so a long id/path can't smuggle content or blow up the row.
53
+ const s = typeof v === 'string' ? (v.length > 80 ? `${v.slice(0, 77)}…` : v) : String(v);
54
+ parts.push(`${key}=${s}`);
55
+ }
56
+ return parts.join(' ');
57
+ }
58
+
59
+ /**
60
+ * Normalize one access into the record everything stores and renders. `client` is the calling
61
+ * agent's self-reported name (MCP clientInfo) — untrusted, so it's coerced to a short string.
62
+ */
63
+ export function makeAccessEvent({ ts, client, tool, ok = true, ms, args, error } = {}) {
64
+ return {
65
+ v: ACCESS_LOG_VERSION,
66
+ ts: Number(ts) || 0,
67
+ client: shortStr(client, 'unknown', 60),
68
+ tool: shortStr(tool, 'unknown', 60),
69
+ ok: !!ok,
70
+ ms: Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : null,
71
+ note: redactAccessArgs(tool, args),
72
+ error: error ? shortStr(error, '', 200) : '',
73
+ };
74
+ }
75
+
76
+ function shortStr(v, fallback, max) {
77
+ const s = (v == null ? '' : String(v)).trim() || fallback;
78
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
79
+ }
80
+
81
+ /**
82
+ * A tiny fixed-capacity ring for access events. Pure and synchronous — the gateway keeps one
83
+ * in memory and snapshots it for the dashboard; the caller decides whether/how to persist.
84
+ */
85
+ export function createAccessLog(max = ACCESS_LOG_MAX) {
86
+ const cap = Math.max(1, max | 0);
87
+ let buf = [];
88
+ return {
89
+ push(evt) { buf.push(evt); if (buf.length > cap) buf = buf.slice(buf.length - cap); return evt; },
90
+ // Newest first, optionally limited — the order a dashboard wants.
91
+ snapshot(limit) { const out = buf.slice().reverse(); return limit ? out.slice(0, limit) : out; },
92
+ get size() { return buf.length; },
93
+ clear() { buf = []; },
94
+ };
95
+ }
96
+
97
+ // ── Storage tiers ────────────────────────────────────────────────────────────────────────
98
+ // One descriptor per place data lives: hot (browser), warm (local gateway), cold (cloud,
99
+ // future). The dashboard renders a row per tier; a tier that isn't configured says so.
100
+
101
+ export function makeStorageTier({ tier, label, present = true, records = null, bytes = null, newest = null, note = '' } = {}) {
102
+ return {
103
+ tier: String(tier || ''),
104
+ label: String(label || tier || ''),
105
+ present: !!present,
106
+ records: records == null ? null : Math.max(0, records | 0),
107
+ bytes: bytes == null ? null : Math.max(0, Number(bytes) || 0),
108
+ newest: newest == null ? null : Number(newest) || 0,
109
+ note: String(note || ''),
110
+ };
111
+ }
112
+
113
+ /** Human-friendly byte size. Binary units, one decimal above KB. */
114
+ export function formatBytes(n) {
115
+ const b = Number(n);
116
+ if (!Number.isFinite(b) || b <= 0) return '0 B';
117
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
118
+ let i = 0, v = b;
119
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
120
+ return `${i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
121
+ }
package/src/server.js CHANGED
@@ -41,11 +41,12 @@ import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MOD
41
41
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
42
42
  import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
43
43
  import { resolveDestination, aggregateModelsAsync } from './router.js';
44
+ import { createAccessLog, makeAccessEvent } from './observability.js';
44
45
  import * as openai from './openai.js';
45
46
  import * as responses from './responses.js';
46
47
  import * as anthropic from './anthropic.js';
47
48
 
48
- export const VERSION = '0.6.38';
49
+ export const VERSION = '0.6.39';
49
50
 
50
51
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
51
52
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -53,6 +54,13 @@ export const VERSION = '0.6.38';
53
54
  // See docs/architecture-data-tiers.
54
55
  const historyStore = await createHistoryStore();
55
56
 
57
+ // OBSERVABILITY — an in-memory ring of "which agent read what, when". Populated by the
58
+ // MCP server process (chatpanel-gateway mcp) reporting each tool call here, so a person
59
+ // can SEE cross-agent access in ChatPanel's dashboard. In-memory by design: a working
60
+ // session's activity, not an audit trail that itself becomes data to protect. The note on
61
+ // each event is redacted (never a search query) by makeAccessEvent.
62
+ const accessLog = createAccessLog();
63
+
56
64
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
57
65
 
58
66
  // Auto-narrow: arm only the top-K most-relevant MCP tools per turn (speed). Mirrors
@@ -522,6 +530,19 @@ export function createGateway(cfg = loadConfig()) {
522
530
  && req.method === 'POST' && !isAdminAuthorized(req)) {
523
531
  return sendJson(res, 403, { error: { message: 'history key route: extension origin or gateway token required', type: 'forbidden' } });
524
532
  }
533
+ // Reads of the warm index are open (that's the product — Codex/OpenCode query it).
534
+ // WRITES are not: a drive-by localhost page or a random local process must not be
535
+ // able to inject records the model then trusts as the user's real history. Ingest
536
+ // requires the extension Origin (which its POST always carries) or the gateway token.
537
+ if (pathname === '/v1/history/ingest' && req.method === 'POST' && !isAdminAuthorized(req)) {
538
+ return sendJson(res, 403, { error: { message: 'history ingest is a write — extension origin or gateway token required', type: 'forbidden' } });
539
+ }
540
+ // The access log is who-read-what — sensitive, and writable only by the local MCP
541
+ // process (which sends the gateway token). Extension Origin or token for both the
542
+ // read (dashboard) and the report (MCP child); a drive-by page has neither.
543
+ if (pathname.startsWith('/v1/observability') && !isAdminAuthorized(req)) {
544
+ return sendJson(res, 403, { error: { message: 'observability: extension origin or gateway token required', type: 'forbidden' } });
545
+ }
525
546
 
526
547
  if (req.method === 'GET' && pathname === '/health') {
527
548
  // `stt` is ADDITIVE (Tesla rule): old clients ignore it, new clients use it
@@ -585,7 +606,28 @@ export function createGateway(cfg = loadConfig()) {
585
606
  }
586
607
  }
587
608
  if (pathname === '/v1/history/status' && req.method === 'GET') {
588
- return sendJson(res, 200, { ok: true, size: historyStore.size, newest: historyStore.newest });
609
+ return sendJson(res, 200, { ok: true, size: historyStore.size, newest: historyStore.newest, bytes: historyStore.bytes });
610
+ }
611
+
612
+ // OBSERVABILITY (admin-gated above).
613
+ // GET /v1/observability → { storage:{warm:{records,bytes,newest}}, access:[…] }
614
+ // POST /v1/observability/access ← the MCP process reports one tool call
615
+ if (pathname === '/v1/observability' && req.method === 'GET') {
616
+ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 200));
617
+ return sendJson(res, 200, {
618
+ ok: true,
619
+ storage: { warm: { records: historyStore.size, bytes: historyStore.bytes, newest: historyStore.newest } },
620
+ access: accessLog.snapshot(limit),
621
+ });
622
+ }
623
+ if (pathname === '/v1/observability/access' && req.method === 'POST') {
624
+ try {
625
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
626
+ const evt = accessLog.push(makeAccessEvent({ ts: Date.now(), ...body }));
627
+ return sendJson(res, 200, { ok: true, event: evt });
628
+ } catch (e) {
629
+ return sendJson(res, 400, { error: { message: `access log failed: ${e.message}`, type: 'observability_error' } });
630
+ }
589
631
  }
590
632
  if (pathname === '/v1/history/list' && req.method === 'GET') {
591
633
  const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 50));
@@ -15,7 +15,7 @@
15
15
  // via history-store.js's saveBackupSecret.
16
16
 
17
17
  import { join } from 'node:path';
18
- import { mkdirSync, chmodSync, existsSync } from 'node:fs';
18
+ import { mkdirSync, chmodSync, existsSync, statSync } from 'node:fs';
19
19
  import os from 'node:os';
20
20
  import { HistoryStore } from './history-store.js';
21
21
 
@@ -92,6 +92,22 @@ export class SqliteHistoryStore {
92
92
  return this.db.get('SELECT COUNT(*) c FROM records')?.c || 0;
93
93
  }
94
94
 
95
+ // Freshness horizon — the newest record's timestamp. The MCP tools surface this so
96
+ // an agent knows how current the warm copy is (and stops denying un-synced items).
97
+ get newest() {
98
+ return this.db.get('SELECT MAX(date) m FROM records')?.m || 0;
99
+ }
100
+
101
+ // On-disk footprint for the storage dashboard: the .db plus its WAL/shm sidecars.
102
+ get bytes() {
103
+ if (this.path === ':memory:') return 0;
104
+ let total = 0;
105
+ for (const p of [this.path, `${this.path}-wal`, `${this.path}-shm`]) {
106
+ try { if (existsSync(p)) total += statSync(p).size; } catch { /* ignore */ }
107
+ }
108
+ return total;
109
+ }
110
+
95
111
  bulk({ upserts = [], removes = [] } = {}) {
96
112
  this.db.exec('BEGIN');
97
113
  try {