@chatpanel/gateway 0.6.73 → 0.6.75
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 +1 -1
- package/src/bridge.js +79 -30
- package/src/config.js +8 -0
- package/src/configstore.js +15 -1
- package/src/server.js +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.75",
|
|
4
4
|
"description": "Local privacy gateway \u2014 redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/bridge.js
CHANGED
|
@@ -58,14 +58,76 @@ export async function resolveBridgeUrl(cfg, { fallback, timeoutMs = 1500, now =
|
|
|
58
58
|
/** Test seam. */
|
|
59
59
|
export function resetBridgeResolution() { bridgeResolved = { at: 0, cfgUrl: '', url: '', fell: false }; }
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Once the bridge has REJECTED the configured token while the file held a different one,
|
|
62
|
+
// the file is what every later call presents. Set by `bridgeTokenRejected`, below.
|
|
63
|
+
let preferFileToken = false;
|
|
64
|
+
|
|
65
|
+
function fileToken(tokenPath) {
|
|
63
66
|
try {
|
|
64
67
|
if (existsSync(tokenPath)) return readFileSync(tokenPath, 'utf8').trim();
|
|
65
68
|
} catch { /* ignore */ }
|
|
66
69
|
return '';
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The bridge token: the config's value first, the file otherwise — until the bridge says
|
|
74
|
+
* the config's value is wrong.
|
|
75
|
+
*
|
|
76
|
+
* `bridge.token` in gateway.config.json is a COPY: the extension's Gateway tab writes its
|
|
77
|
+
* own setting there, and a bridge that regenerates its token leaves the copy behind. The
|
|
78
|
+
* desktop then relayed every Codex turn with the stale copy and got 403 from a bridge ten
|
|
79
|
+
* milliseconds away — an empty streaming bubble, forever. The file is written by the
|
|
80
|
+
* bridge itself, so when the configured token is rejected and the file differs, the file
|
|
81
|
+
* is tried once and, if it works, kept. Nothing is second-guessed before the bridge has
|
|
82
|
+
* actually said no: a pinned token on a loopback bridge stays a pinned token.
|
|
83
|
+
*/
|
|
84
|
+
export function readBridgeToken(cfgToken, tokenPath = DEFAULT_TOKEN_PATH) {
|
|
85
|
+
const file = fileToken(tokenPath);
|
|
86
|
+
if (!cfgToken) return file;
|
|
87
|
+
if (preferFileToken && file && file !== cfgToken) return file;
|
|
88
|
+
return cfgToken;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Called with the token the bridge just refused. Returns the file's token when it is a
|
|
93
|
+
* different one worth trying — and from then on `readBridgeToken` prefers it — or '' when
|
|
94
|
+
* there is nothing else to try.
|
|
95
|
+
*/
|
|
96
|
+
export function bridgeTokenRejected(rejected, tokenPath = DEFAULT_TOKEN_PATH) {
|
|
97
|
+
const file = fileToken(tokenPath);
|
|
98
|
+
if (!file || file === rejected) return '';
|
|
99
|
+
if (!preferFileToken) {
|
|
100
|
+
preferFileToken = true;
|
|
101
|
+
console.warn('[gateway] the bridge rejected bridge.token from gateway.config.json; using ~/.chatpanel/bridge-token (the bridge wrote it). Clear the config value to silence this.');
|
|
102
|
+
}
|
|
103
|
+
return file;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Test seam. */
|
|
107
|
+
export function resetBridgeTokenPreference() { preferFileToken = false; }
|
|
108
|
+
|
|
109
|
+
const isAuthFailure = (status) => status === 401 || status === 403;
|
|
110
|
+
|
|
111
|
+
// One POST to /chat, retried once with the file's token when the bridge refuses the one
|
|
112
|
+
// it was given — the stale-copy case above.
|
|
113
|
+
async function postChat(bridgeUrl, token, body, signal, tokenPath) {
|
|
114
|
+
const send = (t) => fetch(`${bridgeUrl.replace(/\/$/, '')}/chat`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'content-type': 'application/json', ...(t ? { authorization: `Bearer ${t}` } : {}) },
|
|
117
|
+
body,
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
// A caller hands over the token it resolved; once a rejection has flipped the
|
|
121
|
+
// preference, the file's token goes first here too.
|
|
122
|
+
const first = readBridgeToken(token, tokenPath);
|
|
123
|
+
let res = await send(first);
|
|
124
|
+
if (isAuthFailure(res.status)) {
|
|
125
|
+
const alt = bridgeTokenRejected(first, tokenPath);
|
|
126
|
+
if (alt) { await res.text().catch(() => {}); res = await send(alt); }
|
|
127
|
+
}
|
|
128
|
+
return res;
|
|
129
|
+
}
|
|
130
|
+
|
|
69
131
|
// Flatten an OpenAI/Anthropic message's content (string | parts[]) to plain text
|
|
70
132
|
// for the bridge, which expects string content. Image parts are dropped here (the
|
|
71
133
|
// bridge takes images separately; wire that later if needed).
|
|
@@ -88,19 +150,14 @@ export function toBridgeMessages(messages) {
|
|
|
88
150
|
|
|
89
151
|
// Open a bridge /chat stream WITH tool specs (pageTools), returning the raw fetch
|
|
90
152
|
// Response so the tool-relay can hold the reader open across the OpenAI round-trip.
|
|
91
|
-
export async function openBridgeChat({ bridgeUrl, agent, token, messages, system, specs, options, signal }) {
|
|
92
|
-
const res = await
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
options: options || {},
|
|
100
|
-
...(Array.isArray(specs) && specs.length ? { pageTools: { specs } } : {}),
|
|
101
|
-
}),
|
|
102
|
-
signal,
|
|
103
|
-
});
|
|
153
|
+
export async function openBridgeChat({ bridgeUrl, agent, token, messages, system, specs, options, signal, tokenPath }) {
|
|
154
|
+
const res = await postChat(bridgeUrl, token, JSON.stringify({
|
|
155
|
+
agent,
|
|
156
|
+
messages: toBridgeMessages(messages),
|
|
157
|
+
system: system || '',
|
|
158
|
+
options: options || {},
|
|
159
|
+
...(Array.isArray(specs) && specs.length ? { pageTools: { specs } } : {}),
|
|
160
|
+
}), signal, tokenPath);
|
|
104
161
|
if (!res.ok || !res.body) {
|
|
105
162
|
const detail = await res.text().catch(() => '');
|
|
106
163
|
throw new Error(`bridge /chat HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
@@ -112,21 +169,13 @@ export async function openBridgeChat({ bridgeUrl, agent, token, messages, system
|
|
|
112
169
|
// onActivity(event) for everything else the agent reports — status lines, the working
|
|
113
170
|
// directory, tool calls, reasoning.
|
|
114
171
|
// of model text and returns the full (un-restored) text. Throws on bridge error.
|
|
115
|
-
export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal }, onText, onActivity = null) {
|
|
116
|
-
const res = await
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
body: JSON.stringify({
|
|
123
|
-
agent,
|
|
124
|
-
messages: toBridgeMessages(messages),
|
|
125
|
-
system: system || '',
|
|
126
|
-
options: options || {},
|
|
127
|
-
}),
|
|
128
|
-
signal,
|
|
129
|
-
});
|
|
172
|
+
export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal, tokenPath }, onText, onActivity = null) {
|
|
173
|
+
const res = await postChat(bridgeUrl, token, JSON.stringify({
|
|
174
|
+
agent,
|
|
175
|
+
messages: toBridgeMessages(messages),
|
|
176
|
+
system: system || '',
|
|
177
|
+
options: options || {},
|
|
178
|
+
}), signal, tokenPath);
|
|
130
179
|
|
|
131
180
|
if (!res.ok || !res.body) {
|
|
132
181
|
const detail = await res.text().catch(() => '');
|
package/src/config.js
CHANGED
|
@@ -57,6 +57,14 @@ export const DEFAULTS = {
|
|
|
57
57
|
// Bearer token for the bridge's privileged /chat route. Empty = read the
|
|
58
58
|
// per-install token from ~/.chatpanel/bridge-token.
|
|
59
59
|
token: '',
|
|
60
|
+
// What a CLI agent driven THROUGH the gateway may do — the same three modes the
|
|
61
|
+
// extension offers per agent: 'default' (read-only, asks before writing),
|
|
62
|
+
// 'acceptEdits' (edits files in its workspace on its own), 'bypassPermissions' (full
|
|
63
|
+
// access and shell). Absent, the bridge ran every gateway turn read-only and told the
|
|
64
|
+
// model to "set Permissions" in a settings page the desktop did not have.
|
|
65
|
+
permissionMode: 'default',
|
|
66
|
+
// Where the agent works. Empty = the bridge's ChatPanel workspace.
|
|
67
|
+
workingDir: '',
|
|
60
68
|
},
|
|
61
69
|
|
|
62
70
|
upstreams: {
|
package/src/configstore.js
CHANGED
|
@@ -46,7 +46,7 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
|
|
|
46
46
|
backend: cfg.backend,
|
|
47
47
|
// Strip per-destination apiKey (write-only).
|
|
48
48
|
destinations: (Array.isArray(cfg.destinations) ? cfg.destinations : []).map((d) => { const { apiKey, ...rest } = d; return { ...rest, hasKey: !!apiKey }; }),
|
|
49
|
-
bridge: { url: cfg.bridge?.url, agent: cfg.bridge?.agent, hasToken: !!cfg.bridge?.token },
|
|
49
|
+
bridge: { url: cfg.bridge?.url, agent: cfg.bridge?.agent, hasToken: !!cfg.bridge?.token, permissionMode: cfg.bridge?.permissionMode || 'default', workingDir: cfg.bridge?.workingDir || '' },
|
|
50
50
|
upstreams: cfg.upstreams,
|
|
51
51
|
redaction: {
|
|
52
52
|
tier: cfg.redaction?.tier,
|
|
@@ -98,6 +98,18 @@ export function isSelfDestination(d, cfg = {}) {
|
|
|
98
98
|
return loop && port === (Number(cfg.port) || 4320);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
export const PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions'];
|
|
102
|
+
|
|
103
|
+
/** The per-turn options a gateway-driven agent receives — the bridge's own vocabulary. */
|
|
104
|
+
export function bridgeAgentOptions(cfg, extra = {}) {
|
|
105
|
+
const b = cfg?.bridge || {};
|
|
106
|
+
return {
|
|
107
|
+
...(PERMISSION_MODES.includes(b.permissionMode) && b.permissionMode !== 'default' ? { permissionMode: b.permissionMode } : {}),
|
|
108
|
+
...(typeof b.workingDir === 'string' && b.workingDir.trim() ? { workingDir: b.workingDir.trim() } : {}),
|
|
109
|
+
...extra,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
export function applyConfigPatch(cfg, patch = {}) {
|
|
102
114
|
if (patch.backend === 'bridge' || patch.backend === 'api') cfg.backend = patch.backend;
|
|
103
115
|
if (Array.isArray(patch.destinations)) {
|
|
@@ -122,6 +134,8 @@ export function applyConfigPatch(cfg, patch = {}) {
|
|
|
122
134
|
if (patch.bridge && typeof patch.bridge === 'object') {
|
|
123
135
|
if (typeof patch.bridge.url === 'string') cfg.bridge.url = patch.bridge.url;
|
|
124
136
|
if (typeof patch.bridge.agent === 'string') cfg.bridge.agent = patch.bridge.agent;
|
|
137
|
+
if (PERMISSION_MODES.includes(patch.bridge.permissionMode)) cfg.bridge.permissionMode = patch.bridge.permissionMode;
|
|
138
|
+
if (typeof patch.bridge.workingDir === 'string') cfg.bridge.workingDir = patch.bridge.workingDir.trim().slice(0, 1024);
|
|
125
139
|
}
|
|
126
140
|
// api backend: where redacted traffic is forwarded (the client still picks the
|
|
127
141
|
// model + sends its own key).
|
package/src/server.js
CHANGED
|
@@ -47,7 +47,7 @@ import { rawOrtAvailable } from './ort.js';
|
|
|
47
47
|
import * as ttsVoices from './tts-voices.js';
|
|
48
48
|
import { resolveTtsVoice } from './tts-voice-resolve.js';
|
|
49
49
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
50
|
-
import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
|
|
50
|
+
import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath, bridgeAgentOptions } from './configstore.js';
|
|
51
51
|
import { resolveDestination, aggregateModelsAsync, listDestinations } from './router.js';
|
|
52
52
|
import { makeAccessEvent } from './observability.js';
|
|
53
53
|
import { createPersistentAccessLog } from './access-log-store.js';
|
|
@@ -56,7 +56,7 @@ import * as openai from './openai.js';
|
|
|
56
56
|
import * as responses from './responses.js';
|
|
57
57
|
import * as anthropic from './anthropic.js';
|
|
58
58
|
|
|
59
|
-
export const VERSION = '0.6.
|
|
59
|
+
export const VERSION = '0.6.75';
|
|
60
60
|
|
|
61
61
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
62
62
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -401,7 +401,7 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
|
|
|
401
401
|
// redaction in the main handler), so toTurn() carried it here — nothing to add.
|
|
402
402
|
let resp;
|
|
403
403
|
try {
|
|
404
|
-
resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options:
|
|
404
|
+
resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options: bridgeAgentOptions(cfg), signal: undefined });
|
|
405
405
|
} catch (e) { clearTimeout(ttl); endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
|
|
406
406
|
s.reader = resp.body.getReader();
|
|
407
407
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
@@ -453,14 +453,23 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
453
453
|
const shaper = shaperFor(kind, body?.model || agent);
|
|
454
454
|
const token = readBridgeToken(cfg.bridge.token);
|
|
455
455
|
const ac = new AbortController();
|
|
456
|
-
|
|
456
|
+
// STOP MUST REACH THE AGENT. The client pressing Stop aborts ITS request; the bridge only
|
|
457
|
+
// kills the CLI when the gateway's request to it goes away, and that happened only if the
|
|
458
|
+
// gateway noticed. It listened on `req 'close'`, which on this Node fires when the REQUEST
|
|
459
|
+
// is complete — the body was read long ago — not when the socket is cut mid-response. So
|
|
460
|
+
// Stop in the desktop stopped nothing: Codex wrote its whole essay to nobody (measured:
|
|
461
|
+
// the process outlived the abort by the length of the answer). The RESPONSE closing is
|
|
462
|
+
// the signal for a severed connection; a response that finished normally is not a stop.
|
|
463
|
+
res.on('close', () => { if (!res.writableFinished) ac.abort(); });
|
|
457
464
|
|
|
458
465
|
// The model half of `claude/opus`, handed to the CLI as its `--model`. Absent for a bare
|
|
459
466
|
// agent id, which leaves the agent on its own default exactly as before.
|
|
460
467
|
const { agentModel } = parseAgentModel(body?.model, cfg);
|
|
461
468
|
const turn = {
|
|
462
469
|
bridgeUrl: await resolveBridgeUrl(cfg), agent, token, messages, system, signal: ac.signal,
|
|
463
|
-
|
|
470
|
+
// Permissions and working directory from the gateway's config (the desktop's Settings →
|
|
471
|
+
// Engine → Agents), plus the model half of `claude/opus` when the caller named one.
|
|
472
|
+
options: bridgeAgentOptions(cfg, agentModel ? { model: agentModel } : {}),
|
|
464
473
|
};
|
|
465
474
|
|
|
466
475
|
if (!wantStream) {
|
|
@@ -1764,7 +1773,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1764
1773
|
if (!redactionOff) await ensureNer(cfg);
|
|
1765
1774
|
const segs = redactionOff ? [] : r.adapter.collectSegments(body, cfg.redaction);
|
|
1766
1775
|
const ac = new AbortController();
|
|
1767
|
-
|
|
1776
|
+
res.on('close', () => { if (!res.writableFinished) ac.abort(); });
|
|
1768
1777
|
const rd0 = trace ? trace.clock() : 0;
|
|
1769
1778
|
// Redact at the configured tier for everyone (free users get name/org
|
|
1770
1779
|
// redaction within their allowance); the custom dictionary stays capped for
|