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/anthropic.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Anthropic-protocol adapter: /v1/messages (what Claude Code speaks). Same shape
|
|
2
|
+
// as the OpenAI adapter — collect in-place text segments from the request,
|
|
3
|
+
// restore tokens in a non-streaming response. Streaming is handled generically.
|
|
4
|
+
|
|
5
|
+
import { segment } from './redact.js';
|
|
6
|
+
import { restoreDeep } from './stream.js';
|
|
7
|
+
|
|
8
|
+
export function matches(pathname) {
|
|
9
|
+
return /\/messages$/.test(pathname);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Push segments for a content field that may be a string or an array of blocks
|
|
13
|
+
// ({type:'text',text}, {type:'tool_result',content}, …).
|
|
14
|
+
function collectContent(content, segs) {
|
|
15
|
+
if (typeof content === 'string') {
|
|
16
|
+
// Can't set a primitive in place; caller wraps string content separately.
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (!Array.isArray(content)) return;
|
|
20
|
+
for (const block of content) {
|
|
21
|
+
if (!block) continue;
|
|
22
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
23
|
+
segs.push(segment(() => block.text, (v) => { block.text = v; }));
|
|
24
|
+
} else if (block.type === 'tool_result') {
|
|
25
|
+
collectContent(block.content, segs);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function collectSegments(body, redactionCfg) {
|
|
31
|
+
const segs = [];
|
|
32
|
+
|
|
33
|
+
// Top-level system prompt: string or array of {type:'text',text} blocks.
|
|
34
|
+
if (redactionCfg.redactSystem !== false && body) {
|
|
35
|
+
if (typeof body.system === 'string') {
|
|
36
|
+
segs.push(segment(() => body.system, (v) => { body.system = v; }));
|
|
37
|
+
} else if (Array.isArray(body.system)) {
|
|
38
|
+
collectContent(body.system, segs);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const m of Array.isArray(body?.messages) ? body.messages : []) {
|
|
43
|
+
if (!m) continue;
|
|
44
|
+
if (typeof m.content === 'string') {
|
|
45
|
+
segs.push(segment(() => m.content, (v) => { m.content = v; }));
|
|
46
|
+
} else {
|
|
47
|
+
collectContent(m.content, segs);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return segs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Extract the conversation for the bridge backend. Anthropic carries the system
|
|
54
|
+
// prompt at the top level (string or text blocks) — flatten it to a string.
|
|
55
|
+
export function toTurn(body) {
|
|
56
|
+
let system = '';
|
|
57
|
+
if (typeof body?.system === 'string') system = body.system;
|
|
58
|
+
else if (Array.isArray(body?.system)) {
|
|
59
|
+
system = body.system.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
|
|
60
|
+
}
|
|
61
|
+
return { messages: Array.isArray(body?.messages) ? body.messages : [], system };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Restore a buffered response: text blocks + tool_use input objects.
|
|
65
|
+
export function restoreResponse(json, vault) {
|
|
66
|
+
for (const block of json?.content || []) {
|
|
67
|
+
if (!block) continue;
|
|
68
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
69
|
+
block.text = restoreDeep(block.text, vault);
|
|
70
|
+
} else if (block.type === 'tool_use' && block.input) {
|
|
71
|
+
block.input = restoreDeep(block.input, vault);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return json;
|
|
75
|
+
}
|
package/src/bridge.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Bridge backend: drive the ChatPanel bridge's subscription-authed CLI agents
|
|
2
|
+
// (codex / claude / opencode / pi) instead of a pay-per-token provider API. This
|
|
3
|
+
// is what lets opencode talk to codex-behind-your-ChatGPT-login THROUGH the
|
|
4
|
+
// gateway, with redaction in the middle.
|
|
5
|
+
//
|
|
6
|
+
// gateway → POST http://127.0.0.1:4319/chat { agent, messages, system }
|
|
7
|
+
// ← SSE { type:'delta'|'tool'|'reasoning'|'status'|'done'|'error' }
|
|
8
|
+
//
|
|
9
|
+
// We only surface the model's *text* (delta/done) to the caller; the agent's own
|
|
10
|
+
// tool/reasoning events are its local side effects. Auth uses the bridge's
|
|
11
|
+
// per-install bearer token (~/.chatpanel/bridge-token), the same token a
|
|
12
|
+
// non-browser local client is expected to present.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TOKEN_PATH = join(os.homedir(), '.chatpanel', 'bridge-token');
|
|
19
|
+
|
|
20
|
+
export function readBridgeToken(cfgToken, tokenPath = DEFAULT_TOKEN_PATH) {
|
|
21
|
+
if (cfgToken) return cfgToken;
|
|
22
|
+
try {
|
|
23
|
+
if (existsSync(tokenPath)) return readFileSync(tokenPath, 'utf8').trim();
|
|
24
|
+
} catch { /* ignore */ }
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Flatten an OpenAI/Anthropic message's content (string | parts[]) to plain text
|
|
29
|
+
// for the bridge, which expects string content. Image parts are dropped here (the
|
|
30
|
+
// bridge takes images separately; wire that later if needed).
|
|
31
|
+
function flattenContent(content) {
|
|
32
|
+
if (typeof content === 'string') return content;
|
|
33
|
+
if (Array.isArray(content)) {
|
|
34
|
+
return content
|
|
35
|
+
.map((p) => (typeof p === 'string' ? p : (typeof p?.text === 'string' ? p.text : '')))
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join('\n');
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toBridgeMessages(messages) {
|
|
43
|
+
return (messages || [])
|
|
44
|
+
.filter((m) => m && (m.role === 'user' || m.role === 'assistant' || m.role === 'system'))
|
|
45
|
+
.map((m) => ({ role: m.role, content: flattenContent(m.content) }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Stream a turn through the bridge. Calls onText(restorableChunk) for each delta
|
|
49
|
+
// of model text and returns the full (un-restored) text. Throws on bridge error.
|
|
50
|
+
export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal }, onText) {
|
|
51
|
+
const res = await fetch(`${bridgeUrl.replace(/\/$/, '')}/chat`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'content-type': 'application/json',
|
|
55
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
agent,
|
|
59
|
+
messages: toBridgeMessages(messages),
|
|
60
|
+
system: system || '',
|
|
61
|
+
options: options || {},
|
|
62
|
+
}),
|
|
63
|
+
signal,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!res.ok || !res.body) {
|
|
67
|
+
const detail = await res.text().catch(() => '');
|
|
68
|
+
throw new Error(`bridge /chat HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const reader = res.body.getReader();
|
|
72
|
+
const decoder = new TextDecoder();
|
|
73
|
+
let buf = '';
|
|
74
|
+
let full = '';
|
|
75
|
+
let streamed = false;
|
|
76
|
+
let err = null;
|
|
77
|
+
|
|
78
|
+
const handleEvent = (block) => {
|
|
79
|
+
// SSE: lines of "data: <json>" (the bridge emits one JSON object per event).
|
|
80
|
+
for (const line of block.split('\n')) {
|
|
81
|
+
const s = line.trim();
|
|
82
|
+
if (!s.startsWith('data:')) continue;
|
|
83
|
+
const payload = s.slice(5).trim();
|
|
84
|
+
if (!payload || payload === '[DONE]') continue;
|
|
85
|
+
let evt;
|
|
86
|
+
try { evt = JSON.parse(payload); } catch { continue; }
|
|
87
|
+
if (evt.type === 'delta' && typeof evt.text === 'string') {
|
|
88
|
+
streamed = true;
|
|
89
|
+
full += evt.text;
|
|
90
|
+
onText(evt.text);
|
|
91
|
+
} else if (evt.type === 'done') {
|
|
92
|
+
// Some engines only deliver the full text at the end (not streamed).
|
|
93
|
+
if (!streamed && typeof evt.text === 'string' && evt.text) {
|
|
94
|
+
full += evt.text;
|
|
95
|
+
onText(evt.text);
|
|
96
|
+
}
|
|
97
|
+
} else if (evt.type === 'error') {
|
|
98
|
+
err = new Error(evt.error || 'bridge error');
|
|
99
|
+
}
|
|
100
|
+
// tool / reasoning / status events are the agent's local side effects — ignore.
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
for (;;) {
|
|
105
|
+
const { done, value } = await reader.read();
|
|
106
|
+
if (done) break;
|
|
107
|
+
buf += decoder.decode(value, { stream: true });
|
|
108
|
+
let idx;
|
|
109
|
+
// Events are separated by a blank line.
|
|
110
|
+
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
|
111
|
+
handleEvent(buf.slice(0, idx));
|
|
112
|
+
buf = buf.slice(idx + 2);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (buf.trim()) handleEvent(buf);
|
|
116
|
+
if (err) throw err;
|
|
117
|
+
return full;
|
|
118
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Gateway configuration.
|
|
2
|
+
//
|
|
3
|
+
// Loads, in order of precedence: a JSON file (CHATPANEL_GATEWAY_CONFIG or
|
|
4
|
+
// ./gateway.config.json) < environment variables. The gateway forwards the
|
|
5
|
+
// CLIENT's own auth header upstream (it stores no provider keys), so config here
|
|
6
|
+
// is only routing + the redaction policy.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
host: '127.0.0.1',
|
|
13
|
+
port: 4320,
|
|
14
|
+
|
|
15
|
+
// 'bridge' = drive the ChatPanel bridge's subscription-authed CLI agents
|
|
16
|
+
// (codex/claude/opencode/pi) — the privacy-bridge-between-agents use
|
|
17
|
+
// case (opencode → gateway → codex). No API keys; uses your login.
|
|
18
|
+
// 'api' = forward redacted traffic to a native provider API (local models,
|
|
19
|
+
// BYO keys, OpenRouter, …). Client passes its own auth through.
|
|
20
|
+
backend: 'bridge',
|
|
21
|
+
|
|
22
|
+
// Security: legitimate clients (opencode/pi/codex/SDKs) are local processes that
|
|
23
|
+
// send NO Origin header. A browser always attaches one. So we REJECT any request
|
|
24
|
+
// bearing an Origin not in this allowlist — this stops a malicious web page from
|
|
25
|
+
// POSTing to the gateway and driving codex (drive-by / CSRF). Leave empty to
|
|
26
|
+
// block all browser-origin traffic. `maxBodyBytes` caps request size (DoS guard).
|
|
27
|
+
allowedOrigins: [],
|
|
28
|
+
maxBodyBytes: 26214400,
|
|
29
|
+
|
|
30
|
+
// Monetization: the gateway is free to try, paid to rely on. Free = deterministic
|
|
31
|
+
// redaction (basic tier) + a daily request cap. Paste a ChatPanel Pro entitlement
|
|
32
|
+
// token (the same offline-signed token the extension/bridge use) to unlock
|
|
33
|
+
// full-tier redaction (NER names/orgs + full dictionary) and unlimited usage.
|
|
34
|
+
pro: {
|
|
35
|
+
entitlementToken: '',
|
|
36
|
+
free: {
|
|
37
|
+
maxRequestsPerDay: 25,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
bridge: {
|
|
42
|
+
url: 'http://127.0.0.1:4319',
|
|
43
|
+
// Default agent the bridge drives. A request's `model` field may also name an
|
|
44
|
+
// agent (codex/claude/opencode/pi) to override per-call.
|
|
45
|
+
agent: 'codex',
|
|
46
|
+
// Bearer token for the bridge's privileged /chat route. Empty = read the
|
|
47
|
+
// per-install token from ~/.chatpanel/bridge-token.
|
|
48
|
+
token: '',
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
upstreams: {
|
|
52
|
+
// Used only when backend === 'api'. Override to point OpenAI-protocol traffic
|
|
53
|
+
// at a local model, Azure, OpenRouter, etc. Auth is passed through from the
|
|
54
|
+
// client, so no key lives here.
|
|
55
|
+
openai: { baseUrl: 'https://api.openai.com' },
|
|
56
|
+
anthropic: { baseUrl: 'https://api.anthropic.com' },
|
|
57
|
+
},
|
|
58
|
+
redaction: {
|
|
59
|
+
// 'basic' = deterministic regex (emails/phones/cards/SSNs/keys/IPs).
|
|
60
|
+
// 'full' = basic + entity detection (names/orgs via the local detector below)
|
|
61
|
+
// + the custom dictionary.
|
|
62
|
+
tier: 'basic',
|
|
63
|
+
// { value|pattern, type, alias? } — see pii-redact.js. `alias` pseudonymizes
|
|
64
|
+
// permanently (upstream + the agent both see the alias).
|
|
65
|
+
dictionary: [],
|
|
66
|
+
// Local entity detector, passed straight to pii-detect.detectEntities.
|
|
67
|
+
// backend: 'off' | 'endpoint' (POST {text}->{entities}) | 'openai' (local LLM)
|
|
68
|
+
// url, model, timeoutMs, maxChars
|
|
69
|
+
// When `ner.autostart` is on (below), the gateway launches the bundled spaCy
|
|
70
|
+
// NER server and points detection at it automatically — leave this `off`.
|
|
71
|
+
detection: { backend: 'off' },
|
|
72
|
+
// Per-request convenience: also redact the system prompt / system blocks.
|
|
73
|
+
redactSystem: true,
|
|
74
|
+
},
|
|
75
|
+
// Bundled local NER server (spaCy). When autostart is on, `npm start` also
|
|
76
|
+
// launches ./ner on this port and wires `redaction.detection` to it, so name/
|
|
77
|
+
// org redaction works out of the box with one command. Fails open: if Python/
|
|
78
|
+
// spaCy isn't set up, the gateway logs a hint and runs deterministic-only.
|
|
79
|
+
ner: {
|
|
80
|
+
autostart: true,
|
|
81
|
+
port: 9009,
|
|
82
|
+
// Auto-bump redaction.tier to 'full' once NER is up (names/orgs need it).
|
|
83
|
+
enableFullTier: true,
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// Log one line per request (method, tokens redacted) without any raw values.
|
|
87
|
+
logRequests: true,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function deepMerge(base, over) {
|
|
91
|
+
if (Array.isArray(over)) return over;
|
|
92
|
+
if (over && typeof over === 'object' && base && typeof base === 'object') {
|
|
93
|
+
const out = { ...base };
|
|
94
|
+
for (const k of Object.keys(over)) out[k] = deepMerge(base[k], over[k]);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
return over === undefined ? base : over;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loadConfig(env = process.env) {
|
|
101
|
+
let cfg = DEFAULTS;
|
|
102
|
+
|
|
103
|
+
const path = env.CHATPANEL_GATEWAY_CONFIG || join(process.cwd(), 'gateway.config.json');
|
|
104
|
+
if (existsSync(path)) {
|
|
105
|
+
try {
|
|
106
|
+
cfg = deepMerge(cfg, JSON.parse(readFileSync(path, 'utf8')));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new Error(`bad config file ${path}: ${e.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Env overrides (handy for Docker/CI where a file is awkward).
|
|
113
|
+
if (env.CHATPANEL_GATEWAY_HOST) cfg = deepMerge(cfg, { host: env.CHATPANEL_GATEWAY_HOST });
|
|
114
|
+
if (env.CHATPANEL_GATEWAY_PORT) cfg = deepMerge(cfg, { port: Number(env.CHATPANEL_GATEWAY_PORT) });
|
|
115
|
+
if (env.OPENAI_BASE_URL) cfg = deepMerge(cfg, { upstreams: { openai: { baseUrl: env.OPENAI_BASE_URL } } });
|
|
116
|
+
if (env.ANTHROPIC_BASE_URL) cfg = deepMerge(cfg, { upstreams: { anthropic: { baseUrl: env.ANTHROPIC_BASE_URL } } });
|
|
117
|
+
if (env.CHATPANEL_REDACTION_TIER) cfg = deepMerge(cfg, { redaction: { tier: env.CHATPANEL_REDACTION_TIER } });
|
|
118
|
+
|
|
119
|
+
return cfg;
|
|
120
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Read/write the gateway's on-disk config so the extension's "Gateway" tab can
|
|
2
|
+
// configure it live over the localhost API (GET/POST /config). The gateway stays
|
|
3
|
+
// authoritative — the extension is just a UI client.
|
|
4
|
+
|
|
5
|
+
import { writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export function configPath(env = process.env) {
|
|
9
|
+
return env.CHATPANEL_GATEWAY_CONFIG || join(process.cwd(), 'gateway.config.json');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Persist the user-editable subset (not derived runtime state).
|
|
13
|
+
export function persistConfig(cfg, path = configPath()) {
|
|
14
|
+
const out = {
|
|
15
|
+
host: cfg.host, port: cfg.port, backend: cfg.backend,
|
|
16
|
+
bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
|
|
17
|
+
ner: cfg.ner, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
|
|
18
|
+
pro: cfg.pro, logRequests: cfg.logRequests,
|
|
19
|
+
};
|
|
20
|
+
writeFileSync(path, JSON.stringify(out, null, 2));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Safe view for GET /config — never leak secrets (the entitlement + bridge tokens
|
|
24
|
+
// are write-only; the UI shows whether Pro is unlocked, not the token).
|
|
25
|
+
export function publicConfig(cfg, { proUnlocked = false } = {}) {
|
|
26
|
+
return {
|
|
27
|
+
backend: cfg.backend,
|
|
28
|
+
bridge: { url: cfg.bridge?.url, agent: cfg.bridge?.agent, hasToken: !!cfg.bridge?.token },
|
|
29
|
+
upstreams: cfg.upstreams,
|
|
30
|
+
redaction: {
|
|
31
|
+
tier: cfg.redaction?.tier,
|
|
32
|
+
redactSystem: cfg.redaction?.redactSystem !== false,
|
|
33
|
+
detection: cfg.redaction?.detection || { backend: 'off' },
|
|
34
|
+
dictionary: Array.isArray(cfg.redaction?.dictionary) ? cfg.redaction.dictionary : [],
|
|
35
|
+
},
|
|
36
|
+
ner: cfg.ner,
|
|
37
|
+
allowedOrigins: Array.isArray(cfg.allowedOrigins) ? cfg.allowedOrigins : [],
|
|
38
|
+
pro: { unlocked: proUnlocked, hasToken: !!cfg.pro?.entitlementToken, free: cfg.pro?.free },
|
|
39
|
+
logRequests: !!cfg.logRequests,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Merge an editable patch into the live cfg. Only known fields; ignores the rest.
|
|
44
|
+
export function applyConfigPatch(cfg, patch = {}) {
|
|
45
|
+
if (patch.backend === 'bridge' || patch.backend === 'api') cfg.backend = patch.backend;
|
|
46
|
+
if (patch.bridge && typeof patch.bridge === 'object') {
|
|
47
|
+
if (typeof patch.bridge.url === 'string') cfg.bridge.url = patch.bridge.url;
|
|
48
|
+
if (typeof patch.bridge.agent === 'string') cfg.bridge.agent = patch.bridge.agent;
|
|
49
|
+
}
|
|
50
|
+
if (patch.redaction && typeof patch.redaction === 'object') {
|
|
51
|
+
const r = patch.redaction;
|
|
52
|
+
if (r.tier === 'basic' || r.tier === 'full') cfg.redaction.tier = r.tier;
|
|
53
|
+
if ('redactSystem' in r) cfg.redaction.redactSystem = !!r.redactSystem;
|
|
54
|
+
if (Array.isArray(r.dictionary)) cfg.redaction.dictionary = r.dictionary;
|
|
55
|
+
if (r.detection && typeof r.detection === 'object') cfg.redaction.detection = r.detection;
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(patch.allowedOrigins)) cfg.allowedOrigins = patch.allowedOrigins;
|
|
58
|
+
if (patch.pro && typeof patch.pro === 'object') {
|
|
59
|
+
if (typeof patch.pro.entitlementToken === 'string') cfg.pro.entitlementToken = patch.pro.entitlementToken;
|
|
60
|
+
const cap = patch.pro.free?.maxRequestsPerDay;
|
|
61
|
+
if (Number.isFinite(cap) && cap >= 0) cfg.pro.free.maxRequestsPerDay = cap;
|
|
62
|
+
}
|
|
63
|
+
if (typeof patch.logRequests === 'boolean') cfg.logRequests = patch.logRequests;
|
|
64
|
+
return cfg;
|
|
65
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Offline Pro/Team entitlement verification — the HARD gate for paid features
|
|
2
|
+
// (e.g. custom "bring your own CLI" agents).
|
|
3
|
+
//
|
|
4
|
+
// The license server (Cloudflare Worker) signs a compact entitlement token with
|
|
5
|
+
// an ECDSA P-256 private key that lives ONLY there. The bridge ships the matching
|
|
6
|
+
// PUBLIC key and verifies the signature locally — no network, no secret. A forked
|
|
7
|
+
// client or a raw `curl` to the bridge can't forge entitlement without the
|
|
8
|
+
// private key, so this is a real cryptographic gate, not a UI check.
|
|
9
|
+
//
|
|
10
|
+
// Token format (identical to the extension's, extension/js/license.js):
|
|
11
|
+
// token = base64url(JSON payload) + "." + base64url(raw ECDSA signature)
|
|
12
|
+
// signed over UTF-8(head); payload = { typ:'ent', plan, install_id, sub, exp }
|
|
13
|
+
//
|
|
14
|
+
// Keep ENTITLEMENT_PUBLIC_JWK in sync with the extension's copy.
|
|
15
|
+
|
|
16
|
+
import { webcrypto } from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
const ENTITLEMENT_PUBLIC_JWK = {
|
|
19
|
+
kty: 'EC',
|
|
20
|
+
crv: 'P-256',
|
|
21
|
+
x: 'CmgKLC4e3xDMvwhbjVqF7jbDe1JhC1KKQi8JN3qVX_4',
|
|
22
|
+
y: 'r40l6fQiyCcJYqW-SvB4VoSyn4F36yhSt82ZAOSo78E',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const PRO_PLANS = new Set(['pro', 'team']);
|
|
26
|
+
|
|
27
|
+
const b64urlToBytes = (s) => {
|
|
28
|
+
const norm = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
29
|
+
return new Uint8Array(Buffer.from(norm, 'base64'));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let keyPromise = null;
|
|
33
|
+
function publicKey() {
|
|
34
|
+
if (!keyPromise) {
|
|
35
|
+
keyPromise = webcrypto.subtle.importKey(
|
|
36
|
+
'jwk',
|
|
37
|
+
ENTITLEMENT_PUBLIC_JWK,
|
|
38
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
39
|
+
false,
|
|
40
|
+
['verify'],
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return keyPromise;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Verify a server entitlement token. Returns its payload, or null. Checks the
|
|
47
|
+
// ECDSA signature (unforgeable without the private key), the token type, and
|
|
48
|
+
// expiry. install_id binding is the extension's concern — for the bridge gate the
|
|
49
|
+
// signature is what matters.
|
|
50
|
+
export async function verifyEntitlement(token) {
|
|
51
|
+
if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return null;
|
|
52
|
+
const [head, sig] = token.split('.');
|
|
53
|
+
const enc = new TextEncoder();
|
|
54
|
+
let ok = false;
|
|
55
|
+
try {
|
|
56
|
+
ok = await webcrypto.subtle.verify(
|
|
57
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
58
|
+
await publicKey(),
|
|
59
|
+
b64urlToBytes(sig),
|
|
60
|
+
enc.encode(head),
|
|
61
|
+
);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!ok) return null;
|
|
66
|
+
let payload;
|
|
67
|
+
try {
|
|
68
|
+
payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(head)));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (payload.typ !== 'ent') return null;
|
|
73
|
+
if (payload.exp && Date.now() > payload.exp) return null;
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// True when `token` is a valid, unexpired Pro (or Team) entitlement.
|
|
78
|
+
export async function isProEntitled(token) {
|
|
79
|
+
const p = await verifyEntitlement(token);
|
|
80
|
+
return !!(p && PRO_PLANS.has(p.plan));
|
|
81
|
+
}
|
package/src/freegate.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Free vs Pro for the gateway runtime — the "taste" gate.
|
|
2
|
+
//
|
|
3
|
+
// Free (no entitlement token): deterministic redaction only (basic tier) + a
|
|
4
|
+
// capped number of metered requests per day, so anyone can try it. Pro (a valid
|
|
5
|
+
// ChatPanel entitlement token — the same offline-signed token the extension and
|
|
6
|
+
// bridge use) unlocks full-tier redaction (names/orgs via NER + full dictionary)
|
|
7
|
+
// and unlimited usage. The cryptographic check (entitlement.js) means a forked UI
|
|
8
|
+
// can't unlock it — only the configured/paid token does.
|
|
9
|
+
|
|
10
|
+
import { isProEntitled } from './entitlement.js';
|
|
11
|
+
|
|
12
|
+
const proCache = { token: null, val: false };
|
|
13
|
+
const counts = { day: '', n: 0 };
|
|
14
|
+
|
|
15
|
+
// Today's metered usage — for the gateway's /status (the extension's monitoring).
|
|
16
|
+
export function usage(cfg) {
|
|
17
|
+
return { day: counts.day, used: counts.n, cap: cfg.pro?.free?.maxRequestsPerDay ?? 25 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function resolvePro(token) {
|
|
21
|
+
if (!token) return false;
|
|
22
|
+
if (proCache.token === token) return proCache.val;
|
|
23
|
+
const val = await isProEntitled(token).catch(() => false);
|
|
24
|
+
proCache.token = token;
|
|
25
|
+
proCache.val = val;
|
|
26
|
+
return val;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// UTC day bucket. (Runtime — Date is available here, unlike workflow scripts.)
|
|
30
|
+
function dayKey() {
|
|
31
|
+
return new Date().toISOString().slice(0, 10);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Meter one redactable request. Pro = always allowed. Free = allowed until the
|
|
35
|
+
// daily cap, then refused so the client gets a clear upsell.
|
|
36
|
+
export function meter(cfg, isPro) {
|
|
37
|
+
if (isPro) return { allowed: true, remaining: Infinity, isPro: true };
|
|
38
|
+
const cap = cfg.pro?.free?.maxRequestsPerDay ?? 25;
|
|
39
|
+
const d = dayKey();
|
|
40
|
+
if (counts.day !== d) { counts.day = d; counts.n = 0; }
|
|
41
|
+
if (counts.n >= cap) return { allowed: false, remaining: 0, cap, isPro: false };
|
|
42
|
+
counts.n += 1;
|
|
43
|
+
return { allowed: true, remaining: cap - counts.n, cap, isPro: false };
|
|
44
|
+
}
|
package/src/ner.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Managed local NER server. When cfg.ner.autostart is on, launching the gateway
|
|
2
|
+
// also spins up the bundled spaCy detector (./ner) and wires redaction.detection
|
|
3
|
+
// at it — so name/org redaction works with a single command, no second terminal.
|
|
4
|
+
//
|
|
5
|
+
// Fail-open by design: if Python/spaCy isn't set up, we log a one-line hint and
|
|
6
|
+
// the gateway keeps running with deterministic-only redaction (the detector layer
|
|
7
|
+
// is already cached + fail-open per request).
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const NER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'ner');
|
|
14
|
+
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
|
|
17
|
+
async function healthy(port, signal) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal });
|
|
20
|
+
return res.ok;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Launch + supervise the NER server. Returns { stop() } or null if not started.
|
|
27
|
+
// Mutates cfg.redaction once the server answers /health.
|
|
28
|
+
export function startNer(cfg) {
|
|
29
|
+
const n = cfg.ner;
|
|
30
|
+
if (!n || !n.autostart) return null;
|
|
31
|
+
|
|
32
|
+
// Respect an explicitly-configured detector — don't double-launch.
|
|
33
|
+
const det = cfg.redaction?.detection;
|
|
34
|
+
if (det && det.backend && det.backend !== 'off') {
|
|
35
|
+
console.log(`[ner] detection already configured (${det.backend}) — not autostarting bundled server`);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const port = n.port || 9009;
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = spawn('bash', ['run.sh'], {
|
|
43
|
+
cwd: NER_DIR,
|
|
44
|
+
env: { ...process.env, PORT: String(port) },
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.log(`[ner] could not launch bundled NER (${e.message}) — deterministic redaction only`);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let firstRun = true;
|
|
53
|
+
child.stdout?.on('data', (b) => {
|
|
54
|
+
const s = b.toString();
|
|
55
|
+
if (firstRun && /installing dependencies/i.test(s)) {
|
|
56
|
+
firstRun = false;
|
|
57
|
+
console.log('[ner] first run: creating venv + installing spaCy (one-time, may take a minute)…');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
child.stderr?.on('data', () => { /* uvicorn logs to stderr; swallow */ });
|
|
61
|
+
child.on('error', (e) => {
|
|
62
|
+
console.log(`[ner] failed to start (${e.message}). Is python3 installed? Falling back to deterministic redaction.`);
|
|
63
|
+
});
|
|
64
|
+
child.on('exit', (code) => {
|
|
65
|
+
if (code && code !== 0 && !stopped) {
|
|
66
|
+
console.log(`[ner] server exited (code ${code}); redaction continues deterministic-only.`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Poll for readiness without blocking server start; wire detection when up.
|
|
71
|
+
const ac = new AbortController();
|
|
72
|
+
(async () => {
|
|
73
|
+
const deadline = Date.now() + 120_000; // generous: first run installs deps
|
|
74
|
+
while (Date.now() < deadline && !stopped) {
|
|
75
|
+
if (await healthy(port, ac.signal)) {
|
|
76
|
+
cfg.redaction.detection = {
|
|
77
|
+
backend: 'endpoint',
|
|
78
|
+
url: `http://127.0.0.1:${port}/ner`,
|
|
79
|
+
timeoutMs: 1500,
|
|
80
|
+
maxChars: 8000,
|
|
81
|
+
};
|
|
82
|
+
if (n.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
|
|
83
|
+
console.log(`[ner] ready on http://127.0.0.1:${port}/ner — entity redaction active (tier: ${cfg.redaction.tier})`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await sleep(1000);
|
|
87
|
+
}
|
|
88
|
+
if (!stopped) console.log('[ner] not ready after 120s — continuing deterministic-only (run ./ner/run.sh manually to debug).');
|
|
89
|
+
})();
|
|
90
|
+
|
|
91
|
+
let stopped = false;
|
|
92
|
+
const stop = () => {
|
|
93
|
+
if (stopped) return;
|
|
94
|
+
stopped = true;
|
|
95
|
+
ac.abort();
|
|
96
|
+
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
97
|
+
};
|
|
98
|
+
return { stop };
|
|
99
|
+
}
|
package/src/openai.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// OpenAI-protocol adapter: /v1/chat/completions (what opencode, codex, aider,
|
|
2
|
+
// cursor, and most tools speak). Pulls every redactable text string out of the
|
|
3
|
+
// request body as in-place segments, and restores tokens in a non-streaming
|
|
4
|
+
// response. (Streaming is restored generically in stream.js.)
|
|
5
|
+
|
|
6
|
+
import { segment } from './redact.js';
|
|
7
|
+
import { restoreDeep } from './stream.js';
|
|
8
|
+
|
|
9
|
+
export function matches(pathname) {
|
|
10
|
+
return /\/chat\/completions$/.test(pathname) || /\/completions$/.test(pathname);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Collect segments from messages[].content (string or multimodal parts). System
|
|
14
|
+
// messages are included unless redactSystem is false.
|
|
15
|
+
export function collectSegments(body, redactionCfg) {
|
|
16
|
+
const segs = [];
|
|
17
|
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
18
|
+
for (const m of messages) {
|
|
19
|
+
if (!m) continue;
|
|
20
|
+
if (m.role === 'system' && redactionCfg.redactSystem === false) continue;
|
|
21
|
+
if (typeof m.content === 'string') {
|
|
22
|
+
segs.push(segment(() => m.content, (v) => { m.content = v; }));
|
|
23
|
+
} else if (Array.isArray(m.content)) {
|
|
24
|
+
for (const part of m.content) {
|
|
25
|
+
if (part && part.type === 'text' && typeof part.text === 'string') {
|
|
26
|
+
segs.push(segment(() => part.text, (v) => { part.text = v; }));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return segs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Extract the conversation for the bridge backend. OpenAI carries the system
|
|
35
|
+
// prompt as a role:'system' message, so we pass messages through as-is.
|
|
36
|
+
export function toTurn(body) {
|
|
37
|
+
return { messages: Array.isArray(body?.messages) ? body.messages : [], system: '' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Restore a buffered (non-streaming) response: assistant text + tool-call args.
|
|
41
|
+
export function restoreResponse(json, vault) {
|
|
42
|
+
for (const choice of json?.choices || []) {
|
|
43
|
+
const msg = choice?.message;
|
|
44
|
+
if (!msg) continue;
|
|
45
|
+
if (typeof msg.content === 'string') msg.content = restoreDeep(msg.content, vault);
|
|
46
|
+
for (const tc of msg.tool_calls || []) {
|
|
47
|
+
if (tc?.function && typeof tc.function.arguments === 'string') {
|
|
48
|
+
tc.function.arguments = restoreDeep(tc.function.arguments, vault);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return json;
|
|
53
|
+
}
|