@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 +50 -19
- package/package.json +1 -1
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +136 -0
- package/src/__tests__/startup.test.ts +558 -0
- package/src/proxy.mjs +89 -21
- package/src/startup.mjs +94 -0
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.
|
|
5
|
-
|
|
6
|
-
(Codex does no OAuth for stdio servers). Full design:
|
|
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** —
|
|
31
|
-
|
|
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
|
-
##
|
|
35
|
+
## Companion setup and migration (Codex desktop)
|
|
34
36
|
|
|
35
|
-
The
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
@@ -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
|
|
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,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', (
|
|
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) => {
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
344
|
-
process.on('SIGINT',
|
|
395
|
+
process.on('SIGTERM', () => { void shutdownGracefully() })
|
|
396
|
+
process.on('SIGINT', () => { void shutdownGracefully() })
|
|
345
397
|
|
|
346
|
-
|
|
347
|
-
await
|
|
348
|
-
|
|
349
|
-
|
|
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
|
+
}
|
package/src/startup.mjs
ADDED
|
@@ -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
|
+
}
|