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.
- package/README.md +5 -3
- package/index.mjs +144 -15
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
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', [
|