@gemus/mcp-proxy 0.1.6 → 0.1.8

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.
@@ -0,0 +1,266 @@
1
+ // @gemus/mcp-proxy fail-fast (#2068) — surface upstream stream death to Codex immediately.
2
+ //
3
+ // StreamableHTTPClientTransport.send() resolves as soon as it DISPATCHES the SSE read loop,
4
+ // not when the tool result arrives. When the upstream POST SSE body dies before the result,
5
+ // the SDK only fires a global `onerror` with NO request id, and never reconnects a POST (no
6
+ // priming event). So Codex's pending request never settles → it hangs to its own 300s timeout.
7
+ //
8
+ // The only interception point the SDK offers is the constructor `fetch` option: it receives the
9
+ // exact (url, init) per request, so we parse the JSON-RPC id from `init.body` and wrap the
10
+ // response body. We use a SINGLE-PASS observe-while-forward wrapper (not tee()+grace): the same
11
+ // read loop that forwards bytes to the transport also scans the SSE frames for the tracked id's
12
+ // result/error. "Detect order == forward order" on one reader, so there is NO race between "result
13
+ // delivered" and "stream closed" — no wall-clock grace window (which CLAUDE.md forbids). If the
14
+ // stream reaches done/error while a tracked id was never seen on the wire, that result is lost →
15
+ // inject a JSON-RPC error keyed to that id.
16
+
17
+ /**
18
+ * A Codex-originated id is anything the AGENT issued. We exclude our own internal namespaces:
19
+ * - `gemus-bf-*` footprint-0 backfill injections (self-consumed by upstream.onmessage)
20
+ * - `gemus-ping-*` liveness heartbeat pongs (self-consumed)
21
+ * Numeric ids (Codex initialize / tools/list / tools/call) are all tracked.
22
+ */
23
+ export function isCodexOriginated(id) {
24
+ if (id === undefined || id === null) return false
25
+ return !(typeof id === 'string' && (id.startsWith('gemus-bf-') || id.startsWith('gemus-ping-')))
26
+ }
27
+
28
+ /**
29
+ * Extract JSON-RPC request ids from a fetch init. Only POST requests with a JSON body carry a
30
+ * request whose response we must guarantee; GET (standalone SSE) and DELETE (terminateSession)
31
+ * have no body → []. Never throws — a body we can't parse yields [].
32
+ */
33
+ export function parseRequestIds(init) {
34
+ try {
35
+ const method = (init?.method || 'GET').toUpperCase()
36
+ if (method !== 'POST' || init.body == null) return []
37
+ const body = typeof init.body === 'string' ? init.body : String(init.body)
38
+ const parsed = JSON.parse(body)
39
+ const msgs = Array.isArray(parsed) ? parsed : [parsed]
40
+ const ids = []
41
+ for (const m of msgs) {
42
+ // A request has both an id and a method; responses/notifications are not our concern here.
43
+ if (m && m.id !== undefined && m.id !== null && typeof m.method === 'string') ids.push(m.id)
44
+ }
45
+ return ids
46
+ } catch {
47
+ return []
48
+ }
49
+ }
50
+
51
+ /** True for a JSON-RPC RESPONSE (result/error) — has an id and no `method`. A server→client REQUEST
52
+ * (elicitation/sampling) also has an id but carries a `method`, and its id (the server's own counter)
53
+ * can COLLIDE with an in-flight Codex tools/call id — settling on it would disarm the fail-fast guard
54
+ * for the very tool that stream carries. So we settle only on responses, never on relayed requests. */
55
+ export function isJsonRpcResponse(msg) {
56
+ return !!msg && msg.id !== undefined && msg.id !== null && msg.method === undefined
57
+ }
58
+
59
+ /**
60
+ * Minimal SSE frame scanner. Accumulates decoded text, splits on the event boundary, and for each
61
+ * complete event yields the JSON-RPC id of any `data:` payload that is a RESPONSE (see
62
+ * isJsonRpcResponse). Comment lines (`:` — including our injected `: keepalive`) are ignored, so
63
+ * they never count as a delivered result. Half-frames straddling chunk boundaries are buffered until
64
+ * their terminating blank line arrives (no early/missed detection). CRLF line endings are normalized
65
+ * to LF so an intermediary that rewrites them can't wedge the scanner.
66
+ */
67
+ export function createSseIdScanner() {
68
+ const decoder = new TextDecoder()
69
+ let buf = ''
70
+ return {
71
+ /** Push a chunk; returns the JSON-RPC ids of any RESPONSE frames that crossed the wire in it. */
72
+ push(chunk) {
73
+ buf += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n')
74
+ const ids = []
75
+ let sep
76
+ while ((sep = buf.indexOf('\n\n')) !== -1) {
77
+ const block = buf.slice(0, sep)
78
+ buf = buf.slice(sep + 2)
79
+ const dataLines = []
80
+ for (const line of block.split('\n')) {
81
+ if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
82
+ }
83
+ if (!dataLines.length) continue
84
+ try {
85
+ const msg = JSON.parse(dataLines.join('\n'))
86
+ if (isJsonRpcResponse(msg)) ids.push(msg.id)
87
+ } catch {
88
+ /* not JSON-RPC (or partial) — ignore */
89
+ }
90
+ }
91
+ return ids
92
+ },
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Build the fail-fast fetch wrapper + settle/dispose controls.
98
+ *
99
+ * @param {object} opts
100
+ * @param {(id:any, reason:string)=>void} opts.onLost called once per lost id (must be idempotent-safe on caller side)
101
+ * @param {number} [opts.idleTimeoutMs] IDLE backstop reset on every upstream byte (incl. server
102
+ * heartbeats every ~12s), so it trips ONLY on a genuinely silent/zombie connection — never on a
103
+ * healthy-but-slow tool. Must be < Codex 300s; default 120_000.
104
+ * @param {(dir:string, m:any)=>void} [opts.log]
105
+ * @param {typeof fetch} [opts.realFetch] injectable for tests; defaults to global fetch
106
+ * @returns {{ fetch, settle, touch, forget, dispose, failedIds }}
107
+ */
108
+ export function createFailFast(opts) {
109
+ const { onLost, log = () => {} } = opts
110
+ const idleTimeoutMs = Number(opts.idleTimeoutMs) || 120_000
111
+ const realFetch = opts.realFetch || fetch
112
+ const FAILED_IDS_CAP = 512
113
+
114
+ /** idStr → { timer } — Codex-originated in-flight requests awaiting a result. */
115
+ const inFlight = new Map()
116
+ /** ids already failed by onLost — lets onmessage drop a late real result's forward (still taps it). */
117
+ const failedIds = new Set()
118
+
119
+ function armTimer(entry, id) {
120
+ entry.timer = setTimeout(() => lose(id, 'proxy idle timeout (no upstream bytes)'), idleTimeoutMs)
121
+ if (typeof entry.timer.unref === 'function') entry.timer.unref()
122
+ }
123
+
124
+ function register(id) {
125
+ const key = String(id)
126
+ if (inFlight.has(key)) return
127
+ const entry = {}
128
+ inFlight.set(key, entry)
129
+ armTimer(entry, id)
130
+ }
131
+
132
+ /** Reset the idle timer on any upstream byte — a healthy stream (heartbeats + data) never trips it. */
133
+ function touch(id) {
134
+ const entry = inFlight.get(String(id))
135
+ if (!entry) return
136
+ clearTimeout(entry.timer)
137
+ armTimer(entry, id)
138
+ }
139
+
140
+ function settle(id) {
141
+ const key = String(id)
142
+ const entry = inFlight.get(key)
143
+ if (!entry) return
144
+ clearTimeout(entry.timer)
145
+ inFlight.delete(key)
146
+ }
147
+
148
+ /** Drop an id from failedIds once its late result has been handled (bounds the set). */
149
+ function forget(id) {
150
+ failedIds.delete(String(id))
151
+ }
152
+
153
+ function lose(id, reason) {
154
+ const key = String(id)
155
+ if (!inFlight.has(key)) return // already settled or already lost
156
+ settle(id)
157
+ failedIds.add(key)
158
+ // Bound the set: disconnect-case entries never see a late result to forget(), so cap FIFO.
159
+ if (failedIds.size > FAILED_IDS_CAP) failedIds.delete(failedIds.values().next().value)
160
+ log('FAILFAST', { id, reason })
161
+ onLost(id, reason)
162
+ }
163
+
164
+ function observeAndForward(body, codexIds) {
165
+ const reader = body.getReader()
166
+ const scanner = createSseIdScanner()
167
+ let ended = false
168
+ const onEnd = () => {
169
+ if (ended) return
170
+ ended = true
171
+ for (const id of codexIds) lose(id, 'upstream stream closed before result')
172
+ }
173
+ return new ReadableStream({
174
+ async pull(controller) {
175
+ let r
176
+ try {
177
+ r = await reader.read()
178
+ } catch (e) {
179
+ try {
180
+ controller.error(e)
181
+ } catch {
182
+ /* already closed */
183
+ }
184
+ onEnd()
185
+ return
186
+ }
187
+ if (r.done) {
188
+ try {
189
+ controller.close()
190
+ } catch {
191
+ /* already closed */
192
+ }
193
+ onEnd()
194
+ return
195
+ }
196
+ try {
197
+ controller.enqueue(r.value) // forward bytes unchanged to the transport
198
+ } catch {
199
+ /* downstream gone */
200
+ }
201
+ // Any byte (data OR heartbeat) proves the stream is alive → reset the idle backstop.
202
+ for (const id of codexIds) touch(id)
203
+ // Same loop: any tracked id whose RESPONSE crossed the wire is now delivered.
204
+ for (const id of scanner.push(r.value)) settle(id)
205
+ },
206
+ cancel(reason) {
207
+ reader.cancel(reason).catch(() => {})
208
+ onEnd()
209
+ },
210
+ })
211
+ }
212
+
213
+ async function wrappedFetch(url, init) {
214
+ // Parse ids + register. Both are internally safe (parseRequestIds never throws), but guard
215
+ // defensively so a future change can't break the chain — degrade to "no tracking".
216
+ let codexIds = []
217
+ try {
218
+ codexIds = parseRequestIds(init).filter(isCodexOriginated)
219
+ for (const id of codexIds) register(id)
220
+ } catch (e) {
221
+ log('FAILFAST-WRAP-ERR', String(e))
222
+ codexIds = []
223
+ }
224
+
225
+ // The network call. Its failure MUST propagate to the SDK unchanged — do NOT retry here
226
+ // (a second fetch could execute the tool server-side after Codex already got the error).
227
+ let res
228
+ try {
229
+ res = await realFetch(url, init)
230
+ } catch (e) {
231
+ // Connection failed before any response (DNS / TLS / RST) — fail fast, don't wait 270s.
232
+ for (const id of codexIds) lose(id, `upstream fetch failed: ${e}`)
233
+ throw e // preserve the SDK's existing onerror/logging path (no re-fetch)
234
+ }
235
+
236
+ // Response handling + monitoring. On any internal bug, return the response we ALREADY have,
237
+ // unmonitored — never re-fetch (wrappedFetch sits on 100% of upstream traffic incl. GET
238
+ // elicitation + DELETE teardown, so it must never break the chain, but also never duplicate).
239
+ try {
240
+ if (!codexIds.length) return res // GET / DELETE / notification / internal id → passthrough
241
+ if (!res.ok) {
242
+ // Non-SSE HTTP error (404 session-gone / 403 / 429 / …). send() throws before onmessage,
243
+ // and that rejection is swallowed by upstream.send().catch — so nothing ever settles the id.
244
+ for (const id of codexIds) lose(id, `upstream HTTP ${res.status}`)
245
+ return res
246
+ }
247
+ const ct = res.headers.get('content-type') || ''
248
+ if (!ct.includes('text/event-stream') || !res.body) return res // 202 etc. — no id to monitor
249
+ return new Response(observeAndForward(res.body, codexIds), {
250
+ status: res.status,
251
+ statusText: res.statusText,
252
+ headers: res.headers,
253
+ })
254
+ } catch (e) {
255
+ log('FAILFAST-WRAP-ERR', String(e))
256
+ return res
257
+ }
258
+ }
259
+
260
+ function dispose() {
261
+ for (const { timer } of inFlight.values()) clearTimeout(timer)
262
+ inFlight.clear()
263
+ }
264
+
265
+ return { fetch: wrappedFetch, settle, touch, forget, dispose, failedIds }
266
+ }
package/src/proxy.mjs CHANGED
@@ -14,8 +14,9 @@
14
14
  // Validated end-to-end on 2026-07-06 (codex-cli 0.142.2, real dev /api/mcp): transparent passthrough
15
15
  // of all 12 gemus tools; codex spawns one proxy per session; real tool-calls tapped with workflowId
16
16
  // (from arguments) + turn/thread identity (from _meta.x-codex-turn-metadata). Step-0.4: plugin-bundled
17
- // Stop hook fires (needs /hooks trust); codex does NOT interpolate ${VAR} in MCP env → key is a literal
18
- // in local `codex mcp add --env`. See docs/plans/1751-codex-desktop-companion.md.
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.
19
20
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
20
21
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
21
22
  import http from 'node:http'
@@ -26,9 +27,49 @@ import { randomBytes } from 'node:crypto'
26
27
  import { createBackfiller, injectSteering } from './backfill.mjs'
27
28
  import { unwrapMcpContent } from '@gemus/codex-backfill-core'
28
29
  import { buildProxyUpstreamHeaders } from './mcpHeaders.mjs'
30
+ import { createFailFast, isJsonRpcResponse } from './failFast.mjs'
31
+ import {
32
+ assertGemusKey,
33
+ createFatalReporter,
34
+ createShutdownCoordinator,
35
+ safeEndpointLabel,
36
+ startProxy,
37
+ } from './startup.mjs'
29
38
 
30
39
  const KEY = process.env.GEMUS_KEY || ''
31
40
  const GEMUS_URL = process.env.GEMUS_URL || 'https://gemus.ai/api/mcp'
41
+ const LOG = process.env.PROXY_LOG // optional debug log path
42
+ const log = (dir, m) => {
43
+ if (!LOG) return
44
+ try {
45
+ fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${dir} ${typeof m === 'string' ? m : JSON.stringify(m).slice(0, 1000)}\n`)
46
+ } catch {
47
+ // Debug logging is optional and must never affect proxy lifecycle.
48
+ }
49
+ }
50
+
51
+ const reportFatal = createFatalReporter({
52
+ secret: KEY,
53
+ writeStderr: (message) => fs.writeSync(2, message),
54
+ writeLog: (message) => log('FATAL', message),
55
+ })
56
+ let shutdown
57
+ let shutdownRequested = false
58
+ function terminateFatally(error) {
59
+ reportFatal(error)
60
+ shutdownRequested = true
61
+ if (shutdown) return shutdown(1)
62
+ process.exit(1)
63
+ }
64
+ process.on('uncaughtException', (error) => { void terminateFatally(error) })
65
+ process.on('unhandledRejection', (reason) => { void terminateFatally(reason) })
66
+
67
+ try {
68
+ assertGemusKey(KEY)
69
+ } catch (error) {
70
+ terminateFatally(error)
71
+ }
72
+
32
73
  const SERVER_ORIGIN = GEMUS_URL.replace(/\/api\/mcp\/?$/, '') // core delivery appends /api/mcp itself
33
74
  const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
34
75
  // turn-idle fallback (untrusted-hook path). Default 3min: imagegen alone runs 30-60s with NO MCP tool
@@ -39,11 +80,6 @@ const IDLE_MS = Number(process.env.PROXY_BACKFILL_IDLE_MS) || 180_000
39
80
  // (deriveWaitTimeoutMs('gen-image-generation')=180s,见 src/server/generation/waitTimeout.ts),否则
40
81
  // proxy 先超时 → 假失败 → orphan 回退 → 同图重复交付。240s = 180s + 余量。别调到 180s 以下。
41
82
  const INJECT_TIMEOUT_MS = Number(process.env.PROXY_INJECT_TIMEOUT_MS) || 240_000
42
- const LOG = process.env.PROXY_LOG // optional debug log path
43
- const log = (dir, m) => {
44
- if (!LOG) return
45
- fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${dir} ${typeof m === 'string' ? m : JSON.stringify(m).slice(0, 1000)}\n`)
46
- }
47
83
 
48
84
  // ── footprint-0 交付注入(#1756 Layer 1a)─────────────────────────────────────────────────────────
49
85
  // 交付/orphan 不再每图新开 key-only session(connect-per-call,抢 KEY_ONLY_SESSION_CAP 槽),而是把
@@ -235,7 +271,10 @@ const hookServer = http.createServer((req, res) => {
235
271
  .catch((e) => log('HOOK-BACKFILL-ERR', String(e)))
236
272
  })
237
273
  })
