@chatpanel/gateway 0.6.37 → 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.37",
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';
@@ -96,6 +96,19 @@ export class HistoryStore {
96
96
  return this.records.size;
97
97
  }
98
98
 
99
+ // The timestamp of the freshest record, so a client can tell how current this warm copy
100
+ // is — the difference between "no such meeting" and "not synced yet".
101
+ get newest() {
102
+ let max = 0;
103
+ for (const r of this.records.values()) if ((r.date || 0) > max) max = r.date || 0;
104
+ return max || null;
105
+ }
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
+
99
112
  key() {
100
113
  if (!this._key) this._key = loadOrCreateKey();
101
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(/\/+$/, '');
@@ -61,7 +82,7 @@ async function bridgeJson(path) {
61
82
  const TOOLS = [
62
83
  {
63
84
  name: 'search_history',
64
- description: 'Full-text search the user\'s ChatPanel history — past chats, meeting transcripts, and notes — by keyword relevance. Use this to recall what was discussed or written when the current context does not already contain it.',
85
+ description: 'Full-text search the user\'s ChatPanel history — past chats, meeting transcripts, and notes — by keyword relevance. This is a LOCAL WARM COPY that syncs from ChatPanel; very recent items (a meeting from the last few hours) may not be here yet — every result reports how current the index is. If the user is sure something exists and it is not found, it likely has not synced; say so rather than concluding it does not exist. Meeting titles are often generic ("Zoom Meeting"), so search by CONTENT (topics, names, decisions), not the meeting title.',
65
86
  inputSchema: {
66
87
  type: 'object',
67
88
  properties: {
@@ -82,7 +103,7 @@ const TOOLS = [
82
103
  },
83
104
  {
84
105
  name: 'list_history',
85
- description: 'List history records (newest first) with their id, title, type and date — no bodies. Use to browse or page the corpus.',
106
+ description: 'List history records (newest first) with their id, title, type and date — no bodies. Reports how current this warm copy is (its newest record). Use to see the index horizon and browse/page the corpus.',
86
107
  inputSchema: {
87
108
  type: 'object',
88
109
  properties: {
@@ -119,6 +140,15 @@ const TOOLS = [
119
140
  },
120
141
  ];
121
142
 
143
+ // A one-line freshness banner from the store's newest record, so every answer states the
144
+ // index horizon — the model can then say "not synced yet" instead of "does not exist".
145
+ function horizonLine(newest, size) {
146
+ if (!newest) return `Index: ${size} records (warm copy synced from ChatPanel).`;
147
+ const d = new Date(newest);
148
+ const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
149
+ return `Index: ${size} records, current through ${iso} (local warm copy — items newer than this may not have synced from ChatPanel yet).`;
150
+ }
151
+
122
152
  async function gatewayJson(path, init) {
123
153
  let res;
124
154
  try {
@@ -142,8 +172,9 @@ async function callTool(name, args = {}) {
142
172
  body: JSON.stringify({ query: String(args.query || ''), limit: Number(args.limit) || 10 }),
143
173
  });
144
174
  const rows = data.results || [];
145
- if (!rows.length) return `No matching history for: ${args.query}`;
146
- return [`${rows.length} result(s) for "${args.query}" (of ${data.size} indexed):`, ...rows.map((r, i) => `${i + 1}. [${r.id}] ${r.title || '(untitled)'} · ${r.type}${r.date ? ' · ' + new Date(r.date).toISOString().slice(0, 10) : ''} · score ${r.score?.toFixed?.(3) ?? r.score}`)].join('\n') + '\n\nUse get_record with an id for the full text.';
175
+ const horizon = horizonLine(data.newest, data.size);
176
+ if (!rows.length) return `No match for "${args.query}".\n${horizon}\nIf you expected a recent item, it may not have synced yet check ChatPanel directly, or try broader content keywords (titles are often generic).`;
177
+ return [horizon, '', `${rows.length} result(s) for "${args.query}":`, ...rows.map((r, i) => `${i + 1}. [${r.id}] ${r.title || '(untitled)'} · ${r.type}${r.date ? ' · ' + new Date(r.date).toISOString().slice(0, 10) : ''} · score ${r.score?.toFixed?.(3) ?? r.score}`)].join('\n') + '\n\nUse get_record with an id for the full text.';
147
178
  }
148
179
  if (name === 'get_record') {
149
180
  const data = await gatewayJson(`/v1/history/get?id=${encodeURIComponent(String(args.id || ''))}`);
@@ -154,8 +185,9 @@ async function callTool(name, args = {}) {
154
185
  const q = new URLSearchParams({ limit: String(Number(args.limit) || 50), offset: String(Number(args.offset) || 0) });
155
186
  const data = await gatewayJson(`/v1/history/list?${q}`);
156
187
  const items = data.items || [];
157
- if (!items.length) return 'History is empty (or the gateway has not been seeded yet).';
158
- return [`${items.length} of ${data.total} records:`, ...items.map((it) => `[${it.id}] ${it.title || '(untitled)'} · ${it.type}${it.date ? ' · ' + new Date(it.date).toISOString().slice(0, 10) : ''} · ${it.chars} chars`)].join('\n');
188
+ if (!items.length) return 'History is empty (or the gateway has not been seeded yet — open ChatPanel with warm sync enabled).';
189
+ const newest = items[0]?.date || null;
190
+ return [horizonLine(newest, data.total), '', `${items.length} of ${data.total} records:`, ...items.map((it) => `[${it.id}] ${it.title || '(untitled)'} · ${it.type}${it.date ? ' · ' + new Date(it.date).toISOString().slice(0, 10) : ''} · ${it.chars} chars`)].join('\n');
159
191
  }
160
192
  if (name === 'list_skills') {
161
193
  let data;
@@ -190,12 +222,20 @@ export async function handleRpc(msg) {
190
222
  try {
191
223
  switch (method) {
192
224
  case 'initialize':
225
+ clientName = params?.clientInfo?.name || clientName;
193
226
  return ok({ protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER });
194
227
  case 'tools/list':
195
228
  return ok({ tools: TOOLS });
196
229
  case 'tools/call': {
197
- const text = await callTool(params?.name, params?.arguments || {});
198
- 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
+ }
199
239
  }
200
240
  case 'ping':
201
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.37';
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.37';
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
@@ -579,13 +600,34 @@ export function createGateway(cfg = loadConfig()) {
579
600
  try {
580
601
  const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
581
602
  const results = historyStore.search(String(body.query || ''), { limit: Number(body.limit) || 10 });
582
- return sendJson(res, 200, { ok: true, size: historyStore.size, results });
603
+ return sendJson(res, 200, { ok: true, size: historyStore.size, newest: historyStore.newest, results });
583
604
  } catch (e) {
584
605
  return sendJson(res, 400, { error: { message: `search failed: ${e.message}`, type: 'search_error' } });
585
606
  }
586
607
  }
587
608
  if (pathname === '/v1/history/status' && req.method === 'GET') {
588
- return sendJson(res, 200, { ok: true, size: historyStore.size });
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 {