@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.
- package/README.md +50 -19
- package/package.json +1 -1
- package/src/__tests__/failFast.test.ts +254 -0
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +136 -0
- package/src/__tests__/startup.test.ts +558 -0
- package/src/failFast.mjs +266 -0
- package/src/proxy.mjs +127 -22
- package/src/startup.mjs +94 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import {
|
|
8
|
+
assertGemusKey,
|
|
9
|
+
createFatalReporter,
|
|
10
|
+
safeEndpointLabel,
|
|
11
|
+
sanitizeStartupError,
|
|
12
|
+
startProxy,
|
|
13
|
+
} from '../startup.mjs'
|
|
14
|
+
|
|
15
|
+
const PROXY_PATH = path.resolve(
|
|
16
|
+
process.cwd(),
|
|
17
|
+
'packages/gemus-mcp-proxy/src/proxy.mjs',
|
|
18
|
+
)
|
|
19
|
+
const PROXY_RUNTIME_LOADER_PATH = path.resolve(
|
|
20
|
+
process.cwd(),
|
|
21
|
+
'packages/gemus-mcp-proxy/src/__tests__/fixtures/proxy-runtime-loader.mjs',
|
|
22
|
+
)
|
|
23
|
+
const PROXY_RUNTIME_LOADER_URL = pathToFileURL(PROXY_RUNTIME_LOADER_PATH).href
|
|
24
|
+
|
|
25
|
+
async function readFileOrEmpty(file: string) {
|
|
26
|
+
try {
|
|
27
|
+
return await readFile(file, 'utf8')
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runProxy(
|
|
35
|
+
envOverrides: NodeJS.ProcessEnv,
|
|
36
|
+
{
|
|
37
|
+
closeStdinAfterMs = 600,
|
|
38
|
+
input,
|
|
39
|
+
}: {
|
|
40
|
+
closeStdinAfterMs?: number
|
|
41
|
+
input?: Record<string, unknown>
|
|
42
|
+
} = {},
|
|
43
|
+
) {
|
|
44
|
+
const env = { ...process.env, ...envOverrides }
|
|
45
|
+
for (const [key, value] of Object.entries(env)) {
|
|
46
|
+
if (value === undefined) delete env[key]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return await new Promise<{
|
|
50
|
+
code: number | null
|
|
51
|
+
signal: NodeJS.Signals | null
|
|
52
|
+
stdout: string
|
|
53
|
+
stderr: string
|
|
54
|
+
}>((resolve, reject) => {
|
|
55
|
+
const child = spawn(
|
|
56
|
+
process.execPath,
|
|
57
|
+
['--no-warnings', '--experimental-loader', PROXY_RUNTIME_LOADER_URL, PROXY_PATH],
|
|
58
|
+
{
|
|
59
|
+
env,
|
|
60
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
let stdout = ''
|
|
64
|
+
let stderr = ''
|
|
65
|
+
child.stdout.setEncoding('utf8')
|
|
66
|
+
child.stderr.setEncoding('utf8')
|
|
67
|
+
child.stdout.on('data', (chunk) => { stdout += chunk })
|
|
68
|
+
child.stderr.on('data', (chunk) => { stderr += chunk })
|
|
69
|
+
child.on('error', reject)
|
|
70
|
+
if (input) child.stdin.write(`${JSON.stringify(input)}\n`)
|
|
71
|
+
|
|
72
|
+
const closeTimer = setTimeout(() => child.stdin.end(), closeStdinAfterMs)
|
|
73
|
+
const killTimer = setTimeout(() => child.kill(), 5_000)
|
|
74
|
+
child.on('close', (code, signal) => {
|
|
75
|
+
clearTimeout(closeTimer)
|
|
76
|
+
clearTimeout(killTimer)
|
|
77
|
+
resolve({ code, signal, stdout, stderr })
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('assertGemusKey', () => {
|
|
83
|
+
it('rejects a missing GEMUS_KEY with actionable restart guidance', () => {
|
|
84
|
+
expect(() => assertGemusKey('')).toThrow(
|
|
85
|
+
'GEMUS_KEY is required. Set it in your user environment and restart Codex.',
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('sanitizeStartupError', () => {
|
|
91
|
+
it('keeps the missing-key diagnostic readable when the configured secret is empty', () => {
|
|
92
|
+
expect(
|
|
93
|
+
sanitizeStartupError(
|
|
94
|
+
new Error('GEMUS_KEY is required. Set it in your user environment and restart Codex.'),
|
|
95
|
+
{ secret: '' },
|
|
96
|
+
),
|
|
97
|
+
).toBe(
|
|
98
|
+
'Gemus MCP proxy failed to start: GEMUS_KEY is required. Set it in your user environment and restart Codex.',
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('redacts the exact configured secret regardless of its shape', () => {
|
|
103
|
+
const secret = 'arbitrary-secret-without-known-prefix'
|
|
104
|
+
const output = sanitizeStartupError(new Error(`upstream rejected ${secret}`), { secret })
|
|
105
|
+
|
|
106
|
+
expect(output).toBe('Gemus MCP proxy failed to start: upstream rejected [REDACTED]')
|
|
107
|
+
expect(output).not.toContain(secret)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it.each([
|
|
111
|
+
['mak_ token', 'request rejected mak_AbC123-._~+/=', 'request rejected [REDACTED]'],
|
|
112
|
+
['Bearer token', 'Authorization: Bearer opaque-token', 'Authorization: Bearer [REDACTED]'],
|
|
113
|
+
['authorization header', 'Authorization: opaque-token', 'Authorization: [REDACTED]'],
|
|
114
|
+
['API key header', 'X-API-Key=opaque-token', 'X-API-Key=[REDACTED]'],
|
|
115
|
+
])('redacts a %s while preserving non-sensitive context', (_label, input, expected) => {
|
|
116
|
+
const output = sanitizeStartupError(new Error(input), { secret: 'different-secret' })
|
|
117
|
+
|
|
118
|
+
expect(output).toBe(`Gemus MCP proxy failed to start: ${expected}`)
|
|
119
|
+
expect(output).toContain('request rejected'.includes(expected) ? 'request rejected' : expected.split(/[:=]/)[0])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('removes URL userinfo, query, and fragment without hiding the safe endpoint context', () => {
|
|
123
|
+
const output = sanitizeStartupError(
|
|
124
|
+
new Error(
|
|
125
|
+
'connect ECONNREFUSED https://alice:p%40ss@example.test/api/mcp?token=query-secret#fragment-secret',
|
|
126
|
+
),
|
|
127
|
+
{ secret: 'unrelated-secret' },
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
expect(output).toBe(
|
|
131
|
+
'Gemus MCP proxy failed to start: connect ECONNREFUSED https://example.test/api/mcp',
|
|
132
|
+
)
|
|
133
|
+
expect(output).not.toMatch(/alice|p%40ss|query-secret|fragment-secret/)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it.each(['ftp', 'ws'])(
|
|
137
|
+
'sanitizes a credential-bearing %s URL while preserving its safe endpoint',
|
|
138
|
+
(scheme) => {
|
|
139
|
+
const output = sanitizeStartupError(
|
|
140
|
+
new Error(
|
|
141
|
+
`connect failed ${scheme}://alice:password@example.test/api/mcp?token=query-secret#fragment-secret`,
|
|
142
|
+
),
|
|
143
|
+
{ secret: 'unrelated-secret' },
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
expect(output).toBe(
|
|
147
|
+
`Gemus MCP proxy failed to start: connect failed ${scheme}://example.test/api/mcp`,
|
|
148
|
+
)
|
|
149
|
+
expect(output).not.toMatch(
|
|
150
|
+
/alice|password|query-secret|fragment-secret/,
|
|
151
|
+
)
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
it.each([
|
|
156
|
+
[
|
|
157
|
+
'quoted authorization',
|
|
158
|
+
'request failed Authorization: "Basic opaque credential" status=401',
|
|
159
|
+
'request failed Authorization: "Basic [REDACTED]" status=401',
|
|
160
|
+
],
|
|
161
|
+
[
|
|
162
|
+
'JSON authorization',
|
|
163
|
+
'request failed {"authorization":"Bearer opaque-token"} status=401',
|
|
164
|
+
'request failed {"authorization":"Bearer [REDACTED]"} status=401',
|
|
165
|
+
],
|
|
166
|
+
[
|
|
167
|
+
'quoted API key',
|
|
168
|
+
"request failed X-API-Key='opaque credential' status=401",
|
|
169
|
+
"request failed X-API-Key='[REDACTED]' status=401",
|
|
170
|
+
],
|
|
171
|
+
])('redacts %s values without consuming adjacent context', (_label, input, expected) => {
|
|
172
|
+
expect(sanitizeStartupError(new Error(input), { secret: '' })).toBe(
|
|
173
|
+
`Gemus MCP proxy failed to start: ${expected}`,
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('safeEndpointLabel', () => {
|
|
179
|
+
it('returns only URL origin and path', () => {
|
|
180
|
+
expect(
|
|
181
|
+
safeEndpointLabel(
|
|
182
|
+
'https://alice:password@example.test/api/mcp?token=query-secret#fragment-secret',
|
|
183
|
+
),
|
|
184
|
+
).toBe('https://example.test/api/mcp')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('createFatalReporter', () => {
|
|
189
|
+
it('writes one synchronous diagnostic when optional logging throws or fatal repeats', () => {
|
|
190
|
+
const writeStderr = vi.fn()
|
|
191
|
+
const writeLog = vi.fn(() => { throw new Error('unwritable log') })
|
|
192
|
+
const reportFatal = createFatalReporter({
|
|
193
|
+
secret: 'exact-secret',
|
|
194
|
+
writeStderr,
|
|
195
|
+
writeLog,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
reportFatal(new Error('first failure exact-secret'))
|
|
199
|
+
reportFatal(new Error('second failure exact-secret'))
|
|
200
|
+
|
|
201
|
+
expect(writeStderr).toHaveBeenCalledTimes(1)
|
|
202
|
+
expect(writeStderr).toHaveBeenCalledWith(
|
|
203
|
+
'Gemus MCP proxy failed to start: first failure [REDACTED]\n',
|
|
204
|
+
)
|
|
205
|
+
expect(writeLog).toHaveBeenCalledTimes(1)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('startProxy', () => {
|
|
210
|
+
it('starts upstream before stdio and invokes the success callback last', async () => {
|
|
211
|
+
const events: string[] = []
|
|
212
|
+
const upstream = { start: vi.fn(async () => { events.push('upstream') }) }
|
|
213
|
+
const stdio = { start: vi.fn(async () => { events.push('stdio') }) }
|
|
214
|
+
|
|
215
|
+
await startProxy({
|
|
216
|
+
key: 'configured',
|
|
217
|
+
upstream,
|
|
218
|
+
stdio,
|
|
219
|
+
onStarted: () => { events.push('started') },
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(events).toEqual(['upstream', 'stdio', 'started'])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('does not construct startup work when GEMUS_KEY is missing', async () => {
|
|
226
|
+
const upstream = { start: vi.fn() }
|
|
227
|
+
const stdio = { start: vi.fn() }
|
|
228
|
+
|
|
229
|
+
await expect(
|
|
230
|
+
startProxy({ key: '', upstream, stdio, onStarted: vi.fn() }),
|
|
231
|
+
).rejects.toThrow('GEMUS_KEY is required')
|
|
232
|
+
expect(upstream.start).not.toHaveBeenCalled()
|
|
233
|
+
expect(stdio.start).not.toHaveBeenCalled()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('propagates an upstream startup error without starting stdio', async () => {
|
|
237
|
+
const error = new Error('upstream failed')
|
|
238
|
+
const upstream = { start: vi.fn().mockRejectedValue(error) }
|
|
239
|
+
const stdio = { start: vi.fn() }
|
|
240
|
+
|
|
241
|
+
await expect(
|
|
242
|
+
startProxy({ key: 'configured', upstream, stdio, onStarted: vi.fn() }),
|
|
243
|
+
).rejects.toBe(error)
|
|
244
|
+
expect(stdio.start).not.toHaveBeenCalled()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('propagates a stdio startup error without invoking the success callback', async () => {
|
|
248
|
+
const error = new Error('stdio failed')
|
|
249
|
+
const upstream = { start: vi.fn().mockResolvedValue(undefined) }
|
|
250
|
+
const stdio = { start: vi.fn().mockRejectedValue(error) }
|
|
251
|
+
const onStarted = vi.fn()
|
|
252
|
+
|
|
253
|
+
await expect(
|
|
254
|
+
startProxy({ key: 'configured', upstream, stdio, onStarted }),
|
|
255
|
+
).rejects.toBe(error)
|
|
256
|
+
expect(onStarted).not.toHaveBeenCalled()
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('createShutdownCoordinator', () => {
|
|
261
|
+
it('runs cleanup once and preserves a fatal exit requested during graceful close', async () => {
|
|
262
|
+
let releaseCleanup!: () => void
|
|
263
|
+
const cleanup = vi.fn(() => new Promise<void>((resolve) => {
|
|
264
|
+
releaseCleanup = resolve
|
|
265
|
+
}))
|
|
266
|
+
const exit = vi.fn()
|
|
267
|
+
const startup = await import('../startup.mjs') as typeof import('../startup.mjs') & {
|
|
268
|
+
createShutdownCoordinator?: (options: {
|
|
269
|
+
cleanup: () => Promise<void>
|
|
270
|
+
exit: (code: number) => void
|
|
271
|
+
}) => (code?: number) => Promise<void>
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
expect(startup.createShutdownCoordinator).toBeTypeOf('function')
|
|
275
|
+
const shutdown = startup.createShutdownCoordinator!({ cleanup, exit })
|
|
276
|
+
const graceful = shutdown(0)
|
|
277
|
+
const fatal = shutdown(1)
|
|
278
|
+
|
|
279
|
+
expect(cleanup).toHaveBeenCalledTimes(1)
|
|
280
|
+
expect(exit).not.toHaveBeenCalled()
|
|
281
|
+
releaseCleanup()
|
|
282
|
+
await Promise.all([graceful, fatal])
|
|
283
|
+
|
|
284
|
+
expect(exit).toHaveBeenCalledTimes(1)
|
|
285
|
+
expect(exit).toHaveBeenCalledWith(1)
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe('proxy process startup', () => {
|
|
290
|
+
it('fails before runtime construction when GEMUS_KEY is missing', async () => {
|
|
291
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), 'gemus-proxy-startup-'))
|
|
292
|
+
const logPath = path.join(tempRoot, 'proxy.log')
|
|
293
|
+
const constructionLogPath = path.join(tempRoot, 'construction.log')
|
|
294
|
+
try {
|
|
295
|
+
const result = await runProxy({
|
|
296
|
+
GEMUS_KEY: undefined,
|
|
297
|
+
GEMUS_URL: 'https://alice:password@example.test/api/mcp?token=query-secret#fragment-secret',
|
|
298
|
+
PROXY_LOG: logPath,
|
|
299
|
+
PROXY_TEST_CONSTRUCTION_LOG: constructionLogPath,
|
|
300
|
+
})
|
|
301
|
+
const log = await readFileOrEmpty(logPath)
|
|
302
|
+
const constructionLog = await readFileOrEmpty(constructionLogPath)
|
|
303
|
+
const lines = log.trim().split(/\r?\n/)
|
|
304
|
+
|
|
305
|
+
expect(result.code).toBe(1)
|
|
306
|
+
expect(result.signal).toBeNull()
|
|
307
|
+
expect(result.stdout).toBe('')
|
|
308
|
+
expect(result.stderr).toBe(
|
|
309
|
+
'Gemus MCP proxy failed to start: GEMUS_KEY is required. Set it in your user environment and restart Codex.\n',
|
|
310
|
+
)
|
|
311
|
+
expect(lines).toHaveLength(1)
|
|
312
|
+
expect(lines[0]).toContain('GEMUS_KEY is required')
|
|
313
|
+
expect(log).not.toMatch(/HOOK-SERVER|STARTED|alice|password|query-secret|fragment-secret/)
|
|
314
|
+
expect(constructionLog).toBe('')
|
|
315
|
+
} finally {
|
|
316
|
+
await rm(tempRoot, { recursive: true, force: true })
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('logs a successful endpoint using only origin and path', async () => {
|
|
321
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), 'gemus-proxy-started-'))
|
|
322
|
+
const logPath = path.join(tempRoot, 'proxy.log')
|
|
323
|
+
try {
|
|
324
|
+
const result = await runProxy({
|
|
325
|
+
GEMUS_KEY: 'configured-key',
|
|
326
|
+
GEMUS_URL: 'http://alice:password@127.0.0.1:1/api/mcp?token=query-secret#fragment-secret',
|
|
327
|
+
PROXY_LOG: logPath,
|
|
328
|
+
})
|
|
329
|
+
const log = await readFile(logPath, 'utf8')
|
|
330
|
+
const started = log.split(/\r?\n/).find((line) => line.includes('STARTED'))
|
|
331
|
+
|
|
332
|
+
expect(result.code).toBe(0)
|
|
333
|
+
expect(started).toContain('http://127.0.0.1:1/api/mcp')
|
|
334
|
+
expect(started).not.toMatch(/alice|password|query-secret|fragment-secret|userinfo|search|hash/)
|
|
335
|
+
} finally {
|
|
336
|
+
await rm(tempRoot, { recursive: true, force: true })
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('keeps exit code 1 when stdio onclose races with a startup error', async () => {
|
|
341
|
+
const secret = 'arbitrary-secret'
|
|
342
|
+
const result = await runProxy({
|
|
343
|
+
GEMUS_KEY: secret,
|
|
344
|
+
GEMUS_URL: 'https://alice:password@example.test/api/mcp?token=query-secret#fragment-secret',
|
|
345
|
+
PROXY_LOG: undefined,
|
|
346
|
+
PROXY_TEST_STDIO_START_FAILURE: '1',
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
expect(result.code).toBe(1)
|
|
350
|
+
expect(result.signal).toBeNull()
|
|
351
|
+
expect(result.stderr).toContain('Gemus MCP proxy failed to start: stdio failed')
|
|
352
|
+
expect(result.stderr.trim().split(/\r?\n/)).toHaveLength(1)
|
|
353
|
+
expect(result.stderr).toContain('https://example.test/api/mcp')
|
|
354
|
+
expect(result.stderr).not.toMatch(
|
|
355
|
+
/arbitrary-secret|alice|password|query-secret|fragment-secret/,
|
|
356
|
+
)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('exits non-zero when the first upstream initialize send fails', async () => {
|
|
360
|
+
const secret = 'arbitrary-secret'
|
|
361
|
+
const result = await runProxy(
|
|
362
|
+
{
|
|
363
|
+
GEMUS_KEY: secret,
|
|
364
|
+
GEMUS_URL: 'https://alice:password@example.test/api/mcp?token=query-secret#fragment-secret',
|
|
365
|
+
PROXY_LOG: undefined,
|
|
366
|
+
PROXY_TEST_FORWARD_STDIN: '1',
|
|
367
|
+
PROXY_TEST_UPSTREAM_INITIALIZE_FAILURE: '1',
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
input: {
|
|
371
|
+
jsonrpc: '2.0',
|
|
372
|
+
id: 1,
|
|
373
|
+
method: 'initialize',
|
|
374
|
+
params: {
|
|
375
|
+
protocolVersion: '2025-06-18',
|
|
376
|
+
capabilities: {},
|
|
377
|
+
clientInfo: { name: 'startup-test', version: '1.0.0' },
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
expect(result.code).toBe(1)
|
|
384
|
+
expect(result.signal).toBeNull()
|
|
385
|
+
expect(result.stderr).toBe(
|
|
386
|
+
'Gemus MCP proxy failed to start: initialize fetch failed Authorization: Bearer [REDACTED] at https://example.test/api/mcp\n',
|
|
387
|
+
)
|
|
388
|
+
expect(result.stderr).not.toMatch(
|
|
389
|
+
/arbitrary-secret|alice|password|query-secret|fragment-secret/,
|
|
390
|
+
)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it.each(['ftp', 'ws'])(
|
|
394
|
+
'sanitizes a credential-bearing %s endpoint in a real initialize failure',
|
|
395
|
+
async (scheme) => {
|
|
396
|
+
const secret = 'arbitrary-secret'
|
|
397
|
+
const endpoint = `${scheme}://alice:password@example.test/api/mcp?token=query-secret#fragment-secret`
|
|
398
|
+
const result = await runProxy(
|
|
399
|
+
{
|
|
400
|
+
GEMUS_KEY: secret,
|
|
401
|
+
GEMUS_URL: endpoint,
|
|
402
|
+
PROXY_LOG: undefined,
|
|
403
|
+
PROXY_TEST_FORWARD_STDIN: '1',
|
|
404
|
+
PROXY_TEST_UPSTREAM_INITIALIZE_FAILURE: '1',
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
input: {
|
|
408
|
+
jsonrpc: '2.0',
|
|
409
|
+
id: 1,
|
|
410
|
+
method: 'initialize',
|
|
411
|
+
params: {
|
|
412
|
+
protocolVersion: '2025-06-18',
|
|
413
|
+
capabilities: {},
|
|
414
|
+
clientInfo: { name: 'startup-test', version: '1.0.0' },
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
expect(result.code).toBe(1)
|
|
421
|
+
expect(result.signal).toBeNull()
|
|
422
|
+
expect(result.stderr).toBe(
|
|
423
|
+
`Gemus MCP proxy failed to start: initialize fetch failed Authorization: Bearer [REDACTED] at ${scheme}://example.test/api/mcp\n`,
|
|
424
|
+
)
|
|
425
|
+
expect(result.stderr.trim().split(/\r?\n/)).toHaveLength(1)
|
|
426
|
+
expect(result.stderr).not.toContain(endpoint)
|
|
427
|
+
expect(result.stderr).not.toMatch(
|
|
428
|
+
/arbitrary-secret|alice|password|query-secret|fragment-secret/,
|
|
429
|
+
)
|
|
430
|
+
},
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
it('routes an asynchronous hook listen error through one sanitized fatal shutdown', async () => {
|
|
434
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), 'gemus-proxy-hook-listen-'))
|
|
435
|
+
const logPath = path.join(tempRoot, 'proxy.log')
|
|
436
|
+
const constructionLogPath = path.join(tempRoot, 'construction.log')
|
|
437
|
+
const secret = 'arbitrary-secret'
|
|
438
|
+
const endpoint =
|
|
439
|
+
'ws://alice:password@example.test/api/mcp?token=query-secret#fragment-secret'
|
|
440
|
+
try {
|
|
441
|
+
const result = await runProxy({
|
|
442
|
+
GEMUS_KEY: secret,
|
|
443
|
+
GEMUS_URL: endpoint,
|
|
444
|
+
PROXY_LOG: logPath,
|
|
445
|
+
PROXY_TEST_CONSTRUCTION_LOG: constructionLogPath,
|
|
446
|
+
PROXY_TEST_HOOK_LISTEN_FAILURE: '1',
|
|
447
|
+
})
|
|
448
|
+
const log = await readFileOrEmpty(logPath)
|
|
449
|
+
const constructionLog = await readFileOrEmpty(constructionLogPath)
|
|
450
|
+
const fatalLines = log
|
|
451
|
+
.split(/\r?\n/)
|
|
452
|
+
.filter((line) => line.includes(' FATAL '))
|
|
453
|
+
|
|
454
|
+
expect(result.code).toBe(1)
|
|
455
|
+
expect(result.signal).toBeNull()
|
|
456
|
+
expect(result.stderr).toBe(
|
|
457
|
+
'Gemus MCP proxy failed to start: hook listen failed Authorization: Bearer [REDACTED] at ws://example.test/api/mcp\n',
|
|
458
|
+
)
|
|
459
|
+
expect(result.stderr.trim().split(/\r?\n/)).toHaveLength(1)
|
|
460
|
+
expect(fatalLines).toHaveLength(1)
|
|
461
|
+
expect(log).not.toContain('STARTED')
|
|
462
|
+
expect(constructionLog.match(/hook-server-close/g)).toHaveLength(1)
|
|
463
|
+
for (const output of [result.stderr, log]) {
|
|
464
|
+
expect(output).not.toContain(endpoint)
|
|
465
|
+
expect(output).not.toMatch(
|
|
466
|
+
/arbitrary-secret|alice|password|query-secret|fragment-secret/,
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
} finally {
|
|
470
|
+
await rm(tempRoot, { recursive: true, force: true })
|
|
471
|
+
}
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('ignores a hook error emitted after graceful shutdown begins', async () => {
|
|
475
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), 'gemus-proxy-hook-close-'))
|
|
476
|
+
const logPath = path.join(tempRoot, 'proxy.log')
|
|
477
|
+
const constructionLogPath = path.join(tempRoot, 'construction.log')
|
|
478
|
+
const endpoint =
|
|
479
|
+
'ws://alice:password@example.test/api/mcp?token=query-secret#fragment-secret'
|
|
480
|
+
try {
|
|
481
|
+
const result = await runProxy({
|
|
482
|
+
GEMUS_KEY: 'arbitrary-secret',
|
|
483
|
+
GEMUS_URL: endpoint,
|
|
484
|
+
PROXY_LOG: logPath,
|
|
485
|
+
PROXY_TEST_CONSTRUCTION_LOG: constructionLogPath,
|
|
486
|
+
PROXY_TEST_HOOK_ERROR_AFTER_CLOSE: '1',
|
|
487
|
+
})
|
|
488
|
+
const log = await readFileOrEmpty(logPath)
|
|
489
|
+
const constructionLog = await readFileOrEmpty(constructionLogPath)
|
|
490
|
+
|
|
491
|
+
expect(result.code).toBe(0)
|
|
492
|
+
expect(result.signal).toBeNull()
|
|
493
|
+
expect(result.stderr).toBe('')
|
|
494
|
+
expect(log).not.toContain(' FATAL ')
|
|
495
|
+
expect(log).not.toContain(endpoint)
|
|
496
|
+
expect(log).not.toMatch(
|
|
497
|
+
/arbitrary-secret|alice|password|query-secret|fragment-secret/,
|
|
498
|
+
)
|
|
499
|
+
expect(constructionLog.match(/hook-server-close/g)).toHaveLength(1)
|
|
500
|
+
} finally {
|
|
501
|
+
await rm(tempRoot, { recursive: true, force: true })
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('sanitizes a synchronous construction failure outside transport start', async () => {
|
|
506
|
+
const result = await runProxy({
|
|
507
|
+
GEMUS_KEY: 'arbitrary-secret',
|
|
508
|
+
GEMUS_URL: 'https://alice:password@[broken/api/mcp?token=query-secret#fragment-secret',
|
|
509
|
+
PROXY_LOG: undefined,
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
expect(result.code).toBe(1)
|
|
513
|
+
expect(result.signal).toBeNull()
|
|
514
|
+
expect(result.stderr).toBe('Gemus MCP proxy failed to start: Invalid URL\n')
|
|
515
|
+
expect(result.stderr.trim().split(/\r?\n/)).toHaveLength(1)
|
|
516
|
+
expect(result.stderr).not.toMatch(
|
|
517
|
+
/arbitrary-secret|alice|password|query-secret|fragment-secret|ERR_INVALID_URL/,
|
|
518
|
+
)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('keeps the missing-key diagnostic singular when PROXY_LOG is unwritable', async () => {
|
|
522
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), 'gemus-proxy-unwritable-log-'))
|
|
523
|
+
try {
|
|
524
|
+
const result = await runProxy({
|
|
525
|
+
GEMUS_KEY: undefined,
|
|
526
|
+
PROXY_LOG: tempRoot,
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
expect(result.code).toBe(1)
|
|
530
|
+
expect(result.signal).toBeNull()
|
|
531
|
+
expect(result.stderr).toBe(
|
|
532
|
+
'Gemus MCP proxy failed to start: GEMUS_KEY is required. Set it in your user environment and restart Codex.\n',
|
|
533
|
+
)
|
|
534
|
+
} finally {
|
|
535
|
+
await rm(tempRoot, { recursive: true, force: true })
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('keeps a later startup diagnostic singular when PROXY_LOG is unwritable', async () => {
|
|
540
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), 'gemus-proxy-unwritable-log-'))
|
|
541
|
+
try {
|
|
542
|
+
const result = await runProxy({
|
|
543
|
+
GEMUS_KEY: 'arbitrary-secret',
|
|
544
|
+
GEMUS_URL: 'https://alice:password@example.test/api/mcp?token=query-secret#fragment-secret',
|
|
545
|
+
PROXY_LOG: tempRoot,
|
|
546
|
+
PROXY_TEST_STDIO_START_FAILURE: '1',
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
expect(result.code).toBe(1)
|
|
550
|
+
expect(result.signal).toBeNull()
|
|
551
|
+
expect(result.stderr).toBe(
|
|
552
|
+
'Gemus MCP proxy failed to start: stdio failed Authorization: Bearer [REDACTED] at https://example.test/api/mcp\n',
|
|
553
|
+
)
|
|
554
|
+
} finally {
|
|
555
|
+
await rm(tempRoot, { recursive: true, force: true })
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
})
|