@gemus/mcp-proxy 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -19
- package/package.json +3 -2
- package/src/__tests__/failFast.test.ts +108 -6
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +216 -0
- package/src/__tests__/proxyMessages.test.ts +81 -0
- package/src/__tests__/startup.test.ts +752 -0
- package/src/__tests__/upstreamHttp.real.test.ts +37 -0
- package/src/__tests__/upstreamHttp.test.ts +151 -0
- package/src/failFast.mjs +23 -10
- package/src/proxy.mjs +122 -31
- package/src/proxyMessages.mjs +38 -0
- package/src/startup.mjs +110 -0
- package/src/upstreamHttp.mjs +69 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http'
|
|
2
|
+
import type { Socket } from 'node:net'
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { createUpstreamHttp } from '../upstreamHttp.mjs'
|
|
5
|
+
|
|
6
|
+
let server: Server | undefined
|
|
7
|
+
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
server?.closeAllConnections()
|
|
10
|
+
if (server?.listening) {
|
|
11
|
+
await new Promise<void>((resolve) => server?.close(() => resolve()))
|
|
12
|
+
}
|
|
13
|
+
server = undefined
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('createUpstreamHttp with real Undici dispatchers', () => {
|
|
17
|
+
it('force-closes an established hanging response after the graceful budget expires', async () => {
|
|
18
|
+
let activeSocket!: Socket
|
|
19
|
+
server = createServer((request, response) => {
|
|
20
|
+
activeSocket = request.socket
|
|
21
|
+
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
22
|
+
response.write(': heartbeat\n\n')
|
|
23
|
+
})
|
|
24
|
+
await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))
|
|
25
|
+
const address = server.address()
|
|
26
|
+
if (!address || typeof address === 'string') throw new Error('expected a TCP listener')
|
|
27
|
+
|
|
28
|
+
const upstream = createUpstreamHttp()
|
|
29
|
+
const response = await upstream.fetch(`http://127.0.0.1:${address.port}/api/mcp`)
|
|
30
|
+
expect(response.ok).toBe(true)
|
|
31
|
+
expect(activeSocket.destroyed).toBe(false)
|
|
32
|
+
|
|
33
|
+
await upstream.shutdown()
|
|
34
|
+
|
|
35
|
+
await expect.poll(() => activeSocket.destroyed, { timeout: 500 }).toBe(true)
|
|
36
|
+
}, 5000)
|
|
37
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const undiciFetch = vi.fn()
|
|
4
|
+
const agents: Array<{ options: unknown; close: ReturnType<typeof vi.fn>; destroy: ReturnType<typeof vi.fn> }> = []
|
|
5
|
+
const pools: Array<{ origin: unknown; options: unknown; destroy: ReturnType<typeof vi.fn> }> = []
|
|
6
|
+
|
|
7
|
+
const Agent = vi.fn(function Agent(options) {
|
|
8
|
+
const agent = {
|
|
9
|
+
options,
|
|
10
|
+
close: vi.fn(() => Promise.resolve()),
|
|
11
|
+
destroy: vi.fn(),
|
|
12
|
+
}
|
|
13
|
+
agents.push(agent)
|
|
14
|
+
return agent
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const Pool = vi.fn(function Pool(origin, options) {
|
|
18
|
+
const pool = {
|
|
19
|
+
origin,
|
|
20
|
+
options,
|
|
21
|
+
destroy: vi.fn(() => Promise.resolve()),
|
|
22
|
+
}
|
|
23
|
+
pools.push(pool)
|
|
24
|
+
return pool
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
vi.mock('undici', () => ({ Agent, Pool, fetch: undiciFetch }))
|
|
28
|
+
|
|
29
|
+
async function createAdapter() {
|
|
30
|
+
return (await import('../upstreamHttp.mjs')).createUpstreamHttp()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
agents.length = 0
|
|
35
|
+
pools.length = 0
|
|
36
|
+
Agent.mockClear()
|
|
37
|
+
Pool.mockClear()
|
|
38
|
+
undiciFetch.mockReset()
|
|
39
|
+
delete process.env.PROXY_CONNECT_ATTEMPT_TIMEOUT_MS
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.useRealTimers()
|
|
44
|
+
delete process.env.PROXY_CONNECT_ATTEMPT_TIMEOUT_MS
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('createUpstreamHttp', () => {
|
|
48
|
+
it('creates one Agent with the 1000ms address-family attempt window by default', async () => {
|
|
49
|
+
const upstream = await createAdapter()
|
|
50
|
+
|
|
51
|
+
expect(upstream.attemptTimeoutMs).toBe(1000)
|
|
52
|
+
expect(Agent).toHaveBeenCalledTimes(1)
|
|
53
|
+
expect(agents[0].options).toEqual({
|
|
54
|
+
factory: expect.any(Function),
|
|
55
|
+
connect: {
|
|
56
|
+
autoSelectFamily: true,
|
|
57
|
+
autoSelectFamilyAttemptTimeout: 1000,
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it.each([
|
|
63
|
+
['', 1000],
|
|
64
|
+
['not-a-number', 1000],
|
|
65
|
+
['Infinity', 1000],
|
|
66
|
+
['0', 1000],
|
|
67
|
+
['-1', 1000],
|
|
68
|
+
['9.9', 10],
|
|
69
|
+
['10.9', 10],
|
|
70
|
+
['251.9', 251],
|
|
71
|
+
])('normalizes PROXY_CONNECT_ATTEMPT_TIMEOUT_MS=%j to %i milliseconds', async (value, expected) => {
|
|
72
|
+
process.env.PROXY_CONNECT_ATTEMPT_TIMEOUT_MS = value
|
|
73
|
+
|
|
74
|
+
const upstream = await createAdapter()
|
|
75
|
+
|
|
76
|
+
expect(upstream.attemptTimeoutMs).toBe(expected)
|
|
77
|
+
expect(agents[0].options).toEqual({
|
|
78
|
+
factory: expect.any(Function),
|
|
79
|
+
connect: {
|
|
80
|
+
autoSelectFamily: true,
|
|
81
|
+
autoSelectFamilyAttemptTimeout: expected,
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('preserves transport request init while injecting its dispatcher', async () => {
|
|
87
|
+
const upstream = await createAdapter()
|
|
88
|
+
const signal = new AbortController().signal
|
|
89
|
+
const init = {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { authorization: 'Bearer example', accept: 'text/event-stream' },
|
|
92
|
+
body: '{"jsonrpc":"2.0","id":7}',
|
|
93
|
+
signal,
|
|
94
|
+
cache: 'no-store' as RequestCache,
|
|
95
|
+
redirect: 'manual' as RequestRedirect,
|
|
96
|
+
}
|
|
97
|
+
undiciFetch.mockResolvedValue(new Response('ok'))
|
|
98
|
+
|
|
99
|
+
await upstream.fetch('https://example.test/api/mcp', init)
|
|
100
|
+
|
|
101
|
+
expect(undiciFetch).toHaveBeenCalledTimes(1)
|
|
102
|
+
expect(undiciFetch).toHaveBeenCalledWith('https://example.test/api/mcp', {
|
|
103
|
+
...init,
|
|
104
|
+
dispatcher: agents[0],
|
|
105
|
+
})
|
|
106
|
+
expect(init).not.toHaveProperty('dispatcher')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('propagates a failed request after one fetch attempt', async () => {
|
|
110
|
+
const upstream = await createAdapter()
|
|
111
|
+
undiciFetch.mockRejectedValue(new Error('connect failed'))
|
|
112
|
+
|
|
113
|
+
await expect(upstream.fetch('https://example.test/api/mcp', { method: 'POST', body: 'side-effecting' })).rejects.toThrow('connect failed')
|
|
114
|
+
|
|
115
|
+
expect(undiciFetch).toHaveBeenCalledTimes(1)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('shares one graceful shutdown across repeated calls', async () => {
|
|
119
|
+
const upstream = await createAdapter()
|
|
120
|
+
let finishClose!: () => void
|
|
121
|
+
agents[0].close.mockImplementation(() => new Promise<void>((resolve) => { finishClose = resolve }))
|
|
122
|
+
|
|
123
|
+
const first = upstream.shutdown()
|
|
124
|
+
const second = upstream.shutdown()
|
|
125
|
+
expect(second).toBe(first)
|
|
126
|
+
expect(agents[0].close).toHaveBeenCalledTimes(1)
|
|
127
|
+
|
|
128
|
+
finishClose()
|
|
129
|
+
await first
|
|
130
|
+
await upstream.shutdown()
|
|
131
|
+
expect(agents[0].close).toHaveBeenCalledTimes(1)
|
|
132
|
+
expect(agents[0].destroy).not.toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('destroys retained origin dispatchers and the Agent when graceful close exceeds 2000ms', async () => {
|
|
136
|
+
vi.useFakeTimers()
|
|
137
|
+
const upstream = await createAdapter()
|
|
138
|
+
agents[0].close.mockImplementation(() => new Promise<void>(() => {}))
|
|
139
|
+
const factory = (agents[0].options as { factory: (origin: string, options: object) => unknown }).factory
|
|
140
|
+
factory('https://example.test', { connections: 2 })
|
|
141
|
+
|
|
142
|
+
const shutdown = upstream.shutdown()
|
|
143
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
144
|
+
await shutdown
|
|
145
|
+
|
|
146
|
+
expect(agents[0].close).toHaveBeenCalledTimes(1)
|
|
147
|
+
expect(agents[0].destroy).toHaveBeenCalledTimes(1)
|
|
148
|
+
expect(pools[0].destroy).toHaveBeenCalledTimes(1)
|
|
149
|
+
expect(upstream.shutdown()).toBe(shutdown)
|
|
150
|
+
})
|
|
151
|
+
})
|
package/src/failFast.mjs
CHANGED
|
@@ -93,11 +93,24 @@ export function createSseIdScanner() {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Extract an allowlisted network error code without exposing the raw error text. */
|
|
97
|
+
function failureCode(error) {
|
|
98
|
+
let current = error
|
|
99
|
+
for (let depth = 0; depth < 8 && current && typeof current === 'object'; depth++) {
|
|
100
|
+
if (typeof current.code === 'string') {
|
|
101
|
+
const code = current.code.toUpperCase()
|
|
102
|
+
if (/^[A-Z0-9_]{1,64}$/.test(code)) return code
|
|
103
|
+
}
|
|
104
|
+
current = current.cause
|
|
105
|
+
}
|
|
106
|
+
return 'UNKNOWN'
|
|
107
|
+
}
|
|
108
|
+
|
|
96
109
|
/**
|
|
97
110
|
* Build the fail-fast fetch wrapper + settle/dispose controls.
|
|
98
111
|
*
|
|
99
112
|
* @param {object} opts
|
|
100
|
-
* @param {(id:any,
|
|
113
|
+
* @param {(id:any, failure:{kind:string, code?:string, status?:number})=>void} opts.onLost called once per lost id (must be idempotent-safe on caller side)
|
|
101
114
|
* @param {number} [opts.idleTimeoutMs] IDLE backstop reset on every upstream byte (incl. server
|
|
102
115
|
* heartbeats every ~12s), so it trips ONLY on a genuinely silent/zombie connection — never on a
|
|
103
116
|
* healthy-but-slow tool. Must be < Codex 300s; default 120_000.
|
|
@@ -117,7 +130,7 @@ export function createFailFast(opts) {
|
|
|
117
130
|
const failedIds = new Set()
|
|
118
131
|
|
|
119
132
|
function armTimer(entry, id) {
|
|
120
|
-
entry.timer = setTimeout(() => lose(id, '
|
|
133
|
+
entry.timer = setTimeout(() => lose(id, { kind: 'idle' }), idleTimeoutMs)
|
|
121
134
|
if (typeof entry.timer.unref === 'function') entry.timer.unref()
|
|
122
135
|
}
|
|
123
136
|
|
|
@@ -150,15 +163,15 @@ export function createFailFast(opts) {
|
|
|
150
163
|
failedIds.delete(String(id))
|
|
151
164
|
}
|
|
152
165
|
|
|
153
|
-
function lose(id,
|
|
166
|
+
function lose(id, failure) {
|
|
154
167
|
const key = String(id)
|
|
155
168
|
if (!inFlight.has(key)) return // already settled or already lost
|
|
156
169
|
settle(id)
|
|
157
170
|
failedIds.add(key)
|
|
158
171
|
// Bound the set: disconnect-case entries never see a late result to forget(), so cap FIFO.
|
|
159
172
|
if (failedIds.size > FAILED_IDS_CAP) failedIds.delete(failedIds.values().next().value)
|
|
160
|
-
log('FAILFAST', { id,
|
|
161
|
-
onLost(id,
|
|
173
|
+
log('FAILFAST', { id, ...failure })
|
|
174
|
+
onLost(id, failure)
|
|
162
175
|
}
|
|
163
176
|
|
|
164
177
|
function observeAndForward(body, codexIds) {
|
|
@@ -168,7 +181,7 @@ export function createFailFast(opts) {
|
|
|
168
181
|
const onEnd = () => {
|
|
169
182
|
if (ended) return
|
|
170
183
|
ended = true
|
|
171
|
-
for (const id of codexIds) lose(id, '
|
|
184
|
+
for (const id of codexIds) lose(id, { kind: 'stream' })
|
|
172
185
|
}
|
|
173
186
|
return new ReadableStream({
|
|
174
187
|
async pull(controller) {
|
|
@@ -218,7 +231,7 @@ export function createFailFast(opts) {
|
|
|
218
231
|
codexIds = parseRequestIds(init).filter(isCodexOriginated)
|
|
219
232
|
for (const id of codexIds) register(id)
|
|
220
233
|
} catch (e) {
|
|
221
|
-
log('FAILFAST-WRAP-ERR',
|
|
234
|
+
log('FAILFAST-WRAP-ERR', { code: failureCode(e) })
|
|
222
235
|
codexIds = []
|
|
223
236
|
}
|
|
224
237
|
|
|
@@ -229,7 +242,7 @@ export function createFailFast(opts) {
|
|
|
229
242
|
res = await realFetch(url, init)
|
|
230
243
|
} catch (e) {
|
|
231
244
|
// Connection failed before any response (DNS / TLS / RST) — fail fast, don't wait 270s.
|
|
232
|
-
for (const id of codexIds) lose(id,
|
|
245
|
+
for (const id of codexIds) lose(id, { kind: 'connection', code: failureCode(e) })
|
|
233
246
|
throw e // preserve the SDK's existing onerror/logging path (no re-fetch)
|
|
234
247
|
}
|
|
235
248
|
|
|
@@ -241,7 +254,7 @@ export function createFailFast(opts) {
|
|
|
241
254
|
if (!res.ok) {
|
|
242
255
|
// Non-SSE HTTP error (404 session-gone / 403 / 429 / …). send() throws before onmessage,
|
|
243
256
|
// and that rejection is swallowed by upstream.send().catch — so nothing ever settles the id.
|
|
244
|
-
for (const id of codexIds) lose(id,
|
|
257
|
+
for (const id of codexIds) lose(id, { kind: 'http', status: res.status })
|
|
245
258
|
return res
|
|
246
259
|
}
|
|
247
260
|
const ct = res.headers.get('content-type') || ''
|
|
@@ -252,7 +265,7 @@ export function createFailFast(opts) {
|
|
|
252
265
|
headers: res.headers,
|
|
253
266
|
})
|
|
254
267
|
} catch (e) {
|
|
255
|
-
log('FAILFAST-WRAP-ERR',
|
|
268
|
+
log('FAILFAST-WRAP-ERR', { code: failureCode(e) })
|
|
256
269
|
return res
|
|
257
270
|
}
|
|
258
271
|
}
|
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
|
|
18
|
-
//
|
|
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'
|
|
@@ -27,9 +28,51 @@ import { createBackfiller, injectSteering } from './backfill.mjs'
|
|
|
27
28
|
import { unwrapMcpContent } from '@gemus/codex-backfill-core'
|
|
28
29
|
import { buildProxyUpstreamHeaders } from './mcpHeaders.mjs'
|
|
29
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'
|
|
30
41
|
|
|
31
42
|
const KEY = process.env.GEMUS_KEY || ''
|
|
32
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
|
+
|
|
33
76
|
const SERVER_ORIGIN = GEMUS_URL.replace(/\/api\/mcp\/?$/, '') // core delivery appends /api/mcp itself
|
|
34
77
|
const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
35
78
|
// turn-idle fallback (untrusted-hook path). Default 3min: imagegen alone runs 30-60s with NO MCP tool
|
|
@@ -40,11 +83,6 @@ const IDLE_MS = Number(process.env.PROXY_BACKFILL_IDLE_MS) || 180_000
|
|
|
40
83
|
// (deriveWaitTimeoutMs('gen-image-generation')=180s,见 src/server/generation/waitTimeout.ts),否则
|
|
41
84
|
// proxy 先超时 → 假失败 → orphan 回退 → 同图重复交付。240s = 180s + 余量。别调到 180s 以下。
|
|
42
85
|
const INJECT_TIMEOUT_MS = Number(process.env.PROXY_INJECT_TIMEOUT_MS) || 240_000
|
|
43
|
-
const LOG = process.env.PROXY_LOG // optional debug log path
|
|
44
|
-
const log = (dir, m) => {
|
|
45
|
-
if (!LOG) return
|
|
46
|
-
fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${dir} ${typeof m === 'string' ? m : JSON.stringify(m).slice(0, 1000)}\n`)
|
|
47
|
-
}
|
|
48
86
|
|
|
49
87
|
// ── footprint-0 交付注入(#1756 Layer 1a)─────────────────────────────────────────────────────────
|
|
50
88
|
// 交付/orphan 不再每图新开 key-only session(connect-per-call,抢 KEY_ONLY_SESSION_CAP 槽),而是把
|
|
@@ -236,7 +274,10 @@ const hookServer = http.createServer((req, res) => {
|
|
|
236
274
|
.catch((e) => log('HOOK-BACKFILL-ERR', String(e)))
|
|
237
275
|
})
|
|
238
276
|
})
|
|
239
|
-
hookServer.on('error', (
|
|
277
|
+
hookServer.on('error', (error) => {
|
|
278
|
+
if (shutdownRequested) return
|
|
279
|
+
void terminateFatally(error)
|
|
280
|
+
})
|
|
240
281
|
hookServer.listen(0, '127.0.0.1', () => {
|
|
241
282
|
hookPort = hookServer.address().port
|
|
242
283
|
log('HOOK-SERVER', { port: hookPort })
|
|
@@ -246,6 +287,8 @@ if (typeof hookServer.unref === 'function') hookServer.unref()
|
|
|
246
287
|
|
|
247
288
|
// ── transparent JSON-RPC passthrough (raw send/onmessage) ──────────────────────────────────────
|
|
248
289
|
const stdio = new StdioServerTransport()
|
|
290
|
+
const initializingIds = new Set()
|
|
291
|
+
const classifiedInitializeFailures = new Set()
|
|
249
292
|
|
|
250
293
|
// #2068 fail-fast: StreamableHTTPClientTransport.send() resolves before the upstream SSE result
|
|
251
294
|
// arrives and only surfaces a stream death via a global, id-less `onerror` (no reconnect for POST).
|
|
@@ -254,17 +297,21 @@ const stdio = new StdioServerTransport()
|
|
|
254
297
|
// of hanging to its 300s timeout. onLost only injects to stdio — it does NOT tap: tapping here would
|
|
255
298
|
// delete the proxy's `inflight` entry, so the DROP-AFTER-FAILFAST branch below could no longer let
|
|
256
299
|
// backfill observe a late real result (env delivery on the still-open, idle-timed-out case).
|
|
300
|
+
const upstreamHttp = createUpstreamHttp()
|
|
257
301
|
const failFast = createFailFast({
|
|
258
302
|
idleTimeoutMs: Number(process.env.PROXY_FAILFAST_TIMEOUT_MS) || 120_000,
|
|
303
|
+
realFetch: upstreamHttp.fetch,
|
|
259
304
|
log,
|
|
260
|
-
onLost: (id,
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
265
313
|
}
|
|
266
|
-
log('
|
|
267
|
-
stdio.send(errObj).catch((e) => log('ERR-stdio-send', String(e)))
|
|
314
|
+
stdio.send(response).catch((e) => log('ERR-stdio-send', String(e)))
|
|
268
315
|
},
|
|
269
316
|
})
|
|
270
317
|
|
|
@@ -273,7 +320,22 @@ const upstream = new StreamableHTTPClientTransport(new URL(GEMUS_URL), {
|
|
|
273
320
|
fetch: failFast.fetch,
|
|
274
321
|
})
|
|
275
322
|
|
|
276
|
-
stdio.onmessage = (m) => {
|
|
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
|
+
}
|
|
277
339
|
upstream.onmessage = (m) => {
|
|
278
340
|
// footprint-0 (#1756): our injected execute/canvas_edit responses carry a `gemus-bf-*` string id.
|
|
279
341
|
// Self-consume them here (settle the pending injection) and DO NOT forward to Codex — Codex never
|
|
@@ -290,6 +352,7 @@ upstream.onmessage = (m) => {
|
|
|
290
352
|
// (elicitation/sampling) also carries an id that can collide with an in-flight tools/call id, and
|
|
291
353
|
// must be forwarded to Codex untouched, never settled/dropped.
|
|
292
354
|
if (isJsonRpcResponse(m)) {
|
|
355
|
+
initializingIds.delete(String(m.id))
|
|
293
356
|
if (failFast.failedIds.has(String(m.id))) {
|
|
294
357
|
// Already fail-fasted (idle timeout on a still-open stream) → Codex got the error. Still tap so
|
|
295
358
|
// backfill (#1751) observes the late real result (inflight survives — onLost didn't tap), but
|
|
@@ -310,6 +373,7 @@ upstream.onmessage = (m) => {
|
|
|
310
373
|
// **independent of Codex tool activity**, so a proxy mid-imagegen (silent to Codex for minutes) still
|
|
311
374
|
// proves liveness → the platform reclaims only genuinely-dead sessions (short floor), never a live one.
|
|
312
375
|
const HEARTBEAT_MS = Number(process.env.PROXY_HEARTBEAT_MS) || 30_000
|
|
376
|
+
const SHUTDOWN_STEP_TIMEOUT_MS = 2000
|
|
313
377
|
let pingSeq = 0
|
|
314
378
|
let heartbeatTimer
|
|
315
379
|
function startHeartbeat() {
|
|
@@ -319,10 +383,7 @@ function startHeartbeat() {
|
|
|
319
383
|
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref() // don't keep the process alive on this alone
|
|
320
384
|
}
|
|
321
385
|
|
|
322
|
-
|
|
323
|
-
async function shutdown() {
|
|
324
|
-
if (shuttingDown) return
|
|
325
|
-
shuttingDown = true
|
|
386
|
+
async function cleanup() {
|
|
326
387
|
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
|
327
388
|
for (const t of idleTimers.values()) clearTimeout(t)
|
|
328
389
|
idleTimers.clear()
|
|
@@ -332,18 +393,48 @@ async function shutdown() {
|
|
|
332
393
|
failFast.dispose() // #2068: clear fail-fast final-safety timers
|
|
333
394
|
try { hookServer.close() } catch { /* ignore */ }
|
|
334
395
|
if (discoveryFile) { try { fs.unlinkSync(discoveryFile) } catch { /* ignore */ } }
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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)
|
|
338
413
|
}
|
|
339
|
-
stdio.onclose = () => { log('stdio-close', ''); void
|
|
414
|
+
stdio.onclose = () => { log('stdio-close', ''); void shutdownGracefully() }
|
|
340
415
|
upstream.onclose = () => { log('http-close', '') }
|
|
341
416
|
stdio.onerror = (e) => log('stdio-err', String(e))
|
|
342
|
-
upstream.onerror = (
|
|
343
|
-
process.on('SIGTERM',
|
|
344
|
-
process.on('SIGINT',
|
|
417
|
+
upstream.onerror = () => log('http-err', { kind: 'transport' })
|
|
418
|
+
process.on('SIGTERM', () => { void shutdownGracefully() })
|
|
419
|
+
process.on('SIGINT', () => { void shutdownGracefully() })
|
|
345
420
|
|
|
346
|
-
|
|
347
|
-
await
|
|
348
|
-
|
|
349
|
-
|
|
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,38 @@
|
|
|
1
|
+
const PUBLIC_MESSAGES = {
|
|
2
|
+
connection: 'Gemus upstream connection failed before a response was received',
|
|
3
|
+
http: 'Gemus upstream returned an HTTP error before the tool result was delivered',
|
|
4
|
+
stream: 'Gemus upstream stream disconnected before the tool result was delivered',
|
|
5
|
+
idle: 'Gemus upstream stream became unresponsive before the tool result was delivered',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const FALLBACK_MESSAGE = 'Gemus upstream failed before the tool result was delivered'
|
|
9
|
+
const SAFE_CODE = /^[A-Z0-9_]{1,64}$/
|
|
10
|
+
|
|
11
|
+
export function publicMessageForFailure(failure) {
|
|
12
|
+
return Object.hasOwn(PUBLIC_MESSAGES, failure?.kind)
|
|
13
|
+
? PUBLIC_MESSAGES[failure.kind]
|
|
14
|
+
: FALLBACK_MESSAGE
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createProxyFailure(id, failure) {
|
|
18
|
+
const knownKind = Object.hasOwn(PUBLIC_MESSAGES, failure?.kind)
|
|
19
|
+
const kind = knownKind ? failure.kind : 'unknown'
|
|
20
|
+
const diagnostic = { id, kind }
|
|
21
|
+
|
|
22
|
+
if (kind === 'connection') {
|
|
23
|
+
diagnostic.code = typeof failure.code === 'string' && SAFE_CODE.test(failure.code)
|
|
24
|
+
? failure.code
|
|
25
|
+
: 'UNKNOWN'
|
|
26
|
+
} else if (kind === 'http' && Number.isInteger(failure.status)) {
|
|
27
|
+
diagnostic.status = failure.status
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
response: {
|
|
32
|
+
jsonrpc: '2.0',
|
|
33
|
+
id,
|
|
34
|
+
error: { code: -32000, message: publicMessageForFailure(failure) },
|
|
35
|
+
},
|
|
36
|
+
diagnostic,
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/startup.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
}
|
|
95
|
+
|
|
96
|
+
export function settleWithin(promise, timeoutMs) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const timer = setTimeout(() => resolve('timed_out'), timeoutMs)
|
|
99
|
+
Promise.resolve(promise).then(
|
|
100
|
+
() => {
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
resolve('settled')
|
|
103
|
+
},
|
|
104
|
+
() => {
|
|
105
|
+
clearTimeout(timer)
|
|
106
|
+
resolve('settled')
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
}
|