@gemus/mcp-proxy 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -14,6 +15,40 @@ then delivers an unambiguous image to its planned node or conservatively creates
14
15
  `image-upload`. MCP initialization advertises the shared Codex capability contract used by the
15
16
  server blueprint policy.
16
17
 
18
+ ## Upstream connection reliability (#2328)
19
+
20
+ The proxy uses one owned Undici `Agent` for all upstream MCP traffic. By default its TCP
21
+ address-family racing policy sets `autoSelectFamily: true` with a
22
+ `PROXY_CONNECT_ATTEMPT_TIMEOUT_MS=1000` per-address-family attempt window. An operator may set a
23
+ positive numeric override; invalid, empty, and non-positive values fall back to `1000`, while
24
+ values below `10` ms clamp to `10` ms. The transport's request method, body, and auth headers are
25
+ preserved; the proxy adds only that owned dispatcher.
26
+
27
+ This is a connection-attempt policy, not an MCP retry policy. For a post-start request, a rejected
28
+ `POST` before response headers produces one request only: the affected JSON-RPC id receives `-32000`
29
+ with `Gemus upstream connection failed before a response was received`. The raw network reason is
30
+ never returned to Codex. During `initialize`, the same classification writes one sanitized fatal
31
+ diagnostic and exits non-zero. Once an SSE response has started, a body that ends before its result
32
+ remains the distinct #2068 failure:
33
+ `Gemus upstream stream disconnected before the tool result was delivered`.
34
+
35
+ On graceful or fatal shutdown, the proxy bounds session `DELETE`, then transport close, before it
36
+ closes the owned Agent. Each stage has a 2-second bound; an Agent that does not close in its bound
37
+ has its retained per-origin dispatchers force-destroyed before the Agent is destroyed. Repeated
38
+ shutdown signals share the same cleanup. See the [transport reliability
39
+ runbook](../../docs/operations/mcp-transport-reliability.md) for the network boundaries and the
40
+ [Companion architecture](../../docs/architecture/agent%20architecture/codex-desktop-companion.md)
41
+ for the request path.
42
+
43
+ ## Release gate (#2330)
44
+
45
+ This package is prepared as `@gemus/mcp-proxy@0.1.9`; it is not published by this change. Release
46
+ Issue [#2330](https://github.com/Gemus-AI/Gemus/issues/2330) owns the post-merge gate: publish and
47
+ verify the npm package, then in a follow-up change pin the plugin to exact `0.1.9`, forward
48
+ `PROXY_CONNECT_ATTEMPT_TIMEOUT_MS`, bump and sync the plugin, refresh the installed Codex plugin
49
+ cache, and run fresh ESA and direct-domain smoke tests. Until that gate completes, the plugin
50
+ contract stays byte-for-byte at exact `@gemus/mcp-proxy@0.1.8`.
51
+
17
52
  ## What was validated end-to-end (2026-07-06, codex-cli 0.142.2 → local dev `/api/mcp`)
18
53
 
19
54
  - **Transparent passthrough** — initialize + tools/list relay all **12 gemus tools** (incl.
@@ -27,24 +62,55 @@ server blueprint policy.
27
62
  - **(c) turn-end + retrieval** — `Stop` hook fires post-flush carrying `transcript_path` → exact
28
63
  rollout; imagegen writes `generated_images/<session>/<sanitized-call-id>.png` (historically `ig_*`,
29
64
  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.
65
+ - **Session cap** — the key-only session cap defaults to 10 concurrent sessions per user
66
+ (`MCP_KEY_ONLY_SESSION_CAP`); the proxy MUST `DELETE` its upstream session on close (implemented
67
+ in `cleanup()`) to avoid leaking slots.
32
68
 
33
- ## Install (Codex desktop)
69
+ ## Companion setup and migration (Codex desktop)
34
70
 
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:
71
+ The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.8`,
72
+ `startup_timeout_sec = 60`, `tool_timeout_sec = 300`, and a default-disabled rollout. The user
73
+ environment owns `GEMUS_KEY` and optional `GEMUS_URL`; the public plugin and generated commands
74
+ never contain their values.
38
75
 
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.
76
+ Environment ownership and lifetime:
77
+
78
+ - **Windows / PowerShell:** persist the key in the Windows user environment and refresh the current
79
+ process.
80
+ - **macOS:** If changing login context, sign out and back in first; rerun the `launchctl setenv`
81
+ setup in the new login session; then fully quit and restart Codex and open a new task.
82
+ - **Linux:** export the key and launch Codex from the same terminal; no universal desktop-session
83
+ inheritance is assumed.
84
+
85
+ Companion and direct modes are mutually exclusive. The canonical new-user and legacy migration flow
86
+ is:
87
+
88
+ 1. Set `GEMUS_KEY` in the user environment.
89
+ 2. Remove any legacy global server (`codex mcp remove gemus`; not-found is harmless).
90
+ 3. Install with `codex plugin marketplace add Gemus-AI/gemus-codex-plugin`, then
91
+ `codex plugin add gemus@gemus`. Existing users refresh the marketplace snapshot and replace the
92
+ installed cache:
93
+
94
+ ```bash
95
+ codex plugin marketplace upgrade gemus
96
+ codex plugin remove gemus@gemus
97
+ codex plugin add gemus@gemus
98
+ ```
99
+ 4. Explicitly enable the plugin-scoped server:
100
+
101
+ ```toml
102
+ [plugins."gemus@gemus".mcp_servers.gemus]
103
+ enabled = true
104
+ ```
105
+
106
+ 5. Restart Codex and open a new task, then use `/hooks` to trust the Stop hook separately.
107
+
108
+ Intentional direct HTTP/OAuth users keep their global direct server and leave the plugin Companion
109
+ disabled:
110
+
111
+ ```toml
112
+ [plugins."gemus@gemus".mcp_servers.gemus]
113
+ enabled = false
48
114
  ```
49
115
 
50
116
  ## Local dev
@@ -52,7 +118,6 @@ codex plugin add gemus@<marketplace>
52
118
  ```bash
53
119
  pnpm --filter @gemus/codex-backfill-core build # proxy imports the built core at runtime
54
120
  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
121
  ```
57
122
 
58
123
  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.9",
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": {
@@ -25,10 +25,11 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
+ "undici": "^6.28.0",
28
29
  "@gemus/codex-backfill-core": "0.1.3"
29
30
  },
30
31
  "engines": {
31
- "node": ">=18"
32
+ "node": ">=18.17"
32
33
  },
33
34
  "files": [
34
35
  "src/",
@@ -122,7 +122,7 @@ describe('createFailFast.fetch', () => {
122
122
  await drain(res)
123
123
 
124
124
  expect(onLost).toHaveBeenCalledTimes(1)
125
- expect(onLost.mock.calls[0][0]).toBe(42)
125
+ expect(onLost).toHaveBeenCalledWith(42, { kind: 'stream' })
126
126
  expect(ff.failedIds.has('42')).toBe(true)
127
127
  })
128
128
 
@@ -140,21 +140,90 @@ describe('createFailFast.fetch', () => {
140
140
 
141
141
  it('fails fast immediately when realFetch throws before any response', async () => {
142
142
  const onLost = vi.fn()
143
- const realFetch = vi.fn(async () => { throw new Error('ECONNRESET') })
143
+ const realFetch = vi.fn(async () => {
144
+ throw new Error('socket reset', { cause: Object.assign(new Error('inner socket failure'), { code: 'ECONNRESET' }) })
145
+ })
144
146
  const ff = createFailFast({ onLost, realFetch })
145
147
 
146
- await expect(ff.fetch('u', post(7))).rejects.toThrow('ECONNRESET')
147
- expect(onLost).toHaveBeenCalledWith(7, expect.stringContaining('upstream fetch failed'))
148
+ await expect(ff.fetch('u', post(7))).rejects.toThrow('socket reset')
149
+ expect(onLost).toHaveBeenCalledWith(7, { kind: 'connection', code: 'ECONNRESET' })
148
150
  expect(realFetch).toHaveBeenCalledTimes(1) // must NOT retry — a 2nd fetch could execute the tool
149
151
  })
150
152
 
153
+ it('allowlists a nested connection code without exposing hostile raw diagnostics or retrying', async () => {
154
+ const hostile =
155
+ 'Authorization: Bearer arbitrary-secret at '
156
+ + 'https://alice:password@example.test/api/mcp?token=query-secret '
157
+ + 'via 203.0.113.1,2001:db8::1'
158
+ const onLost = vi.fn()
159
+ const log = vi.fn()
160
+ const rejection = new Error(`outer ${hostile}`, {
161
+ cause: new Error(`middle ${hostile}`, {
162
+ cause: Object.assign(new Error(`inner ${hostile}`), {
163
+ code: 'UND_ERR_CONNECT_TIMEOUT',
164
+ }),
165
+ }),
166
+ })
167
+ const realFetch = vi.fn(async () => { throw rejection })
168
+ const ff = createFailFast({ onLost, log, realFetch })
169
+
170
+ await expect(ff.fetch('u', post(71))).rejects.toBe(rejection)
171
+
172
+ expect(realFetch).toHaveBeenCalledTimes(1)
173
+ expect(onLost).toHaveBeenCalledTimes(1)
174
+ expect(onLost).toHaveBeenCalledWith(71, {
175
+ kind: 'connection',
176
+ code: 'UND_ERR_CONNECT_TIMEOUT',
177
+ })
178
+ const diagnostics = JSON.stringify(log.mock.calls)
179
+ expect(diagnostics).not.toMatch(
180
+ /arbitrary-secret|alice|password|query-secret|203\.0\.113\.1|2001:db8/,
181
+ )
182
+ })
183
+
184
+ it('classifies an established SSE reader error before its result as a stream failure', async () => {
185
+ const readerError = new Error('established stream failed')
186
+ let pulls = 0
187
+ const body = new ReadableStream<Uint8Array>({
188
+ pull(controller) {
189
+ if (pulls++ === 0) {
190
+ controller.enqueue(enc.encode(': established\n\n'))
191
+ } else {
192
+ controller.error(readerError)
193
+ }
194
+ },
195
+ })
196
+ const onLost = vi.fn()
197
+ const realFetch = vi.fn(async () => sseRes(body))
198
+ const ff = createFailFast({ onLost, realFetch })
199
+
200
+ const res = await ff.fetch('u', post(72))
201
+ await expect(drain(res)).rejects.toBe(readerError)
202
+
203
+ expect(onLost).toHaveBeenCalledTimes(1)
204
+ expect(onLost).toHaveBeenCalledWith(72, { kind: 'stream' })
205
+ })
206
+
207
+ it('classifies downstream cancellation before its result as a stream failure', async () => {
208
+ const onLost = vi.fn()
209
+ const realFetch = vi.fn(async () => sseRes(neverBody()))
210
+ const ff = createFailFast({ onLost, realFetch })
211
+ const res = await ff.fetch('u', post(73))
212
+ const reader = res.body!.getReader()
213
+
214
+ await reader.cancel('downstream closed')
215
+
216
+ expect(onLost).toHaveBeenCalledTimes(1)
217
+ expect(onLost).toHaveBeenCalledWith(73, { kind: 'stream' })
218
+ })
219
+
151
220
  it('fails fast on a non-ok HTTP response', async () => {
152
221
  const onLost = vi.fn()
153
222
  const realFetch = vi.fn(async () => sseRes(null, { status: 404, contentType: 'application/json' }))
154
223
  const ff = createFailFast({ onLost, realFetch })
155
224
 
156
225
  await ff.fetch('u', post(7))
157
- expect(onLost).toHaveBeenCalledWith(7, 'upstream HTTP 404')
226
+ expect(onLost).toHaveBeenCalledWith(7, { kind: 'http', status: 404 })
158
227
  })
159
228
 
160
229
  it('passes GET and DELETE through untouched (no body → no monitoring, no onLost)', async () => {
@@ -208,6 +277,39 @@ describe('createFailFast.fetch', () => {
208
277
  expect(onLost.mock.calls[0][0]).toBe(1)
209
278
  })
210
279
 
280
+ it('does not settle a healthy concurrent request when another connection rejects before response', async () => {
281
+ let healthyController!: ReadableStreamDefaultController<Uint8Array>
282
+ const healthyBody = new ReadableStream<Uint8Array>({
283
+ start(controller) { healthyController = controller },
284
+ })
285
+ const connectionError = Object.assign(new Error('connection rejected'), {
286
+ code: 'ECONNREFUSED',
287
+ })
288
+ const onLost = vi.fn()
289
+ const realFetch = vi.fn(async (_url: unknown, init: { body?: unknown }) => {
290
+ const [{ id }] = [JSON.parse(String(init.body))]
291
+ if (id === 74) throw connectionError
292
+ return sseRes(healthyBody)
293
+ })
294
+ const ff = createFailFast({ onLost, realFetch })
295
+
296
+ const rejecting = ff.fetch('u', post(74))
297
+ const healthy = ff.fetch('u', post(75))
298
+ await expect(rejecting).rejects.toBe(connectionError)
299
+ const healthyResponse = await healthy
300
+
301
+ expect(onLost).toHaveBeenCalledTimes(1)
302
+ expect(onLost).toHaveBeenCalledWith(74, {
303
+ kind: 'connection',
304
+ code: 'ECONNREFUSED',
305
+ })
306
+ healthyController.enqueue(enc.encode(resultFrame(75)))
307
+ healthyController.close()
308
+ await drain(healthyResponse)
309
+ expect(onLost).toHaveBeenCalledTimes(1)
310
+ expect(ff.failedIds.has('75')).toBe(false)
311
+ })
312
+
211
313
  describe('idle safety timeout', () => {
212
314
  beforeEach(() => vi.useFakeTimers())
213
315
  afterEach(() => vi.useRealTimers())
@@ -220,7 +322,7 @@ describe('createFailFast.fetch', () => {
220
322
  await ff.fetch('u', post(99)) // never emits → idle timer is never reset
221
323
  expect(onLost).not.toHaveBeenCalled()
222
324
  await vi.advanceTimersByTimeAsync(120_000)
223
- expect(onLost).toHaveBeenCalledWith(99, expect.stringContaining('idle'))
325
+ expect(onLost).toHaveBeenCalledWith(99, { kind: 'idle' })
224
326
  ff.dispose()
225
327
  })
226
328
 
@@ -0,0 +1,216 @@
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
+ if (process.env.PROXY_TEST_SIGNAL_AFTER_START === 'SIGTERM') {
51
+ setTimeout(() => {
52
+ if (process.platform === 'win32') {
53
+ process.emit('SIGTERM')
54
+ } else {
55
+ process.kill(process.pid, 'SIGTERM')
56
+ }
57
+ }, 0)
58
+ }
59
+ }
60
+ async send(message) {
61
+ if (process.env.PROXY_TEST_STDIO_CAPTURE_SEND === '1') {
62
+ process.stdout.write(JSON.stringify(message) + '\\n')
63
+ }
64
+ }
65
+ }
66
+ `],
67
+ ['@modelcontextprotocol/sdk/client/streamableHttp.js', `
68
+ ${markerPrelude}
69
+ export class StreamableHTTPClientTransport {
70
+ constructor(url, options) {
71
+ mark('upstream-transport')
72
+ this.url = url
73
+ this.fetch = options.fetch
74
+ }
75
+ async start() {}
76
+ async send(message) {
77
+ if (
78
+ process.env.PROXY_TEST_UPSTREAM_INITIALIZE_FAILURE === '1'
79
+ && message?.method === 'initialize'
80
+ ) {
81
+ throw new Error(
82
+ 'initialize fetch failed Authorization: Bearer '
83
+ + process.env.GEMUS_KEY
84
+ + ' at '
85
+ + process.env.GEMUS_URL
86
+ )
87
+ }
88
+ if (
89
+ process.env.PROXY_TEST_UPSTREAM_INITIALIZE_FETCH_FAILURE === '1'
90
+ && message?.method === 'initialize'
91
+ ) {
92
+ await this.fetch(this.url, {
93
+ method: 'POST',
94
+ headers: { Authorization: 'Bearer ' + process.env.GEMUS_KEY },
95
+ body: JSON.stringify(message),
96
+ })
97
+ }
98
+ if (
99
+ process.env.PROXY_TEST_LATE_RESULT === '1'
100
+ && message?.method === 'tools/call'
101
+ ) {
102
+ await this.fetch(this.url, {
103
+ method: 'POST',
104
+ body: JSON.stringify(message),
105
+ })
106
+ setTimeout(() => {
107
+ this.onmessage?.({
108
+ jsonrpc: '2.0',
109
+ id: message.id,
110
+ result: { content: [{ type: 'text', text: 'late-real-result' }] },
111
+ })
112
+ }, 80)
113
+ }
114
+ }
115
+ async terminateSession() {
116
+ mark('upstream-terminate-session')
117
+ if (process.env.PROXY_TEST_TERMINATE_SESSION_HANG === '1') {
118
+ return await new Promise(() => {})
119
+ }
120
+ if (process.env.PROXY_TEST_TERMINATE_SESSION_REJECT === '1') {
121
+ throw new Error('terminate session rejected')
122
+ }
123
+ }
124
+ async close() { mark('upstream-transport-close') }
125
+ }
126
+ `],
127
+ ['undici', `
128
+ ${markerPrelude}
129
+ export class Agent {
130
+ constructor() { mark('upstream-http-agent') }
131
+ async close() { mark('upstream-http-shutdown') }
132
+ destroy() { mark('upstream-http-destroy') }
133
+ }
134
+ export class Pool {
135
+ destroy() { mark('upstream-origin-destroy') }
136
+ }
137
+ export async function fetch() {
138
+ mark('undici-fetch')
139
+ if (process.env.PROXY_TEST_LATE_RESULT === '1') {
140
+ return new Response(
141
+ new ReadableStream({ start() {} }),
142
+ { headers: { 'content-type': 'text/event-stream' } },
143
+ )
144
+ }
145
+ const error = new Error(
146
+ 'connect failed Authorization: Bearer '
147
+ + process.env.GEMUS_KEY
148
+ + ' at '
149
+ + process.env.GEMUS_URL
150
+ + ' via 203.0.113.1,2001:db8::1'
151
+ )
152
+ error.cause = { code: 'UND_ERR_CONNECT_TIMEOUT' }
153
+ throw error
154
+ }
155
+ `],
156
+ ['node:http', `
157
+ ${markerPrelude}
158
+ function hookError() {
159
+ return new Error(
160
+ 'hook listen failed Authorization: Bearer '
161
+ + process.env.GEMUS_KEY
162
+ + ' at '
163
+ + process.env.GEMUS_URL
164
+ )
165
+ }
166
+ const http = {
167
+ createServer() {
168
+ mark('hook-server')
169
+ const listeners = new Map()
170
+ return {
171
+ on(event, handler) { listeners.set(event, handler); return this },
172
+ listen(_port, _host, callback) {
173
+ if (process.env.PROXY_TEST_HOOK_LISTEN_FAILURE === '1') {
174
+ queueMicrotask(() => listeners.get('error')?.(hookError()))
175
+ } else {
176
+ queueMicrotask(callback)
177
+ }
178
+ return this
179
+ },
180
+ address() { return { port: 43114 } },
181
+ unref() {},
182
+ close() {
183
+ mark('hook-server-close')
184
+ if (process.env.PROXY_TEST_HOOK_ERROR_AFTER_CLOSE === '1') {
185
+ queueMicrotask(() => listeners.get('error')?.(hookError()))
186
+ }
187
+ },
188
+ }
189
+ },
190
+ }
191
+ export default http
192
+ `],
193
+ ['./backfill.mjs', `
194
+ ${markerPrelude}
195
+ export function createBackfiller() {
196
+ mark('backfiller')
197
+ return {
198
+ observeCall() { mark('backfiller-observe-call') },
199
+ observeResult() { mark('backfiller-observe-result') },
200
+ async backfillTurn() {},
201
+ }
202
+ }
203
+ export function injectSteering() { return false }
204
+ `],
205
+ ])
206
+
207
+ export async function resolve(specifier, context, nextResolve) {
208
+ const source = modules.get(specifier)
209
+ if (
210
+ source !== undefined
211
+ && (specifier !== './backfill.mjs' || context.parentURL?.endsWith('/proxy.mjs'))
212
+ ) {
213
+ return { url: moduleUrl(source), shortCircuit: true }
214
+ }
215
+ return nextResolve(specifier, context)
216
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createProxyFailure, publicMessageForFailure } from '../proxyMessages.mjs'
3
+
4
+ describe('publicMessageForFailure', () => {
5
+ it.each([
6
+ [
7
+ { kind: 'connection', code: 'UND_ERR_CONNECT_TIMEOUT' },
8
+ 'Gemus upstream connection failed before a response was received',
9
+ ],
10
+ [
11
+ { kind: 'http', status: 503 },
12
+ 'Gemus upstream returned an HTTP error before the tool result was delivered',
13
+ ],
14
+ [
15
+ { kind: 'stream' },
16
+ 'Gemus upstream stream disconnected before the tool result was delivered',
17
+ ],
18
+ [
19
+ { kind: 'idle' },
20
+ 'Gemus upstream stream became unresponsive before the tool result was delivered',
21
+ ],
22
+ ])('maps $kind to its delivery-stage message', (failure, expected) => {
23
+ expect(publicMessageForFailure(failure)).toBe(expected)
24
+ })
25
+
26
+ it('fails closed for an unknown failure kind', () => {
27
+ expect(publicMessageForFailure({ kind: 'hostile-secret-kind' })).toBe(
28
+ 'Gemus upstream failed before the tool result was delivered',
29
+ )
30
+ })
31
+
32
+ it.each(['toString', 'constructor', '__proto__'])(
33
+ 'fails closed for the Object.prototype property name %s',
34
+ (kind) => {
35
+ expect(publicMessageForFailure({ kind })).toBe(
36
+ 'Gemus upstream failed before the tool result was delivered',
37
+ )
38
+ },
39
+ )
40
+ })
41
+
42
+ describe('createProxyFailure', () => {
43
+ it('preserves the original JSON-RPC id in the public error envelope', () => {
44
+ expect(createProxyFailure('request-17', { kind: 'stream' }).response).toEqual({
45
+ jsonrpc: '2.0',
46
+ id: 'request-17',
47
+ error: {
48
+ code: -32000,
49
+ message: 'Gemus upstream stream disconnected before the tool result was delivered',
50
+ },
51
+ })
52
+ })
53
+
54
+ it.each([
55
+ [
56
+ {
57
+ kind: 'connection',
58
+ code: 'UND_ERR_CONNECT_TIMEOUT',
59
+ reason: 'Authorization: Bearer secret at https://alice:password@example.test/api/mcp?token=secret',
60
+ addresses: ['203.0.113.1', '2001:db8::1'],
61
+ },
62
+ { id: 41, kind: 'connection', code: 'UND_ERR_CONNECT_TIMEOUT' },
63
+ ],
64
+ [
65
+ {
66
+ kind: 'http',
67
+ status: 503,
68
+ reason: 'https://alice:password@example.test/api/mcp?token=secret',
69
+ },
70
+ { id: 41, kind: 'http', status: 503 },
71
+ ],
72
+ ])('keeps only safe diagnostic fields for $kind failures', (failure, expected) => {
73
+ const result = createProxyFailure(41, failure)
74
+ const serialized = JSON.stringify(result)
75
+
76
+ expect(result.diagnostic).toEqual(expected)
77
+ expect(serialized).not.toMatch(
78
+ /Authorization|Bearer|secret|alice|password|example\.test|203\.0\.113\.1|2001:db8/,
79
+ )
80
+ })
81
+ })