238
- hookServer.on('error', (e) => log('HOOK-SERVER-ERR', String(e)))
274
+ hookServer.on('error', (error) => {
275
+ if (shutdownRequested) return
276
+ void terminateFatally(error)
277
+ })
239
278
  hookServer.listen(0, '127.0.0.1', () => {
240
279
  hookPort = hookServer.address().port
241
280
  log('HOOK-SERVER', { port: hookPort })
@@ -244,12 +283,45 @@ hookServer.listen(0, '127.0.0.1', () => {
244
283
  if (typeof hookServer.unref === 'function') hookServer.unref()
245
284
 
246
285
  // ── transparent JSON-RPC passthrough (raw send/onmessage) ──────────────────────────────────────
286
+ const stdio = new StdioServerTransport()
287
+
288
+ // #2068 fail-fast: StreamableHTTPClientTransport.send() resolves before the upstream SSE result
289
+ // arrives and only surfaces a stream death via a global, id-less `onerror` (no reconnect for POST).
290
+ // The custom `fetch` wrapper parses each request's JSON-RPC id and, if the response stream dies
291
+ // before that id's result crosses the wire, injects a JSON-RPC error so Codex fails in <2s instead
292
+ // of hanging to its 300s timeout. onLost only injects to stdio — it does NOT tap: tapping here would
293
+ // delete the proxy's `inflight` entry, so the DROP-AFTER-FAILFAST branch below could no longer let
294
+ // backfill observe a late real result (env delivery on the still-open, idle-timed-out case).
295
+ const failFast = createFailFast({
296
+ idleTimeoutMs: Number(process.env.PROXY_FAILFAST_TIMEOUT_MS) || 120_000,
297
+ log,
298
+ onLost: (id, reason) => {
299
+ const errObj = {
300
+ jsonrpc: '2.0',
301
+ id,
302
+ error: { code: -32000, message: 'Gemus upstream stream disconnected before the tool result was delivered' },
303
+ }
304
+ log('FAILFAST-INJECT', { id, reason })
305
+ stdio.send(errObj).catch((e) => log('ERR-stdio-send', String(e)))
306
+ },
307
+ })
308
+
247
309
  const upstream = new StreamableHTTPClientTransport(new URL(GEMUS_URL), {
248
310
  requestInit: { headers: buildProxyUpstreamHeaders(KEY) },
311
+ fetch: failFast.fetch,
249
312
  })
250
- const stdio = new StdioServerTransport()
251
313
 
252
- stdio.onmessage = (m) => { tapClientToServer(m); log('C->G', m); upstream.send(m).catch((e) => log('ERR-http-send', String(e))) }
314
+ stdio.onmessage = (m) => {
315
+ tapClientToServer(m)
316
+ log('C->G', m)
317
+ upstream.send(m).catch((error) => {
318
+ if (m?.method === 'initialize') {
319
+ void terminateFatally(error)
320
+ return
321
+ }
322
+ log('ERR-http-send', String(error))
323
+ })
324
+ }
253
325
  upstream.onmessage = (m) => {
254
326
  // footprint-0 (#1756): our injected execute/canvas_edit responses carry a `gemus-bf-*` string id.
255
327
  // Self-consume them here (settle the pending injection) and DO NOT forward to Codex — Codex never
@@ -262,6 +334,18 @@ upstream.onmessage = (m) => {
262
334
  // #1756 liveness heartbeat: our own `ping` pongs (gemus-ping-*) are self-consumed — Codex never
263
335
  // issued them, so it must never see the responses.
264
336
  if (typeof m?.id === 'string' && m.id.startsWith('gemus-ping-')) return
337
+ // #2068 fail-fast bookkeeping. Gate on RESPONSE only (isJsonRpcResponse) — a server→client request
338
+ // (elicitation/sampling) also carries an id that can collide with an in-flight tools/call id, and
339
+ // must be forwarded to Codex untouched, never settled/dropped.
340
+ if (isJsonRpcResponse(m)) {
341
+ if (failFast.failedIds.has(String(m.id))) {
342
+ // Already fail-fasted (idle timeout on a still-open stream) → Codex got the error. Still tap so
343
+ // backfill (#1751) observes the late real result (inflight survives — onLost didn't tap), but
344
+ // do NOT re-forward it to Codex (no double-send). forget() bounds failedIds.
345
+ tapServerToClient(m); failFast.forget(m.id); log('DROP-AFTER-FAILFAST', m.id); return
346
+ }
347
+ failFast.settle(m.id)
348
+ }
265
349
  tapServerToClient(m); steerOnInit(m); log('G->C', m); stdio.send(m).catch((e) => log('ERR-stdio-send', String(e)))
266
350
  }
267
351
 
@@ -283,30 +367,51 @@ function startHeartbeat() {
283
367
  if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref() // don't keep the process alive on this alone
284
368
  }
285
369
 
286
- let shuttingDown = false
287
- async function shutdown() {
288
- if (shuttingDown) return
289
- shuttingDown = true
370
+ async function cleanup() {
290
371
  if (heartbeatTimer) clearInterval(heartbeatTimer)
291
372
  for (const t of idleTimers.values()) clearTimeout(t)
292
373
  idleTimers.clear()
293
374
  // Settle any in-flight footprint-0 injections so a mid-delivery shutdown doesn't hang their awaits.
294
375
  for (const p of pendingInjections.values()) p.settle({ ok: false, error: 'proxy shutting down' })
295
376
  pendingInjections.clear()
377
+ failFast.dispose() // #2068: clear fail-fast final-safety timers
296
378
  try { hookServer.close() } catch { /* ignore */ }
297
379
  if (discoveryFile) { try { fs.unlinkSync(discoveryFile) } catch { /* ignore */ } }
298
380
  try { await upstream.terminateSession() } catch { /* ignore */ }
299
381
  try { await upstream.close() } catch { /* ignore */ }
300
- process.exit(0)
301
382
  }
302
- stdio.onclose = () => { log('stdio-close', ''); void shutdown() }
383
+ shutdown = createShutdownCoordinator({
384
+ cleanup,
385
+ exit: (code) => process.exit(code),
386
+ })
387
+ function shutdownGracefully() {
388
+ shutdownRequested = true
389
+ return shutdown(0)
390
+ }
391
+ stdio.onclose = () => { log('stdio-close', ''); void shutdownGracefully() }
303
392
  upstream.onclose = () => { log('http-close', '') }
304
393
  stdio.onerror = (e) => log('stdio-err', String(e))
305
394
  upstream.onerror = (e) => log('http-err', String(e))
306
- process.on('SIGTERM', shutdown)
307
- process.on('SIGINT', shutdown)
395
+ process.on('SIGTERM', () => { void shutdownGracefully() })
396
+ process.on('SIGINT', () => { void shutdownGracefully() })
308
397
 
309
- await upstream.start()
310
- await stdio.start()
311
- startHeartbeat()
312
- log('STARTED', { GEMUS_URL, SERVER_ORIGIN, hasKey: Boolean(KEY), pid: process.pid, idleMs: IDLE_MS, heartbeatMs: HEARTBEAT_MS })
398
+ try {
399
+ await startProxy({
400
+ key: KEY,
401
+ upstream,
402
+ stdio,
403
+ onStarted: () => {
404
+ if (shutdownRequested) return
405
+ startHeartbeat()
406
+ log('STARTED', {
407
+ endpoint: safeEndpointLabel(GEMUS_URL),
408
+ hasKey: Boolean(KEY),
409
+ pid: process.pid,
410
+ idleMs: IDLE_MS,
411
+ heartbeatMs: HEARTBEAT_MS,
412
+ })
413
+ },
414
+ })
415
+ } catch (error) {
416
+ await terminateFatally(error)
417
+ }
@@ -0,0 +1,94 @@
1
+ const REDACTED = '[REDACTED]'
2
+
3
+ export function assertGemusKey(key) {
4
+ if (!key) {
5
+ throw new Error('GEMUS_KEY is required. Set it in your user environment and restart Codex.')
6
+ }
7
+ }
8
+
9
+ export function safeEndpointLabel(value) {
10
+ try {
11
+ const url = value instanceof URL ? value : new URL(value)
12
+ return `${url.origin}${url.pathname}`
13
+ } catch {
14
+ return '[invalid endpoint]'
15
+ }
16
+ }
17
+
18
+ export function sanitizeStartupError(error, { secret }) {
19
+ const message = error instanceof Error ? error.message : String(error)
20
+ const exactRedacted = secret ? message.replaceAll(secret, REDACTED) : message
21
+ const withoutUrlCredentials = exactRedacted.replace(
22
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi,
23
+ (value) => safeEndpointLabel(value),
24
+ )
25
+ const withoutQuotedHeaders = withoutUrlCredentials.replace(
26
+ /((?:(?:Proxy-)?Authorization|X-API-Key|API-Key|X-Gemus-Key|Gemus-Key)["']?\s*[:=]\s*)(["'])(.*?)\2/gi,
27
+ (_match, prefix, quote, value) => {
28
+ const scheme = /^(Bearer|Basic|Digest)\s+/i.exec(value)?.[1]
29
+ return `${prefix}${quote}${scheme ? `${scheme} ` : ''}${REDACTED}${quote}`
30
+ },
31
+ )
32
+ const withoutAuthorizationHeaders = withoutQuotedHeaders.replace(
33
+ /((?:Proxy-)?Authorization\s*[:=]\s*)(?!["'])(?:(Bearer|Basic|Digest)\s+)?[^\s,;}]+/gi,
34
+ (_match, prefix, scheme) => `${prefix}${scheme ? `${scheme} ` : ''}${REDACTED}`,
35
+ )
36
+ const withoutBearerTokens = withoutAuthorizationHeaders.replace(
37
+ /Bearer\s+(?!\[REDACTED\])[^\s"',;}]+/gi,
38
+ `Bearer ${REDACTED}`,
39
+ )
40
+ const withoutKeyHeaders = withoutBearerTokens.replace(
41
+ /((?:X-API-Key|API-Key|X-Gemus-Key|Gemus-Key)\s*[:=]\s*)(?!["'])[^\s,;}]+/gi,
42
+ `$1${REDACTED}`,
43
+ )
44
+ const sanitized = withoutKeyHeaders.replace(
45
+ /mak_[A-Za-z0-9._~+/=-]+/g,
46
+ REDACTED,
47
+ )
48
+
49
+ return `Gemus MCP proxy failed to start: ${sanitized}`
50
+ }
51
+
52
+ export function createFatalReporter({ secret, writeStderr, writeLog = () => {} }) {
53
+ let reported = false
54
+
55
+ return function reportFatal(error) {
56
+ const message = sanitizeStartupError(error, { secret })
57
+ if (reported) return message
58
+ reported = true
59
+
60
+ writeStderr(`${message}\n`)
61
+ try {
62
+ writeLog(message)
63
+ } catch {
64
+ // Optional logging must never replace the guaranteed stderr diagnostic.
65
+ }
66
+ return message
67
+ }
68
+ }
69
+
70
+ export async function startProxy({ key, upstream, stdio, onStarted }) {
71
+ assertGemusKey(key)
72
+ await upstream.start()
73
+ await stdio.start()
74
+ onStarted()
75
+ }
76
+
77
+ export function createShutdownCoordinator({ cleanup, exit }) {
78
+ let requestedExitCode = 0
79
+ let cleanupPromise
80
+
81
+ return function shutdown(exitCode = 0) {
82
+ requestedExitCode = Math.max(requestedExitCode, exitCode)
83
+ if (!cleanupPromise) {
84
+ cleanupPromise = (async () => {
85
+ try {
86
+ await cleanup()
87
+ } finally {
88
+ exit(requestedExitCode)
89
+ }
90
+ })()
91
+ }
92
+ return cleanupPromise
93
+ }
94
+ }