customer-map-codex-bridge 0.5.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/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Customer Map Codex Bridge
2
+
3
+ This local helper connects a user's already-authenticated Codex installation to Customer Map without sending Codex credentials or API keys to Customer Map.
4
+
5
+ ## Requirements
6
+
7
+ - Codex CLI installed and already signed in locally (`codex login`).
8
+ - Optional Gmail actions require `gog` v0.11.0+ with the intended Gmail account already authorized locally.
9
+ - Node.js 18+.
10
+ - The Customer Map Agent settings panel must provide a one-time `CMAP-CODEX-*` code.
11
+
12
+ The settings panel reports **Connected** only after both the local Codex app-server and the Customer Map relay WebSocket are online. A claimed code by itself is only a pending binding.
13
+
14
+ ## Run
15
+
16
+ ```bash
17
+ npx -y customer-map-codex-bridge@0.5.0 \
18
+ --site https://your-customer-map.example \
19
+ --code CMAP-CODEX-XXXXXXXXXXXX
20
+ ```
21
+
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
+
24
+ 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
+
26
+ 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.
27
+
28
+ If the protocol handler cannot find `codex`, pass its absolute path explicitly:
29
+
30
+ ```bash
31
+ npx -y customer-map-codex-bridge@0.5.0 \
32
+ --site https://your-customer-map.example \
33
+ --code CMAP-CODEX-XXXXXXXXXXXX \
34
+ --codex /absolute/path/to/codex
35
+ ```
36
+
37
+ If `gog` is outside the standard Homebrew/system paths, pass it explicitly:
38
+
39
+ ```bash
40
+ npx -y customer-map-codex-bridge@0.5.0 \
41
+ --site https://your-customer-map.example \
42
+ --code CMAP-CODEX-XXXXXXXXXXXX \
43
+ --gog /absolute/path/to/gog
44
+ ```
45
+
46
+ The website can then open:
47
+
48
+ ```text
49
+ customer-map-codex://connect?site=https%3A%2F%2Fyour-customer-map.example&code=CMAP-CODEX-XXXXXXXXXXXX
50
+ ```
package/index.mjs ADDED
@@ -0,0 +1,527 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { createInterface } from 'node:readline';
5
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { delimiter, dirname, join } from 'node:path';
8
+ import process from 'node:process';
9
+ import WebSocket from 'ws';
10
+ import { executeCustomerMapMailAction } from './mail-action.mjs';
11
+
12
+ const BRIDGE_VERSION = '0.5.0';
13
+
14
+ const args = parseArgs(process.argv.slice(2));
15
+ if (args.help) {
16
+ console.log('Usage: customer-map-codex-bridge --site <Customer Map URL> --code <one-time code> [--cwd <folder>] [--codex <path>] [--gog <path>]');
17
+ process.exit(0);
18
+ }
19
+
20
+ const uri = args.uri || process.argv.slice(2).find(value => String(value).startsWith('customer-map-codex://'));
21
+ const uriValues = uri ? parseConnectUri(uri) : {};
22
+ const site = validateSite(args.site || uriValues.site);
23
+ const code = validateCode(args.code || uriValues.code);
24
+ const clientId = args.client || `codex-bridge-${process.platform}-${process.pid}`;
25
+ const stateDir = args.state || join(homedir(), '.customer-map-codex');
26
+ const stateFile = join(stateDir, 'state.json');
27
+
28
+ let bridgeToken = '';
29
+ let connectionId = '';
30
+ let codex = null;
31
+ let codexLines = null;
32
+ let relay = null;
33
+ let state = { threads: {} };
34
+ let rpcId = 0;
35
+ let initialized = false;
36
+ let closed = false;
37
+ let active = null;
38
+ let shutdownPromise = null;
39
+ const pendingRpc = new Map();
40
+ const loadedThreads = new Set();
41
+
42
+ process.once('SIGINT', () => { void shutdown(new Error('Codex Bridge interrupted')); });
43
+ process.once('SIGTERM', () => { void shutdown(new Error('Codex Bridge terminated')); });
44
+
45
+ await main();
46
+
47
+ async function main() {
48
+ try {
49
+ await requestAuthorization(site);
50
+ state = await loadState();
51
+
52
+ const codexCommand = resolveCodexCommand(args.codex);
53
+ codex = startCodexAppServer(args.cwd || process.cwd(), codexCommand);
54
+ codexLines = createInterface({ input: codex.stdout });
55
+ attachCodexHandlers();
56
+
57
+ await rpc('initialize', {
58
+ clientInfo: { name: 'customer_map_codex_bridge', title: 'Customer Map Codex Bridge', version: BRIDGE_VERSION },
59
+ capabilities: { experimentalApi: true },
60
+ });
61
+ sendCodex({ method: 'initialized', params: {} });
62
+ initialized = true;
63
+ const accountStatus = await rpc('account/read', { refreshToken: true }, 30_000);
64
+ if (accountStatus?.requiresOpenaiAuth && !accountStatus.account) {
65
+ throw new Error('Codex CLI 尚未登录,请先运行 codex login。');
66
+ }
67
+
68
+ // Only consume the one-time code after the local Codex runtime is healthy.
69
+ // A failed app-server launch must not strand the code as "expired".
70
+ const bridge = await claim();
71
+ bridgeToken = bridge.bridgeToken;
72
+ connectionId = bridge.connectionId;
73
+ relay = await connectRelay(bridge);
74
+ console.log('Customer Map Codex Bridge connected.');
75
+ } catch (error) {
76
+ console.error(error instanceof Error ? error.message : String(error || 'Codex Bridge failed'));
77
+ await shutdown(error);
78
+ process.exitCode = 1;
79
+ }
80
+ }
81
+
82
+ function attachCodexHandlers() {
83
+ codexLines.on('line', handleCodexLine);
84
+ codex.on('error', error => {
85
+ rejectPendingRpc(error);
86
+ if (!closed) {
87
+ console.error(`Could not start Codex app-server: ${error.message}`);
88
+ void shutdown(error);
89
+ }
90
+ });
91
+ codex.on('exit', codeValue => {
92
+ const error = new Error(`Codex app-server exited (${codeValue ?? 'unknown'})`);
93
+ rejectPendingRpc(error);
94
+ if (active) failActiveJob(error.message);
95
+ if (!closed) {
96
+ console.error(error.message);
97
+ void shutdown(error);
98
+ }
99
+ });
100
+ }
101
+
102
+ function handleCodexLine(line) {
103
+ let message;
104
+ try { message = JSON.parse(line); } catch { return; }
105
+ if (message.id !== undefined && pendingRpc.has(message.id)) {
106
+ const entry = pendingRpc.get(message.id);
107
+ pendingRpc.delete(message.id);
108
+ if (message.error) entry.reject(new Error(message.error.message || 'Codex app-server request failed'));
109
+ else entry.resolve(message.result);
110
+ return;
111
+ }
112
+ if (message.method === 'item/agentMessage/delta') {
113
+ const job = activeJob(message.params);
114
+ const delta = String(message.params?.delta || message.params?.text || '');
115
+ if (job && delta) {
116
+ job.text += delta;
117
+ sendRelay({ type: 'progress', jobId: job.id, content: job.text.slice(-100000) });
118
+ }
119
+ }
120
+ if (message.method === 'item/completed') {
121
+ const item = message.params?.item;
122
+ if ((item?.type === 'agent_message' || item?.type === 'agentMessage') && typeof item.text === 'string') {
123
+ const job = activeJob(message.params);
124
+ if (job) job.text = item.text;
125
+ }
126
+ }
127
+ if (message.method === 'error' && !message.params?.willRetry && activeJob(message.params)) {
128
+ failActiveJob(message.params?.error?.message || 'Codex turn failed');
129
+ }
130
+ if (message.method === 'turn/completed' && activeJob(message.params)) {
131
+ const turn = message.params?.turn;
132
+ if (turn?.status === 'failed' || turn?.status === 'interrupted') {
133
+ failActiveJob(turn?.error?.message || `Codex turn ${turn.status}`);
134
+ } else {
135
+ finishActiveJob();
136
+ }
137
+ }
138
+ if (message.method === 'turn/failed' && activeJob(message.params)) {
139
+ failActiveJob(message.params?.error?.message || 'Codex turn failed');
140
+ }
141
+ }
142
+
143
+ function connectRelay(bridge) {
144
+ return new Promise((resolve, reject) => {
145
+ const socket = new WebSocket(webSocketUrl(bridge.relayUrl));
146
+ relay = socket;
147
+ let ready = false;
148
+ let settled = false;
149
+ const timer = setTimeout(() => {
150
+ if (settled) return;
151
+ settled = true;
152
+ socket.close();
153
+ reject(new Error('Codex Relay 握手超时,请检查 Relay 或 Tunnel 是否在线。'));
154
+ }, 20_000);
155
+
156
+ socket.on('open', () => {
157
+ sendRelay({
158
+ type: 'hello',
159
+ runtime: 'codex',
160
+ bridgeToken: bridge.bridgeToken,
161
+ connectionId: bridge.connectionId,
162
+ clientId,
163
+ pluginVersion: BRIDGE_VERSION,
164
+ capabilities: {
165
+ runtime: 'verified',
166
+ sessionContext: 'verified',
167
+ customerMapData: 'declared',
168
+ webRead: 'declared',
169
+ webSearch: 'declared',
170
+ gmailDraft: 'declared',
171
+ gmailSend: 'confirmation',
172
+ },
173
+ });
174
+ });
175
+
176
+ socket.on('message', raw => {
177
+ let message;
178
+ try { message = JSON.parse(String(raw)); } catch { return; }
179
+ if (message.type === 'ready') {
180
+ if (ready) return;
181
+ ready = true;
182
+ settled = true;
183
+ clearTimeout(timer);
184
+ resolve(socket);
185
+ return;
186
+ }
187
+ if (message.type === 'error' && !ready && !settled) {
188
+ settled = true;
189
+ clearTimeout(timer);
190
+ socket.close();
191
+ reject(new Error(String(message.error || 'Codex Relay rejected the Bridge')));
192
+ return;
193
+ }
194
+ handleRelayMessage(message);
195
+ });
196
+
197
+ socket.on('error', error => {
198
+ if (!ready && !settled) {
199
+ settled = true;
200
+ clearTimeout(timer);
201
+ reject(new Error(`Codex Relay 连接失败:${error.message}`));
202
+ } else {
203
+ console.error(`Codex Relay error: ${error.message}`);
204
+ }
205
+ });
206
+
207
+ socket.on('close', (codeValue, reasonValue) => {
208
+ 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}` : ''}`));
213
+ return;
214
+ }
215
+ if (!closed) void shutdown(new Error(`Codex Relay 已断开 (${codeValue ?? 'unknown'})${reason ? `: ${reason}` : ''}`));
216
+ });
217
+ });
218
+ }
219
+
220
+ function handleRelayMessage(message) {
221
+ if (message.type === 'ping') {
222
+ sendRelay({ type: 'pong', at: Date.now() });
223
+ return;
224
+ }
225
+ if (message.type === 'job') {
226
+ void runJob(message.job).catch(error => sendRelay({ type: 'complete', jobId: message.job?.id, error: error?.message || 'Codex request failed' }));
227
+ }
228
+ }
229
+
230
+ async function runJob(job) {
231
+ if (!initialized) throw new Error('Codex app-server is not initialized');
232
+ if (!job?.id) throw new Error('Missing relay job id');
233
+ if (activeJob()) throw new Error('Codex is already processing another request');
234
+
235
+ if (job.request?.mailAction) {
236
+ const controller = new AbortController();
237
+ active = { id: String(job.id), threadId: '', text: '', abort: () => controller.abort() };
238
+ try {
239
+ const response = await executeCustomerMapMailAction(job.request.mailAction, {
240
+ signal: controller.signal,
241
+ timeoutMs: job.timeoutMs,
242
+ gogCommand: args.gog,
243
+ });
244
+ if (!active || active.id !== String(job.id)) return;
245
+ active = null;
246
+ sendRelay({ type: 'complete', jobId: String(job.id), response: { output_text: JSON.stringify(response) } });
247
+ } catch (error) {
248
+ failActiveJob(error instanceof Error ? error.message : String(error || 'Codex mail action failed'));
249
+ }
250
+ return;
251
+ }
252
+
253
+ const sessionKey = String(job.request?.sessionKey || job.request?.conversationSessionId || 'default');
254
+ const threadId = await ensureThread(sessionKey);
255
+ const input = Array.isArray(job.request?.input) ? job.request.input : [];
256
+ const prompt = input
257
+ .map(item => `${String(item.role || 'user').toUpperCase()}:\n${stringifyContent(item.content)}`)
258
+ .join('\n\n')
259
+ .slice(-200000);
260
+ active = { id: String(job.id), threadId, text: '' };
261
+ try {
262
+ await rpc('turn/start', {
263
+ threadId,
264
+ approvalPolicy: 'never',
265
+ sandboxPolicy: { type: 'readOnly', networkAccess: false },
266
+ input: [{ type: 'text', text: prompt }],
267
+ });
268
+ } catch (error) {
269
+ failActiveJob(error instanceof Error ? error.message : String(error || 'Codex turn failed'));
270
+ }
271
+ }
272
+
273
+ async function ensureThread(sessionKey) {
274
+ let threadId = String(state.threads?.[sessionKey] || '');
275
+ if (threadId && !loadedThreads.has(threadId)) {
276
+ try {
277
+ const resumed = await rpc('thread/resume', {
278
+ threadId,
279
+ sandbox: 'read-only',
280
+ approvalPolicy: 'never',
281
+ excludeTurns: true,
282
+ }, 60_000);
283
+ threadId = String(resumed?.thread?.id || threadId);
284
+ loadedThreads.add(threadId);
285
+ } catch (error) {
286
+ if (!/thread not found/i.test(String(error?.message || error))) throw error;
287
+ threadId = '';
288
+ }
289
+ }
290
+ if (!threadId) {
291
+ threadId = String((await rpc('thread/start', {
292
+ sandbox: 'read-only',
293
+ approvalPolicy: 'never',
294
+ }, 60_000)).thread?.id || '');
295
+ if (!threadId) throw new Error('Codex did not return a thread id');
296
+ loadedThreads.add(threadId);
297
+ }
298
+ state.threads = { ...(state.threads || {}), [sessionKey]: threadId };
299
+ await saveState();
300
+ return threadId;
301
+ }
302
+
303
+ function activeJob(params) {
304
+ if (!active) return null;
305
+ const threadId = String(params?.threadId || '');
306
+ return threadId && threadId !== active.threadId ? null : active;
307
+ }
308
+ function finishActiveJob() {
309
+ if (!active) return;
310
+ const job = active;
311
+ active = null;
312
+ sendRelay({ type: 'complete', jobId: job.id, response: { output_text: job.text.trim() || 'Codex returned an empty response.' } });
313
+ }
314
+ function failActiveJob(error) {
315
+ if (!active) return;
316
+ const job = active;
317
+ active = null;
318
+ sendRelay({ type: 'complete', jobId: job.id, error: String(error || 'Codex request failed') });
319
+ }
320
+
321
+ function rpc(method, params, timeoutMs = 20_000) {
322
+ const id = ++rpcId;
323
+ return new Promise((resolve, reject) => {
324
+ const timer = setTimeout(() => {
325
+ pendingRpc.delete(id);
326
+ reject(new Error(`Codex app-server ${method} 超时`));
327
+ }, timeoutMs);
328
+ pendingRpc.set(id, {
329
+ resolve: value => { clearTimeout(timer); resolve(value); },
330
+ reject: error => { clearTimeout(timer); reject(error); },
331
+ });
332
+ try {
333
+ sendCodex({ method, id, params });
334
+ } catch (error) {
335
+ pendingRpc.delete(id);
336
+ clearTimeout(timer);
337
+ reject(error);
338
+ }
339
+ });
340
+ }
341
+
342
+ function rejectPendingRpc(error) {
343
+ for (const entry of pendingRpc.values()) entry.reject(error);
344
+ pendingRpc.clear();
345
+ }
346
+
347
+ function sendCodex(message) {
348
+ if (!codex?.stdin?.writable) throw new Error('Codex app-server stdin is unavailable');
349
+ codex.stdin.write(`${JSON.stringify(message)}\n`);
350
+ }
351
+
352
+ function sendRelay(message) {
353
+ if (relay?.readyState === WebSocket.OPEN) relay.send(JSON.stringify(message));
354
+ }
355
+
356
+ function startCodexAppServer(cwd, command) {
357
+ const codexArgs = ['app-server', '--listen', 'stdio://', '-c', 'web_search="live"'];
358
+ const pathValue = [dirname(process.execPath), dirname(command), process.env.PATH || ''].filter(Boolean).join(delimiter);
359
+ const options = { cwd, stdio: ['pipe', 'pipe', 'inherit'], windowsHide: true, env: { ...process.env, PATH: pathValue } };
360
+ if (process.platform !== 'win32') return spawn(command, codexArgs, options);
361
+ const commandShell = process.env.ComSpec || process.env.COMSPEC || 'C:\\Windows\\System32\\cmd.exe';
362
+ const commandLine = [command, ...codexArgs].map(quoteWindowsArg).join(' ');
363
+ return spawn(commandShell, ['/d', '/s', '/c', commandLine], options);
364
+ }
365
+
366
+ function resolveCodexCommand(value) {
367
+ const requested = String(value || process.env.CUSTOMER_MAP_CODEX_BIN || '').trim();
368
+ if (requested && requested !== 'codex' && requested !== 'codex.cmd') return requested;
369
+ if (process.platform === 'win32') return requested || 'codex.cmd';
370
+ const candidates = [
371
+ join(homedir(), '.npm-global', 'bin', 'codex'),
372
+ join(homedir(), '.local', 'bin', 'codex'),
373
+ '/opt/homebrew/bin/codex',
374
+ '/usr/local/bin/codex',
375
+ '/usr/bin/codex',
376
+ ];
377
+ return candidates.find(candidate => existsSync(candidate)) || 'codex';
378
+ }
379
+
380
+ function quoteWindowsArg(value) {
381
+ return `"${String(value).replaceAll('"', '\\"')}"`;
382
+ }
383
+
384
+ async function claim() {
385
+ const response = await fetch(`${site.replace(/\/$/, '')}/api/codex-link`, {
386
+ method: 'POST',
387
+ headers: { 'Content-Type': 'application/json' },
388
+ body: JSON.stringify({ action: 'claim', code, clientId }),
389
+ });
390
+ const data = await response.json().catch(() => ({}));
391
+ if (!response.ok) throw new Error(data?.error || `Claim failed (${response.status})`);
392
+ const nextBridgeToken = String(data?.bridgeToken || '');
393
+ const nextConnectionId = String(data?.connectionId || '');
394
+ if (!nextBridgeToken || !nextConnectionId) throw new Error('Customer Map returned an incomplete Codex connection');
395
+ return { ...data, bridgeToken: nextBridgeToken, connectionId: nextConnectionId };
396
+ }
397
+
398
+ async function notifyOffline(error) {
399
+ if (!bridgeToken || !clientId) return;
400
+ const response = await fetch(`${site.replace(/\/$/, '')}/api/codex-link`, {
401
+ method: 'POST',
402
+ headers: { 'Content-Type': 'application/json' },
403
+ body: JSON.stringify({ action: 'bridge-offline', bridgeToken, clientId, error: String(error?.message || error || '') }),
404
+ });
405
+ if (!response.ok) throw new Error(`Customer Map offline status failed (${response.status})`);
406
+ }
407
+
408
+ async function shutdown(error) {
409
+ if (shutdownPromise) return shutdownPromise;
410
+ shutdownPromise = (async () => {
411
+ if (closed) return;
412
+ closed = true;
413
+ active?.abort?.();
414
+ if (active) failActiveJob(error instanceof Error ? error.message : String(error || 'Codex Bridge stopped'));
415
+ rejectPendingRpc(error instanceof Error ? error : new Error(String(error || 'Codex Bridge stopped')));
416
+ await notifyOffline(error).catch(() => undefined);
417
+ if (relay && (relay.readyState === WebSocket.OPEN || relay.readyState === WebSocket.CONNECTING)) relay.close();
418
+ if (codex && !codex.killed) codex.kill();
419
+ codexLines?.close();
420
+ })();
421
+ return shutdownPromise;
422
+ }
423
+
424
+ async function loadState() {
425
+ try {
426
+ const parsed = JSON.parse(await readFile(stateFile, 'utf8'));
427
+ return parsed && typeof parsed === 'object' && parsed.threads && typeof parsed.threads === 'object'
428
+ ? parsed
429
+ : { threads: {} };
430
+ } catch {
431
+ return { threads: {} };
432
+ }
433
+ }
434
+
435
+ async function saveState() {
436
+ await mkdir(stateDir, { recursive: true });
437
+ await writeFile(stateFile, JSON.stringify(state, null, 2), 'utf8');
438
+ }
439
+
440
+ function stringifyContent(value) {
441
+ if (typeof value === 'string') return value;
442
+ if (Array.isArray(value)) return value.map(item => stringifyContent(item)).join('\n');
443
+ if (value && typeof value === 'object') {
444
+ const text = value.text || value.content || value.value;
445
+ if (typeof text === 'string') return text;
446
+ try { return JSON.stringify(value); } catch { return String(value); }
447
+ }
448
+ return String(value ?? '');
449
+ }
450
+
451
+ function required(value, name) { if (!value) throw new Error(`Missing ${name}`); return value; }
452
+ function validateSite(value) {
453
+ const url = new URL(required(value, '--site'));
454
+ const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname);
455
+ if (url.protocol !== 'https:' && !localHttp) throw new Error('Customer Map site must use HTTPS (localhost may use HTTP)');
456
+ return url.origin;
457
+ }
458
+ function validateCode(value) {
459
+ const normalized = String(required(value, '--code')).trim().toUpperCase().replace(/\s+/g, '');
460
+ if (!/^CMAP-CODEX-[A-F0-9]{12}$/.test(normalized)) throw new Error('Invalid Customer Map Codex connection code');
461
+ return normalized;
462
+ }
463
+ function parseArgs(values) {
464
+ const result = {};
465
+ for (let i = 0; i < values.length; i += 1) {
466
+ const value = values[i];
467
+ if (!value.startsWith('--')) continue;
468
+ const key = value.slice(2);
469
+ const next = values[i + 1];
470
+ if (!next || next.startsWith('--')) result[key] = true;
471
+ else { result[key] = next; i += 1; }
472
+ }
473
+ return result;
474
+ }
475
+ function webSocketUrl(value) {
476
+ const url = new URL(required(value, 'relayUrl'));
477
+ if (url.protocol === 'https:') url.protocol = 'wss:';
478
+ if (url.protocol === 'http:') url.protocol = 'ws:';
479
+ if (!/^wss?:$/.test(url.protocol)) throw new Error('Invalid relay URL');
480
+ return url.toString();
481
+ }
482
+ function parseConnectUri(value) {
483
+ const url = new URL(value);
484
+ return { site: url.searchParams.get('site') || '', code: url.searchParams.get('code') || '' };
485
+ }
486
+
487
+ async function requestAuthorization(siteUrl) {
488
+ if (process.platform === 'win32') {
489
+ const dialog = spawnSync('powershell.exe', [
490
+ '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
491
+ '-WindowStyle', 'Hidden', '-Command',
492
+ "$ErrorActionPreference='Stop'; Add-Type -AssemblyName PresentationFramework; $site = $env:CUSTOMER_MAP_CODEX_SITE; $message = \"Customer Map wants to connect to the Codex installed on this computer.`n`nSite: $site`n`nCodex chat stays read-only. Live web search may read public sites; shell network and file writes stay blocked. Only explicit Gmail draft/send actions may run local gog. Credentials stay local.\"; $result = [System.Windows.MessageBox]::Show($message, 'Customer Map - Codex authorization', 'YesNo', 'Question', 'No'); if ($result -eq 'Yes') { exit 0 }; exit 1",
493
+ ], { env: { ...process.env, CUSTOMER_MAP_CODEX_SITE: siteUrl }, stdio: 'ignore', windowsHide: true });
494
+ if (!dialog.error) {
495
+ if (dialog.status === 0) return;
496
+ console.log('Codex connection cancelled.');
497
+ process.exit(0);
498
+ }
499
+ }
500
+ if (process.platform === 'darwin') {
501
+ const dialog = spawnSync('osascript', ['-e', [
502
+ 'on run argv',
503
+ ' set siteUrl to item 1 of argv',
504
+ ' set promptText to "Customer Map wants to connect to the Codex installed on this Mac." & return & return & "Site: " & siteUrl & return & return & "Codex chat stays read-only. Live web search may read public sites; shell network and file writes stay blocked. Only explicit Gmail draft/send actions may run local gog. Credentials stay local."',
505
+ ' display dialog promptText with title "Customer Map - Codex authorization" buttons {"Cancel", "Allow"} default button "Cancel"',
506
+ ' if button returned of result is "Allow" then return 0',
507
+ ' return 1',
508
+ 'end run',
509
+ ].join('\n'), siteUrl], { stdio: 'ignore' });
510
+ if (!dialog.error) {
511
+ if (dialog.status === 0) return;
512
+ console.log('Codex connection cancelled.');
513
+ process.exit(0);
514
+ }
515
+ }
516
+ console.log(`\nCustomer Map wants to connect to the Codex installed on this computer.`);
517
+ console.log(`Site: ${siteUrl}`);
518
+ console.log('Codex chat stays read-only. Live web search may read public sites; shell network and file writes stay blocked. Only explicit Gmail draft/send actions may run local gog. Credentials stay local.');
519
+ process.stdout.write('Allow this connection? [y/N] ');
520
+ const input = createInterface({ input: process.stdin, output: process.stdout });
521
+ const answer = await new Promise(resolve => input.once('line', resolve));
522
+ input.close();
523
+ if (String(answer).trim().toLowerCase() !== 'y') {
524
+ console.log('Codex connection cancelled.');
525
+ process.exit(0);
526
+ }
527
+ }
@@ -0,0 +1,363 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { delimiter, isAbsolute, join, resolve } from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ const MIN_GOG_VERSION = [0, 11, 0];
9
+ const MAX_GOG_BODY_BYTES = 100_000;
10
+ const MAX_PROCESS_OUTPUT_CHARS = 1_000_000;
11
+ const mailActionResults = new Map();
12
+ const gogVersionCache = new Map();
13
+
14
+ export async function executeCustomerMapMailAction(
15
+ value,
16
+ { signal, timeoutMs, gogCommand: requestedGogCommand = '' },
17
+ ) {
18
+ let action;
19
+ try {
20
+ action = normalizeMailAction(value);
21
+ } catch (error) {
22
+ return mailActionResult(value, 'failed', { error: formatError(error) });
23
+ }
24
+
25
+ const cacheKey = `${action.kind}:${action.actionId}`;
26
+ const cached = mailActionResults.get(cacheKey);
27
+ if (cached) {
28
+ if (cached.bodyHash !== action.bodyHash) {
29
+ return mailActionResult(action, 'failed', { error: 'Mail action id was reused with different content.' });
30
+ }
31
+ return cached.result;
32
+ }
33
+
34
+ let gogCommand;
35
+ let toolVersion;
36
+ try {
37
+ gogCommand = resolveGogCommand(requestedGogCommand);
38
+ toolVersion = await readGogVersion(gogCommand, signal);
39
+ } catch (error) {
40
+ return cacheMailResult(cacheKey, action, mailActionResult(action, 'failed', {
41
+ error: `gog is unavailable or unsupported: ${compactStatus(formatError(error))}`,
42
+ }));
43
+ }
44
+
45
+ const processResult = await runProcess(gogCommand, buildGogMailArgs(action), {
46
+ signal,
47
+ timeoutMs: Math.max(5_000, Math.min(Number(timeoutMs) - 2_000 || 90_000, 90_000)),
48
+ });
49
+ return cacheMailResult(cacheKey, action, classifyGogProcessResult(action, processResult, toolVersion));
50
+ }
51
+
52
+ export function normalizeMailAction(value) {
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
54
+ throw new Error('Invalid Customer Map mail action.');
55
+ }
56
+ const source = value;
57
+ const kind = source.kind === 'sendEmail' || source.kind === 'saveDraft' ? source.kind : '';
58
+ const actionId = cleanText(source.actionId).toLowerCase();
59
+ const account = cleanText(source.account).toLowerCase();
60
+ const recipient = cleanText(source.recipient).toLowerCase();
61
+ const subject = cleanText(source.subject);
62
+ const plainTextBody = stringValue(source.plainTextBody);
63
+ const htmlBody = stringValue(source.htmlBody);
64
+ const bodyHash = cleanText(source.bodyHash).toLowerCase();
65
+ if (source.version !== 1 || !kind) throw new Error('Unsupported Customer Map mail action version or kind.');
66
+ if (!/^[0-9a-f]{32}$/.test(actionId)) throw new Error('Invalid Customer Map mail action id.');
67
+ if (!validEmail(account) || !validEmail(recipient)) throw new Error('Invalid Gmail account or recipient.');
68
+ if (!subject || /[\r\n]/.test(subject)) throw new Error('Invalid email subject.');
69
+ if (!plainTextBody && !htmlBody) throw new Error('Email body is empty.');
70
+ if (looksLikeBodyPath(plainTextBody) || looksLikeBodyPath(htmlBody)) {
71
+ throw new Error('Email body cannot be a filesystem or stdin path.');
72
+ }
73
+ if (Buffer.byteLength(plainTextBody, 'utf8') > MAX_GOG_BODY_BYTES || Buffer.byteLength(htmlBody, 'utf8') > MAX_GOG_BODY_BYTES) {
74
+ throw new Error('Email body is too large for safe gog argument execution.');
75
+ }
76
+ if (!htmlBody && containsMarkdownTable(plainTextBody)) {
77
+ throw new Error('Markdown table requires an HTML body before using Gmail.');
78
+ }
79
+ if (bodyHash !== mailBodyHash(recipient, subject, plainTextBody, htmlBody)) {
80
+ throw new Error('Email body integrity check failed.');
81
+ }
82
+ return { version: 1, actionId, kind, account, recipient, subject, plainTextBody, htmlBody, bodyHash };
83
+ }
84
+
85
+ export function buildGogMailArgs(action) {
86
+ const args = action.kind === 'sendEmail' ? ['gmail', 'send'] : ['gmail', 'drafts', 'create'];
87
+ args.push(`--to=${action.recipient}`, `--subject=${action.subject}`);
88
+ if (action.htmlBody) args.push(`--body-html=${action.htmlBody}`);
89
+ if (action.plainTextBody) args.push(`--body=${action.plainTextBody}`);
90
+ args.push(`--account=${action.account}`);
91
+ if (action.kind === 'sendEmail') args.push('--force');
92
+ args.push('--json', '--no-input');
93
+ return args;
94
+ }
95
+
96
+ export function mailBodyHash(recipient, subject, plainTextBody, htmlBody) {
97
+ return createHash('sha256')
98
+ .update(recipient)
99
+ .update('\n')
100
+ .update(subject)
101
+ .update('\n')
102
+ .update(plainTextBody)
103
+ .update('\n')
104
+ .update(htmlBody)
105
+ .digest('hex');
106
+ }
107
+
108
+ export function extractMailResultId(text, kind) {
109
+ if (!text) return '';
110
+ const candidates = [];
111
+ try {
112
+ candidates.push(JSON.parse(text));
113
+ } catch {
114
+ for (const line of String(text).split(/\r?\n/).reverse()) {
115
+ try {
116
+ candidates.push(JSON.parse(line));
117
+ break;
118
+ } catch {
119
+ // Keep looking for the final JSON line.
120
+ }
121
+ }
122
+ }
123
+ for (const candidate of candidates) {
124
+ const found = findMailResultId(candidate, kind);
125
+ if (found) return found;
126
+ }
127
+ return /"(?:draftId|draft_id|messageId|message_id|id)"\s*:\s*"([^"\s]{6,})"/.exec(String(text))?.[1] || '';
128
+ }
129
+
130
+ export function classifyGogProcessResult(action, result, toolVersion = '') {
131
+ const operation = action.kind === 'sendEmail' ? 'send' : 'draft create';
132
+ const mailbox = action.kind === 'sendEmail' ? 'Gmail Sent' : 'Gmail Drafts';
133
+ if (result.timedOut || result.aborted) {
134
+ return mailActionResult(action, 'needsConfirmation', {
135
+ error: `gog ${operation} timed out or was cancelled; check ${mailbox} before retrying.`,
136
+ toolVersion,
137
+ exitCode: result.exitCode,
138
+ });
139
+ }
140
+ if (result.error) {
141
+ return mailActionResult(action, 'failed', {
142
+ error: `gog ${operation} could not start: ${compactStatus(result.error)}`,
143
+ toolVersion,
144
+ exitCode: result.exitCode,
145
+ });
146
+ }
147
+ const messageId = extractMailResultId(result.stdout, action.kind);
148
+ if (result.exitCode === 0 && messageId) {
149
+ return mailActionResult(action, 'succeeded', { messageId, toolVersion, exitCode: 0 });
150
+ }
151
+ if (result.exitCode === 0) {
152
+ return mailActionResult(action, 'needsConfirmation', {
153
+ error: `gog ${operation} returned success without a verifiable Gmail id; check ${mailbox} before retrying.`,
154
+ toolVersion,
155
+ exitCode: 0,
156
+ });
157
+ }
158
+ const details = compactStatus(result.stderr || result.stdout || '');
159
+ return mailActionResult(action, 'failed', {
160
+ error: `gog ${operation} failed with exit code ${result.exitCode ?? 'unknown'}${details ? `: ${details}` : '.'}`,
161
+ toolVersion,
162
+ exitCode: result.exitCode,
163
+ });
164
+ }
165
+
166
+ export function resolveGogCommand(value = '') {
167
+ const requested = cleanText(value || process.env.CUSTOMER_MAP_GOG_BIN);
168
+ const fixedCandidates = process.platform === 'win32'
169
+ ? []
170
+ : [
171
+ '/opt/homebrew/bin/gog',
172
+ '/usr/local/bin/gog',
173
+ '/usr/bin/gog',
174
+ join(homedir(), '.local', 'bin', 'gog'),
175
+ join(homedir(), 'go', 'bin', 'gog'),
176
+ ];
177
+ const candidates = [
178
+ ...resolveCommandCandidate(requested),
179
+ ...fixedCandidates,
180
+ ...pathCommandCandidates(),
181
+ ];
182
+ const found = candidates.find(candidate => candidate && existsSync(candidate));
183
+ if (!found) throw new Error('gog was not found. Install it or pass --gog /absolute/path/to/gog.');
184
+ return found;
185
+ }
186
+
187
+ function mailActionResult(value, status, options = {}) {
188
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
189
+ const error = options.error || '';
190
+ const successReply = source.kind === 'saveDraft'
191
+ ? '邮件已通过 Codex Bridge 的固定 gog 参数保存到 Gmail 草稿。'
192
+ : '邮件已通过 Codex Bridge 的固定 gog 参数发送。';
193
+ return {
194
+ reply: status === 'succeeded' ? successReply : error,
195
+ subject: '',
196
+ sendText: '',
197
+ tag: '',
198
+ aiNote: '',
199
+ continue: false,
200
+ actionReceipt: {
201
+ kind: source.kind === 'saveDraft' ? 'saveDraft' : 'sendEmail',
202
+ status,
203
+ provider: 'gmail',
204
+ messageId: options.messageId || '',
205
+ recipient: cleanText(source.recipient).toLowerCase(),
206
+ occurredAt: status === 'succeeded' ? new Date().toISOString() : '',
207
+ error,
208
+ tool: 'gog',
209
+ toolVersion: options.toolVersion || '',
210
+ bodyMode: bodyMode(source),
211
+ bodyHash: cleanText(source.bodyHash).toLowerCase(),
212
+ actionId: cleanText(source.actionId).toLowerCase(),
213
+ exitCode: options.exitCode ?? null,
214
+ },
215
+ };
216
+ }
217
+
218
+ async function readGogVersion(command, signal) {
219
+ if (gogVersionCache.has(command)) return gogVersionCache.get(command);
220
+ const result = await runProcess(command, ['--version'], { signal, timeoutMs: 10_000 });
221
+ if (result.timedOut || result.aborted) throw new Error('unable to read gog version before timeout');
222
+ if (result.error) throw new Error(result.error);
223
+ const match = /v?(\d+)\.(\d+)\.(\d+)/.exec(result.stdout || result.stderr);
224
+ if (result.exitCode !== 0 || !match) throw new Error('unable to read gog version');
225
+ const version = match.slice(1, 4).map(Number);
226
+ if (compareVersion(version, MIN_GOG_VERSION) < 0) {
227
+ throw new Error(`gog v${version.join('.')} is older than v${MIN_GOG_VERSION.join('.')}`);
228
+ }
229
+ const normalized = version.join('.');
230
+ gogVersionCache.set(command, normalized);
231
+ return normalized;
232
+ }
233
+
234
+ function runProcess(executable, args, { signal, timeoutMs }) {
235
+ return new Promise(resolveResult => {
236
+ let stdout = '';
237
+ let stderr = '';
238
+ let settled = false;
239
+ let child;
240
+ const finish = result => {
241
+ if (settled) return;
242
+ settled = true;
243
+ clearTimeout(timer);
244
+ signal?.removeEventListener('abort', abort);
245
+ resolveResult({ ...result, stdout, stderr });
246
+ };
247
+ const terminate = (timedOut, aborted) => {
248
+ child?.kill();
249
+ finish({ exitCode: null, timedOut, aborted, error: '' });
250
+ };
251
+ const abort = () => terminate(false, true);
252
+ const timer = setTimeout(() => terminate(true, false), Math.max(1_000, timeoutMs));
253
+ try {
254
+ child = spawn(executable, args, { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
255
+ } catch (error) {
256
+ finish({ exitCode: null, timedOut: false, aborted: false, error: formatError(error) });
257
+ return;
258
+ }
259
+ child.stdout?.on('data', chunk => { stdout = appendBounded(stdout, chunk); });
260
+ child.stderr?.on('data', chunk => { stderr = appendBounded(stderr, chunk); });
261
+ child.once('error', error => finish({ exitCode: null, timedOut: false, aborted: false, error: formatError(error) }));
262
+ child.once('close', code => finish({ exitCode: code, timedOut: false, aborted: false, error: '' }));
263
+ if (signal?.aborted) abort();
264
+ else signal?.addEventListener('abort', abort, { once: true });
265
+ });
266
+ }
267
+
268
+ function cacheMailResult(cacheKey, action, result) {
269
+ mailActionResults.set(cacheKey, { bodyHash: action.bodyHash, result });
270
+ while (mailActionResults.size > 200) mailActionResults.delete(mailActionResults.keys().next().value);
271
+ return result;
272
+ }
273
+
274
+ function findMailResultId(value, kind) {
275
+ if (Array.isArray(value)) {
276
+ for (const child of value) {
277
+ const found = findMailResultId(child, kind);
278
+ if (found) return found;
279
+ }
280
+ return '';
281
+ }
282
+ if (!value || typeof value !== 'object') return '';
283
+ const keys = kind === 'saveDraft'
284
+ ? ['draftId', 'draft_id', 'id', 'messageId', 'message_id']
285
+ : ['messageId', 'message_id', 'id', 'threadId', 'thread_id'];
286
+ for (const key of keys) {
287
+ const candidate = cleanText(value[key]);
288
+ if (candidate.length >= 6) return candidate;
289
+ }
290
+ for (const child of Object.values(value)) {
291
+ const found = findMailResultId(child, kind);
292
+ if (found) return found;
293
+ }
294
+ return '';
295
+ }
296
+
297
+ function resolveCommandCandidate(value) {
298
+ if (!value) return [];
299
+ if (isAbsolute(value)) return [value];
300
+ if (value.includes('/') || value.includes('\\')) return [resolve(value)];
301
+ return pathCommandCandidates(value);
302
+ }
303
+
304
+ function pathCommandCandidates(command = process.platform === 'win32' ? 'gog.exe' : 'gog') {
305
+ return cleanText(process.env.PATH)
306
+ .split(delimiter)
307
+ .filter(Boolean)
308
+ .flatMap(pathEntry => {
309
+ const path = join(pathEntry, command);
310
+ return process.platform === 'win32' && !/\.(?:exe|cmd)$/i.test(command) ? [path, `${path}.exe`, `${path}.cmd`] : [path];
311
+ });
312
+ }
313
+
314
+ function bodyMode(value) {
315
+ const hasHtml = Boolean(stringValue(value.htmlBody));
316
+ const hasPlain = Boolean(stringValue(value.plainTextBody));
317
+ if (hasHtml && hasPlain) return 'body-html+body';
318
+ if (hasHtml) return 'body-html';
319
+ if (hasPlain) return 'body';
320
+ return '';
321
+ }
322
+
323
+ function compareVersion(left, right) {
324
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
325
+ const difference = (left[index] || 0) - (right[index] || 0);
326
+ if (difference) return difference > 0 ? 1 : -1;
327
+ }
328
+ return 0;
329
+ }
330
+
331
+ function appendBounded(current, chunk) {
332
+ if (current.length >= MAX_PROCESS_OUTPUT_CHARS) return current;
333
+ return (current + String(chunk)).slice(0, MAX_PROCESS_OUTPUT_CHARS);
334
+ }
335
+
336
+ function validEmail(value) {
337
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
338
+ }
339
+
340
+ function looksLikeBodyPath(value) {
341
+ return /^(?:\/dev\/stdin|\/(?:private\/)?tmp\/\S+|[A-Za-z]:\\\S+|file:\/\/\S+)$/i.test(value.trim());
342
+ }
343
+
344
+ function containsMarkdownTable(value) {
345
+ return /(?:^|\n)\s*\|?.+\|.+\n\s*\|?\s*:?-{3,}/.test(value);
346
+ }
347
+
348
+ function compactStatus(value) {
349
+ const compact = String(value || '').replace(/\s+/g, ' ').trim();
350
+ return compact.length <= 320 ? compact : `${compact.slice(0, 317).trimEnd()}...`;
351
+ }
352
+
353
+ function cleanText(value) {
354
+ return typeof value === 'string' ? value.trim() : '';
355
+ }
356
+
357
+ function stringValue(value) {
358
+ return typeof value === 'string' ? value : '';
359
+ }
360
+
361
+ function formatError(error) {
362
+ return error instanceof Error ? error.message : String(error || 'Unknown error');
363
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "customer-map-codex-bridge",
3
+ "version": "0.5.0",
4
+ "description": "Connect a locally authenticated Codex CLI to Customer Map.",
5
+ "type": "module",
6
+ "bin": {
7
+ "customer-map-codex-bridge": "index.mjs"
8
+ },
9
+ "files": [
10
+ "index.mjs",
11
+ "mail-action.mjs",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "homepage": "https://www.customer-map.com",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "ws": "^8.21.0"
23
+ }
24
+ }