@gemus/mcp-proxy 0.1.7 → 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 CHANGED
@@ -1,9 +1,10 @@
1
1
  # @gemus/mcp-proxy (Issues #1751, #1756, #1917)
2
2
 
3
3
  Local stdio↔HTTP MCP proxy that gives **Codex desktop (direct-connect / Mode B)** users canvas
4
- image write-back. Bundled into the Codex plugin (`.codex-plugin/`), declared as a **command-based**
5
- MCP server so codex spawns it locally; the proxy owns upstream auth to the remote gemus `/api/mcp`
6
- (Codex does no OAuth for stdio servers). Full design: `docs/plans/1751-codex-desktop-companion.md`
4
+ image write-back. The Codex plugin declares it as a **default-disabled, command-based** MCP server
5
+ so Codex can spawn it locally after explicit enablement; the proxy owns upstream auth to the remote
6
+ Gemus `/api/mcp` (Codex does no OAuth for stdio servers). Full design:
7
+ `docs/plans/1751-codex-desktop-companion.md`
7
8
  and `docs/architecture/agent architecture/codex-desktop-companion.md`.
8
9
 
9
10
  ## Status: implemented
@@ -27,24 +28,55 @@ server blueprint policy.
27
28
  - **(c) turn-end + retrieval** — `Stop` hook fires post-flush carrying `transcript_path` → exact
28
29
  rollout; imagegen writes `generated_images/<session>/<sanitized-call-id>.png` (historically `ig_*`,
29
30
  currently `call_*`) + rollout `saved_path`+base64.
30
- - **Session cap** — `KEY_ONLY_SESSION_CAP=3`/user is real; the proxy MUST `DELETE` its upstream
31
- session on close (implemented in `shutdown()`) to avoid leaking slots.
31
+ - **Session cap** — the key-only session cap defaults to 10 concurrent sessions per user
32
+ (`MCP_KEY_ONLY_SESSION_CAP`); the proxy MUST `DELETE` its upstream session on close (implemented
33
+ in `cleanup()`) to avoid leaking slots.
32
34
 
33
- ## Install (Codex desktop)
35
+ ## Companion setup and migration (Codex desktop)
34
36
 
35
- The key is supplied as a **literal** in the user's *local* codex config — codex does NOT interpolate
36
- `${VAR}` in an MCP server's `env`, and a public plugin manifest must never carry a key (Step-0.4
37
- findings). So the keyed MCP server is registered locally, not via the plugin manifest:
37
+ The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.8`,
38
+ `startup_timeout_sec = 60`, `tool_timeout_sec = 300`, and a default-disabled rollout. The user
39
+ environment owns `GEMUS_KEY` and optional `GEMUS_URL`; the public plugin and generated commands
40
+ never contain their values.
38
41
 
39
- ```bash
40
- # 1. Register the proxy as codex's gemus MCP server (writes a literal key into ~/.codex/config.toml).
41
- codex mcp add gemus --env GEMUS_KEY=mak_<your key> -- npx -y @gemus/mcp-proxy
42
- # (get a mak_ key from gemus.ai → Settings → MCP Keys; GEMUS_URL defaults to https://gemus.ai/api/mcp)
43
-
44
- # 2. Install the plugin (Stop hook + skills) from the gemus marketplace, then TRUST the hook:
45
- codex plugin add gemus@<marketplace>
46
- # In interactive codex run `/hooks` and trust the gemus Stop hook — REQUIRED for canvas backfill.
47
- # Without a trusted hook, images still land on the canvas (orphan node), just not the planned node.
42
+ Environment ownership and lifetime:
43
+
44
+ - **Windows / PowerShell:** persist the key in the Windows user environment and refresh the current
45
+ process.
46
+ - **macOS:** If changing login context, sign out and back in first; rerun the `launchctl setenv`
47
+ setup in the new login session; then fully quit and restart Codex and open a new task.
48
+ - **Linux:** export the key and launch Codex from the same terminal; no universal desktop-session
49
+ inheritance is assumed.
50
+
51
+ Companion and direct modes are mutually exclusive. The canonical new-user and legacy migration flow
52
+ is:
53
+
54
+ 1. Set `GEMUS_KEY` in the user environment.
55
+ 2. Remove any legacy global server (`codex mcp remove gemus`; not-found is harmless).
56
+ 3. Install with `codex plugin marketplace add Gemus-AI/gemus-codex-plugin`, then
57
+ `codex plugin add gemus@gemus`. Existing users refresh the marketplace snapshot and replace the
58
+ installed cache:
59
+
60
+ ```bash
61
+ codex plugin marketplace upgrade gemus
62
+ codex plugin remove gemus@gemus
63
+ codex plugin add gemus@gemus
64
+ ```
65
+ 4. Explicitly enable the plugin-scoped server:
66
+
67
+ ```toml
68
+ [plugins."gemus@gemus".mcp_servers.gemus]
69
+ enabled = true
70
+ ```
71
+
72
+ 5. Restart Codex and open a new task, then use `/hooks` to trust the Stop hook separately.
73
+
74
+ Intentional direct HTTP/OAuth users keep their global direct server and leave the plugin Companion
75
+ disabled:
76
+
77
+ ```toml
78
+ [plugins."gemus@gemus".mcp_servers.gemus]
79
+ enabled = false
48
80
  ```
