@gemus/mcp-proxy 0.1.8 → 0.1.10

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/src/relay.mjs ADDED
@@ -0,0 +1,440 @@
1
+ #!/usr/bin/env node
2
+ // @gemus/mcp-proxy — local stdio<->HTTP MCP proxy for Codex desktop (Issue #1751, P1).
3
+ //
4
+ // Codex (stdio) <-> this proxy <-> remote gemus /api/mcp (streamable HTTP, Bearer mak_ key).
5
+ // The proxy is a TRANSPARENT JSON-RPC passthrough (tools + resources + notifications all relay
6
+ // untouched) that additionally TAPS execute/batch_execute to observe client-delivered image
7
+ // backfill anchors, then at turn end backfills codex imagegen output into the planned gen-* nodes.
8
+ // Codex does no auth for stdio MCP servers, so THIS process owns upstream auth (the user's mak_ key).
9
+ //
10
+ // Turn-end backfill has two triggers:
11
+ // • Stop hook (trusted) → loopback HTTP POST /backfill { transcript_path, thread_id } → claim path.
12
+ // • idle-timer fallback (untrusted hook) → salvageOnly (orphan-only). Both idempotent via seen-set.
13
+ //
14
+ // Validated end-to-end on 2026-07-06 (codex-cli 0.142.2, real dev /api/mcp): transparent passthrough
15
+ // of all 12 gemus tools; codex spawns one proxy per session; real tool-calls tapped with workflowId
16
+ // (from arguments) + turn/thread identity (from _meta.x-codex-turn-metadata). Step-0.4: plugin-bundled
17
+ // Stop hook fires after /hooks trust. The plugin-scoped Companion forwards GEMUS_KEY from the user
18
+ // environment, so neither the plugin config nor generated commands contain a literal key.
19
+ // See docs/plans/1751-codex-desktop-companion.md.
20
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
21
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
22
+ import http from 'node:http'
23
+ import os from 'node:os'
24
+ import path from 'node:path'
25
+ import fs from 'node:fs'
26
+ import { randomBytes } from 'node:crypto'
27
+ import { createBackfiller, injectSteering } from './backfill.mjs'
28
+ import { unwrapMcpContent } from '@gemus/codex-backfill-core'
29
+ import { buildProxyUpstreamHeaders } from './mcpHeaders.mjs'
30
+ import { createFailFast, isJsonRpcResponse } from './failFast.mjs'
31
+ import { createProxyFailure } from './proxyMessages.mjs'
32
+ import { createUpstreamHttp } from './upstreamHttp.mjs'
33
+ import {
34
+ assertGemusKey,
35
+ createFatalReporter,
36
+ createShutdownCoordinator,
37
+ safeEndpointLabel,
38
+ settleWithin,
39
+ startProxy,
40
+ } from './startup.mjs'
41
+
42
+ const KEY = process.env.GEMUS_KEY || ''
43
+ const GEMUS_URL = process.env.GEMUS_URL || 'https://gemus.ai/api/mcp'
44
+ const LOG = process.env.PROXY_LOG // optional debug log path
45
+ const log = (dir, m) => {
46
+ if (!LOG) return
47
+ try {
48
+ fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${dir} ${typeof m === 'string' ? m : JSON.stringify(m).slice(0, 1000)}\n`)
49
+ } catch {
50
+ // Debug logging is optional and must never affect proxy lifecycle.
51
+ }
52
+ }
53
+
54
+ const reportFatal = createFatalReporter({
55
+ secret: KEY,
56
+ writeStderr: (message) => fs.writeSync(2, message),
57
+ writeLog: (message) => log('FATAL', message),
58
+ })
59
+ let shutdown
60
+ let shutdownRequested = false
61
+ function terminateFatally(error) {
62
+ reportFatal(error)
63
+ shutdownRequested = true
64
+ if (shutdown) return shutdown(1)
65
+ process.exit(1)
66
+ }
67
+ process.on('uncaughtException', (error) => { void terminateFatally(error) })
68
+ process.on('unhandledRejection', (reason) => { void terminateFatally(reason) })
69
+
70
+ try {
71
+ assertGemusKey(KEY)
72
+ } catch (error) {
73
+ terminateFatally(error)
74
+ }
75
+
76
+ const SERVER_ORIGIN = GEMUS_URL.replace(/\/api\/mcp\/?$/, '') // core delivery appends /api/mcp itself
77
+ const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
78
+ // turn-idle fallback (untrusted-hook path). Default 3min: imagegen alone runs 30-60s with NO MCP tool
79
+ // calls, so a short idle looks like "turn ended" mid-generation and fires prematurely. A trusted Stop
80
+ // hook fires at turn end and clears this timer, so trusted users never hit it (#1751 real-machine fix).
81
+ const IDLE_MS = Number(process.env.PROXY_BACKFILL_IDLE_MS) || 180_000
82
+ // footprint-0 注入交付超时(#1756 Layer 1a)。必须 ≥ 平台对 codex-imagen 回填节点的同步等待上限
83
+ // (deriveWaitTimeoutMs('gen-image-generation')=180s,见 src/server/generation/waitTimeout.ts),否则
84
+ // proxy 先超时 → 假失败 → orphan 回退 → 同图重复交付。240s = 180s + 余量。别调到 180s 以下。
85
+ const INJECT_TIMEOUT_MS = Number(process.env.PROXY_INJECT_TIMEOUT_MS) || 240_000
86
+
87
+ // ── footprint-0 交付注入(#1756 Layer 1a)─────────────────────────────────────────────────────────
88
+ // 交付/orphan 不再每图新开 key-only session(connect-per-call,抢 KEY_ONLY_SESSION_CAP 槽),而是把
89
+ // execute/canvas_edit 的 tools/call **注入到 proxy 已建好、已计入 cap 的那条 upstream 连接**上(footprint
90
+ // 0)——即便 cap 被泄漏填满也能交付。注入用字符串 id 命名空间 `gemus-bf-*`(绝不撞 Codex 的数字 id),
91
+ // 响应在 upstream.onmessage 里按 id 自消费、不转发给 Codex。`upstream` 在下方声明,注入在 turn 末才调,
92
+ // 故闭包内按调用时解析、无 TDZ 问题。
93
+ /** in-flight 注入:id(string) → { settle }。settle 幂等收敛(响应/超时/取消/关停任一先到即结算)。 */
94
+ const pendingInjections = new Map()
95
+ let injectSeq = 0
96
+
97
+ /** 把 JSON-RPC 响应转成与 core `callGemusTool` 逐字一致的 CallGemusToolResult 形状。 */
98
+ function interpretInjection(m) {
99
+ if (m?.error) return { ok: false, error: m.error.message || `MCP error ${m.error.code}` }
100
+ const result = m?.result
101
+ if (result && result.isError === true) {
102
+ const first = Array.isArray(result.content) ? result.content[0] : undefined
103
+ return { ok: false, error: (first && first.text) || 'MCP tool error' }
104
+ }
105
+ return { ok: true, result: unwrapMcpContent(result && result.content) }
106
+ }
107
+
108
+ /** @type {import('@gemus/codex-backfill-core').GemusToolCall} */
109
+ function injectedCall(toolName, args, opts = {}) {
110
+ const { progressToken, signal } = opts
111
+ const id = `gemus-bf-${++injectSeq}`
112
+ const req = {
113
+ jsonrpc: '2.0',
114
+ id,
115
+ method: 'tools/call',
116
+ params: { name: toolName, arguments: args, ...(progressToken ? { _meta: { progressToken } } : {}) },
117
+ }
118
+ return new Promise((resolve) => {
119
+ const settle = (v) => {
120
+ clearTimeout(timer)
121
+ if (signal) signal.removeEventListener('abort', onAbort)
122
+ pendingInjections.delete(id)
123
+ resolve(v)
124
+ }
125
+ const onAbort = () => settle({ ok: false, error: 'aborted' })
126
+ const timer = setTimeout(() => settle({ ok: false, timedOut: true, error: 'inject timeout' }), INJECT_TIMEOUT_MS)
127
+ pendingInjections.set(id, { settle })
128
+ if (signal) signal.addEventListener('abort', onAbort)
129
+ upstream.send(req).catch((e) => settle({ ok: false, error: String(e) }))
130
+ log('INJECT', { id, toolName, progressToken })
131
+ })
132
+ }
133
+
134
+ const backfiller = createBackfiller({
135
+ server: SERVER_ORIGIN,
136
+ apiKey: KEY,
137
+ codexHome: CODEX_HOME,
138
+ call: injectedCall,
139
+ log: (msg, obj) => log('BACKFILL', obj ? `${msg} ${JSON.stringify(obj)}` : msg),
140
+ })
141
+
142
+ // ── turn identity + request↔response correlation ───────────────────────────────────────────────
143
+ // Step-0.4 capture: tool-call `_meta` + Stop-hook payload both carry `turn_id` + `session_id` (no
144
+ // thread_id). Turn state keys on turn_id; session_id locates the rollout in the idle fallback.
145
+ /** in-flight tools/call: jsonrpc id (string) → { turnKey, name } — so the server→client response
146
+ * (which carries no _meta) can be attributed to its turn + tool. */
147
+ const inflight = new Map()
148
+ /** per-turn idle timer — turn-END fallback when no Stop hook fires (untrusted). Reset on activity. */
149
+ const idleTimers = new Map()
150
+ let sessionId // learned from first tool-call _meta.session_id; used for the hook discovery file
151
+ let discoveryFile // <tmpdir>/gemus-proxy-<sessionId>.json — written once sessionId is known
152
+
153
+ function metaOf(msg) {
154
+ const meta = msg?.params?._meta?.['x-codex-turn-metadata']
155
+ return {
156
+ turnId: meta?.turn_id || meta?.turnId,
157
+ sessionId: meta?.session_id || meta?.sessionId,
158
+ }
159
+ }
160
+
161
+ function armIdleTimer(turnKey) {
162
+ if (!turnKey) return
163
+ const existing = idleTimers.get(turnKey)
164
+ if (existing) clearTimeout(existing)
165
+ const timer = setTimeout(() => {
166
+ idleTimers.delete(turnKey)
167
+ // salvageOnly fallback: turn went quiet with no Stop hook. Orphan any fresh images (never claim).
168
+ // seen-set makes this a no-op if a hook already backfilled this turn.
169
+ backfiller
170
+ .backfillTurn({ turnKey, rolloutPath: null, salvageOnly: true })
171
+ .catch((e) => log('IDLE-BACKFILL-ERR', String(e)))
172
+ }, IDLE_MS)
173
+ if (typeof timer.unref === 'function') timer.unref() // don't keep the process alive on this timer alone
174
+ idleTimers.set(turnKey, timer)
175
+ }
176
+
177
+ function writeDiscoveryOnce() {
178
+ if (discoveryFile || !sessionId || !hookPort || !hookToken) return
179
+ discoveryFile = path.join(os.tmpdir(), `gemus-proxy-${sessionId}.json`)
180
+ try {
181
+ fs.writeFileSync(discoveryFile, JSON.stringify({ port: hookPort, token: hookToken }))
182
+ log('DISCOVERY-WRITE', { discoveryFile, port: hookPort })
183
+ } catch (e) {
184
+ log('DISCOVERY-ERR', String(e))
185
+ }
186
+ }
187
+
188
+ // injectSteering (imported) mutates the initialize response's `instructions` to tell codex not to
189
+ // self-forward imagegen output — the one always-on steering channel in Mode B. See backfill.mjs.
190
+ function steerOnInit(msg) {
191
+ try {
192
+ if (injectSteering(msg)) log('STEERING-INJECTED', { instructionsLen: msg.result.instructions.length })
193
+ } catch (e) {
194
+ log('STEERING-ERR', String(e))
195
+ }
196
+ }
197
+
198
+ // ── taps (do not mutate the relayed messages) ──────────────────────────────────────────────────
199
+ function tapClientToServer(msg) {
200
+ try {
201
+ if (msg?.method !== 'tools/call') return
202
+ const { turnId, sessionId: sid } = metaOf(msg)
203
+ if (sid && !sessionId) {
204
+ sessionId = sid
205
+ writeDiscoveryOnce()
206
+ }
207
+ if (turnId) armIdleTimer(turnId)
208
+ const id = msg.id
209
+ if (id === undefined || id === null) return // notifications: no response to correlate
210
+ const name = msg.params?.name || ''
211
+ inflight.set(String(id), { turnKey: turnId, name })
212
+ backfiller.observeCall({ jsonrpcId: id, turnKey: turnId, sessionId: sid, name, args: msg.params?.arguments || {} })
213
+ } catch (e) {
214
+ log('TAP-C2S-ERR', String(e))
215
+ }
216
+ }
217
+
218
+ function tapServerToClient(msg) {
219
+ try {
220
+ const id = msg?.id
221
+ if (id === undefined || id === null) return
222
+ const pending = inflight.get(String(id))
223
+ if (!pending) return
224
+ inflight.delete(String(id))
225
+ if (!msg.result) return // errors: nothing to observe for claim tracking
226
+ backfiller.observeResult({
227
+ jsonrpcId: id,
228
+ turnKey: pending.turnKey,
229
+ name: pending.name,
230
+ resultContent: msg.result?.content,
231
+ })
232
+ } catch (e) {
233
+ log('TAP-S2C-ERR', String(e))
234
+ }
235
+ }
236
+
237
+ // ── loopback Stop-hook endpoint (127.0.0.1:<random port> + per-run token) ──────────────────────
238
+ let hookPort
239
+ const hookToken = randomBytes(16).toString('hex')
240
+ const hookServer = http.createServer((req, res) => {
241
+ if (req.method !== 'POST' || !req.url?.startsWith('/backfill')) {
242
+ res.writeHead(404).end()
243
+ return
244
+ }
245
+ let body = ''
246
+ req.on('data', (c) => {
247
+ body += c
248
+ if (body.length > 64 * 1024) req.destroy() // trivial DoS guard; payload is tiny JSON
249
+ })
250
+ req.on('end', () => {
251
+ let payload
252
+ try {
253
+ payload = JSON.parse(body)
254
+ } catch {
255
+ res.writeHead(400).end()
256
+ return
257
+ }
258
+ if (payload?.token !== hookToken) {
259
+ res.writeHead(403).end()
260
+ return
261
+ }
262
+ // Stop-hook payload (Step-0.4 capture): { session_id, turn_id, transcript_path, ... } — no thread_id.
263
+ const turnKey = payload.turn_id || payload.turnId
264
+ const sid = payload.session_id || payload.sessionId
265
+ const rolloutPath = payload.transcript_path || payload.transcriptPath || null
266
+ log('HOOK-BACKFILL', { turnKey, sid, rolloutPath })
267
+ res.writeHead(200).end('ok')
268
+ // A Stop hook backfilled this turn → cancel its idle-timer fallback (avoid a redundant salvage pass).
269
+ const timer = idleTimers.get(turnKey)
270
+ if (timer) { clearTimeout(timer); idleTimers.delete(turnKey) }
271
+ // Stop hook fires post-flush → claim path (not salvage). Fire-and-forget; never throw.
272
+ backfiller
273
+ .backfillTurn({ turnKey, sessionId: sid, rolloutPath, salvageOnly: false })
274
+ .catch((e) => log('HOOK-BACKFILL-ERR', String(e)))
275
+ })
276
+ })
277
+ hookServer.on('error', (error) => {
278
+ if (shutdownRequested) return
279
+ void terminateFatally(error)
280
+ })
281
+ hookServer.listen(0, '127.0.0.1', () => {
282
+ hookPort = hookServer.address().port
283
+ log('HOOK-SERVER', { port: hookPort })
284
+ writeDiscoveryOnce() // in case sessionId was already learned before listen resolved
285
+ })
286
+ if (typeof hookServer.unref === 'function') hookServer.unref()
287
+
288
+ // ── transparent JSON-RPC passthrough (raw send/onmessage) ──────────────────────────────────────
289
+ const stdio = new StdioServerTransport()
290
+ const initializingIds = new Set()
291
+ const classifiedInitializeFailures = new Set()
292
+
293
+ // #2068 fail-fast: StreamableHTTPClientTransport.send() resolves before the upstream SSE result
294
+ // arrives and only surfaces a stream death via a global, id-less `onerror` (no reconnect for POST).
295
+ // The custom `fetch` wrapper parses each request's JSON-RPC id and, if the response stream dies
296
+ // before that id's result crosses the wire, injects a JSON-RPC error so Codex fails in <2s instead
297
+ // of hanging to its 300s timeout. onLost only injects to stdio — it does NOT tap: tapping here would
298
+ // delete the proxy's `inflight` entry, so the DROP-AFTER-FAILFAST branch below could no longer let
299
+ // backfill observe a late real result (env delivery on the still-open, idle-timed-out case).
300
+ const upstreamHttp = createUpstreamHttp()
301
+ const failFast = createFailFast({
302
+ idleTimeoutMs: Number(process.env.PROXY_FAILFAST_TIMEOUT_MS) || 120_000,
303
+ realFetch: upstreamHttp.fetch,
304
+ log,
305
+ onLost: (id, failure) => {
306
+ const { response, diagnostic } = createProxyFailure(id, failure)
307
+ const key = String(id)
308
+ log('FAILFAST-INJECT', diagnostic)
309
+ if (initializingIds.has(key)) {
310
+ classifiedInitializeFailures.add(key)
311
+ void terminateFatally(new Error(response.error.message))
312
+ return
313
+ }
314
+ stdio.send(response).catch((e) => log('ERR-stdio-send', String(e)))
315
+ },
316
+ })
317
+
318
+ const upstream = new StreamableHTTPClientTransport(new URL(GEMUS_URL), {
319
+ requestInit: { headers: buildProxyUpstreamHeaders(KEY) },
320
+ fetch: failFast.fetch,
321
+ })
322
+
323
+ stdio.onmessage = (m) => {
324
+ tapClientToServer(m)
325
+ log('C->G', m)
326
+ const key = String(m?.id)
327
+ if (m?.method === 'initialize') initializingIds.add(key)
328
+ upstream.send(m).catch((error) => {
329
+ const classified = classifiedInitializeFailures.delete(key)
330
+ initializingIds.delete(key)
331
+ if (m?.method === 'initialize') {
332
+ if (classified) return
333
+ void terminateFatally(error)
334
+ return
335
+ }
336
+ log('ERR-http-send', { kind: 'transport' })
337
+ })
338
+ }
339
+ upstream.onmessage = (m) => {
340
+ // footprint-0 (#1756): our injected execute/canvas_edit responses carry a `gemus-bf-*` string id.
341
+ // Self-consume them here (settle the pending injection) and DO NOT forward to Codex — Codex never
342
+ // issued these calls, so it must never see the responses.
343
+ if (typeof m?.id === 'string' && m.id.startsWith('gemus-bf-')) {
344
+ const p = pendingInjections.get(m.id)
345
+ if (p) p.settle(interpretInjection(m))
346
+ return
347
+ }
348
+ // #1756 liveness heartbeat: our own `ping` pongs (gemus-ping-*) are self-consumed — Codex never
349
+ // issued them, so it must never see the responses.
350
+ if (typeof m?.id === 'string' && m.id.startsWith('gemus-ping-')) return
351
+ // #2068 fail-fast bookkeeping. Gate on RESPONSE only (isJsonRpcResponse) — a server→client request
352
+ // (elicitation/sampling) also carries an id that can collide with an in-flight tools/call id, and
353
+ // must be forwarded to Codex untouched, never settled/dropped.
354
+ if (isJsonRpcResponse(m)) {
355
+ initializingIds.delete(String(m.id))
356
+ if (failFast.failedIds.has(String(m.id))) {
357
+ // Already fail-fasted (idle timeout on a still-open stream) → Codex got the error. Still tap so
358
+ // backfill (#1751) observes the late real result (inflight survives — onLost didn't tap), but
359
+ // do NOT re-forward it to Codex (no double-send). forget() bounds failedIds.
360
+ tapServerToClient(m); failFast.forget(m.id); log('DROP-AFTER-FAILFAST', m.id); return
361
+ }
362
+ failFast.settle(m.id)
363
+ }
364
+ tapServerToClient(m); steerOnInit(m); log('G->C', m); stdio.send(m).catch((e) => log('ERR-stdio-send', String(e)))
365
+ }
366
+
367
+ // ── session hygiene (spike: KEY_ONLY_SESSION_CAP=3) ────────────────────────────────────────────
368
+ // We must DELETE the upstream session or we leak a key-only slot when codex ends the conversation.
369
+ // NOTE: transport.close() does NOT issue DELETE (it only aborts + fires onclose, streamableHttp.js:280);
370
+ // terminateSession() sends the DELETE (:431). Call it first, then close(). Also clean up local artifacts.
371
+ // #1756 proxy-death detection: send an MCP `ping` every 30s so the platform can tell a live proxy
372
+ // (keeps pinging) from a dead one (hard-killed → no graceful DELETE → leaked session). This is
373
+ // **independent of Codex tool activity**, so a proxy mid-imagegen (silent to Codex for minutes) still
374
+ // proves liveness → the platform reclaims only genuinely-dead sessions (short floor), never a live one.
375
+ const HEARTBEAT_MS = Number(process.env.PROXY_HEARTBEAT_MS) || 30_000
376
+ const SHUTDOWN_STEP_TIMEOUT_MS = 2000
377
+ let pingSeq = 0
378
+ let heartbeatTimer
379
+ function startHeartbeat() {
380
+ heartbeatTimer = setInterval(() => {
381
+ upstream.send({ jsonrpc: '2.0', id: `gemus-ping-${++pingSeq}`, method: 'ping' }).catch((e) => log('PING-ERR', String(e)))
382
+ }, HEARTBEAT_MS)
383
+ if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref() // don't keep the process alive on this alone
384
+ }
385
+
386
+ async function cleanup() {
387
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
388
+ for (const t of idleTimers.values()) clearTimeout(t)
389
+ idleTimers.clear()
390
+ // Settle any in-flight footprint-0 injections so a mid-delivery shutdown doesn't hang their awaits.
391
+ for (const p of pendingInjections.values()) p.settle({ ok: false, error: 'proxy shutting down' })
392
+ pendingInjections.clear()
393
+ failFast.dispose() // #2068: clear fail-fast final-safety timers
394
+ try { hookServer.close() } catch { /* ignore */ }
395
+ if (discoveryFile) { try { fs.unlinkSync(discoveryFile) } catch { /* ignore */ } }
396
+ await settleWithin(
397
+ Promise.resolve().then(() => upstream.terminateSession()),
398
+ SHUTDOWN_STEP_TIMEOUT_MS,
399
+ )
400
+ await settleWithin(
401
+ Promise.resolve().then(() => upstream.close()),
402
+ SHUTDOWN_STEP_TIMEOUT_MS,
403
+ )
404
+ try { await upstreamHttp.shutdown() } catch { /* ignore */ }
405
+ }
406
+ shutdown = createShutdownCoordinator({
407
+ cleanup,
408
+ exit: (code) => process.exit(code),
409
+ })
410
+ function shutdownGracefully() {
411
+ shutdownRequested = true
412
+ return shutdown(0)
413
+ }
414
+ stdio.onclose = () => { log('stdio-close', ''); void shutdownGracefully() }
415
+ upstream.onclose = () => { log('http-close', '') }
416
+ stdio.onerror = (e) => log('stdio-err', String(e))
417
+ upstream.onerror = () => log('http-err', { kind: 'transport' })
418
+ process.on('SIGTERM', () => { void shutdownGracefully() })
419
+ process.on('SIGINT', () => { void shutdownGracefully() })
420
+
421
+ try {
422
+ await startProxy({
423
+ key: KEY,
424
+ upstream,
425
+ stdio,
426
+ onStarted: () => {
427
+ if (shutdownRequested) return
428
+ startHeartbeat()
429
+ log('STARTED', {
430
+ endpoint: safeEndpointLabel(GEMUS_URL),
431
+ hasKey: Boolean(KEY),
432
+ pid: process.pid,
433
+ idleMs: IDLE_MS,
434
+ heartbeatMs: HEARTBEAT_MS,
435
+ })
436
+ },
437
+ })
438
+ } catch (error) {
439
+ await terminateFatally(error)
440
+ }
@@ -0,0 +1,130 @@
1
+ import { pathToFileURL } from 'node:url'
2
+ import { acquireGemusSecret } from './secret.mjs'
3
+ import { installGemusEnvironment } from './environment.mjs'
4
+ import { inspectCodexConfig, planCodexConfigMigration } from './config.mjs'
5
+ import { CodexConfigChangedError, commitCodexConfig, preflightCodexConfig } from './configFile.mjs'
6
+ import { createCodexRunner, reconcileCodex } from './codex.mjs'
7
+
8
+ const MAX_STALE_REPLANS = 2
9
+
10
+ class CredentialChangedError extends Error {
11
+ constructor() {
12
+ super('Codex credential settings changed during setup; rerun setup to use the latest credentials.')
13
+ this.code = 'CODEX_CREDENTIALS_CHANGED'
14
+ }
15
+ }
16
+
17
+ function redact(text, secrets) {
18
+ let result = String(text ?? '')
19
+ for (const secret of secrets) {
20
+ if (typeof secret === 'string' && secret) result = result.split(secret).join('[redacted]')
21
+ }
22
+ return result
23
+ }
24
+
25
+ function safeSetupError(error, secrets, lastCompletedPhase) {
26
+ const message = redact(error?.message, secrets)
27
+ let summary
28
+ if (error?.code === 'CODEX_CREDENTIALS_CHANGED') summary = message
29
+ else if (/supports only/i.test(message)) summary = message
30
+ else if (/^Codex \w+ failed \(exit \d+\)\.$/.test(message)) {
31
+ summary = `Gemus Companion setup failed during ${message} Verify Codex Desktop.`
32
+ } else if (/Codex/i.test(message)) {
33
+ summary = 'Gemus Companion setup failed: Codex could not be reconciled. Verify Codex Desktop.'
34
+ } else if (/GEMUS_KEY is required/i.test(message)) summary = message
35
+ else summary = 'Gemus Companion setup failed. No configuration changes were committed.'
36
+ return `${summary} Last completed phase: ${lastCompletedPhase}. It is safe to rerun setup.`
37
+ }
38
+
39
+ export function validateSetupArgs(args = []) {
40
+ if (!Array.isArray(args) || args.length !== 0) throw new Error('Usage: gemus-mcp-proxy setup (no arguments accepted).')
41
+ }
42
+
43
+ export async function runSetup(deps = {}) {
44
+ const env = deps.env ?? process.env
45
+ const platform = deps.platform ?? process.platform
46
+ const stdout = deps.stdout ?? process.stdout
47
+ let lastCompletedPhase = 'none'
48
+ if (platform !== 'win32' && platform !== 'darwin') {
49
+ throw new Error(safeSetupError(
50
+ new Error('Gemus Companion setup supports only Windows and macOS.'),
51
+ [env.GEMUS_KEY, env.GEMUS_URL],
52
+ lastCompletedPhase,
53
+ ))
54
+ }
55
+ const preflight = deps.preflightCodexConfig ?? preflightCodexConfig
56
+ const readConfig = deps.readConfig ?? (async () => (await preflight({ env })).source)
57
+ const runReconcile = deps.reconcileCodex ?? ((options) => reconcileCodex({ run: createCodexRunner(options) }))
58
+ const commit = deps.commitCodexConfig ?? commitCodexConfig
59
+ const acquire = deps.acquireGemusSecret ?? acquireGemusSecret
60
+ const install = deps.installGemusEnvironment ?? installGemusEnvironment
61
+
62
+ let initialSource
63
+ let initialSecret
64
+ let initialInspection
65
+ try {
66
+ initialSource = (await preflight({ env })).source
67
+ initialInspection = inspectCodexConfig(initialSource)
68
+ if (initialInspection.issues.length) throw new Error('Codex configuration contains unsupported Gemus settings.')
69
+ lastCompletedPhase = 'config preflight'
70
+ initialSecret = await acquire({ env, legacyKey: initialInspection.legacyKey, legacyUrl: initialInspection.legacyUrl, stdin: deps.stdin, stdout })
71
+ lastCompletedPhase = 'credential acquisition'
72
+ await runReconcile({ env, platform })
73
+ lastCompletedPhase = 'Codex reconciliation'
74
+ } catch (error) {
75
+ throw new Error(safeSetupError(error, [env.GEMUS_KEY, env.GEMUS_URL], lastCompletedPhase))
76
+ }
77
+
78
+ let environmentInstalled = false
79
+ for (let attempt = 0; attempt <= MAX_STALE_REPLANS; attempt += 1) {
80
+ let source
81
+ let migration
82
+ try {
83
+ source = await readConfig()
84
+ migration = planCodexConfigMigration(source)
85
+ const keyFromEnv = env.GEMUS_KEY !== undefined && env.GEMUS_KEY !== ''
86
+ const urlFromEnv = env.GEMUS_URL !== undefined && env.GEMUS_URL !== ''
87
+ if ((!keyFromEnv && migration.legacyKey !== initialInspection.legacyKey) || (!urlFromEnv && migration.legacyUrl !== initialInspection.legacyUrl)) {
88
+ throw new CredentialChangedError()
89
+ }
90
+ lastCompletedPhase = 'latest config planning'
91
+ if (!environmentInstalled) {
92
+ await install({ key: initialSecret.key, url: initialSecret.url, platform, stdout, parentEnv: env })
93
+ environmentInstalled = true
94
+ }
95
+ lastCompletedPhase = 'environment installation'
96
+ const committed = await commit({ expectedSource: source, nextText: migration.nextText, env })
97
+ lastCompletedPhase = 'config commit'
98
+ if (committed.backupPath) {
99
+ if (migration.legacyKeyWasPresent) {
100
+ stdout?.write(
101
+ `Warning: the faithful legacy backup may contain the old GEMUS_KEY. Backup path: ${committed.backupPath}\n`,
102
+ )
103
+ } else {
104
+ stdout?.write(`A backup of your previous Codex configuration was created: ${committed.backupPath}\n`)
105
+ }
106
+ }
107
+ stdout?.write('Gemus Companion is ready. Restart Codex Desktop to use it.\n')
108
+ return committed
109
+ } catch (error) {
110
+ if ((error instanceof CodexConfigChangedError || error?.code === 'CODEX_CONFIG_CHANGED') && attempt < MAX_STALE_REPLANS) continue
111
+ throw new Error(safeSetupError(
112
+ error,
113
+ [env.GEMUS_KEY, env.GEMUS_URL, initialSecret?.key, initialSecret?.url, migration?.legacyKey, migration?.legacyUrl],
114
+ lastCompletedPhase,
115
+ ))
116
+ }
117
+ }
118
+ }
119
+
120
+ async function main() {
121
+ try {
122
+ validateSetupArgs(process.argv.slice(2))
123
+ await runSetup()
124
+ } catch (error) {
125
+ process.stderr.write(`${error.message}\n`)
126
+ process.exitCode = 1
127
+ }
128
+ }
129
+
130
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) void main()