customer-map-codex-bridge 0.5.0 → 0.5.2

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/index.mjs CHANGED
@@ -1,527 +1,654 @@
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';
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { 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 { join } from 'node:path';
8
+ import process from 'node:process';
9
9
  import WebSocket from 'ws';
10
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
-
11
+ import { startCodexAppServer } from './codex-process.mjs';
12
+
13
+ const BRIDGE_VERSION = '0.5.2';
14
+ const RELAY_RECONNECT_BASE_MS = 1_000;
15
+ const RELAY_RECONNECT_MAX_MS = 30_000;
16
+
17
+ const args = parseArgs(process.argv.slice(2));
18
+ if (args.help) {
19
+ console.log('Usage: customer-map-codex-bridge --site <Customer Map URL> --code <one-time code> [--cwd <folder>] [--codex <path>] [--gog <path>]');
20
+ process.exit(0);
21
+ }
22
+
23
+ const uri = args.uri || process.argv.slice(2).find(value => String(value).startsWith('customer-map-codex://'));
24
+ const uriValues = uri ? parseConnectUri(uri) : {};
25
+ const site = validateSite(args.site || uriValues.site);
26
+ const code = validateCode(args.code || uriValues.code);
27
+ const clientId = args.client || `codex-bridge-${process.platform}-${process.pid}`;
28
+ const stateDir = args.state || join(homedir(), '.customer-map-codex');
29
+ const stateFile = join(stateDir, 'state.json');
30
+
31
+ let bridgeToken = '';
32
+ let connectionId = '';
33
+ let codex = null;
34
+ let codexLines = null;
35
+ let relay = null;
36
+ let state = { threads: {} };
37
+ let rpcId = 0;
38
+ let initialized = false;
39
+ let closed = false;
40
+ let active = null;
41
+ let shutdownPromise = null;
42
+ let relayReconnectPromise = null;
43
+ let pendingRelayDisconnect = null;
44
+ const pendingRpc = new Map();
45
+ const loadedThreads = new Set();
46
+
47
+ process.once('SIGINT', () => { void shutdown(new Error('Codex Bridge interrupted')); });
48
+ process.once('SIGTERM', () => { void shutdown(new Error('Codex Bridge terminated')); });
49
+
50
+ await main();
51
+
52
+ async function main() {
53
+ try {
54
+ await requestAuthorization(site);
55
+ state = await loadState();
56
+
57
+ const codexCommand = resolveCodexCommand(args.codex);
58
+ codex = startCodexAppServer(args.cwd || process.cwd(), codexCommand);
59
+ codexLines = createInterface({ input: codex.stdout });
60
+ attachCodexHandlers();
61
+
62
+ await rpc('initialize', {
63
+ clientInfo: { name: 'customer_map_codex_bridge', title: 'Customer Map Codex Bridge', version: BRIDGE_VERSION },
64
+ capabilities: { experimentalApi: true },
65
+ });
66
+ sendCodex({ method: 'initialized', params: {} });
67
+ initialized = true;
68
+ const accountStatus = await rpc('account/read', { refreshToken: true }, 30_000);
69
+ if (accountStatus?.requiresOpenaiAuth && !accountStatus.account) {
70
+ throw new Error('Codex CLI 尚未登录,请先运行 codex login。');
71
+ }
72
+
73
+ // Only consume the one-time code after the local Codex runtime is healthy.
74
+ // A failed app-server launch must not strand the code as "expired".
75
+ const bridge = await claim();
76
+ bridgeToken = bridge.bridgeToken;
77
+ connectionId = bridge.connectionId;
78
+ await connectRelayWithRetry(bridge);
79
+ console.log('Customer Map Codex Bridge connected.');
80
+ } catch (error) {
81
+ console.error(error instanceof Error ? error.message : String(error || 'Codex Bridge failed'));
82
+ await shutdown(error);
83
+ process.exitCode = 1;
84
+ }
85
+ }
86
+
87
+ function attachCodexHandlers() {
88
+ codexLines.on('line', handleCodexLine);
89
+ codex.on('error', error => {
90
+ rejectPendingRpc(error);
91
+ if (!closed) {
92
+ console.error(`Could not start Codex app-server: ${error.message}`);
93
+ void shutdown(error);
94
+ }
95
+ });
96
+ codex.on('exit', codeValue => {
97
+ const error = new Error(`Codex app-server exited (${codeValue ?? 'unknown'})`);
98
+ rejectPendingRpc(error);
99
+ if (active) failActiveJob(error.message);
100
+ if (!closed) {
101
+ console.error(error.message);
102
+ void shutdown(error);
103
+ }
104
+ });
105
+ }
106
+
107
+ function handleCodexLine(line) {
108
+ let message;
109
+ try { message = JSON.parse(line); } catch { return; }
110
+ if (message.id !== undefined && pendingRpc.has(message.id)) {
111
+ const entry = pendingRpc.get(message.id);
112
+ pendingRpc.delete(message.id);
113
+ if (message.error) entry.reject(new Error(message.error.message || 'Codex app-server request failed'));
114
+ else entry.resolve(message.result);
115
+ return;
116
+ }
117
+ if (message.method === 'item/agentMessage/delta') {
118
+ const job = activeJob(message.params);
119
+ const delta = String(message.params?.delta || message.params?.text || '');
120
+ if (job && delta) {
121
+ job.text += delta;
122
+ sendRelay({ type: 'progress', jobId: job.id, content: job.text.slice(-100000) });
123
+ }
124
+ }
125
+ if (message.method === 'item/completed') {
126
+ const item = message.params?.item;
127
+ if ((item?.type === 'agent_message' || item?.type === 'agentMessage') && typeof item.text === 'string') {
128
+ const job = activeJob(message.params);
129
+ if (job) job.text = item.text;
130
+ }
131
+ }
132
+ if (message.method === 'error' && !message.params?.willRetry && activeJob(message.params)) {
133
+ failActiveJob(message.params?.error?.message || 'Codex turn failed');
134
+ }
135
+ if (message.method === 'turn/completed' && activeJob(message.params)) {
136
+ const turn = message.params?.turn;
137
+ if (turn?.status === 'failed' || turn?.status === 'interrupted') {
138
+ failActiveJob(turn?.error?.message || `Codex turn ${turn.status}`);
139
+ } else {
140
+ finishActiveJob();
141
+ }
142
+ }
143
+ if (message.method === 'turn/failed' && activeJob(message.params)) {
144
+ failActiveJob(message.params?.error?.message || 'Codex turn failed');
145
+ }
146
+ }
147
+
148
+ async function connectRelayWithRetry(bridge) {
149
+ let attempt = 0;
150
+ while (!closed) {
151
+ try {
152
+ await connectRelay(bridge);
153
+ return;
154
+ } catch (error) {
155
+ if (isPermanentRelayError(error)) throw error;
156
+ const delayMs = relayReconnectDelay(attempt);
157
+ attempt += 1;
158
+ console.warn(`Codex Relay unavailable; retrying in ${Math.ceil(delayMs / 1000)}s.`);
159
+ await wait(delayMs);
160
+ }
161
+ }
162
+ throw new Error('Codex Bridge stopped');
163
+ }
164
+
165
+ function connectRelay(bridge) {
166
+ return new Promise((resolve, reject) => {
167
+ const socket = new WebSocket(webSocketUrl(bridge.relayUrl));
168
+ relay = socket;
169
+ let ready = false;
170
+ let settled = false;
171
+ const timer = setTimeout(() => {
172
+ if (settled) return;
173
+ settled = true;
174
+ socket.close();
175
+ reject(createRelayError('Codex Relay 握手超时,请检查 Relay 或 Tunnel 是否在线。'));
176
+ }, 20_000);
177
+
178
+ socket.on('open', () => {
179
+ sendRelay({
180
+ type: 'hello',
181
+ runtime: 'codex',
182
+ bridgeToken: bridge.bridgeToken,
183
+ connectionId: bridge.connectionId,
184
+ clientId,
185
+ pluginVersion: BRIDGE_VERSION,
186
+ capabilities: {
187
+ runtime: 'verified',
188
+ sessionContext: 'verified',
189
+ customerMapData: 'declared',
190
+ webRead: 'declared',
191
+ webSearch: 'declared',
192
+ gmailDraft: 'declared',
193
+ gmailSend: 'confirmation',
194
+ },
195
+ }, socket);
196
+ });
197
+
198
+ socket.on('message', raw => {
199
+ let message;
200
+ try { message = JSON.parse(String(raw)); } catch { return; }
201
+ if (message.type === 'ready') {
202
+ if (ready) return;
203
+ if (relay !== socket) {
204
+ settled = true;
205
+ clearTimeout(timer);
206
+ socket.close();
207
+ reject(new Error('Codex Relay connection superseded'));
208
+ return;
209
+ }
210
+ ready = true;
211
+ settled = true;
212
+ clearTimeout(timer);
213
+ resolve(socket);
214
+ return;
215
+ }
216
+ if (message.type === 'error' && !ready && !settled) {
217
+ settled = true;
218
+ clearTimeout(timer);
219
+ socket.close();
220
+ reject(createRelayError(String(message.error || 'Codex Relay rejected the Bridge'), message.code));
221
+ return;
222
+ }
223
+ if (relay !== socket) return;
224
+ handleRelayMessage(message);
225
+ });
226
+
227
+ socket.on('error', error => {
228
+ if (!ready && !settled) {
229
+ settled = true;
230
+ clearTimeout(timer);
231
+ socket.close();
232
+ reject(createRelayError(`Codex Relay 连接失败:${error.message}`));
233
+ } else {
234
+ console.error(`Codex Relay error: ${error.message}`);
235
+ }
236
+ });
237
+
238
+ socket.on('close', (codeValue, reasonValue) => {
239
+ const reason = String(reasonValue || '').trim();
240
+ const current = relay === socket;
241
+ if (current) relay = null;
242
+ if (!current) return;
243
+ if (!ready) {
244
+ if (!settled) {
245
+ settled = true;
246
+ clearTimeout(timer);
247
+ reject(createRelayError(`Codex Relay 在握手前断开 (${codeValue ?? 'unknown'})${reason ? `: ${reason}` : ''}`, codeValue));
248
+ }
249
+ return;
250
+ }
251
+ if (!closed) {
252
+ const error = createRelayError(`Codex Relay 已断开 (${codeValue ?? 'unknown'})${reason ? `: ${reason}` : ''}`, codeValue);
253
+ void handleRelayDisconnect(bridge, error);
254
+ }
255
+ });
256
+ });
257
+ }
258
+
259
+ async function handleRelayDisconnect(bridge, error) {
260
+ if (closed) return;
261
+ if (isPermanentRelayError(error)) {
262
+ await shutdown(error);
263
+ return;
264
+ }
265
+ if (active) {
266
+ active.abort?.();
267
+ failActiveJob(error instanceof Error ? error.message : String(error || 'Codex Relay disconnected'));
268
+ }
269
+ if (relayReconnectPromise) {
270
+ pendingRelayDisconnect = { bridge, error };
271
+ return;
272
+ }
273
+ const reconnectPromise = (async () => {
274
+ let attempt = 0;
275
+ while (!closed) {
276
+ const delayMs = relayReconnectDelay(attempt);
277
+ attempt += 1;
278
+ console.warn(`Codex Relay disconnected; retrying in ${Math.ceil(delayMs / 1000)}s.`);
279
+ await wait(delayMs);
280
+ if (closed) return;
281
+ try {
282
+ await connectRelay(bridge);
283
+ console.log('Customer Map Codex Bridge reconnected.');
284
+ return;
285
+ } catch (retryError) {
286
+ if (isPermanentRelayError(retryError)) {
287
+ await shutdown(retryError);
288
+ return;
289
+ }
290
+ console.warn(`Codex Relay reconnect failed: ${retryError instanceof Error ? retryError.message : String(retryError || '')}`);
291
+ }
292
+ }
293
+ })();
294
+ relayReconnectPromise = reconnectPromise;
295
+ try {
296
+ await reconnectPromise;
297
+ } finally {
298
+ if (relayReconnectPromise === reconnectPromise) relayReconnectPromise = null;
299
+ const pending = pendingRelayDisconnect;
300
+ pendingRelayDisconnect = null;
301
+ if (pending && !closed) void handleRelayDisconnect(pending.bridge, pending.error);
302
+ }
303
+ }
304
+
305
+ function createRelayError(message, code) {
306
+ const error = new Error(message);
307
+ if (code !== undefined && code !== null && code !== '') error.relayCode = Number(code) || String(code);
308
+ return error;
309
+ }
310
+
311
+ function isPermanentRelayError(error) {
312
+ const code = error?.relayCode;
313
+ if ([4001, 4002, 4401].includes(Number(code))) return true;
314
+ const message = String(error?.message || error || '');
315
+ 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);
316
+ }
317
+
318
+ function relayReconnectDelay(attempt) {
319
+ return Math.min(RELAY_RECONNECT_MAX_MS, RELAY_RECONNECT_BASE_MS * (2 ** Math.min(attempt, 5)));
320
+ }
321
+
322
+ function handleRelayMessage(message) {
323
+ if (message.type === 'ping') {
324
+ sendRelay({ type: 'pong', at: Date.now() });
325
+ return;
326
+ }
327
+ if (message.type === 'job') {
328
+ void runJob(message.job).catch(error => sendRelay({ type: 'complete', jobId: message.job?.id, error: error?.message || 'Codex request failed' }));
329
+ }
330
+ }
331
+
332
+ async function runJob(job) {
333
+ if (!initialized) throw new Error('Codex app-server is not initialized');
334
+ if (!job?.id) throw new Error('Missing relay job id');
335
+ if (relay?.readyState !== WebSocket.OPEN) throw new Error('Codex Relay disconnected');
336
+ if (activeJob()) throw new Error('Codex is already processing another request');
337
+
338
+ if (job.request?.mailAction) {
339
+ const controller = new AbortController();
340
+ active = { id: String(job.id), threadId: '', text: '', abort: () => controller.abort() };
341
+ try {
342
+ const response = await executeCustomerMapMailAction(job.request.mailAction, {
343
+ signal: controller.signal,
344
+ timeoutMs: job.timeoutMs,
345
+ gogCommand: args.gog,
346
+ });
347
+ if (!active || active.id !== String(job.id)) return;
348
+ active = null;
349
+ sendRelay({ type: 'complete', jobId: String(job.id), response: { output_text: JSON.stringify(response) } });
350
+ } catch (error) {
351
+ failActiveJob(error instanceof Error ? error.message : String(error || 'Codex mail action failed'));
352
+ }
353
+ return;
354
+ }
355
+
356
+ const sessionKey = String(job.request?.sessionKey || job.request?.conversationSessionId || 'default');
357
+ const threadId = await ensureThread(sessionKey);
358
+ if (relay?.readyState !== WebSocket.OPEN) throw new Error('Codex Relay disconnected');
359
+ const input = Array.isArray(job.request?.input) ? job.request.input : [];
360
+ const prompt = input
361
+ .map(item => `${String(item.role || 'user').toUpperCase()}:\n${stringifyContent(item.content)}`)
362
+ .join('\n\n')
363
+ .slice(-200000);
364
+ const jobState = {
365
+ id: String(job.id),
366
+ threadId,
367
+ text: '',
368
+ turnId: '',
369
+ interrupted: false,
370
+ abort: () => {
371
+ jobState.interrupted = true;
372
+ interruptActiveTurn(jobState);
373
+ },
374
+ };
375
+ active = jobState;
376
+ try {
377
+ const started = await rpc('turn/start', {
378
+ threadId,
379
+ approvalPolicy: 'never',
380
+ sandboxPolicy: { type: 'readOnly', networkAccess: false },
381
+ input: [{ type: 'text', text: prompt }],
382
+ });
383
+ jobState.turnId = String(started?.turn?.id || '');
384
+ interruptActiveTurn(jobState);
385
+ } catch (error) {
386
+ failActiveJob(error instanceof Error ? error.message : String(error || 'Codex turn failed'));
387
+ }
388
+ }
389
+
390
+ function interruptActiveTurn(job) {
391
+ if (!job?.interrupted || !job.turnId || job.interruptSent) return;
392
+ job.interruptSent = true;
393
+ void rpc('turn/interrupt', { threadId: job.threadId, turnId: job.turnId }, 10_000).catch(() => undefined);
394
+ }
395
+
396
+ async function ensureThread(sessionKey) {
397
+ let threadId = String(state.threads?.[sessionKey] || '');
398
+ if (threadId && !loadedThreads.has(threadId)) {
399
+ try {
400
+ const resumed = await rpc('thread/resume', {
401
+ threadId,
402
+ sandbox: 'read-only',
403
+ approvalPolicy: 'never',
404
+ excludeTurns: true,
405
+ }, 60_000);
406
+ threadId = String(resumed?.thread?.id || threadId);
407
+ loadedThreads.add(threadId);
408
+ } catch (error) {
409
+ if (!/thread not found/i.test(String(error?.message || error))) throw error;
410
+ threadId = '';
411
+ }
412
+ }
413
+ if (!threadId) {
414
+ threadId = String((await rpc('thread/start', {
415
+ sandbox: 'read-only',
416
+ approvalPolicy: 'never',
417
+ }, 60_000)).thread?.id || '');
418
+ if (!threadId) throw new Error('Codex did not return a thread id');
419
+ loadedThreads.add(threadId);
420
+ }
421
+ state.threads = { ...(state.threads || {}), [sessionKey]: threadId };
422
+ await saveState();
423
+ return threadId;
424
+ }
425
+
426
+ function activeJob(params) {
427
+ if (!active) return null;
428
+ const threadId = String(params?.threadId || '');
429
+ return threadId && threadId !== active.threadId ? null : active;
430
+ }
431
+ function finishActiveJob() {
432
+ if (!active) return;
433
+ const job = active;
434
+ active = null;
435
+ sendRelay({ type: 'complete', jobId: job.id, response: { output_text: job.text.trim() || 'Codex returned an empty response.' } });
436
+ }
437
+ function failActiveJob(error) {
438
+ if (!active) return;
439
+ const job = active;
440
+ active = null;
441
+ sendRelay({ type: 'complete', jobId: job.id, error: String(error || 'Codex request failed') });
442
+ }
443
+
444
+ function rpc(method, params, timeoutMs = 20_000) {
445
+ const id = ++rpcId;
446
+ return new Promise((resolve, reject) => {
447
+ const timer = setTimeout(() => {
448
+ pendingRpc.delete(id);
449
+ reject(new Error(`Codex app-server ${method} 超时`));
450
+ }, timeoutMs);
451
+ pendingRpc.set(id, {
452
+ resolve: value => { clearTimeout(timer); resolve(value); },
453
+ reject: error => { clearTimeout(timer); reject(error); },
454
+ });
455
+ try {
456
+ sendCodex({ method, id, params });
457
+ } catch (error) {
458
+ pendingRpc.delete(id);
459
+ clearTimeout(timer);
460
+ reject(error);
461
+ }
462
+ });
463
+ }
464
+
465
+ function rejectPendingRpc(error) {
466
+ for (const entry of pendingRpc.values()) entry.reject(error);
467
+ pendingRpc.clear();
468
+ }
469
+
470
+ function sendCodex(message) {
471
+ if (!codex?.stdin?.writable) throw new Error('Codex app-server stdin is unavailable');
472
+ codex.stdin.write(`${JSON.stringify(message)}\n`);
473
+ }
474
+
475
+ function sendRelay(message, socket = relay) {
476
+ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
477
+ }
478
+
366
479
  function resolveCodexCommand(value) {
367
480
  const requested = String(value || process.env.CUSTOMER_MAP_CODEX_BIN || '').trim();
481
+ if (process.platform === 'win32') {
482
+ if (requested && requested !== 'codex' && requested !== 'codex.cmd') {
483
+ return normalizeWindowsCodexPath(requested);
484
+ }
485
+ return requested || 'codex.cmd';
486
+ }
368
487
  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
- ];
488
+ const candidates = [
489
+ join(homedir(), '.npm-global', 'bin', 'codex'),
490
+ join(homedir(), '.local', 'bin', 'codex'),
491
+ '/opt/homebrew/bin/codex',
492
+ '/usr/local/bin/codex',
493
+ '/usr/bin/codex',
494
+ ];
377
495
  return candidates.find(candidate => existsSync(candidate)) || 'codex';