49
81
 
50
82
  ## Local dev
@@ -52,7 +84,6 @@ codex plugin add gemus@<marketplace>
52
84
  ```bash
53
85
  pnpm --filter @gemus/codex-backfill-core build # proxy imports the built core at runtime
54
86
  GEMUS_KEY=mak_... GEMUS_URL=http://localhost:3000/api/mcp PROXY_LOG=/tmp/proxy.log node src/proxy.mjs
55
- # or register against dev: codex mcp add gemus --env GEMUS_KEY=mak_... GEMUS_URL=http://localhost:3000/api/mcp -- node <abs>/src/proxy.mjs
56
87
  ```
57
88
 
58
89
  Env: `GEMUS_KEY` (required), `GEMUS_URL` (default `https://gemus.ai/api/mcp`), `PROXY_LOG` (optional
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemus/mcp-proxy",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Local stdio<->HTTP MCP proxy for Codex desktop: transparent passthrough + execute tap + imagegen backfill (Issue #1751, P1).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,136 @@
1
+ function moduleUrl(source) {
2
+ return `data:text/javascript,${encodeURIComponent(source)}`
3
+ }
4
+
5
+ const markerPrelude = `
6
+ import fs from 'node:fs'
7
+ function mark(name) {
8
+ const file = process.env.PROXY_TEST_CONSTRUCTION_LOG
9
+ if (file) fs.appendFileSync(file, name + '\\n')
10
+ }
11
+ `
12
+
13
+ const modules = new Map([
14
+ ['@gemus/codex-backfill-core', `
15
+ export const CLIENT_CAPABILITIES = []
16
+ export const CLIENT_CAPABILITIES_HEADER = 'X-Gemus-Client-Capabilities'
17
+ export function serializeClientCapabilities(value) { return JSON.stringify(value) }
18
+ export function unwrapMcpContent(value) { return value }
19
+ `],
20
+ ['@modelcontextprotocol/sdk/server/stdio.js', `
21
+ ${markerPrelude}
22
+ export class StdioServerTransport {
23
+ constructor() { mark('stdio-transport') }
24
+ async start() {
25
+ if (process.env.PROXY_TEST_STDIO_START_FAILURE === '1') {
26
+ this.onclose?.()
27
+ throw new Error(
28
+ 'stdio failed Authorization: Bearer '
29
+ + process.env.GEMUS_KEY
30
+ + ' at '
31
+ + process.env.GEMUS_URL
32
+ )
33
+ }
34
+ if (process.env.PROXY_TEST_FORWARD_STDIN === '1') {
35
+ let input = ''
36
+ process.stdin.setEncoding('utf8')
37
+ process.stdin.on('data', (chunk) => {
38
+ input += chunk
39
+ for (;;) {
40
+ const newline = input.indexOf('\\n')
41
+ if (newline < 0) break
42
+ const line = input.slice(0, newline).trim()
43
+ input = input.slice(newline + 1)
44
+ if (line) this.onmessage?.(JSON.parse(line))
45
+ }
46
+ })
47
+ }
48
+ process.stdin.resume()
49
+ process.stdin.once('end', () => this.onclose?.())
50
+ }
51
+ async send() {}
52
+ }
53
+ `],
54
+ ['@modelcontextprotocol/sdk/client/streamableHttp.js', `
55
+ ${markerPrelude}
56
+ export class StreamableHTTPClientTransport {
57
+ constructor() { mark('upstream-transport') }
58
+ async start() {}
59
+ async send(message) {
60
+ if (
61
+ process.env.PROXY_TEST_UPSTREAM_INITIALIZE_FAILURE === '1'
62
+ && message?.method === 'initialize'
63
+ ) {
64
+ throw new Error(
65
+ 'initialize fetch failed Authorization: Bearer '
66
+ + process.env.GEMUS_KEY
67
+ + ' at '
68
+ + process.env.GEMUS_URL
69
+ )
70
+ }
71
+ }
72
+ async terminateSession() {}
73
+ async close() {}
74
+ }
75
+ `],
76
+ ['node:http', `
77
+ ${markerPrelude}
78
+ function hookError() {
79
+ return new Error(
80
+ 'hook listen failed Authorization: Bearer '
81
+ + process.env.GEMUS_KEY
82
+ + ' at '
83
+ + process.env.GEMUS_URL
84
+ )
85
+ }
86
+ const http = {
87
+ createServer() {
88
+ mark('hook-server')
89
+ const listeners = new Map()
90
+ return {
91
+ on(event, handler) { listeners.set(event, handler); return this },
92
+ listen(_port, _host, callback) {
93
+ if (process.env.PROXY_TEST_HOOK_LISTEN_FAILURE === '1') {
94
+ queueMicrotask(() => listeners.get('error')?.(hookError()))
95
+ } else {
96
+ queueMicrotask(callback)
97
+ }
98
+ return this
99
+ },
100
+ address() { return { port: 43114 } },
101
+ unref() {},
102
+ close() {
103
+ mark('hook-server-close')
104
+ if (process.env.PROXY_TEST_HOOK_ERROR_AFTER_CLOSE === '1') {
105
+ queueMicrotask(() => listeners.get('error')?.(hookError()))
106
+ }
107
+ },
108
+ }
109
+ },
110
+ }
111
+ export default http
112
+ `],
113
+ ['./backfill.mjs', `
114
+ ${markerPrelude}
115
+ export function createBackfiller() {
116
+ mark('backfiller')
117
+ return {
118
+ observeCall() {},
119
+ observeResult() {},
120
+ async backfillTurn() {},
121
+ }
122
+ }
123
+ export function injectSteering() { return false }
124
+ `],
125
+ ])
126
+
127
+ export async function resolve(specifier, context, nextResolve) {
128
+ const source = modules.get(specifier)
129
+ if (
130
+ source !== undefined
131
+ && (specifier !== './backfill.mjs' || context.parentURL?.endsWith('/proxy.mjs'))
132
+ ) {
133
+ return { url: moduleUrl(source), shortCircuit: true }
134
+ }
135
+ return nextResolve(specifier, context)
136
+ }
@@ -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
+ })
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'
@@ -27,9 +28,48 @@ 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 {
32
+ assertGemusKey,
33
+ createFatalReporter,
34
+ createShutdownCoordinator,
35
+ safeEndpointLabel,
36
+ startProxy,
37
+ } from './startup.mjs'
30
38
 
