@chatpanel/gateway 0.6.73 → 0.6.74

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.73",
3
+ "version": "0.6.74",
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
- export function readBridgeToken(cfgToken, tokenPath = DEFAULT_TOKEN_PATH) {
62
- if (cfgToken) return cfgToken;
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 fetch(`${bridgeUrl.replace(/\/$/, '')}/chat`, {
93
- method: 'POST',
94
- headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
95
- body: JSON.stringify({
96
- agent,
97
- messages: toBridgeMessages(messages),
98
- system: system || '',
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 fetch(`${bridgeUrl.replace(/\/$/, '')}/chat`, {
117
- method: 'POST',
118
- headers: {
119
- 'content-type': 'application/json',
120
- ...(token ? { authorization: `Bearer ${token}` } : {}),
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/server.js CHANGED
@@ -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.73';
59
+ export const VERSION = '0.6.74';
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.