378
496
  }
379
497
 
380
- function quoteWindowsArg(value) {
381
- return `"${String(value).replaceAll('"', '\\"')}"`;
498
+ function normalizeWindowsCodexPath(value) {
499
+ if (!/\.ps1$/i.test(value)) return value;
500
+ const cmdPath = value.replace(/\.ps1$/i, '.cmd');
501
+ return existsSync(cmdPath) ? cmdPath : value;
382
502
  }
383
503
 
384
504
  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
- }
505
+ const response = await fetch(`${site.replace(/\/$/, '')}/api/codex-link`, {
506
+ method: 'POST',
507
+ headers: { 'Content-Type': 'application/json' },
508
+ body: JSON.stringify({ action: 'claim', code, clientId }),
509
+ });
510
+ const data = await response.json().catch(() => ({}));
511
+ if (!response.ok) throw new Error(data?.error || `Claim failed (${response.status})`);
512
+ const nextBridgeToken = String(data?.bridgeToken || '');
513
+ const nextConnectionId = String(data?.connectionId || '');
514
+ if (!nextBridgeToken || !nextConnectionId) throw new Error('Customer Map returned an incomplete Codex connection');
515
+ return { ...data, bridgeToken: nextBridgeToken, connectionId: nextConnectionId };
516
+ }
517
+
518
+ async function notifyOffline(error) {
519
+ if (!bridgeToken || !clientId) return;
520
+ const response = await fetch(`${site.replace(/\/$/, '')}/api/codex-link`, {
521
+ method: 'POST',
522
+ headers: { 'Content-Type': 'application/json' },
523
+ body: JSON.stringify({ action: 'bridge-offline', bridgeToken, clientId, error: String(error?.message || error || '') }),
524
+ });
525
+ if (!response.ok) throw new Error(`Customer Map offline status failed (${response.status})`);
526
+ }
527
+
528
+ async function shutdown(error) {
529
+ if (shutdownPromise) return shutdownPromise;
530
+ shutdownPromise = (async () => {
531
+ if (closed) return;
532
+ closed = true;
533
+ active?.abort?.();
534
+ if (active) failActiveJob(error instanceof Error ? error.message : String(error || 'Codex Bridge stopped'));
535
+ rejectPendingRpc(error instanceof Error ? error : new Error(String(error || 'Codex Bridge stopped')));
536
+ await notifyOffline(error).catch(() => undefined);
537
+ if (relay && (relay.readyState === WebSocket.OPEN || relay.readyState === WebSocket.CONNECTING)) relay.close();
538
+ if (codex && !codex.killed) codex.kill();
539
+ codexLines?.close();
540
+ })();
541
+ return shutdownPromise;
542
+ }
543
+
544
+ async function loadState() {
545
+ try {
546
+ const parsed = JSON.parse(await readFile(stateFile, 'utf8'));
547
+ return parsed && typeof parsed === 'object' && parsed.threads && typeof parsed.threads === 'object'
548
+ ? parsed
549
+ : { threads: {} };
550
+ } catch {
551
+ return { threads: {} };
552
+ }
553
+ }
554
+
555
+ async function saveState() {
556
+ await mkdir(stateDir, { recursive: true });
557
+ await writeFile(stateFile, JSON.stringify(state, null, 2), 'utf8');
558
+ }
559
+
560
+ function stringifyContent(value) {
561
+ if (typeof value === 'string') return value;
562
+ if (Array.isArray(value)) return value.map(item => stringifyContent(item)).join('\n');
563
+ if (value && typeof value === 'object') {
564
+ const text = value.text || value.content || value.value;
565
+ if (typeof text === 'string') return text;
566
+ try { return JSON.stringify(value); } catch { return String(value); }
567
+ }
568
+ return String(value ?? '');
569
+ }
570
+
571
+ function required(value, name) { if (!value) throw new Error(`Missing ${name}`); return value; }
572
+ function validateSite(value) {
573
+ const url = new URL(required(value, '--site'));
574
+ const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname);
575
+ if (url.protocol !== 'https:' && !localHttp) throw new Error('Customer Map site must use HTTPS (localhost may use HTTP)');
576
+ return url.origin;
577
+ }
578
+ function validateCode(value) {
579
+ const normalized = String(required(value, '--code')).trim().toUpperCase().replace(/\s+/g, '');
580
+ if (!/^CMAP-CODEX-[A-F0-9]{12}$/.test(normalized)) throw new Error('Invalid Customer Map Codex connection code');
581
+ return normalized;
582
+ }
583
+ function parseArgs(values) {
584
+ const result = {};
585
+ for (let i = 0; i < values.length; i += 1) {
586
+ const value = values[i];
587
+ if (!value.startsWith('--')) continue;
588
+ const key = value.slice(2);
589
+ const next = values[i + 1];
590
+ if (!next || next.startsWith('--')) result[key] = true;
591
+ else { result[key] = next; i += 1; }
592
+ }
593
+ return result;
594
+ }
595
+ function webSocketUrl(value) {
596
+ const url = new URL(required(value, 'relayUrl'));
597
+ if (url.protocol === 'https:') url.protocol = 'wss:';
598
+ if (url.protocol === 'http:') url.protocol = 'ws:';
599
+ if (!/^wss?:$/.test(url.protocol)) throw new Error('Invalid relay URL');
600
+ return url.toString();
601
+ }
602
+ function parseConnectUri(value) {
603
+ const url = new URL(value);
604
+ return { site: url.searchParams.get('site') || '', code: url.searchParams.get('code') || '' };
605
+ }
606
+
607
+ function wait(delayMs) {
608
+ return new Promise(resolve => {
609
+ const timer = setTimeout(resolve, delayMs);
610
+ timer.unref?.();
611
+ });
612
+ }
613
+
614
+ async function requestAuthorization(siteUrl) {
615
+ if (process.platform === 'win32') {
616
+ const dialog = spawnSync('powershell.exe', [
617
+ '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
618
+ '-WindowStyle', 'Hidden', '-Command',
619
+ "$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",
620
+ ], { env: { ...process.env, CUSTOMER_MAP_CODEX_SITE: siteUrl }, stdio: 'ignore', windowsHide: true });
621
+ if (!dialog.error) {
622
+ if (dialog.status === 0) return;
623
+ console.log('Codex connection cancelled.');
624
+ process.exit(0);
625
+ }
626
+ }
627
+ if (process.platform === 'darwin') {
628
+ const dialog = spawnSync('osascript', ['-e', [
629
+ 'on run argv',
630
+ ' set siteUrl to item 1 of argv',
631
+ ' 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."',
632
+ ' display dialog promptText with title "Customer Map - Codex authorization" buttons {"Cancel", "Allow"} default button "Cancel"',
633
+ ' if button returned of result is "Allow" then return 0',
634
+ ' return 1',
635
+ 'end run',
636
+ ].join('\n'), siteUrl], { stdio: 'ignore' });
637
+ if (!dialog.error) {
638
+ if (dialog.status === 0) return;
639
+ console.log('Codex connection cancelled.');
640
+ process.exit(0);
641
+ }
642
+ }
643
+ console.log(`\nCustomer Map wants to connect to the Codex installed on this computer.`);
644
+ console.log(`Site: ${siteUrl}`);
645
+ 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.');
646
+ process.stdout.write('Allow this connection? [y/N] ');
647
+ const input = createInterface({ input: process.stdin, output: process.stdout });
648
+ const answer = await new Promise(resolve => input.once('line', resolve));
649
+ input.close();
650
+ if (String(answer).trim().toLowerCase() !== 'y') {
651
+ console.log('Codex connection cancelled.');
652
+ process.exit(0);
653
+ }
654
+ }