chatpanel-gateway 0.1.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/LICENSE +168 -0
- package/README.md +150 -0
- package/bin/chatpanel-gateway.js +43 -0
- package/gateway.config.example.json +33 -0
- package/ner/README.md +84 -0
- package/ner/requirements.txt +3 -0
- package/ner/run.sh +21 -0
- package/ner/server.py +95 -0
- package/package.json +53 -0
- package/src/anthropic.js +75 -0
- package/src/bridge.js +118 -0
- package/src/config.js +120 -0
- package/src/configstore.js +65 -0
- package/src/entitlement.js +81 -0
- package/src/freegate.js +44 -0
- package/src/ner.js +99 -0
- package/src/openai.js +53 -0
- package/src/redact.js +57 -0
- package/src/responses.js +74 -0
- package/src/server.js +297 -0
- package/src/service.js +145 -0
- package/src/shape.js +97 -0
- package/src/stream.js +83 -0
package/src/redact.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Gateway-level redaction: blind an arbitrary set of text segments into one
|
|
2
|
+
// shared vault, so [[PERSON_1]] means the same entity across every message in a
|
|
3
|
+
// request. Protocol adapters (openai.js / anthropic.js) extract the text, call
|
|
4
|
+
// redactSegments, and splice the redacted strings back in.
|
|
5
|
+
//
|
|
6
|
+
// A fresh vault per request is correct here: coding agents resend the full
|
|
7
|
+
// history each turn and the engine assigns tokens by first-appearance, so the
|
|
8
|
+
// mapping is self-consistent within the request. (Same reasoning as the
|
|
9
|
+
// extension's pii-pipeline.)
|
|
10
|
+
|
|
11
|
+
import { createVault, redactText, detectEntities, effectiveTier, gatedDictionary } from 'chatpanel-pii';
|
|
12
|
+
|
|
13
|
+
// tier: 'basic' | 'full'. For 'full' we run the local detector over the combined
|
|
14
|
+
// text to harvest names/orgs, then redact every segment against that entity set.
|
|
15
|
+
// `isPro` applies the SAME free/Pro gating as the extension (shared package):
|
|
16
|
+
// free → deterministic 'basic' tier + a capped dictionary; Pro → full tier.
|
|
17
|
+
export async function redactSegments(segments, redactionCfg, { signal, isPro = true } = {}) {
|
|
18
|
+
const vault = createVault();
|
|
19
|
+
const texts = segments.map((s) => s.get()).filter((t) => typeof t === 'string' && t);
|
|
20
|
+
if (texts.length === 0) return { vault, count: 0 };
|
|
21
|
+
|
|
22
|
+
// effectiveTier downgrades 'full'→'basic' for free; gatedDictionary trims to the
|
|
23
|
+
// free limit. This reuses chatpanel-pii's gating so the gateway and extension
|
|
24
|
+
// enforce free/Pro identically.
|
|
25
|
+
const tier = effectiveTier({ tier: redactionCfg.tier }, isPro);
|
|
26
|
+
const dictionary = gatedDictionary(redactionCfg, isPro);
|
|
27
|
+
|
|
28
|
+
let entities = [];
|
|
29
|
+
if (tier === 'full' && redactionCfg.detection?.backend && redactionCfg.detection.backend !== 'off') {
|
|
30
|
+
// One detection pass over the joined text — the detector is cached + fail-open,
|
|
31
|
+
// so a slow/broken NER service never blocks the request (deterministic layer
|
|
32
|
+
// still runs).
|
|
33
|
+
try {
|
|
34
|
+
entities = await detectEntities(texts.join('\n\n'), { detection: redactionCfg.detection }, { signal });
|
|
35
|
+
} catch {
|
|
36
|
+
entities = [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const opts = { tier, entities, dictionary };
|
|
41
|
+
|
|
42
|
+
let count = 0;
|
|
43
|
+
for (const seg of segments) {
|
|
44
|
+
const before = seg.get();
|
|
45
|
+
if (typeof before !== 'string' || !before) continue;
|
|
46
|
+
const after = redactText(before, vault, opts);
|
|
47
|
+
if (after !== before) count++;
|
|
48
|
+
seg.set(after);
|
|
49
|
+
}
|
|
50
|
+
return { vault, count };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A `segment` is a tiny getter/setter over wherever the text lives in the parsed
|
|
54
|
+
// request body, so we can redact in place without rebuilding the structure.
|
|
55
|
+
export function segment(getter, setter) {
|
|
56
|
+
return { get: getter, set: setter };
|
|
57
|
+
}
|
package/src/responses.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// OpenAI Responses API adapter: /v1/responses (what the Codex CLI and newer
|
|
2
|
+
// OpenAI SDKs use — a different body shape from chat/completions). Without this,
|
|
3
|
+
// Codex traffic would pass through un-redacted. Forwarded to the OpenAI upstream.
|
|
4
|
+
|
|
5
|
+
import { segment } from './redact.js';
|
|
6
|
+
import { restoreDeep } from './stream.js';
|
|
7
|
+
|
|
8
|
+
export function matches(pathname) {
|
|
9
|
+
return /\/responses$/.test(pathname);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Redactable text in a Responses request lives in `instructions` (system) and
|
|
13
|
+
// `input` (a string, or an array of items whose content parts carry text).
|
|
14
|
+
function collectInputItem(item, segs) {
|
|
15
|
+
if (!item || typeof item !== 'object') return;
|
|
16
|
+
if (typeof item.content === 'string') {
|
|
17
|
+
segs.push(segment(() => item.content, (v) => { item.content = v; }));
|
|
18
|
+
} else if (Array.isArray(item.content)) {
|
|
19
|
+
for (const part of item.content) {
|
|
20
|
+
if (part && typeof part.text === 'string' && /text$/.test(part.type || 'text')) {
|
|
21
|
+
segs.push(segment(() => part.text, (v) => { part.text = v; }));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// function_call_output items carry a plain `output` string.
|
|
26
|
+
if (typeof item.output === 'string') {
|
|
27
|
+
segs.push(segment(() => item.output, (v) => { item.output = v; }));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function collectSegments(body, redactionCfg) {
|
|
32
|
+
const segs = [];
|
|
33
|
+
if (redactionCfg.redactSystem !== false && typeof body?.instructions === 'string') {
|
|
34
|
+
segs.push(segment(() => body.instructions, (v) => { body.instructions = v; }));
|
|
35
|
+
}
|
|
36
|
+
if (typeof body?.input === 'string') {
|
|
37
|
+
segs.push(segment(() => body.input, (v) => { body.input = v; }));
|
|
38
|
+
} else if (Array.isArray(body?.input)) {
|
|
39
|
+
for (const item of body.input) collectInputItem(item, segs);
|
|
40
|
+
}
|
|
41
|
+
return segs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Extract the conversation for the bridge backend. Responses carries the system
|
|
45
|
+
// prompt as `instructions` and the turn as `input` (string or item array).
|
|
46
|
+
export function toTurn(body) {
|
|
47
|
+
const system = typeof body?.instructions === 'string' ? body.instructions : '';
|
|
48
|
+
let messages = [];
|
|
49
|
+
if (typeof body?.input === 'string') {
|
|
50
|
+
messages = [{ role: 'user', content: body.input }];
|
|
51
|
+
} else if (Array.isArray(body?.input)) {
|
|
52
|
+
messages = body.input.map((it) => ({
|
|
53
|
+
role: it?.role || 'user',
|
|
54
|
+
content: it?.content != null ? it.content : (typeof it?.output === 'string' ? it.output : ''),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
return { messages, system };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Restore a buffered response: output[].content[].text, function_call args, and
|
|
61
|
+
// the `output_text` convenience field some SDKs add.
|
|
62
|
+
export function restoreResponse(json, vault) {
|
|
63
|
+
if (typeof json?.output_text === 'string') json.output_text = restoreDeep(json.output_text, vault);
|
|
64
|
+
for (const item of json?.output || []) {
|
|
65
|
+
if (!item) continue;
|
|
66
|
+
if (Array.isArray(item.content)) {
|
|
67
|
+
for (const part of item.content) {
|
|
68
|
+
if (part && typeof part.text === 'string') part.text = restoreDeep(part.text, vault);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (typeof item.arguments === 'string') item.arguments = restoreDeep(item.arguments, vault);
|
|
72
|
+
}
|
|
73
|
+
return json;
|
|
74
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// ChatPanel Privacy Gateway — a localhost server that redacts PII out of every
|
|
2
|
+
// LLM request, then restores the placeholders in the reply. The model only ever
|
|
3
|
+
// sees [[PERSON_1]] / [[EMAIL_2]] — the real values never leave the machine.
|
|
4
|
+
//
|
|
5
|
+
// Two backends (see config.js):
|
|
6
|
+
// 'bridge' — drive the ChatPanel bridge's subscription-authed CLI agents
|
|
7
|
+
// (codex/claude/opencode/pi). This is the privacy-bridge-between-
|
|
8
|
+
// agents path: opencode → gateway (redact) → bridge → codex → restore.
|
|
9
|
+
// 'api' — forward redacted traffic to a native provider API (local models,
|
|
10
|
+
// BYO keys). The client's own auth header is passed through verbatim.
|
|
11
|
+
//
|
|
12
|
+
// GET /health → { ok, version, backend, tier }
|
|
13
|
+
// GET /v1/models → list the agent(s) this gateway exposes
|
|
14
|
+
// POST /v1/chat/completions → OpenAI protocol
|
|
15
|
+
// POST /v1/responses → OpenAI Responses protocol (Codex)
|
|
16
|
+
// POST /v1/messages → Anthropic protocol
|
|
17
|
+
//
|
|
18
|
+
// Binds 127.0.0.1 only and enforces a loopback Host (anti DNS-rebinding).
|
|
19
|
+
|
|
20
|
+
import { createServer } from 'node:http';
|
|
21
|
+
import { loadConfig } from './config.js';
|
|
22
|
+
import { redactSegments } from './redact.js';
|
|
23
|
+
import { pipeRestoredStream, makeTokenRestorer } from './stream.js';
|
|
24
|
+
import { restoreText } from 'chatpanel-pii';
|
|
25
|
+
import { streamBridgeChat, readBridgeToken } from './bridge.js';
|
|
26
|
+
import { shaperFor } from './shape.js';
|
|
27
|
+
import { startNer } from './ner.js';
|
|
28
|
+
import { resolvePro, meter, usage } from './freegate.js';
|
|
29
|
+
import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
|
|
30
|
+
import * as openai from './openai.js';
|
|
31
|
+
import * as responses from './responses.js';
|
|
32
|
+
import * as anthropic from './anthropic.js';
|
|
33
|
+
|
|
34
|
+
export const VERSION = '0.1.0';
|
|
35
|
+
|
|
36
|
+
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
37
|
+
|
|
38
|
+
const HOP_BY_HOP = new Set([
|
|
39
|
+
'host', 'connection', 'content-length', 'transfer-encoding',
|
|
40
|
+
'accept-encoding', 'content-encoding', 'keep-alive',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function isLoopbackHost(host) {
|
|
44
|
+
if (!host) return false;
|
|
45
|
+
const name = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
|
|
46
|
+
return name === 'localhost' || name === '127.0.0.1' || name === '::1';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Local CLI clients send no Origin; browsers always do. We allow the trusted
|
|
50
|
+
// local UIs (the ChatPanel extension + localhost) and anything the operator
|
|
51
|
+
// allowlists — and reject every other web origin, so a malicious page can't drive
|
|
52
|
+
// the gateway (and thus codex). Mirrors the bridge's origin model.
|
|
53
|
+
function originAllowed(origin, cfg) {
|
|
54
|
+
if (!origin) return true; // no Origin → a local process (opencode/codex/SDK)
|
|
55
|
+
if (/^chrome-extension:\/\//.test(origin) || /^moz-extension:\/\//.test(origin)) return true;
|
|
56
|
+
if (/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(origin)) return true;
|
|
57
|
+
return Array.isArray(cfg.allowedOrigins) && cfg.allowedOrigins.includes(origin);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setCors(res, origin) {
|
|
61
|
+
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
62
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
63
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-ChatPanel-Token');
|
|
64
|
+
res.setHeader('Vary', 'Origin');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const STARTED_AT = Date.now();
|
|
68
|
+
|
|
69
|
+
// Classify a request: which protocol kind + adapter, whether it's a redactable
|
|
70
|
+
// chat endpoint, and (api backend) which upstream base URL.
|
|
71
|
+
function route(pathname, headers, cfg) {
|
|
72
|
+
if (anthropic.matches(pathname) || 'anthropic-version' in headers) {
|
|
73
|
+
return { kind: 'anthropic', adapter: anthropic, redactable: anthropic.matches(pathname), base: cfg.upstreams.anthropic.baseUrl };
|
|
74
|
+
}
|
|
75
|
+
if (responses.matches(pathname)) {
|
|
76
|
+
return { kind: 'responses', adapter: responses, redactable: true, base: cfg.upstreams.openai.baseUrl };
|
|
77
|
+
}
|
|
78
|
+
return { kind: 'openai', adapter: openai, redactable: openai.matches(pathname), base: cfg.upstreams.openai.baseUrl };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pickAgent(model, cfg) {
|
|
82
|
+
return KNOWN_AGENTS.has(model) ? model : cfg.bridge.agent;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readBody(req, maxBytes) {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let size = 0;
|
|
88
|
+
for await (const c of req) {
|
|
89
|
+
size += c.length;
|
|
90
|
+
if (maxBytes && size > maxBytes) { const e = new Error('payload too large'); e.code = 413; throw e; }
|
|
91
|
+
chunks.push(c);
|
|
92
|
+
}
|
|
93
|
+
return Buffer.concat(chunks);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sendJson(res, status, obj) {
|
|
97
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
98
|
+
res.end(JSON.stringify(obj));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function forwardHeaders(headers, base) {
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
104
|
+
if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
|
105
|
+
}
|
|
106
|
+
out['accept-encoding'] = 'identity'; // must read plain text to restore tokens
|
|
107
|
+
try { out.host = new URL(base).host; } catch { /* leave unset */ }
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- backend: bridge -------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async function handleBridge(req, res, { kind, adapter, redactable, pathname }, body, vault, cfg) {
|
|
114
|
+
if (!redactable) {
|
|
115
|
+
if (/\/models$/.test(pathname)) {
|
|
116
|
+
const agents = [...new Set([cfg.bridge.agent, 'codex', 'claude', 'opencode', 'pi'])];
|
|
117
|
+
return sendJson(res, 200, { object: 'list', data: agents.map((id) => ({ id, object: 'model', owned_by: 'chatpanel-bridge' })) });
|
|
118
|
+
}
|
|
119
|
+
return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { messages, system } = adapter.toTurn(body);
|
|
123
|
+
const agent = pickAgent(body?.model, cfg);
|
|
124
|
+
const wantStream = body?.stream === true;
|
|
125
|
+
const shaper = shaperFor(kind, body?.model || agent);
|
|
126
|
+
const token = readBridgeToken(cfg.bridge.token);
|
|
127
|
+
const ac = new AbortController();
|
|
128
|
+
req.on('close', () => ac.abort());
|
|
129
|
+
|
|
130
|
+
const turn = { bridgeUrl: cfg.bridge.url, agent, token, messages, system, signal: ac.signal };
|
|
131
|
+
|
|
132
|
+
if (!wantStream) {
|
|
133
|
+
try {
|
|
134
|
+
let full = '';
|
|
135
|
+
await streamBridgeChat(turn, (t) => { full += t; });
|
|
136
|
+
res.writeHead(200, { 'content-type': shaper.contentType });
|
|
137
|
+
return res.end(shaper.full(restoreText(full, vault)));
|
|
138
|
+
} catch (e) {
|
|
139
|
+
return sendJson(res, 502, { error: { message: `bridge backend failed: ${e.message}`, type: 'bridge_error' } });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
144
|
+
res.write(shaper.sseHead());
|
|
145
|
+
const restorer = makeTokenRestorer(vault);
|
|
146
|
+
try {
|
|
147
|
+
await streamBridgeChat(turn, (chunk) => {
|
|
148
|
+
const restored = restorer.push(chunk);
|
|
149
|
+
if (restored) res.write(shaper.sseDelta(restored));
|
|
150
|
+
});
|
|
151
|
+
const tail = restorer.flush();
|
|
152
|
+
if (tail) res.write(shaper.sseDelta(tail));
|
|
153
|
+
res.write(shaper.sseTail());
|
|
154
|
+
} catch (e) {
|
|
155
|
+
res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`);
|
|
156
|
+
}
|
|
157
|
+
res.end();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- backend: api ----------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
async function handleApi(req, res, { adapter, pathname, search, base }, outBody, vault) {
|
|
163
|
+
let upstream;
|
|
164
|
+
try {
|
|
165
|
+
upstream = await fetch(base.replace(/\/$/, '') + pathname + search, {
|
|
166
|
+
method: req.method,
|
|
167
|
+
headers: forwardHeaders(req.headers, base),
|
|
168
|
+
body: ['GET', 'HEAD'].includes(req.method) ? undefined : outBody,
|
|
169
|
+
});
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return sendJson(res, 502, { error: `upstream fetch failed: ${e.message}` });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const ct = upstream.headers.get('content-type') || '';
|
|
175
|
+
const resHeaders = {};
|
|
176
|
+
upstream.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) resHeaders[k] = v; });
|
|
177
|
+
|
|
178
|
+
if (ct.includes('text/event-stream') && upstream.body) {
|
|
179
|
+
res.writeHead(upstream.status, resHeaders);
|
|
180
|
+
return pipeRestoredStream(upstream.body, res, vault);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
184
|
+
if (vault && ct.includes('application/json')) {
|
|
185
|
+
try {
|
|
186
|
+
const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault);
|
|
187
|
+
res.writeHead(upstream.status, { ...resHeaders, 'content-type': 'application/json' });
|
|
188
|
+
return res.end(Buffer.from(JSON.stringify(json), 'utf8'));
|
|
189
|
+
} catch { /* fall through */ }
|
|
190
|
+
}
|
|
191
|
+
res.writeHead(upstream.status, resHeaders);
|
|
192
|
+
res.end(buf);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- server ----------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
export function createGateway(cfg = loadConfig()) {
|
|
198
|
+
return createServer(async (req, res) => {
|
|
199
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
200
|
+
const pathname = url.pathname;
|
|
201
|
+
|
|
202
|
+
if (!isLoopbackHost(req.headers.host)) return sendJson(res, 403, { error: 'loopback only' });
|
|
203
|
+
if (!originAllowed(req.headers.origin, cfg)) return sendJson(res, 403, { error: 'origin not allowed' });
|
|
204
|
+
|
|
205
|
+
// CORS for the trusted local UIs (extension/localhost). Preflight ends here.
|
|
206
|
+
if (req.headers.origin) setCors(res, req.headers.origin);
|
|
207
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
|
|
208
|
+
|
|
209
|
+
if (req.method === 'GET' && pathname === '/health') {
|
|
210
|
+
return sendJson(res, 200, { ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Config API (the extension's "Gateway" tab is a client of these) ---
|
|
214
|
+
if (pathname === '/status' && req.method === 'GET') {
|
|
215
|
+
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
216
|
+
const nerOn = cfg.redaction?.detection?.backend && cfg.redaction.detection.backend !== 'off';
|
|
217
|
+
return sendJson(res, 200, {
|
|
218
|
+
ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
|
|
219
|
+
ner: { autostart: !!cfg.ner?.autostart, ready: !!nerOn },
|
|
220
|
+
pro: { unlocked: proUnlocked }, usage: usage(cfg),
|
|
221
|
+
uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (pathname === '/config' && req.method === 'GET') {
|
|
225
|
+
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
226
|
+
return sendJson(res, 200, publicConfig(cfg, { proUnlocked }));
|
|
227
|
+
}
|
|
228
|
+
if (pathname === '/config' && req.method === 'POST') {
|
|
229
|
+
let patch = null;
|
|
230
|
+
try { patch = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { patch = null; }
|
|
231
|
+
if (!patch || typeof patch !== 'object') return sendJson(res, 400, { error: 'invalid config patch' });
|
|
232
|
+
applyConfigPatch(cfg, patch);
|
|
233
|
+
try { persistConfig(cfg, configPath()); } catch (e) { return sendJson(res, 500, { error: `could not persist config: ${e.message}` }); }
|
|
234
|
+
const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
|
|
235
|
+
return sendJson(res, 200, publicConfig(cfg, { proUnlocked }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const r = route(pathname, req.headers, cfg);
|
|
239
|
+
let raw;
|
|
240
|
+
try {
|
|
241
|
+
raw = await readBody(req, cfg.maxBodyBytes);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
sendJson(res, e.code === 413 ? 413 : 400, { error: e.code === 413 ? 'payload too large' : 'bad request' });
|
|
244
|
+
req.destroy(); // stop reading an oversized/aborted upload; don't leave the socket half-open
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Redact the request body for the known chat endpoints.
|
|
249
|
+
let vault = null;
|
|
250
|
+
let body = null;
|
|
251
|
+
let outBody = raw;
|
|
252
|
+
if (r.redactable && req.method === 'POST' && raw.length) {
|
|
253
|
+
try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
|
|
254
|
+
if (body) {
|
|
255
|
+
// Free/Pro gate: meter the request and pick the effective tier.
|
|
256
|
+
const isPro = await resolvePro(cfg.pro?.entitlementToken);
|
|
257
|
+
const allow = meter(cfg, isPro);
|
|
258
|
+
if (!allow.allowed) {
|
|
259
|
+
return sendJson(res, 402, { error: {
|
|
260
|
+
message: `ChatPanel Gateway free limit reached (${allow.cap}/day). Add a ChatPanel Pro entitlement token to unlock unlimited usage + full-tier redaction (names/orgs).`,
|
|
261
|
+
type: 'free_limit_reached',
|
|
262
|
+
} });
|
|
263
|
+
}
|
|
264
|
+
const segs = r.adapter.collectSegments(body, cfg.redaction);
|
|
265
|
+
const ac = new AbortController();
|
|
266
|
+
req.on('close', () => ac.abort());
|
|
267
|
+
const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
|
|
268
|
+
vault = v;
|
|
269
|
+
outBody = Buffer.from(JSON.stringify(body), 'utf8');
|
|
270
|
+
if (cfg.logRequests) console.log(`[gateway] ${req.method} ${pathname} · redacted ${count}/${segs.length} segment(s) · ${cfg.backend}`);
|
|
271
|
+
}
|
|
272
|
+
} else if (cfg.logRequests) {
|
|
273
|
+
console.log(`[gateway] ${req.method} ${pathname} · ${cfg.backend}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (cfg.backend === 'bridge') {
|
|
277
|
+
return handleBridge(req, res, { ...r, pathname }, body, vault, cfg);
|
|
278
|
+
}
|
|
279
|
+
if (!r.base) return sendJson(res, 502, { error: 'no upstream configured' });
|
|
280
|
+
return handleApi(req, res, { ...r, pathname, search: url.search }, outBody, vault);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function start(cfg = loadConfig()) {
|
|
285
|
+
const server = createGateway(cfg);
|
|
286
|
+
const ner = startNer(cfg); // may mutate cfg.redaction when it comes up
|
|
287
|
+
server.listen(cfg.port, cfg.host, () => {
|
|
288
|
+
console.log(`ChatPanel Privacy Gateway v${VERSION} on http://${cfg.host}:${cfg.port}`);
|
|
289
|
+
console.log(` backend : ${cfg.backend}` + (cfg.backend === 'bridge' ? ` (agent: ${cfg.bridge.agent}, via ${cfg.bridge.url})` : ''));
|
|
290
|
+
console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
|
|
291
|
+
? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
|
|
292
|
+
});
|
|
293
|
+
const shutdown = () => { ner?.stop(); server.close(() => process.exit(0)); };
|
|
294
|
+
process.on('SIGINT', shutdown);
|
|
295
|
+
process.on('SIGTERM', shutdown);
|
|
296
|
+
return server;
|
|
297
|
+
}
|
package/src/service.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Background auto-start for the ChatPanel Privacy Gateway — same approach as the
|
|
2
|
+
// bridge, so it can run as an always-on login process with no terminal.
|
|
3
|
+
//
|
|
4
|
+
// chatpanel-gateway --install register login auto-start + start now
|
|
5
|
+
// chatpanel-gateway --uninstall remove it
|
|
6
|
+
// chatpanel-gateway --status is it registered?
|
|
7
|
+
//
|
|
8
|
+
// macOS → LaunchAgent · Windows → HKCU Run (hidden VBS) · Linux → systemd user.
|
|
9
|
+
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
const LABEL = 'net.chatpanel.gateway';
|
|
16
|
+
const DISPLAY = 'ChatPanel Privacy Gateway';
|
|
17
|
+
|
|
18
|
+
// The command that launches THIS gateway. A compiled single-file binary launches
|
|
19
|
+
// itself; running under node launches the interpreter + the bin entry.
|
|
20
|
+
export function resolveLaunch() {
|
|
21
|
+
const exe = process.execPath;
|
|
22
|
+
const base = path.basename(exe).toLowerCase();
|
|
23
|
+
const underInterpreter = base.startsWith('node') || base.startsWith('bun');
|
|
24
|
+
if (underInterpreter && process.argv[1]) {
|
|
25
|
+
return { program: exe, args: [path.resolve(process.argv[1])] };
|
|
26
|
+
}
|
|
27
|
+
return { program: exe, args: [] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function logPaths() {
|
|
31
|
+
const dir = path.join(os.homedir(), '.chatpanel');
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
return { out: path.join(dir, 'gateway.log'), err: path.join(dir, 'gateway.err.log') };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function run(cmd, args, opts = {}) {
|
|
37
|
+
return spawnSync(cmd, args, { encoding: 'utf8', ...opts });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------- macOS
|
|
41
|
+
const macPlist = () => path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
42
|
+
|
|
43
|
+
function macInstall() {
|
|
44
|
+
const { program, args } = resolveLaunch();
|
|
45
|
+
const { out, err } = logPaths();
|
|
46
|
+
const progArgs = [program, ...args].map((a) => ` <string>${a}</string>`).join('\n');
|
|
47
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
48
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
49
|
+
<plist version="1.0">
|
|
50
|
+
<dict>
|
|
51
|
+
<key>Label</key><string>${LABEL}</string>
|
|
52
|
+
<key>ProgramArguments</key>
|
|
53
|
+
<array>
|
|
54
|
+
${progArgs}
|
|
55
|
+
</array>
|
|
56
|
+
<key>RunAtLoad</key><true/>
|
|
57
|
+
<key>KeepAlive</key><true/>
|
|
58
|
+
<key>StandardOutPath</key><string>${out}</string>
|
|
59
|
+
<key>StandardErrorPath</key><string>${err}</string>
|
|
60
|
+
</dict>
|
|
61
|
+
</plist>
|
|
62
|
+
`;
|
|
63
|
+
const p = macPlist();
|
|
64
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
65
|
+
writeFileSync(p, plist);
|
|
66
|
+
run('launchctl', ['unload', p]);
|
|
67
|
+
const r = run('launchctl', ['load', '-w', p]);
|
|
68
|
+
if (r.status !== 0) throw new Error((r.stderr || '').trim() || 'launchctl load failed');
|
|
69
|
+
}
|
|
70
|
+
function macUninstall() {
|
|
71
|
+
const p = macPlist();
|
|
72
|
+
run('launchctl', ['unload', '-w', p]);
|
|
73
|
+
if (existsSync(p)) rmSync(p);
|
|
74
|
+
}
|
|
75
|
+
function macStatus() {
|
|
76
|
+
return (run('launchctl', ['list']).stdout || '').includes(LABEL);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------- Windows
|
|
80
|
+
const WIN_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
81
|
+
const WIN_RUN_NAME = 'ChatPanelGateway';
|
|
82
|
+
const winVbs = () => path.join(os.homedir(), '.chatpanel', 'gateway-launch.vbs');
|
|
83
|
+
|
|
84
|
+
function winInstall() {
|
|
85
|
+
const { program, args } = resolveLaunch();
|
|
86
|
+
const parts = [program, ...args].map((p) => `""${p}""`).join(' ');
|
|
87
|
+
const vbs = winVbs();
|
|
88
|
+
mkdirSync(path.dirname(vbs), { recursive: true });
|
|
89
|
+
writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${parts}", 0, False\r\n`);
|
|
90
|
+
const r = run('reg', ['add', WIN_RUN_KEY, '/v', WIN_RUN_NAME, '/t', 'REG_SZ', '/d', `wscript.exe "${vbs}"`, '/f']);
|
|
91
|
+
if (r.status !== 0) throw new Error((r.stderr || '').trim() || 'reg add failed');
|
|
92
|
+
run('wscript.exe', [vbs]);
|
|
93
|
+
}
|
|
94
|
+
function winUninstall() {
|
|
95
|
+
run('reg', ['delete', WIN_RUN_KEY, '/v', WIN_RUN_NAME, '/f']);
|
|
96
|
+
run('taskkill', ['/IM', 'chatpanel-gateway.exe', '/F']);
|
|
97
|
+
}
|
|
98
|
+
function winStatus() {
|
|
99
|
+
return run('reg', ['query', WIN_RUN_KEY, '/v', WIN_RUN_NAME]).status === 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------- Linux (systemd user)
|
|
103
|
+
const linUnit = () => path.join(os.homedir(), '.config', 'systemd', 'user', 'chatpanel-gateway.service');
|
|
104
|
+
|
|
105
|
+
function linInstall() {
|
|
106
|
+
const { program, args } = resolveLaunch();
|
|
107
|
+
const exec = [program, ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
|
|
108
|
+
const unit = `[Unit]
|
|
109
|
+
Description=${DISPLAY}
|
|
110
|
+
After=network.target
|
|
111
|
+
|
|
112
|
+
[Service]
|
|
113
|
+
ExecStart=${exec}
|
|
114
|
+
Restart=on-failure
|
|
115
|
+
|
|
116
|
+
[Install]
|
|
117
|
+
WantedBy=default.target
|
|
118
|
+
`;
|
|
119
|
+
const p = linUnit();
|
|
120
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
121
|
+
writeFileSync(p, unit);
|
|
122
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
123
|
+
const r = run('systemctl', ['--user', 'enable', '--now', 'chatpanel-gateway']);
|
|
124
|
+
if (r.status !== 0) throw new Error((r.stderr || '').trim() || 'systemctl enable failed');
|
|
125
|
+
}
|
|
126
|
+
function linUninstall() {
|
|
127
|
+
run('systemctl', ['--user', 'disable', '--now', 'chatpanel-gateway']);
|
|
128
|
+
const p = linUnit();
|
|
129
|
+
if (existsSync(p)) rmSync(p);
|
|
130
|
+
}
|
|
131
|
+
function linStatus() {
|
|
132
|
+
return (run('systemctl', ['--user', 'is-enabled', 'chatpanel-gateway']).stdout || '').trim() === 'enabled';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------- dispatch
|
|
136
|
+
function byPlatform(mac, win, lin) {
|
|
137
|
+
if (process.platform === 'darwin') return mac();
|
|
138
|
+
if (process.platform === 'win32') return win();
|
|
139
|
+
if (process.platform === 'linux') return lin();
|
|
140
|
+
throw new Error(`Auto-start isn't supported on ${process.platform} yet — run the gateway directly.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function installService() { return byPlatform(macInstall, winInstall, linInstall); }
|
|
144
|
+
export function uninstallService() { return byPlatform(macUninstall, winUninstall, linUninstall); }
|
|
145
|
+
export function serviceStatus() { return byPlatform(macStatus, winStatus, linStatus); }
|
package/src/shape.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Shape bridge text (already restored) into each client protocol — both a
|
|
2
|
+
// non-streaming body and an SSE event sequence. Used only by the bridge backend,
|
|
3
|
+
// where the gateway synthesizes the provider response itself.
|
|
4
|
+
//
|
|
5
|
+
// Each shaper exposes:
|
|
6
|
+
// contentType 'application/json' | 'text/event-stream'
|
|
7
|
+
// full(text) -> string non-streaming JSON body
|
|
8
|
+
// sseHead() -> string opening SSE events (may be '')
|
|
9
|
+
// sseDelta(text) -> string one chunk of text
|
|
10
|
+
// sseTail() -> string closing SSE events
|
|
11
|
+
|
|
12
|
+
function id(prefix) {
|
|
13
|
+
// Runtime (not a workflow) — Date.now()/random are fine here.
|
|
14
|
+
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
15
|
+
}
|
|
16
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
17
|
+
const sse = (obj) => `data: ${JSON.stringify(obj)}\n\n`;
|
|
18
|
+
const event = (name, obj) => `event: ${name}\n` + sse(obj);
|
|
19
|
+
|
|
20
|
+
export function openaiChat(model) {
|
|
21
|
+
const rid = id('chatcmpl');
|
|
22
|
+
const base = { id: rid, object: 'chat.completion.chunk', created: now(), model };
|
|
23
|
+
return {
|
|
24
|
+
contentType: 'application/json',
|
|
25
|
+
full(text) {
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
id: rid, object: 'chat.completion', created: now(), model,
|
|
28
|
+
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
|
|
29
|
+
usage: {},
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
sseHead() {
|
|
33
|
+
return sse({ ...base, choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] });
|
|
34
|
+
},
|
|
35
|
+
sseDelta(text) {
|
|
36
|
+
return sse({ ...base, choices: [{ index: 0, delta: { content: text }, finish_reason: null }] });
|
|
37
|
+
},
|
|
38
|
+
sseTail() {
|
|
39
|
+
return sse({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }) + 'data: [DONE]\n\n';
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function openaiResponses(model) {
|
|
45
|
+
const rid = id('resp');
|
|
46
|
+
return {
|
|
47
|
+
contentType: 'application/json',
|
|
48
|
+
full(text) {
|
|
49
|
+
return JSON.stringify({
|
|
50
|
+
id: rid, object: 'response', created_at: now(), model, status: 'completed',
|
|
51
|
+
output: [{ id: id('msg'), type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] }],
|
|
52
|
+
output_text: text,
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
sseHead() {
|
|
56
|
+
return event('response.created', { type: 'response.created', response: { id: rid, status: 'in_progress', model } });
|
|
57
|
+
},
|
|
58
|
+
sseDelta(text) {
|
|
59
|
+
return event('response.output_text.delta', { type: 'response.output_text.delta', delta: text });
|
|
60
|
+
},
|
|
61
|
+
sseTail() {
|
|
62
|
+
return event('response.completed', { type: 'response.completed', response: { id: rid, status: 'completed', model } });
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function anthropicMessages(model) {
|
|
68
|
+
const rid = id('msg');
|
|
69
|
+
return {
|
|
70
|
+
contentType: 'application/json',
|
|
71
|
+
full(text) {
|
|
72
|
+
return JSON.stringify({
|
|
73
|
+
id: rid, type: 'message', role: 'assistant', model,
|
|
74
|
+
content: [{ type: 'text', text }], stop_reason: 'end_turn', stop_sequence: null, usage: {},
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
sseHead() {
|
|
78
|
+
return event('message_start', { type: 'message_start', message: { id: rid, type: 'message', role: 'assistant', model, content: [], stop_reason: null, usage: {} } })
|
|
79
|
+
+ event('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
|
|
80
|
+
},
|
|
81
|
+
sseDelta(text) {
|
|
82
|
+
return event('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } });
|
|
83
|
+
},
|
|
84
|
+
sseTail() {
|
|
85
|
+
return event('content_block_stop', { type: 'content_block_stop', index: 0 })
|
|
86
|
+
+ event('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: {} })
|
|
87
|
+
+ event('message_stop', { type: 'message_stop' });
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Pick a shaper for a parsed request + its adapter name.
|
|
93
|
+
export function shaperFor(kind, model) {
|
|
94
|
+
if (kind === 'anthropic') return anthropicMessages(model);
|
|
95
|
+
if (kind === 'responses') return openaiResponses(model);
|
|
96
|
+
return openaiChat(model);
|
|
97
|
+
}
|