customer-map-codex-bridge 0.5.0 → 0.5.1

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.
Files changed (3) hide show
  1. package/README.md +5 -3
  2. package/index.mjs +144 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,13 +14,15 @@ The settings panel reports **Connected** only after both the local Codex app-ser
14
14
  ## Run
15
15
 
16
16
  ```bash
17
- npx -y customer-map-codex-bridge@0.5.0 \
17
+ npx -y customer-map-codex-bridge@0.5.1 \
18
18
  --site https://your-customer-map.example \
19
19
  --code CMAP-CODEX-XXXXXXXXXXXX
20
20
  ```
21
21
 
22
22
  Starting a newly authorized Bridge automatically replaces the older Bridge for the same Customer Map account. Clicking **Remove connection** in Customer Map disconnects the running Bridge, so users do not need to find or kill local processes.
23
23
 
24
+ The Bridge automatically reconnects to the Relay after temporary network or Relay interruptions, using exponential backoff from 1 second up to 30 seconds. It keeps the local Codex process and bridge token in memory during retries. A removed connection, an expired token, or a Bridge replaced by a newer Bridge exits instead of retrying.
25
+
24
26
  The bridge initializes Codex App Server with native live web search enabled, then explicitly applies a read-only sandbox with no approval prompts and command network access disabled to every new thread and every turn. This lets the assistants search and read public pages without granting model-generated shell commands arbitrary network or file-write access. Credentials stay on the local machine, and thread IDs are stored under `~/.customer-map-codex/state.json` and resumed after Bridge restarts. Task Assistant, Workbench Assistant, and Sales Assistant use separate thread scopes.
25
27
 
26
28
  When the user explicitly chooses **Save Gmail draft** or **Send email** in Customer Map, the bridge bypasses the Codex turn and executes only a validated, fixed `gog gmail drafts create` or `gog gmail send` argument list. It verifies the content hash and requires a real Gmail message/draft ID before reporting success. Chat cannot turn this into an arbitrary shell or network action.
@@ -28,7 +30,7 @@ When the user explicitly chooses **Save Gmail draft** or **Send email** in Custo
28
30
  If the protocol handler cannot find `codex`, pass its absolute path explicitly:
29
31
 
30
32
  ```bash
31
- npx -y customer-map-codex-bridge@0.5.0 \
33
+ npx -y customer-map-codex-bridge@0.5.1 \
32
34
  --site https://your-customer-map.example \
33
35
  --code CMAP-CODEX-XXXXXXXXXXXX \
34
36
  --codex /absolute/path/to/codex
@@ -37,7 +39,7 @@ npx -y customer-map-codex-bridge@0.5.0 \
37
39
  If `gog` is outside the standard Homebrew/system paths, pass it explicitly:
38
40
 
39
41
  ```bash
40
- npx -y customer-map-codex-bridge@0.5.0 \
42
+ npx -y customer-map-codex-bridge@0.5.1 \
41
43
  --site https://your-customer-map.example \
42
44
  --code CMAP-CODEX-XXXXXXXXXXXX \
43
45
  --gog /absolute/path/to/gog
package/index.mjs CHANGED
@@ -9,7 +9,9 @@ import process from 'node:process';
9
9
  import WebSocket from 'ws';
10
10
  import { executeCustomerMapMailAction } from './mail-action.mjs';
11
11
 
12
- const BRIDGE_VERSION = '0.5.0';
12
+ const BRIDGE_VERSION = '0.5.1';
13
+ const RELAY_RECONNECT_BASE_MS = 1_000;
14
+ const RELAY_RECONNECT_MAX_MS = 30_000;
13
15
 
14
16
  const args = parseArgs(process.argv.slice(2));
