@gemus/mcp-proxy 0.1.8 → 0.1.10

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
@@ -9,11 +9,49 @@ and `docs/architecture/agent architecture/codex-desktop-companion.md`.
9
9
 
10
10
  ## Status: implemented
11
11
 
12
- `src/proxy.mjs` is the production passthrough, execute-tap, and deterministic image-backfill
13
- companion. A trusted Stop hook triggers `generated_images`/rollout discovery; `ExecuteClaimTracker`
14
- then delivers an unambiguous image to its planned node or conservatively creates an orphan
15
- `image-upload`. MCP initialization advertises the shared Codex capability contract used by the
16
- server blueprint policy.
12
+ `src/proxy.mjs` is the executable router: `setup` loads the isolated installer, while no arguments
13
+ load `src/relay.mjs`. The relay is the production passthrough, execute-tap, and deterministic
14
+ image-backfill companion. A trusted Stop hook triggers `generated_images`/rollout discovery;
15
+ `ExecuteClaimTracker` then delivers an unambiguous image to its planned node or conservatively
16
+ creates an orphan `image-upload`. MCP initialization advertises the shared Codex capability
17
+ contract used by the server blueprint policy.
18
+
19
+ ## Upstream connection reliability (#2328)
20
+
21
+ The proxy uses one owned Undici `Agent` for all upstream MCP traffic. By default its TCP
22
+ address-family racing policy sets `autoSelectFamily: true` with a
23
+ `PROXY_CONNECT_ATTEMPT_TIMEOUT_MS=1000` per-address-family attempt window. An operator may set a
24
+ positive numeric override; invalid, empty, and non-positive values fall back to `1000`, while
25
+ values below `10` ms clamp to `10` ms. The transport's request method, body, and auth headers are
26
+ preserved; the proxy adds only that owned dispatcher.
27
+
28
+ This is a connection-attempt policy, not an MCP retry policy. For a post-start request, a rejected
29
+ `POST` before response headers produces one request only: the affected JSON-RPC id receives `-32000`
30
+ with `Gemus upstream connection failed before a response was received`. The raw network reason is
31
+ never returned to Codex. During `initialize`, the same classification writes one sanitized fatal
32
+ diagnostic and exits non-zero. Once an SSE response has started, a body that ends before its result
33
+ remains the distinct #2068 failure:
34
+ `Gemus upstream stream disconnected before the tool result was delivered`.
35
+
36
+ On graceful or fatal shutdown, the proxy bounds session `DELETE`, then transport close, before it
37
+ closes the owned Agent. Each stage has a 2-second bound; an Agent that does not close in its bound
38
+ has its retained per-origin dispatchers force-destroyed before the Agent is destroyed. Repeated
39
+ shutdown signals share the same cleanup. See the [transport reliability
40
+ runbook](../../docs/operations/mcp-transport-reliability.md) for the network boundaries and the
41
+ [Companion architecture](../../docs/architecture/agent%20architecture/codex-desktop-companion.md)
42
+ for the request path.
43
+
44
+ ## Release gate (#2330)
45
+
46
+ `@gemus/mcp-proxy@0.1.9` was published and verified on 2026-07-28. Release Issue
47
+ [#2330](https://github.com/Gemus-AI/Gemus/issues/2330) rolls the plugin to exact `0.1.9`, forwards
48
+ `PROXY_CONNECT_ATTEMPT_TIMEOUT_MS`, and bumps the plugin contract to 0.1.12. After that change
49
+ merges, the remaining gate is to verify the public mirror, refresh the installed Codex plugin
50
+ cache, and run repeated fresh-process ESA and, when available, isolated direct-domain smoke tests.
51
+
52
+ That public `0.1.9` package is immutable and transport-only: it does not contain the one-command
53
+ setup added by #2324. The setup release therefore advances to `@gemus/mcp-proxy@0.1.10`, with
54
+ plugin contract `0.1.13`; the historical #2328/#2330 release remains `0.1.9`/`0.1.12`.
17
55
 
18
56
  ## What was validated end-to-end (2026-07-06, codex-cli 0.142.2 → local dev `/api/mcp`)
19
57
 
@@ -34,24 +72,40 @@ server blueprint policy.
34
72
 
35
73
  ## Companion setup and migration (Codex desktop)
36
74
 
37
- The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.8`,
75
+ The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.10`,
38
76
  `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.
77
+ environment owns `GEMUS_KEY`, optional `GEMUS_URL`, and optional
78
+ `PROXY_CONNECT_ATTEMPT_TIMEOUT_MS`; the public plugin and generated commands never contain their
79
+ values.
80
+
81
+ For the production Gemus service, use this Windows/macOS setup command; it is safe to rerun:
82
+
83
+ ```bash
84
+ npx -y @gemus/mcp-proxy@0.1.10 setup
85
+ ```
86
+
87
+ It securely prompts for the key, reconciles the marketplace/plugin, migrates legacy Gemus config,
88
+ enables the Companion, and preserves a backup when it changes `config.toml`. Fully quit and restart
89
+ Codex when it finishes, then open a new task and run `/hooks` to trust the Gemus Stop hook.
90
+
91
+ The public setup command, shell history, and setup diagnostics remain key-free. On macOS, the
92
+ short-lived `launchctl setenv` child necessarily receives the key in its argv.
41
93
 
42
94
  Environment ownership and lifetime:
43
95
 
44
- - **Windows / PowerShell:** persist the key in the Windows user environment and refresh the current
45
- process.
96
+ - **Windows / PowerShell:** persist the key in the Windows user environment scope. Fully quit and
97
+ restart Codex so it receives the updated environment.
46
98
  - **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.
99
+ setup in the new login session; the value lasts for the current login session only. Then fully quit
100
+ and restart Codex and open a new task.
48
101
  - **Linux:** export the key and launch Codex from the same terminal; no universal desktop-session
49
102
  inheritance is assumed.
50
103
 
51
- Companion and direct modes are mutually exclusive. The canonical new-user and legacy migration flow
52
- is:
104
+ Companion and direct modes are mutually exclusive. The following advanced manual fallback is for
105
+ Linux, self-hosted `GEMUS_URL`, or troubleshooting when the production one-command setup is not
106
+ applicable:
53
107
 
54
- 1. Set `GEMUS_KEY` in the user environment.
108
+ 1. Set `GEMUS_KEY` in the user environment (and optional `GEMUS_URL` for self-hosted/development).
55
109
  2. Remove any legacy global server (`codex mcp remove gemus`; not-found is harmless).
56
110
  3. Install with `codex plugin marketplace add Gemus-AI/gemus-codex-plugin`, then
57
111
  `codex plugin add gemus@gemus`. Existing users refresh the marketplace snapshot and replace the
@@ -69,7 +123,7 @@ is:
69
123
  enabled = true
70
124
  ```
71
125
 
72
- 5. Restart Codex and open a new task, then use `/hooks` to trust the Stop hook separately.
126
+ 5. Fully quit and restart Codex, open a new task, then trust the Gemus Stop hook with `/hooks`.
73
127
 
74
128
  Intentional direct HTTP/OAuth users keep their global direct server and leave the plugin Companion
75
129
  disabled:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemus/mcp-proxy",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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,13 +25,15 @@
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
- "src/",
35
+ "src/*.mjs",
36
+ "src/setup/*.mjs",
35
37
  "README.md"
36
38
  ],