31
39
  const KEY = process.env.GEMUS_KEY || ''
32
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
+
33
73
  const SERVER_ORIGIN = GEMUS_URL.replace(/\/api\/mcp\/?$/, '') // core delivery appends /api/mcp itself
34
74
  const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
35
75
  // turn-idle fallback (untrusted-hook path). Default 3min: imagegen alone runs 30-60s with NO MCP tool
@@ -40,11 +80,6 @@ const IDLE_MS = Number(process.env.PROXY_BACKFILL_IDLE_MS) || 180_000
40
80
  // (deriveWaitTimeoutMs('gen-image-generation')=180s,见 src/server/generation/waitTimeout.ts),否则
41
81
  // proxy 先超时 → 假失败 → orphan 回退 → 同图重复交付。240s = 180s + 余量。别调到 180s 以下。
42
82
  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
83
 
49
84
  // ── footprint-0 交付注入(#1756 Layer 1a)─────────────────────────────────────────────────────────
50
85
  // 交付/orphan 不再每图新开 key-only session(connect-per-call,抢 KEY_ONLY_SESSION_CAP 槽),而是把
@@ -236,7 +271,10 @@ const hookServer = http.createServer((req, res) => {
236
271
  .catch((e) => log('HOOK-BACKFILL-ERR', String(e)))
237
272
  })