15
17
  if (args.help) {
@@ -36,6 +38,8 @@ let initialized = false;
36
38
  let closed = false;
37
39
  let active = null;
38
40
  let shutdownPromise = null;
41
+ let relayReconnectPromise = null;
42
+ let pendingRelayDisconnect = null;
39
43
  const pendingRpc = new Map();
40
44
  const loadedThreads = new Set();
41
45
 
@@ -70,7 +74,7 @@ async function main() {
70
74
  const bridge = await claim();
71
75
  bridgeToken = bridge.bridgeToken;
72
76
  connectionId = bridge.connectionId;
73
- relay = await connectRelay(bridge);
77
+ await connectRelayWithRetry(bridge);
74
78
  console.log('Customer Map Codex Bridge connected.');
75
79
  } catch (error) {
76
80
  console.error(error instanceof Error ? error.message : String(error || 'Codex Bridge failed'));
@@ -140,6 +144,23 @@ function handleCodexLine(line) {
140
144
  }
141
145
  }
142
146
 
147
+ async function connectRelayWithRetry(bridge) {
148
+ let attempt = 0;
149
+ while (!closed) {
150
+ try {
151
+ await connectRelay(bridge);
152
+ return;
153
+ } catch (error) {
154
+ if (isPermanentRelayError(error)) throw error;
155
+ const delayMs = relayReconnectDelay(attempt);
156
+ attempt += 1;
157
+ console.warn(`Codex Relay unavailable; retrying in ${Math.ceil(delayMs / 1000)}s.`);
158
+ await wait(delayMs);
159
+ }
160
+ }
161
+ throw new Error('Codex Bridge stopped');
162
+ }
163
+
143
164
  function connectRelay(bridge) {
144
165
  return new Promise((resolve, reject) => {
145
166
  const socket = new WebSocket(webSocketUrl(bridge.relayUrl));
@@ -150,7 +171,7 @@ function connectRelay(bridge) {
150
171
  if (settled) return;
151
172
  settled = true;
152
173
  socket.close();
153
- reject(new Error('Codex Relay 握手超时,请检查 Relay 或 Tunnel 是否在线。'));
174
+ reject(createRelayError('Codex Relay 握手超时,请检查 Relay 或 Tunnel 是否在线。'));
154
175
  }, 20_000);
155
176
 
156
177
  socket.on('open', () => {
@@ -170,7 +191,7 @@ function connectRelay(bridge) {
170
191
  gmailDraft: 'declared',
171
192
  gmailSend: 'confirmation',
172
193
  },
173
- });
194
+ }, socket);
174
195
  });
175
196
 
