@gemus/mcp-proxy 0.1.9 → 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 +41 -21
- package/package.json +3 -2
- package/src/proxy.mjs +29 -427
- package/src/relay.mjs +440 -0
- package/src/setup/cli.mjs +130 -0
- package/src/setup/codex.mjs +110 -0
- package/src/setup/config.mjs +530 -0
- package/src/setup/configFile.mjs +155 -0
- package/src/setup/environment.mjs +79 -0
- package/src/setup/secret.mjs +91 -0
- package/src/__tests__/backfill.test.ts +0 -394
- package/src/__tests__/failFast.test.ts +0 -356
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +0 -216
- package/src/__tests__/mcpHeaders.test.ts +0 -32
- package/src/__tests__/proxyMessages.test.ts +0 -81
- package/src/__tests__/route.integration.test.ts +0 -68
- package/src/__tests__/startup.test.ts +0 -752
- package/src/__tests__/upstreamHttp.real.test.ts +0 -37
- package/src/__tests__/upstreamHttp.test.ts +0 -151
package/README.md
CHANGED
|
@@ -9,11 +9,12 @@ and `docs/architecture/agent architecture/codex-desktop-companion.md`.
|
|
|
9
9
|
|
|
10
10
|
## Status: implemented
|
|
11
11
|
|
|
12
|
-
`src/proxy.mjs` is the
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
`
|
|
16
|
-
|
|
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.
|
|
17
18
|
|
|
18
19
|
## Upstream connection reliability (#2328)
|
|
19
20
|
|
|
@@ -42,12 +43,15 @@ for the request path.
|
|
|
42
43
|
|
|
43
44
|
## Release gate (#2330)
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
cache, and run fresh ESA and direct-domain smoke tests.
|
|
50
|
-
|
|
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`.
|
|
51
55
|
|
|
52
56
|
## What was validated end-to-end (2026-07-06, codex-cli 0.142.2 → local dev `/api/mcp`)
|
|
53
57
|
|
|
@@ -68,24 +72,40 @@ contract stays byte-for-byte at exact `@gemus/mcp-proxy@0.1.8`.
|
|
|
68
72
|
|
|
69
73
|
## Companion setup and migration (Codex desktop)
|
|
70
74
|
|
|
71
|
-
The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.
|
|
75
|
+
The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.10`,
|
|
72
76
|
`startup_timeout_sec = 60`, `tool_timeout_sec = 300`, and a default-disabled rollout. The user
|
|
73
|
-
environment owns `GEMUS_KEY
|
|
74
|
-
never contain their
|
|
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.
|
|
75
93
|
|
|
76
94
|
Environment ownership and lifetime:
|
|
77
95
|
|
|
78
|
-
- **Windows / PowerShell:** persist the key in the Windows user environment
|
|
79
|
-
|
|
96
|
+
- **Windows / PowerShell:** persist the key in the Windows user environment scope. Fully quit and
|
|
97
|
+
restart Codex so it receives the updated environment.
|
|
80
98
|
- **macOS:** If changing login context, sign out and back in first; rerun the `launchctl setenv`
|
|
81
|
-
setup in the new login session;
|
|
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.
|
|
82
101
|
- **Linux:** export the key and launch Codex from the same terminal; no universal desktop-session
|
|
83
102
|
inheritance is assumed.
|
|
84
103
|
|
|
85
|
-
Companion and direct modes are mutually exclusive. The
|
|
86
|
-
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:
|
|
87
107
|
|
|
88
|
-
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).
|
|
89
109
|
2. Remove any legacy global server (`codex mcp remove gemus`; not-found is harmless).
|
|
90
110
|
3. Install with `codex plugin marketplace add Gemus-AI/gemus-codex-plugin`, then
|
|
91
111
|
`codex plugin add gemus@gemus`. Existing users refresh the marketplace snapshot and replace the
|
|
@@ -103,7 +123,7 @@ is:
|
|
|
103
123
|
enabled = true
|
|
104
124
|
```
|
|
105
125
|
|
|
106
|
-
5.
|
|
126
|
+
5. Fully quit and restart Codex, open a new task, then trust the Gemus Stop hook with `/hooks`.
|
|
107
127
|
|
|
108
128
|
Intentional direct HTTP/OAuth users keep their global direct server and leave the plugin Companion
|
|
109
129
|
disabled:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemus/mcp-proxy",
|
|
3
|
-
"version": "0.1.
|
|
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": {
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"node": ">=18.17"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
|
-
"src
|
|
35
|
+
"src/*.mjs",
|
|
36
|
+
"src/setup/*.mjs",
|
|
36
37
|
"README.md"
|
|
37
38
|
],
|
|
38
39
|
"license": "MIT",
|
package/src/proxy.mjs
CHANGED
|
@@ -1,440 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
// Codex (stdio) <-> this proxy <-> remote gemus /api/mcp (streamable HTTP, Bearer mak_ key).
|
|
5
|
-
// The proxy is a TRANSPARENT JSON-RPC passthrough (tools + resources + notifications all relay
|
|
6
|
-
// untouched) that additionally TAPS execute/batch_execute to observe client-delivered image
|
|
7
|
-
// backfill anchors, then at turn end backfills codex imagegen output into the planned gen-* nodes.
|
|
8
|
-
// Codex does no auth for stdio MCP servers, so THIS process owns upstream auth (the user's mak_ key).
|
|
9
|
-
//
|
|
10
|
-
// Turn-end backfill has two triggers:
|
|
11
|
-
// • Stop hook (trusted) → loopback HTTP POST /backfill { transcript_path, thread_id } → claim path.
|
|
12
|
-
// • idle-timer fallback (untrusted hook) → salvageOnly (orphan-only). Both idempotent via seen-set.
|
|
13
|
-
//
|
|
14
|
-
// Validated end-to-end on 2026-07-06 (codex-cli 0.142.2, real dev /api/mcp): transparent passthrough
|
|
15
|
-
// of all 12 gemus tools; codex spawns one proxy per session; real tool-calls tapped with workflowId
|
|
16
|
-
// (from arguments) + turn/thread identity (from _meta.x-codex-turn-metadata). Step-0.4: plugin-bundled
|
|
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.
|
|
20
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
21
|
-
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
22
|
-
import http from 'node:http'
|
|
23
|
-
import os from 'node:os'
|
|
24
|
-
import path from 'node:path'
|
|
25
|
-
import fs from 'node:fs'
|
|
26
|
-
import { randomBytes } from 'node:crypto'
|
|
27
|
-
import { createBackfiller, injectSteering } from './backfill.mjs'
|
|
28
|
-
import { unwrapMcpContent } from '@gemus/codex-backfill-core'
|
|
29
|
-
import { buildProxyUpstreamHeaders } from './mcpHeaders.mjs'
|
|
30
|
-
import { createFailFast, isJsonRpcResponse } from './failFast.mjs'
|
|
31
|
-
import { createProxyFailure } from './proxyMessages.mjs'
|
|
32
|
-
import { createUpstreamHttp } from './upstreamHttp.mjs'
|
|
33
|
-
import {
|
|
34
|
-
assertGemusKey,
|
|
35
|
-
createFatalReporter,
|
|
36
|
-
createShutdownCoordinator,
|
|
37
|
-
safeEndpointLabel,
|
|
38
|
-
settleWithin,
|
|
39
|
-
startProxy,
|
|
40
|
-
} from './startup.mjs'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
41
3
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!LOG) return
|
|
47
|
-
try {
|
|
48
|
-
fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${dir} ${typeof m === 'string' ? m : JSON.stringify(m).slice(0, 1000)}\n`)
|
|
49
|
-
} catch {
|
|
50
|
-
// Debug logging is optional and must never affect proxy lifecycle.
|
|
4
|
+
class ProxyUsageError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super('Usage: gemus-mcp-proxy [setup]')
|
|
7
|
+
this.code = 'PROXY_USAGE'
|
|
51
8
|
}
|
|
52
9
|
}
|
|
53
10
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
writeLog: (message) => log('FATAL', message),
|
|
58
|
-
})
|
|
59
|
-
let shutdown
|
|
60
|
-
let shutdownRequested = false
|
|
61
|
-
function terminateFatally(error) {
|
|
62
|
-
reportFatal(error)
|
|
63
|
-
shutdownRequested = true
|
|
64
|
-
if (shutdown) return shutdown(1)
|
|
65
|
-
process.exit(1)
|
|
66
|
-
}
|
|
67
|
-
process.on('uncaughtException', (error) => { void terminateFatally(error) })
|
|
68
|
-
process.on('unhandledRejection', (reason) => { void terminateFatally(reason) })
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
assertGemusKey(KEY)
|
|
72
|
-
} catch (error) {
|
|
73
|
-
terminateFatally(error)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const SERVER_ORIGIN = GEMUS_URL.replace(/\/api\/mcp\/?$/, '') // core delivery appends /api/mcp itself
|
|
77
|
-
const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
78
|
-
// turn-idle fallback (untrusted-hook path). Default 3min: imagegen alone runs 30-60s with NO MCP tool
|
|
79
|
-
// calls, so a short idle looks like "turn ended" mid-generation and fires prematurely. A trusted Stop
|
|
80
|
-
// hook fires at turn end and clears this timer, so trusted users never hit it (#1751 real-machine fix).
|
|
81
|
-
const IDLE_MS = Number(process.env.PROXY_BACKFILL_IDLE_MS) || 180_000
|
|
82
|
-
// footprint-0 注入交付超时(#1756 Layer 1a)。必须 ≥ 平台对 codex-imagen 回填节点的同步等待上限
|
|
83
|
-
// (deriveWaitTimeoutMs('gen-image-generation')=180s,见 src/server/generation/waitTimeout.ts),否则
|
|
84
|
-
// proxy 先超时 → 假失败 → orphan 回退 → 同图重复交付。240s = 180s + 余量。别调到 180s 以下。
|
|
85
|
-
const INJECT_TIMEOUT_MS = Number(process.env.PROXY_INJECT_TIMEOUT_MS) || 240_000
|
|
86
|
-
|
|
87
|
-
// ── footprint-0 交付注入(#1756 Layer 1a)─────────────────────────────────────────────────────────
|
|
88
|
-
// 交付/orphan 不再每图新开 key-only session(connect-per-call,抢 KEY_ONLY_SESSION_CAP 槽),而是把
|
|
89
|
-
// execute/canvas_edit 的 tools/call **注入到 proxy 已建好、已计入 cap 的那条 upstream 连接**上(footprint
|
|
90
|
-
// 0)——即便 cap 被泄漏填满也能交付。注入用字符串 id 命名空间 `gemus-bf-*`(绝不撞 Codex 的数字 id),
|
|
91
|
-
// 响应在 upstream.onmessage 里按 id 自消费、不转发给 Codex。`upstream` 在下方声明,注入在 turn 末才调,
|
|
92
|
-
// 故闭包内按调用时解析、无 TDZ 问题。
|
|
93
|
-
/** in-flight 注入:id(string) → { settle }。settle 幂等收敛(响应/超时/取消/关停任一先到即结算)。 */
|
|
94
|
-
const pendingInjections = new Map()
|
|
95
|
-
let injectSeq = 0
|
|
96
|
-
|
|
97
|
-
/** 把 JSON-RPC 响应转成与 core `callGemusTool` 逐字一致的 CallGemusToolResult 形状。 */
|
|
98
|
-
function interpretInjection(m) {
|
|
99
|
-
if (m?.error) return { ok: false, error: m.error.message || `MCP error ${m.error.code}` }
|
|
100
|
-
const result = m?.result
|
|
101
|
-
if (result && result.isError === true) {
|
|
102
|
-
const first = Array.isArray(result.content) ? result.content[0] : undefined
|
|
103
|
-
return { ok: false, error: (first && first.text) || 'MCP tool error' }
|
|
104
|
-
}
|
|
105
|
-
return { ok: true, result: unwrapMcpContent(result && result.content) }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** @type {import('@gemus/codex-backfill-core').GemusToolCall} */
|
|
109
|
-
function injectedCall(toolName, args, opts = {}) {
|
|
110
|
-
const { progressToken, signal } = opts
|
|
111
|
-
const id = `gemus-bf-${++injectSeq}`
|
|
112
|
-
const req = {
|
|
113
|
-
jsonrpc: '2.0',
|
|
114
|
-
id,
|
|
115
|
-
method: 'tools/call',
|
|
116
|
-
params: { name: toolName, arguments: args, ...(progressToken ? { _meta: { progressToken } } : {}) },
|
|
11
|
+
export function validateProxyArgs(args = []) {
|
|
12
|
+
if (!Array.isArray(args) || args.length > 1 || (args.length === 1 && args[0] !== 'setup')) {
|
|
13
|
+
throw new ProxyUsageError()
|
|
117
14
|
}
|
|
118
|
-
return new Promise((resolve) => {
|
|
119
|
-
const settle = (v) => {
|
|
120
|
-
clearTimeout(timer)
|
|
121
|
-
if (signal) signal.removeEventListener('abort', onAbort)
|
|
122
|
-
pendingInjections.delete(id)
|
|
123
|
-
resolve(v)
|
|
124
|
-
}
|
|
125
|
-
const onAbort = () => settle({ ok: false, error: 'aborted' })
|
|
126
|
-
const timer = setTimeout(() => settle({ ok: false, timedOut: true, error: 'inject timeout' }), INJECT_TIMEOUT_MS)
|
|
127
|
-
pendingInjections.set(id, { settle })
|
|
128
|
-
if (signal) signal.addEventListener('abort', onAbort)
|
|
129
|
-
upstream.send(req).catch((e) => settle({ ok: false, error: String(e) }))
|
|
130
|
-
log('INJECT', { id, toolName, progressToken })
|
|
131
|
-
})
|
|
132
15
|
}
|
|
133
16
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
log: (msg, obj) => log('BACKFILL', obj ? `${msg} ${JSON.stringify(obj)}` : msg),
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
// ── turn identity + request↔response correlation ───────────────────────────────────────────────
|
|
143
|
-
// Step-0.4 capture: tool-call `_meta` + Stop-hook payload both carry `turn_id` + `session_id` (no
|
|
144
|
-
// thread_id). Turn state keys on turn_id; session_id locates the rollout in the idle fallback.
|
|
145
|
-
/** in-flight tools/call: jsonrpc id (string) → { turnKey, name } — so the server→client response
|
|
146
|
-
* (which carries no _meta) can be attributed to its turn + tool. */
|
|
147
|
-
const inflight = new Map()
|
|
148
|
-
/** per-turn idle timer — turn-END fallback when no Stop hook fires (untrusted). Reset on activity. */
|
|
149
|
-
const idleTimers = new Map()
|
|
150
|
-
let sessionId // learned from first tool-call _meta.session_id; used for the hook discovery file
|
|
151
|
-
let discoveryFile // <tmpdir>/gemus-proxy-<sessionId>.json — written once sessionId is known
|
|
152
|
-
|
|
153
|
-
function metaOf(msg) {
|
|
154
|
-
const meta = msg?.params?._meta?.['x-codex-turn-metadata']
|
|
155
|
-
return {
|
|
156
|
-
turnId: meta?.turn_id || meta?.turnId,
|
|
157
|
-
sessionId: meta?.session_id || meta?.sessionId,
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function armIdleTimer(turnKey) {
|
|
162
|
-
if (!turnKey) return
|
|
163
|
-
const existing = idleTimers.get(turnKey)
|
|
164
|
-
if (existing) clearTimeout(existing)
|
|
165
|
-
const timer = setTimeout(() => {
|
|
166
|
-
idleTimers.delete(turnKey)
|
|
167
|
-
// salvageOnly fallback: turn went quiet with no Stop hook. Orphan any fresh images (never claim).
|
|
168
|
-
// seen-set makes this a no-op if a hook already backfilled this turn.
|
|
169
|
-
backfiller
|
|
170
|
-
.backfillTurn({ turnKey, rolloutPath: null, salvageOnly: true })
|
|
171
|
-
.catch((e) => log('IDLE-BACKFILL-ERR', String(e)))
|
|
172
|
-
}, IDLE_MS)
|
|
173
|
-
if (typeof timer.unref === 'function') timer.unref() // don't keep the process alive on this timer alone
|
|
174
|
-
idleTimers.set(turnKey, timer)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function writeDiscoveryOnce() {
|
|
178
|
-
if (discoveryFile || !sessionId || !hookPort || !hookToken) return
|
|
179
|
-
discoveryFile = path.join(os.tmpdir(), `gemus-proxy-${sessionId}.json`)
|
|
180
|
-
try {
|
|
181
|
-
fs.writeFileSync(discoveryFile, JSON.stringify({ port: hookPort, token: hookToken }))
|
|
182
|
-
log('DISCOVERY-WRITE', { discoveryFile, port: hookPort })
|
|
183
|
-
} catch (e) {
|
|
184
|
-
log('DISCOVERY-ERR', String(e))
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// injectSteering (imported) mutates the initialize response's `instructions` to tell codex not to
|
|
189
|
-
// self-forward imagegen output — the one always-on steering channel in Mode B. See backfill.mjs.
|
|
190
|
-
function steerOnInit(msg) {
|
|
191
|
-
try {
|
|
192
|
-
if (injectSteering(msg)) log('STEERING-INJECTED', { instructionsLen: msg.result.instructions.length })
|
|
193
|
-
} catch (e) {
|
|
194
|
-
log('STEERING-ERR', String(e))
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ── taps (do not mutate the relayed messages) ──────────────────────────────────────────────────
|
|
199
|
-
function tapClientToServer(msg) {
|
|
200
|
-
try {
|
|
201
|
-
if (msg?.method !== 'tools/call') return
|
|
202
|
-
const { turnId, sessionId: sid } = metaOf(msg)
|
|
203
|
-
if (sid && !sessionId) {
|
|
204
|
-
sessionId = sid
|
|
205
|
-
writeDiscoveryOnce()
|
|
206
|
-
}
|
|
207
|
-
if (turnId) armIdleTimer(turnId)
|
|
208
|
-
const id = msg.id
|
|
209
|
-
if (id === undefined || id === null) return // notifications: no response to correlate
|
|
210
|
-
const name = msg.params?.name || ''
|
|
211
|
-
inflight.set(String(id), { turnKey: turnId, name })
|
|
212
|
-
backfiller.observeCall({ jsonrpcId: id, turnKey: turnId, sessionId: sid, name, args: msg.params?.arguments || {} })
|
|
213
|
-
} catch (e) {
|
|
214
|
-
log('TAP-C2S-ERR', String(e))
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function tapServerToClient(msg) {
|
|
219
|
-
try {
|
|
220
|
-
const id = msg?.id
|
|
221
|
-
if (id === undefined || id === null) return
|
|
222
|
-
const pending = inflight.get(String(id))
|
|
223
|
-
if (!pending) return
|
|
224
|
-
inflight.delete(String(id))
|
|
225
|
-
if (!msg.result) return // errors: nothing to observe for claim tracking
|
|
226
|
-
backfiller.observeResult({
|
|
227
|
-
jsonrpcId: id,
|
|
228
|
-
turnKey: pending.turnKey,
|
|
229
|
-
name: pending.name,
|
|
230
|
-
resultContent: msg.result?.content,
|
|
231
|
-
})
|
|
232
|
-
} catch (e) {
|
|
233
|
-
log('TAP-S2C-ERR', String(e))
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ── loopback Stop-hook endpoint (127.0.0.1:<random port> + per-run token) ──────────────────────
|
|
238
|
-
let hookPort
|
|
239
|
-
const hookToken = randomBytes(16).toString('hex')
|
|
240
|
-
const hookServer = http.createServer((req, res) => {
|
|
241
|
-
if (req.method !== 'POST' || !req.url?.startsWith('/backfill')) {
|
|
242
|
-
res.writeHead(404).end()
|
|
243
|
-
return
|
|
244
|
-
}
|
|
245
|
-
let body = ''
|
|
246
|
-
req.on('data', (c) => {
|
|
247
|
-
body += c
|
|
248
|
-
if (body.length > 64 * 1024) req.destroy() // trivial DoS guard; payload is tiny JSON
|
|
249
|
-
})
|
|
250
|
-
req.on('end', () => {
|
|
251
|
-
let payload
|
|
252
|
-
try {
|
|
253
|
-
payload = JSON.parse(body)
|
|
254
|
-
} catch {
|
|
255
|
-
res.writeHead(400).end()
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
if (payload?.token !== hookToken) {
|
|
259
|
-
res.writeHead(403).end()
|
|
260
|
-
return
|
|
261
|
-
}
|
|
262
|
-
// Stop-hook payload (Step-0.4 capture): { session_id, turn_id, transcript_path, ... } — no thread_id.
|
|
263
|
-
const turnKey = payload.turn_id || payload.turnId
|
|
264
|
-
const sid = payload.session_id || payload.sessionId
|
|
265
|
-
const rolloutPath = payload.transcript_path || payload.transcriptPath || null
|
|
266
|
-
log('HOOK-BACKFILL', { turnKey, sid, rolloutPath })
|
|
267
|
-
res.writeHead(200).end('ok')
|
|
268
|
-
// A Stop hook backfilled this turn → cancel its idle-timer fallback (avoid a redundant salvage pass).
|
|
269
|
-
const timer = idleTimers.get(turnKey)
|
|
270
|
-
if (timer) { clearTimeout(timer); idleTimers.delete(turnKey) }
|
|
271
|
-
// Stop hook fires post-flush → claim path (not salvage). Fire-and-forget; never throw.
|
|
272
|
-
backfiller
|
|
273
|
-
.backfillTurn({ turnKey, sessionId: sid, rolloutPath, salvageOnly: false })
|
|
274
|
-
.catch((e) => log('HOOK-BACKFILL-ERR', String(e)))
|
|
275
|
-
})
|
|
276
|
-
})
|
|
277
|
-
hookServer.on('error', (error) => {
|
|
278
|
-
if (shutdownRequested) return
|
|
279
|
-
void terminateFatally(error)
|
|
280
|
-
})
|
|
281
|
-
hookServer.listen(0, '127.0.0.1', () => {
|
|
282
|
-
hookPort = hookServer.address().port
|
|
283
|
-
log('HOOK-SERVER', { port: hookPort })
|
|
284
|
-
writeDiscoveryOnce() // in case sessionId was already learned before listen resolved
|
|
285
|
-
})
|
|
286
|
-
if (typeof hookServer.unref === 'function') hookServer.unref()
|
|
287
|
-
|
|
288
|
-
// ── transparent JSON-RPC passthrough (raw send/onmessage) ──────────────────────────────────────
|
|
289
|
-
const stdio = new StdioServerTransport()
|
|
290
|
-
const initializingIds = new Set()
|
|
291
|
-
const classifiedInitializeFailures = new Set()
|
|
292
|
-
|
|
293
|
-
// #2068 fail-fast: StreamableHTTPClientTransport.send() resolves before the upstream SSE result
|
|
294
|
-
// arrives and only surfaces a stream death via a global, id-less `onerror` (no reconnect for POST).
|
|
295
|
-
// The custom `fetch` wrapper parses each request's JSON-RPC id and, if the response stream dies
|
|
296
|
-
// before that id's result crosses the wire, injects a JSON-RPC error so Codex fails in <2s instead
|
|
297
|
-
// of hanging to its 300s timeout. onLost only injects to stdio — it does NOT tap: tapping here would
|
|
298
|
-
// delete the proxy's `inflight` entry, so the DROP-AFTER-FAILFAST branch below could no longer let
|
|
299
|
-
// backfill observe a late real result (env delivery on the still-open, idle-timed-out case).
|
|
300
|
-
const upstreamHttp = createUpstreamHttp()
|
|
301
|
-
const failFast = createFailFast({
|
|
302
|
-
idleTimeoutMs: Number(process.env.PROXY_FAILFAST_TIMEOUT_MS) || 120_000,
|
|
303
|
-
realFetch: upstreamHttp.fetch,
|
|
304
|
-
log,
|
|
305
|
-
onLost: (id, failure) => {
|
|
306
|
-
const { response, diagnostic } = createProxyFailure(id, failure)
|
|
307
|
-
const key = String(id)
|
|
308
|
-
log('FAILFAST-INJECT', diagnostic)
|
|
309
|
-
if (initializingIds.has(key)) {
|
|
310
|
-
classifiedInitializeFailures.add(key)
|
|
311
|
-
void terminateFatally(new Error(response.error.message))
|
|
312
|
-
return
|
|
313
|
-
}
|
|
314
|
-
stdio.send(response).catch((e) => log('ERR-stdio-send', String(e)))
|
|
315
|
-
},
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
const upstream = new StreamableHTTPClientTransport(new URL(GEMUS_URL), {
|
|
319
|
-
requestInit: { headers: buildProxyUpstreamHeaders(KEY) },
|
|
320
|
-
fetch: failFast.fetch,
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
stdio.onmessage = (m) => {
|
|
324
|
-
tapClientToServer(m)
|
|
325
|
-
log('C->G', m)
|
|
326
|
-
const key = String(m?.id)
|
|
327
|
-
if (m?.method === 'initialize') initializingIds.add(key)
|
|
328
|
-
upstream.send(m).catch((error) => {
|
|
329
|
-
const classified = classifiedInitializeFailures.delete(key)
|
|
330
|
-
initializingIds.delete(key)
|
|
331
|
-
if (m?.method === 'initialize') {
|
|
332
|
-
if (classified) return
|
|
333
|
-
void terminateFatally(error)
|
|
334
|
-
return
|
|
335
|
-
}
|
|
336
|
-
log('ERR-http-send', { kind: 'transport' })
|
|
337
|
-
})
|
|
338
|
-
}
|
|
339
|
-
upstream.onmessage = (m) => {
|
|
340
|
-
// footprint-0 (#1756): our injected execute/canvas_edit responses carry a `gemus-bf-*` string id.
|
|
341
|
-
// Self-consume them here (settle the pending injection) and DO NOT forward to Codex — Codex never
|
|
342
|
-
// issued these calls, so it must never see the responses.
|
|
343
|
-
if (typeof m?.id === 'string' && m.id.startsWith('gemus-bf-')) {
|
|
344
|
-
const p = pendingInjections.get(m.id)
|
|
345
|
-
if (p) p.settle(interpretInjection(m))
|
|
17
|
+
export async function main(args = process.argv.slice(2)) {
|
|
18
|
+
validateProxyArgs(args)
|
|
19
|
+
if (args[0] === 'setup') {
|
|
20
|
+
const { runSetup } = await import('./setup/cli.mjs')
|
|
21
|
+
await runSetup()
|
|
346
22
|
return
|
|
347
23
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
24
|
+
await import('./relay.mjs')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
28
|
+
const args = process.argv.slice(2)
|
|
29
|
+
main(args).catch(async (error) => {
|
|
30
|
+
if (args.length === 0) {
|
|
31
|
+
const { createFatalReporter } = await import('./startup.mjs')
|
|
32
|
+
createFatalReporter({
|
|
33
|
+
secret: process.env.GEMUS_KEY || '',
|
|
34
|
+
writeStderr: (message) => process.stderr.write(message),
|
|
35
|
+
writeLog: () => {},
|
|
36
|
+
})(error)
|
|
37
|
+
} else {
|
|
38
|
+
process.stderr.write(`${error.message}\n`)
|
|
361
39
|
}
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
tapServerToClient(m); steerOnInit(m); log('G->C', m); stdio.send(m).catch((e) => log('ERR-stdio-send', String(e)))
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// ── session hygiene (spike: KEY_ONLY_SESSION_CAP=3) ────────────────────────────────────────────
|
|
368
|
-
// We must DELETE the upstream session or we leak a key-only slot when codex ends the conversation.
|
|
369
|
-
// NOTE: transport.close() does NOT issue DELETE (it only aborts + fires onclose, streamableHttp.js:280);
|
|
370
|
-
// terminateSession() sends the DELETE (:431). Call it first, then close(). Also clean up local artifacts.
|
|
371
|
-
// #1756 proxy-death detection: send an MCP `ping` every 30s so the platform can tell a live proxy
|
|
372
|
-
// (keeps pinging) from a dead one (hard-killed → no graceful DELETE → leaked session). This is
|
|
373
|
-
// **independent of Codex tool activity**, so a proxy mid-imagegen (silent to Codex for minutes) still
|
|
374
|
-
// proves liveness → the platform reclaims only genuinely-dead sessions (short floor), never a live one.
|
|
375
|
-
const HEARTBEAT_MS = Number(process.env.PROXY_HEARTBEAT_MS) || 30_000
|
|
376
|
-
const SHUTDOWN_STEP_TIMEOUT_MS = 2000
|
|
377
|
-
let pingSeq = 0
|
|
378
|
-
let heartbeatTimer
|
|
379
|
-
function startHeartbeat() {
|
|
380
|
-
heartbeatTimer = setInterval(() => {
|
|
381
|
-
upstream.send({ jsonrpc: '2.0', id: `gemus-ping-${++pingSeq}`, method: 'ping' }).catch((e) => log('PING-ERR', String(e)))
|
|
382
|
-
}, HEARTBEAT_MS)
|
|
383
|
-
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref() // don't keep the process alive on this alone
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
async function cleanup() {
|
|
387
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
|
388
|
-
for (const t of idleTimers.values()) clearTimeout(t)
|
|
389
|
-
idleTimers.clear()
|
|
390
|
-
// Settle any in-flight footprint-0 injections so a mid-delivery shutdown doesn't hang their awaits.
|
|
391
|
-
for (const p of pendingInjections.values()) p.settle({ ok: false, error: 'proxy shutting down' })
|
|
392
|
-
pendingInjections.clear()
|
|
393
|
-
failFast.dispose() // #2068: clear fail-fast final-safety timers
|
|
394
|
-
try { hookServer.close() } catch { /* ignore */ }
|
|
395
|
-
if (discoveryFile) { try { fs.unlinkSync(discoveryFile) } catch { /* ignore */ } }
|
|
396
|
-
await settleWithin(
|
|
397
|
-
Promise.resolve().then(() => upstream.terminateSession()),
|
|
398
|
-
SHUTDOWN_STEP_TIMEOUT_MS,
|
|
399
|
-
)
|
|
400
|
-
await settleWithin(
|
|
401
|
-
Promise.resolve().then(() => upstream.close()),
|
|
402
|
-
SHUTDOWN_STEP_TIMEOUT_MS,
|
|
403
|
-
)
|
|
404
|
-
try { await upstreamHttp.shutdown() } catch { /* ignore */ }
|
|
405
|
-
}
|
|
406
|
-
shutdown = createShutdownCoordinator({
|
|
407
|
-
cleanup,
|
|
408
|
-
exit: (code) => process.exit(code),
|
|
409
|
-
})
|
|
410
|
-
function shutdownGracefully() {
|
|
411
|
-
shutdownRequested = true
|
|
412
|
-
return shutdown(0)
|
|
413
|
-
}
|
|
414
|
-
stdio.onclose = () => { log('stdio-close', ''); void shutdownGracefully() }
|
|
415
|
-
upstream.onclose = () => { log('http-close', '') }
|
|
416
|
-
stdio.onerror = (e) => log('stdio-err', String(e))
|
|
417
|
-
upstream.onerror = () => log('http-err', { kind: 'transport' })
|
|
418
|
-
process.on('SIGTERM', () => { void shutdownGracefully() })
|
|
419
|
-
process.on('SIGINT', () => { void shutdownGracefully() })
|
|
420
|
-
|
|
421
|
-
try {
|
|
422
|
-
await startProxy({
|
|
423
|
-
key: KEY,
|
|
424
|
-
upstream,
|
|
425
|
-
stdio,
|
|
426
|
-
onStarted: () => {
|
|
427
|
-
if (shutdownRequested) return
|
|
428
|
-
startHeartbeat()
|
|
429
|
-
log('STARTED', {
|
|
430
|
-
endpoint: safeEndpointLabel(GEMUS_URL),
|
|
431
|
-
hasKey: Boolean(KEY),
|
|
432
|
-
pid: process.pid,
|
|
433
|
-
idleMs: IDLE_MS,
|
|
434
|
-
heartbeatMs: HEARTBEAT_MS,
|
|
435
|
-
})
|
|
436
|
-
},
|
|
40
|
+
process.exitCode = error?.code === 'PROXY_USAGE' ? 2 : 1
|
|
437
41
|
})
|
|
438
|
-
} catch (error) {
|
|
439
|
-
await terminateFatally(error)
|
|
440
42
|
}
|