238
273
  })
239
- hookServer.on('error', (e) => log('HOOK-SERVER-ERR', String(e)))
274
+ hookServer.on('error', (error) => {
275
+ if (shutdownRequested) return
276
+ void terminateFatally(error)
277
+ })
240
278
  hookServer.listen(0, '127.0.0.1', () => {
241
279
  hookPort = hookServer.address().port
242
280
  log('HOOK-SERVER', { port: hookPort })
@@ -273,7 +311,17 @@ const upstream = new StreamableHTTPClientTransport(new URL(GEMUS_URL), {
273
311
  fetch: failFast.fetch,
274
312
  })
275
313
 
276
- 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
+ }
277
325
  upstream.onmessage = (m) => {
278
326
  // footprint-0 (#1756): our injected execute/canvas_edit responses carry a `gemus-bf-*` string id.
279
327
  // Self-consume them here (settle the pending injection) and DO NOT forward to Codex — Codex never
@@ -319,10 +367,7 @@ function startHeartbeat() {
319
367
  if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref() // don't keep the process alive on this alone
320
368
  }
321
369
 
322
- let shuttingDown = false
323
- async function shutdown() {
324
- if (shuttingDown) return
325
- shuttingDown = true
370
+ async function cleanup() {
326
371
  if (heartbeatTimer) clearInterval(heartbeatTimer)
327
372
  for (const t of idleTimers.values()) clearTimeout(t)
328
373
  idleTimers.clear()
@@ -334,16 +379,39 @@ async function shutdown() {
334
379
  if (discoveryFile) { try { fs.unlinkSync(discoveryFile) } catch { /* ignore */ } }
335
380
  try { await upstream.terminateSession() } catch { /* ignore */ }
336
381
  try { await upstream.close() } catch { /* ignore */ }
337
- process.exit(0)
338
382
  }
339
- 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() }
340
392
  upstream.onclose = () => { log('http-close', '') }
341
393
  stdio.onerror = (e) => log('stdio-err', String(e))
342
394
  upstream.onerror = (e) => log('http-err', String(e))
343
- process.on('SIGTERM', shutdown)
344
- process.on('SIGINT', shutdown)
395
+ process.on('SIGTERM', () => { void shutdownGracefully() })
396
+ process.on('SIGINT', () => { void shutdownGracefully() })
345
397
 
346
- await upstream.start()
347
- await stdio.start()
348
- startHeartbeat()
349
- 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
+ }