176
197
  socket.on('message', raw => {
@@ -178,6 +199,13 @@ function connectRelay(bridge) {
178
199
  try { message = JSON.parse(String(raw)); } catch { return; }
179
200
  if (message.type === 'ready') {
180
201
  if (ready) return;
202
+ if (relay !== socket) {
203
+ settled = true;
204
+ clearTimeout(timer);
205
+ socket.close();
206
+ reject(new Error('Codex Relay connection superseded'));
207
+ return;
208
+ }
181
209
  ready = true;
182
210
  settled = true;
183
211
  clearTimeout(timer);
@@ -188,9 +216,10 @@ function connectRelay(bridge) {
188
216
  settled = true;
189
217
  clearTimeout(timer);
190
218
  socket.close();
191
- reject(new Error(String(message.error || 'Codex Relay rejected the Bridge')));
219
+ reject(createRelayError(String(message.error || 'Codex Relay rejected the Bridge'), message.code));
192
220
  return;
193
221
  }
222
+ if (relay !== socket) return;
194
223
  handleRelayMessage(message);
195
224
  });
196
225
 
@@ -198,7 +227,8 @@ function connectRelay(bridge) {
198
227
  if (!ready && !settled) {
199
228
  settled = true;
200
229
  clearTimeout(timer);
201
- reject(new Error(`Codex Relay 连接失败:${error.message}`));
230
+ socket.close();
231
+ reject(createRelayError(`Codex Relay 连接失败:${error.message}`));
202
232
  } else {
203
233
  console.error(`Codex Relay error: ${error.message}`);
204
234
  }
@@ -206,17 +236,88 @@ function connectRelay(bridge) {
206
236
 
207
237
  socket.on('close', (codeValue, reasonValue) => {
208
238
  const reason = String(reasonValue || '').trim();
209
- if (!ready && !settled) {
210
- settled = true;
211
- clearTimeout(timer);
212
- reject(new Error(`Codex Relay 在握手前断开 (${codeValue ?? 'unknown'})${reason ? `: ${reason}` : ''}`));
239
+ const current = relay === socket;
240
+ if (current) relay = null;
241
+ if (!current) return;
242
+ if (!ready) {
243
+ if (!settled) {
244
+ settled = true;
245
+ clearTimeout(timer);
246
+ reject(createRelayError(`Codex Relay 在握手前断开 (${codeValue ?? 'unknown'})${reason ? `: ${reason}` : ''}`, codeValue));
247
+ }
213
248
  return;
214
249
  }
215
- if (!closed) void shutdown(new Error(`Codex Relay 已断开 (${codeValue ?? 'unknown'})${reason ? `: ${reason}` : ''}`));
250
+ if (!closed) {
251
+ const error = createRelayError(`Codex Relay 已断开 (${codeValue ?? 'unknown'})${reason ? `: ${reason}` : ''}`, codeValue);
252
+ void handleRelayDisconnect(bridge, error);
253
+ }
216
254
  });
217
255
  });
218
256
  }
219
257
 
258
+ async function handleRelayDisconnect(bridge, error) {
259
+ if (closed) return;
260
+ if (isPermanentRelayError(error)) {
261
+ await shutdown(error);
262
+ return;
263
+ }
264
+ if (active) {
265
+ active.abort?.();
266
+ failActiveJob(error instanceof Error ? error.message : String(error || 'Codex Relay disconnected'));
267
+ }
268
+ if (relayReconnectPromise) {
269
+ pendingRelayDisconnect = { bridge, error };
270
+ return;
271
+ }
272
+ const reconnectPromise = (async () => {
273
+ let attempt = 0;
274
+ while (!closed) {
275
+ const delayMs = relayReconnectDelay(attempt);
276
+ attempt += 1;
277
+ console.warn(`Codex Relay disconnected; retrying in ${Math.ceil(delayMs / 1000)}s.`);
278
+ await wait(delayMs);
279
+ if (closed) return;
280
+ try {
281
+ await connectRelay(bridge);
282
+ console.log('Customer Map Codex Bridge reconnected.');
283
+ return;
284
+ } catch (retryError) {
285
+ if (isPermanentRelayError(retryError)) {
286
+ await shutdown(retryError);
287
+ return;
288
+ }
289
+ console.warn(`Codex Relay reconnect failed: ${retryError instanceof Error ? retryError.message : String(retryError || '')}`);
290
+ }
291
+ }
292
+ })();
293
+ relayReconnectPromise = reconnectPromise;
294
+ try {
295
+ await reconnectPromise;
296
+ } finally {
297
+ if (relayReconnectPromise === reconnectPromise) relayReconnectPromise = null;
298
+ const pending = pendingRelayDisconnect;
299
+ pendingRelayDisconnect = null;
300
+ if (pending && !closed) void handleRelayDisconnect(pending.bridge, pending.error);
301
+ }
302
+ }
303
+
304
+ function createRelayError(message, code) {
305
+ const error = new Error(message);
306
+ if (code !== undefined && code !== null && code !== '') error.relayCode = Number(code) || String(code);
307
+ return error;
308
+ }
309
+
310
+ function isPermanentRelayError(error) {
311
+ const code = error?.relayCode;
312
+ if ([4001, 4002, 4401].includes(Number(code))) return true;
313
+ const message = String(error?.message || error || '');
314
+ return /unauthorized|invalid.*token|token.*invalid|connection removed|replaced by newer|bridge token|invalid relay url|invalid url|missing relayurl|connectionid does not match/i.test(message);
315
+ }
316
+
317
+ function relayReconnectDelay(attempt) {
318
+ return Math.min(RELAY_RECONNECT_MAX_MS, RELAY_RECONNECT_BASE_MS * (2 ** Math.min(attempt, 5)));
319
+ }
320
+
220
321
  function handleRelayMessage(message) {
221
322
  if (message.type === 'ping') {
222
323
  sendRelay({ type: 'pong', at: Date.now() });
@@ -230,6 +331,7 @@ function handleRelayMessage(message) {
230
331
  async function runJob(job) {
231
332
  if (!initialized) throw new Error('Codex app-server is not initialized');
232
333
  if (!job?.id) throw new Error('Missing relay job id');
334
+ if (relay?.readyState !== WebSocket.OPEN) throw new Error('Codex Relay disconnected');
233
335
  if (activeJob()) throw new Error('Codex is already processing another request');
234
336
 
235
337
  if (job.request?.mailAction) {
@@ -252,24 +354,44 @@ async function runJob(job) {
252
354
 
253
355
  const sessionKey = String(job.request?.sessionKey || job.request?.conversationSessionId || 'default');
254
356
  const threadId = await ensureThread(sessionKey);
357
+ if (relay?.readyState !== WebSocket.OPEN) throw new Error('Codex Relay disconnected');
255
358
  const input = Array.isArray(job.request?.input) ? job.request.input : [];
256
359
  const prompt = input
257
360
  .map(item => `${String(item.role || 'user').toUpperCase()}:\n${stringifyContent(item.content)}`)
258
361
  .join('\n\n')
259
362
  .slice(-200000);
260
- active = { id: String(job.id), threadId, text: '' };
363
+ const jobState = {
364
+ id: String(job.id),
365
+ threadId,
366
+ text: '',
367
+ turnId: '',
368
+ interrupted: false,
369
+ abort: () => {
370
+ jobState.interrupted = true;
371
+ interruptActiveTurn(jobState);
372
+ },
373
+ };
374
+ active = jobState;
261
375
  try {
262
- await rpc('turn/start', {
376
+ const started = await rpc('turn/start', {
263
377
  threadId,
264
378
  approvalPolicy: 'never',
265
379
  sandboxPolicy: { type: 'readOnly', networkAccess: false },
266
380
  input: [{ type: 'text', text: prompt }],
267
381
  });
382
+ jobState.turnId = String(started?.turn?.id || '');
383
+ interruptActiveTurn(jobState);
268
384
  } catch (error) {
269
385
  failActiveJob(error instanceof Error ? error.message : String(error || 'Codex turn failed'));
270
386
  }
271
387
  }
272
388
 
389
+ function interruptActiveTurn(job) {
390
+ if (!job?.interrupted || !job.turnId || job.interruptSent) return;
391
+ job.interruptSent = true;
392
+ void rpc('turn/interrupt', { threadId: job.threadId, turnId: job.turnId }, 10_000).catch(() => undefined);
393
+ }
394
+
273
395
  async function ensureThread(sessionKey) {
274
396
  let threadId = String(state.threads?.[sessionKey] || '');
275
397
  if (threadId && !loadedThreads.has(threadId)) {
@@ -349,8 +471,8 @@ function sendCodex(message) {
349
471
  codex.stdin.write(`${JSON.stringify(message)}\n`);
350
472
  }
351
473
 
352
- function sendRelay(message) {
353
- if (relay?.readyState === WebSocket.OPEN) relay.send(JSON.stringify(message));
474
+ function sendRelay(message, socket = relay) {
475
+ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
354
476
  }
355
477
 
356
478
  function startCodexAppServer(cwd, command) {
@@ -484,6 +606,13 @@ function parseConnectUri(value) {
484
606
  return { site: url.searchParams.get('site') || '', code: url.searchParams.get('code') || '' };
485
607
  }
486
608
 
609
+ function wait(delayMs) {
610
+ return new Promise(resolve => {
611
+ const timer = setTimeout(resolve, delayMs);
612
+ timer.unref?.();
613
+ });
614
+ }
615
+
487
616
  async function requestAuthorization(siteUrl) {
488
617
  if (process.platform === 'win32') {
489
618
  const dialog = spawnSync('powershell.exe', [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "customer-map-codex-bridge",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Connect a locally authenticated Codex CLI to Customer Map.",
5
5
  "type": "module",
6
6
  "bin": {