37
39
  "license": "MIT",
package/src/failFast.mjs CHANGED
@@ -93,11 +93,24 @@ export function createSseIdScanner() {
93
93
  }
94
94
  }
95
95
 
96
+ /** Extract an allowlisted network error code without exposing the raw error text. */
97
+ function failureCode(error) {
98
+ let current = error
99
+ for (let depth = 0; depth < 8 && current && typeof current === 'object'; depth++) {
100
+ if (typeof current.code === 'string') {
101
+ const code = current.code.toUpperCase()
102
+ if (/^[A-Z0-9_]{1,64}$/.test(code)) return code
103
+ }
104
+ current = current.cause
105
+ }
106
+ return 'UNKNOWN'
107
+ }
108
+
96
109
  /**
97
110
  * Build the fail-fast fetch wrapper + settle/dispose controls.
98
111
  *
99
112
  * @param {object} opts
100
- * @param {(id:any, reason:string)=>void} opts.onLost called once per lost id (must be idempotent-safe on caller side)
113
+ * @param {(id:any, failure:{kind:string, code?:string, status?:number})=>void} opts.onLost called once per lost id (must be idempotent-safe on caller side)
101
114
  * @param {number} [opts.idleTimeoutMs] IDLE backstop reset on every upstream byte (incl. server
102
115
  * heartbeats every ~12s), so it trips ONLY on a genuinely silent/zombie connection — never on a
103
116
  * healthy-but-slow tool. Must be < Codex 300s; default 120_000.
@@ -117,7 +130,7 @@ export function createFailFast(opts) {
117
130
  const failedIds = new Set()
118
131
 
119
132
  function armTimer(entry, id) {
120
- entry.timer = setTimeout(() => lose(id, 'proxy idle timeout (no upstream bytes)'), idleTimeoutMs)
133
+ entry.timer = setTimeout(() => lose(id, { kind: 'idle' }), idleTimeoutMs)
121
134
  if (typeof entry.timer.unref === 'function') entry.timer.unref()
122
135
  }
123
136
 
@@ -150,15 +163,15 @@ export function createFailFast(opts) {
150
163
  failedIds.delete(String(id))
151
164
  }
152
165
 
153
- function lose(id, reason) {
166
+ function lose(id, failure) {
154
167
  const key = String(id)
155
168
  if (!inFlight.has(key)) return // already settled or already lost
156
169
  settle(id)
157
170
  failedIds.add(key)
158
171
  // Bound the set: disconnect-case entries never see a late result to forget(), so cap FIFO.
159
172
  if (failedIds.size > FAILED_IDS_CAP) failedIds.delete(failedIds.values().next().value)
160
- log('FAILFAST', { id, reason })
161
- onLost(id, reason)
173
+ log('FAILFAST', { id, ...failure })
174
+ onLost(id, failure)
162
175
  }
163
176
 
164
177
  function observeAndForward(body, codexIds) {
@@ -168,7 +181,7 @@ export function createFailFast(opts) {
168
181
  const onEnd = () => {
169
182
  if (ended) return
170
183
  ended = true
171
- for (const id of codexIds) lose(id, 'upstream stream closed before result')
184
+ for (const id of codexIds) lose(id, { kind: 'stream' })
172
185
  }
173
186
  return new ReadableStream({
174
187
  async pull(controller) {
@@ -218,7 +231,7 @@ export function createFailFast(opts) {
218
231
  codexIds = parseRequestIds(init).filter(isCodexOriginated)
219
232
  for (const id of codexIds) register(id)
220
233
  } catch (e) {
221
- log('FAILFAST-WRAP-ERR', String(e))
234
+ log('FAILFAST-WRAP-ERR', { code: failureCode(e) })
222
235
  codexIds = []
223
236
  }
224
237
 
@@ -229,7 +242,7 @@ export function createFailFast(opts) {
229
242
  res = await realFetch(url, init)
230
243
  } catch (e) {
231
244
  // Connection failed before any response (DNS / TLS / RST) — fail fast, don't wait 270s.
232
- for (const id of codexIds) lose(id, `upstream fetch failed: ${e}`)
245
+ for (const id of codexIds) lose(id, { kind: 'connection', code: failureCode(e) })
233
246
  throw e // preserve the SDK's existing onerror/logging path (no re-fetch)
234
247
  }
235
248
 
@@ -241,7 +254,7 @@ export function createFailFast(opts) {
241
254
  if (!res.ok) {
242
255
  // Non-SSE HTTP error (404 session-gone / 403 / 429 / …). send() throws before onmessage,
243
256
  // and that rejection is swallowed by upstream.send().catch — so nothing ever settles the id.
244
- for (const id of codexIds) lose(id, `upstream HTTP ${res.status}`)
257
+ for (const id of codexIds) lose(id, { kind: 'http', status: res.status })
245
258
  return res
246
259
  }
247
260
  const ct = res.headers.get('content-type') || ''
@@ -252,7 +265,7 @@ export function createFailFast(opts) {
252
265
  headers: res.headers,
253
266
  })
254
267
  } catch (e) {
255
- log('FAILFAST-WRAP-ERR', String(e))
268
+ log('FAILFAST-WRAP-ERR', { code: failureCode(e) })
256
269
  return res
257
270
  }
258
271
  }