@gemus/mcp-proxy 0.1.1 → 0.1.4
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 +10 -7
- package/package.json +2 -2
- package/src/__tests__/backfill.test.ts +102 -3
- package/src/__tests__/mcpHeaders.test.ts +11 -0
- package/src/backfill.mjs +39 -27
- package/src/mcpHeaders.mjs +12 -0
- package/src/proxy.mjs +89 -3
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @gemus/mcp-proxy (
|
|
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
4
|
image write-back. Bundled into the Codex plugin (`.codex-plugin/`), declared as a **command-based**
|
|
@@ -6,11 +6,13 @@ MCP server so codex spawns it locally; the proxy owns upstream auth to the remot
|
|
|
6
6
|
(Codex does no OAuth for stdio servers). Full design: `docs/plans/1751-codex-desktop-companion.md`
|
|
7
7
|
and `docs/architecture/agent architecture/codex-desktop-companion.md`.
|
|
8
8
|
|
|
9
|
-
## Status:
|
|
9
|
+
## Status: implemented
|
|
10
10
|
|
|
11
|
-
`src/proxy.mjs` is the
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
`src/proxy.mjs` is the production passthrough, execute-tap, and deterministic image-backfill
|
|
12
|
+
companion. A trusted Stop hook triggers `generated_images`/rollout discovery; `ExecuteClaimTracker`
|
|
13
|
+
then delivers an unambiguous image to its planned node or conservatively creates an orphan
|
|
14
|
+
`image-upload`. MCP initialization advertises the shared Codex capability contract used by the
|
|
15
|
+
server blueprint policy.
|
|
14
16
|
|
|
15
17
|
## What was validated end-to-end (2026-07-06, codex-cli 0.142.2 → local dev `/api/mcp`)
|
|
16
18
|
|
|
@@ -23,7 +25,8 @@ is the next build increment (plan Steps 2–4).
|
|
|
23
25
|
`workflowId` from `arguments`, and **turn/thread identity from `_meta.x-codex-turn-metadata`
|
|
24
26
|
(`thread_id`/`turn_id`)** — so per-turn scoping needs no Stop hook. `execute` is the same path.
|
|
25
27
|
- **(c) turn-end + retrieval** — `Stop` hook fires post-flush carrying `transcript_path` → exact
|
|
26
|
-
rollout; imagegen writes `generated_images/<session
|
|
28
|
+
rollout; imagegen writes `generated_images/<session>/<sanitized-call-id>.png` (historically `ig_*`,
|
|
29
|
+
currently `call_*`) + rollout `saved_path`+base64.
|
|
27
30
|
- **Session cap** — `KEY_ONLY_SESSION_CAP=3`/user is real; the proxy MUST `DELETE` its upstream
|
|
28
31
|
session on close (implemented in `shutdown()`) to avoid leaking slots.
|
|
29
32
|
|
|
@@ -53,4 +56,4 @@ GEMUS_KEY=mak_... GEMUS_URL=http://localhost:3000/api/mcp PROXY_LOG=/tmp/proxy.l
|
|
|
53
56
|
```
|
|
54
57
|
|
|
55
58
|
Env: `GEMUS_KEY` (required), `GEMUS_URL` (default `https://gemus.ai/api/mcp`), `PROXY_LOG` (optional
|
|
56
|
-
debug log), `PROXY_BACKFILL_IDLE_MS` (default
|
|
59
|
+
debug log), `PROXY_BACKFILL_IDLE_MS` (default 180000 — turn-idle fallback before salvage-orphan).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemus/mcp-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
|
-
"@gemus/codex-backfill-core": "0.1.
|
|
28
|
+
"@gemus/codex-backfill-core": "0.1.2"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=18"
|
|
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
11
11
|
addImageToCanvas: vi.fn(),
|
|
12
12
|
readRollout: vi.fn(),
|
|
13
13
|
findRolloutFile: vi.fn(),
|
|
14
|
+
readGeneratedImageFiles: vi.fn(),
|
|
14
15
|
}))
|
|
15
16
|
|
|
16
17
|
vi.mock('@gemus/codex-backfill-core', async (importOriginal) => {
|
|
@@ -21,6 +22,7 @@ vi.mock('@gemus/codex-backfill-core', async (importOriginal) => {
|
|
|
21
22
|
addImageToCanvas: mocks.addImageToCanvas,
|
|
22
23
|
readRollout: mocks.readRollout,
|
|
23
24
|
findRolloutFile: mocks.findRolloutFile,
|
|
25
|
+
readGeneratedImageFiles: mocks.readGeneratedImageFiles,
|
|
24
26
|
}
|
|
25
27
|
})
|
|
26
28
|
|
|
@@ -71,6 +73,8 @@ beforeEach(() => {
|
|
|
71
73
|
mocks.addImageToCanvas.mockReset().mockResolvedValue({ ok: true, entityId: 'agent_x' })
|
|
72
74
|
mocks.readRollout.mockReset().mockReturnValue('')
|
|
73
75
|
mocks.findRolloutFile.mockReset().mockReturnValue('/fake/rollout.jsonl')
|
|
76
|
+
// Default: generated_images unavailable → backfill falls back to the rollout source (existing tests).
|
|
77
|
+
mocks.readGeneratedImageFiles.mockReset().mockReturnValue({ available: false, images: [] })
|
|
74
78
|
})
|
|
75
79
|
|
|
76
80
|
describe('normalizeToolName (tap → tracker)', () => {
|
|
@@ -88,14 +92,25 @@ describe('normalizeToolName (tap → tracker)', () => {
|
|
|
88
92
|
})
|
|
89
93
|
|
|
90
94
|
describe('injectSteering (Mode-B initialize instructions)', () => {
|
|
91
|
-
it('appends
|
|
95
|
+
it('appends a search-neutral skill bootstrap to initialize instructions', () => {
|
|
92
96
|
const msg = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05', serverInfo: { name: 'gemus' } } }
|
|
93
97
|
expect(injectSteering(msg)).toBe(true)
|
|
94
|
-
expect(msg.result.instructions).toContain('
|
|
95
|
-
expect(msg.result.instructions).toContain('
|
|
98
|
+
expect(msg.result.instructions).toContain('gemus-getting-started')
|
|
99
|
+
expect(msg.result.instructions).toContain('gemus-codex-imagen')
|
|
96
100
|
expect(msg.result.instructions).toBe(COMPANION_STEERING)
|
|
97
101
|
})
|
|
98
102
|
|
|
103
|
+
it('does not pollute deferred search documents with registered tool names', () => {
|
|
104
|
+
const toolNames = [
|
|
105
|
+
'canvas_read', 'view_image', 'node_list', 'workflow_list', 'canvas_edit', 'ppt_edit',
|
|
106
|
+
'open_canvas', 'blueprint', 'execute', 'batch_execute', 'recall_history',
|
|
107
|
+
]
|
|
108
|
+
for (const toolName of toolNames) {
|
|
109
|
+
const exactName = new RegExp(`(?<![A-Za-z0-9_])${toolName}(?![A-Za-z0-9_])`, 'i')
|
|
110
|
+
expect(COMPANION_STEERING, `companion steering references ${toolName}`).not.toMatch(exactName)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
99
114
|
it('preserves any existing server instructions (appends, not replaces)', () => {
|
|
100
115
|
const msg = { id: 1, result: { protocolVersion: '1', instructions: 'server says hi' } }
|
|
101
116
|
injectSteering(msg)
|
|
@@ -255,3 +270,87 @@ describe('backfillTurn — idempotency + guards', () => {
|
|
|
255
270
|
expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
|
|
256
271
|
})
|
|
257
272
|
})
|
|
273
|
+
|
|
274
|
+
describe('backfillTurn — footprint-0 call passthrough + timeout semantics (#1756 Layer 1a)', () => {
|
|
275
|
+
it('threads the injected `call` into deliver AND orphan (footprint-0)', async () => {
|
|
276
|
+
const call = vi.fn()
|
|
277
|
+
const bf = createBackfiller({
|
|
278
|
+
server: 'http://localhost:3000',
|
|
279
|
+
apiKey: 'mak_test',
|
|
280
|
+
codexHome: '/fake/.codex',
|
|
281
|
+
call,
|
|
282
|
+
})
|
|
283
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
284
|
+
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
285
|
+
mocks.deliverImageToNode.mockResolvedValue({ ok: false, code: 'ALREADY_COMPLETED' }) // force orphan fallback
|
|
286
|
+
|
|
287
|
+
await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
288
|
+
|
|
289
|
+
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ call })
|
|
290
|
+
expect(mocks.addImageToCanvas.mock.calls[0][0]).toMatchObject({ call })
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('a timed-out deliver leaves the image FRESH — no orphan, not marked seen (no duplicate)', async () => {
|
|
294
|
+
const bf = makeBackfiller()
|
|
295
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
296
|
+
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
297
|
+
// deliver times out → result unknown; the platform job may still land, so we must NOT orphan.
|
|
298
|
+
mocks.deliverImageToNode.mockResolvedValue({ ok: false, timedOut: true, error: 'inject timeout' })
|
|
299
|
+
|
|
300
|
+
const r1 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
301
|
+
expect(r1).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
302
|
+
expect(mocks.addImageToCanvas).not.toHaveBeenCalled() // NO orphan on timeout
|
|
303
|
+
|
|
304
|
+
// Image stayed fresh (not seen): a later Stop-hook pass can still deliver it once the job lands.
|
|
305
|
+
mocks.deliverImageToNode.mockResolvedValue({ ok: true, status: 'processing' })
|
|
306
|
+
const r2 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
307
|
+
expect(r2.delivered).toBe(1)
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('backfillTurn — Layer 2 file byte source (#1756)', () => {
|
|
312
|
+
it('uses generated_images when available and does NOT read the rollout', async () => {
|
|
313
|
+
const bf = makeBackfiller()
|
|
314
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
315
|
+
// generated_images available with one fresh image → file source wins, rollout untouched.
|
|
316
|
+
mocks.readGeneratedImageFiles.mockReturnValue({
|
|
317
|
+
available: true,
|
|
318
|
+
images: [{ callId: 'call_current', base64: 'iVBORw0KGgoAAAA1', mimeType: 'image/png' }],
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
322
|
+
|
|
323
|
+
expect(mocks.readGeneratedImageFiles).toHaveBeenCalled()
|
|
324
|
+
expect(mocks.readRollout).not.toHaveBeenCalled() // file source is authoritative when available
|
|
325
|
+
expect(r.delivered).toBe(1)
|
|
326
|
+
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({
|
|
327
|
+
nodeId: 'nodeA',
|
|
328
|
+
image: { callId: 'call_current' },
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('available but empty → no delivery, and does NOT fall back to rollout (mtime guard preserved)', async () => {
|
|
333
|
+
const bf = makeBackfiller()
|
|
334
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
335
|
+
mocks.readGeneratedImageFiles.mockReturnValue({ available: true, images: [] })
|
|
336
|
+
// Even though the rollout has an image, availability=true must lock us to the (empty) file source.
|
|
337
|
+
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
338
|
+
|
|
339
|
+
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
340
|
+
|
|
341
|
+
expect(mocks.readRollout).not.toHaveBeenCalled()
|
|
342
|
+
expect(r).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('unavailable → falls back to rollout-scrape (older codex)', async () => {
|
|
346
|
+
const bf = makeBackfiller()
|
|
347
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
348
|
+
mocks.readGeneratedImageFiles.mockReturnValue({ available: false, images: [] })
|
|
349
|
+
mocks.readRollout.mockReturnValue(rollout([IMG_A]))
|
|
350
|
+
|
|
351
|
+
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
352
|
+
|
|
353
|
+
expect(mocks.readRollout).toHaveBeenCalledWith('/x')
|
|
354
|
+
expect(r.delivered).toBe(1)
|
|
355
|
+
})
|
|
356
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildProxyUpstreamHeaders } from '../mcpHeaders.mjs'
|
|
3
|
+
|
|
4
|
+
describe('buildProxyUpstreamHeaders', () => {
|
|
5
|
+
it('advertises the canonical Codex MCP capabilities alongside bearer authentication', () => {
|
|
6
|
+
expect(buildProxyUpstreamHeaders('mak_test123')).toEqual({
|
|
7
|
+
Authorization: 'Bearer mak_test123',
|
|
8
|
+
'X-Gemus-Client-Capabilities': 'agent-authored-prompts,codex-imagen',
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
})
|
package/src/backfill.mjs
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
// a later trigger no-ops. salvageOnly NEVER calls claim() — mirrors bridge salvage semantics (图不丢, 不认领).
|
|
17
17
|
import * as core from '@gemus/codex-backfill-core'
|
|
18
18
|
|
|
19
|
+
// #1756 Layer 2 mtime cutoff:只认本进程启动后落盘的 generated_images 文件,防 proxy 重启后把该
|
|
20
|
+
// session 历史图当 fresh 重复交付(session_id 跨 resume 复用、seen 是进程内存)。模块加载≈proxy 启动。
|
|
21
|
+
const procStartMs = Date.now()
|
|
22
|
+
|
|
19
23
|
/**
|
|
20
24
|
* Normalize a raw MCP wire tool name to the tracker's expected form. The proxy sees `execute` /
|
|
21
25
|
* `batch_execute` on the wire; ExecuteClaimTracker only matches `mcp__gemus__execute` /
|
|
@@ -30,27 +34,14 @@ export function normalizeToolName(name) {
|
|
|
30
34
|
return WIRE_TO_NORMALIZED[name] ?? name
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// self-forward imagegen output. Mirrors src/lib/agent/core/prompt/workflowBehavior.ts (condensed). Without
|
|
36
|
-
// it codex hunts the file / uploads it to a public host to self-deliver (#1751 real-machine observation).
|
|
37
|
+
// Keep initialize instructions search-neutral. Codex appends this namespace text to every deferred
|
|
38
|
+
// tool's BM25 document, so operational rules and exact tool names live in installed plugin skills.
|
|
37
39
|
export const COMPANION_STEERING = [
|
|
38
|
-
'# Gemus companion
|
|
39
|
-
'',
|
|
40
|
-
'A local Gemus companion backfills your own image generations onto the planned canvas nodes at turn',
|
|
41
|
-
'end. For a `codex-imagen` (gen-image) node: (1) create the node with config.model="codex-imagen",',
|
|
42
|
-
'(2) call `execute` with dryRun:true and read the returned resolvedPrompt, (3) generate the image',
|
|
43
|
-
'LOCALLY with your built-in imagegen, then (4) STOP.',
|
|
40
|
+
'# Gemus companion',
|
|
44
41
|
'',
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'clientDeliveredStatus:"awaiting_backfill" DURING your turn is ON TRACK, not a failure — do not re-read',
|
|
49
|
-
'it in a loop, do not switch its model to a platform provider (gemini/etc), do not self-forward. Just',
|
|
50
|
-
'end your turn; the image arrives on the node after it.',
|
|
51
|
-
'',
|
|
52
|
-
'(Text is the exception: codex-text has no backfill — self-forward with execute dryRun:false +',
|
|
53
|
-
'content:{text:"..."}.)',
|
|
42
|
+
'Before using Gemus, load and follow the installed `gemus-getting-started` skill.',
|
|
43
|
+
'For local image generation and turn-end backfill, also load `gemus-codex-imagen`.',
|
|
44
|
+
'These skills are the authoritative operational guidance for this direct MCP connection.',
|
|
54
45
|
].join('\n')
|
|
55
46
|
|
|
56
47
|
/**
|
|
@@ -71,9 +62,12 @@ export function injectSteering(msg) {
|
|
|
71
62
|
* @param {string} deps.server 平台 origin(不含 /api/mcp)
|
|
72
63
|
* @param {string} deps.apiKey 用户 mak_ key
|
|
73
64
|
* @param {string} deps.codexHome ~/.codex(idle-timer fallback 用 findRolloutFile(session_id) 定位 rollout)
|
|
65
|
+
* @param {import('@gemus/codex-backfill-core').GemusToolCall} [deps.call] footprint-0 交付 transport
|
|
66
|
+
* (#1756 Layer 1a):省略 → connect-per-call(每图新开 key-only session);传入 → 在 proxy 已有
|
|
67
|
+
* upstream 连接上注入 execute/canvas_edit(不占新槽,即便 cap 被泄漏填满仍能交付)。
|
|
74
68
|
* @param {(msg: string, obj?: unknown) => void} [deps.log]
|
|
75
69
|
*/
|
|
76
|
-
export function createBackfiller({ server, apiKey, codexHome, log = () => {} }) {
|
|
70
|
+
export function createBackfiller({ server, apiKey, codexHome, call, log = () => {} }) {
|
|
77
71
|
/** @type {Map<string, { tracker: InstanceType<typeof core.ExecuteClaimTracker>, workflowId?: string, sessionId?: string }>} */
|
|
78
72
|
const turns = new Map() // keyed by turn_id; bounded (evict oldest) so a long session can't leak
|
|
79
73
|
const TURN_CAP = 64
|
|
@@ -123,7 +117,7 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
|
|
|
123
117
|
|
|
124
118
|
/** orphan 一张图(Mode B 需真实 workflowId),**只在成功后**标记 seen;返回是否成功。 */
|
|
125
119
|
async function orphanOne(workflowId, image) {
|
|
126
|
-
const r = await core.addImageToCanvas({ server, apiKey, workflowId, image })
|
|
120
|
+
const r = await core.addImageToCanvas({ server, apiKey, workflowId, image, call })
|
|
127
121
|
if (r.ok) {
|
|
128
122
|
seen.add(image.callId)
|
|
129
123
|
return true
|
|
@@ -143,14 +137,27 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
|
|
|
143
137
|
async function backfillTurnInner({ turnKey, sessionId, rolloutPath, salvageOnly }) {
|
|
144
138
|
const t = turns.get(turnKey)
|
|
145
139
|
const sid = sessionId || t?.sessionId
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
140
|
+
// #1756 Layer 2: 优先读 generated_images 落盘文件(去 rollout-scrape 脆化)。三态 fallback——
|
|
141
|
+
// 仅目录整体不可用(旧 codex 不落盘)才回退 rollout;目录可用即锁定文件源(即便过滤后空),
|
|
142
|
+
// 否则会绕过 mtime 守卫把历史图从 rollout 重新捞出重复交付。
|
|
143
|
+
const fileResult = sid
|
|
144
|
+
? core.readGeneratedImageFiles(codexHome, sid, procStartMs)
|
|
145
|
+
: { available: false, images: [] }
|
|
146
|
+
let all
|
|
147
|
+
let source
|
|
148
|
+
if (fileResult.available) {
|
|
149
|
+
all = fileResult.images
|
|
150
|
+
source = 'generated_images'
|
|
151
|
+
} else {
|
|
152
|
+
const path = rolloutPath || core.findRolloutFile(codexHome, sid)
|
|
153
|
+
all = core.extractImageOutputs(core.readRollout(path))
|
|
154
|
+
source = path || '(none)'
|
|
155
|
+
}
|
|
149
156
|
const fresh = all.filter((img) => !seen.has(img.callId))
|
|
150
157
|
|
|
151
158
|
if (fresh.length === 0) {
|
|
152
159
|
// 图还没 flush(idle 过早触发)或都已交付 —— 都不动 seen/turn 状态,留给后续 hook。
|
|
153
|
-
log('backfill: no fresh images', { turnKey, salvageOnly,
|
|
160
|
+
log('backfill: no fresh images', { turnKey, salvageOnly, source })
|
|
154
161
|
return { delivered: 0, orphaned: 0, dropped: 0 }
|
|
155
162
|
}
|
|
156
163
|
|
|
@@ -175,12 +182,17 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
|
|
|
175
182
|
let delivered = 0
|
|
176
183
|
let orphaned = 0
|
|
177
184
|
for (const { nodeId, image } of claims) {
|
|
178
|
-
const r = await core.deliverImageToNode({ server, apiKey, workflowId, nodeId, image })
|
|
185
|
+
const r = await core.deliverImageToNode({ server, apiKey, workflowId, nodeId, image, call })
|
|
179
186
|
if (r.ok) {
|
|
180
187
|
seen.add(image.callId)
|
|
181
188
|
delivered++
|
|
189
|
+
} else if (r.timedOut) {
|
|
190
|
+
// #1756: 注入交付超时 = 结果未知(平台 job 可能仍在跑并最终写 DB)。**不 orphan、不标 seen**——
|
|
191
|
+
// 否则同图既落节点又多一个 image-upload 孤儿。留 fresh,交给下轮 pass(Stop hook/idle)在 job
|
|
192
|
+
// 落地后经 execute 幂等门重判(already_completed 才安全 orphan)。
|
|
193
|
+
log('backfill: deliver timed out — leaving image fresh (no orphan)', { nodeId, callId: image.callId })
|
|
182
194
|
} else if (await orphanOne(workflowId, image)) {
|
|
183
|
-
// already_completed /
|
|
195
|
+
// already_completed / 其它确定性失败 → orphan 保底(镜像 bridge:新图未落节点则至少 image-upload)。
|
|
184
196
|
orphaned++
|
|
185
197
|
} else {
|
|
186
198
|
log('backfill: deliver+orphan both failed', { nodeId, callId: image.callId, error: r.error ?? r.code })
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLIENT_CAPABILITIES,
|
|
3
|
+
CLIENT_CAPABILITIES_HEADER,
|
|
4
|
+
serializeClientCapabilities,
|
|
5
|
+
} from '@gemus/codex-backfill-core'
|
|
6
|
+
|
|
7
|
+
export function buildProxyUpstreamHeaders(apiKey) {
|
|
8
|
+
return {
|
|
9
|
+
Authorization: `Bearer ${apiKey}`,
|
|
10
|
+
[CLIENT_CAPABILITIES_HEADER]: serializeClientCapabilities(CLIENT_CAPABILITIES),
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/proxy.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import path from 'node:path'
|
|
|
24
24
|
import fs from 'node:fs'
|
|
25
25
|
import { randomBytes } from 'node:crypto'
|
|
26
26
|
import { createBackfiller, injectSteering } from './backfill.mjs'
|
|
27
|
+
import { unwrapMcpContent } from '@gemus/codex-backfill-core'
|
|
28
|
+
import { buildProxyUpstreamHeaders } from './mcpHeaders.mjs'
|
|
27
29
|
|
|
28
30
|
const KEY = process.env.GEMUS_KEY || ''
|
|
29
31
|
const GEMUS_URL = process.env.GEMUS_URL || 'https://gemus.ai/api/mcp'
|
|
@@ -33,16 +35,68 @@ const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
|
33
35
|
// calls, so a short idle looks like "turn ended" mid-generation and fires prematurely. A trusted Stop
|
|
34
36
|
// hook fires at turn end and clears this timer, so trusted users never hit it (#1751 real-machine fix).
|
|
35
37
|
const IDLE_MS = Number(process.env.PROXY_BACKFILL_IDLE_MS) || 180_000
|
|
38
|
+
// footprint-0 注入交付超时(#1756 Layer 1a)。必须 ≥ 平台对 codex-imagen 回填节点的同步等待上限
|
|
39
|
+
// (deriveWaitTimeoutMs('gen-image-generation')=180s,见 src/server/generation/waitTimeout.ts),否则
|
|
40
|
+
// proxy 先超时 → 假失败 → orphan 回退 → 同图重复交付。240s = 180s + 余量。别调到 180s 以下。
|
|
41
|
+
const INJECT_TIMEOUT_MS = Number(process.env.PROXY_INJECT_TIMEOUT_MS) || 240_000
|
|
36
42
|
const LOG = process.env.PROXY_LOG // optional debug log path
|
|
37
43
|
const log = (dir, m) => {
|
|
38
44
|
if (!LOG) return
|
|
39
45
|
fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${dir} ${typeof m === 'string' ? m : JSON.stringify(m).slice(0, 1000)}\n`)
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
// ── footprint-0 交付注入(#1756 Layer 1a)─────────────────────────────────────────────────────────
|
|
49
|
+
// 交付/orphan 不再每图新开 key-only session(connect-per-call,抢 KEY_ONLY_SESSION_CAP 槽),而是把
|
|
50
|
+
// execute/canvas_edit 的 tools/call **注入到 proxy 已建好、已计入 cap 的那条 upstream 连接**上(footprint
|
|
51
|
+
// 0)——即便 cap 被泄漏填满也能交付。注入用字符串 id 命名空间 `gemus-bf-*`(绝不撞 Codex 的数字 id),
|
|
52
|
+
// 响应在 upstream.onmessage 里按 id 自消费、不转发给 Codex。`upstream` 在下方声明,注入在 turn 末才调,
|
|
53
|
+
// 故闭包内按调用时解析、无 TDZ 问题。
|
|
54
|
+
/** in-flight 注入:id(string) → { settle }。settle 幂等收敛(响应/超时/取消/关停任一先到即结算)。 */
|
|
55
|
+
const pendingInjections = new Map()
|
|
56
|
+
let injectSeq = 0
|
|
57
|
+
|
|
58
|
+
/** 把 JSON-RPC 响应转成与 core `callGemusTool` 逐字一致的 CallGemusToolResult 形状。 */
|
|
59
|
+
function interpretInjection(m) {
|
|
60
|
+
if (m?.error) return { ok: false, error: m.error.message || `MCP error ${m.error.code}` }
|
|
61
|
+
const result = m?.result
|
|
62
|
+
if (result && result.isError === true) {
|
|
63
|
+
const first = Array.isArray(result.content) ? result.content[0] : undefined
|
|
64
|
+
return { ok: false, error: (first && first.text) || 'MCP tool error' }
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, result: unwrapMcpContent(result && result.content) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @type {import('@gemus/codex-backfill-core').GemusToolCall} */
|
|
70
|
+
function injectedCall(toolName, args, opts = {}) {
|
|
71
|
+
const { progressToken, signal } = opts
|
|
72
|
+
const id = `gemus-bf-${++injectSeq}`
|
|
73
|
+
const req = {
|
|
74
|
+
jsonrpc: '2.0',
|
|
75
|
+
id,
|
|
76
|
+
method: 'tools/call',
|
|
77
|
+
params: { name: toolName, arguments: args, ...(progressToken ? { _meta: { progressToken } } : {}) },
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const settle = (v) => {
|
|
81
|
+
clearTimeout(timer)
|
|
82
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
83
|
+
pendingInjections.delete(id)
|
|
84
|
+
resolve(v)
|
|
85
|
+
}
|
|
86
|
+
const onAbort = () => settle({ ok: false, error: 'aborted' })
|
|
87
|
+
const timer = setTimeout(() => settle({ ok: false, timedOut: true, error: 'inject timeout' }), INJECT_TIMEOUT_MS)
|
|
88
|
+
pendingInjections.set(id, { settle })
|
|
89
|
+
if (signal) signal.addEventListener('abort', onAbort)
|
|
90
|
+
upstream.send(req).catch((e) => settle({ ok: false, error: String(e) }))
|
|
91
|
+
log('INJECT', { id, toolName, progressToken })
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
42
95
|
const backfiller = createBackfiller({
|
|
43
96
|
server: SERVER_ORIGIN,
|
|
44
97
|
apiKey: KEY,
|
|
45
98
|
codexHome: CODEX_HOME,
|
|
99
|
+
call: injectedCall,
|
|
46
100
|
log: (msg, obj) => log('BACKFILL', obj ? `${msg} ${JSON.stringify(obj)}` : msg),
|
|
47
101
|
})
|
|
48
102
|
|
|
@@ -191,23 +245,54 @@ if (typeof hookServer.unref === 'function') hookServer.unref()
|
|
|
191
245
|
|
|
192
246
|
// ── transparent JSON-RPC passthrough (raw send/onmessage) ──────────────────────────────────────
|
|
193
247
|
const upstream = new StreamableHTTPClientTransport(new URL(GEMUS_URL), {
|
|
194
|
-
requestInit: { headers:
|
|
248
|
+
requestInit: { headers: buildProxyUpstreamHeaders(KEY) },
|
|
195
249
|
})
|
|
196
250
|
const stdio = new StdioServerTransport()
|
|
197
251
|
|
|
198
252
|
stdio.onmessage = (m) => { tapClientToServer(m); log('C->G', m); upstream.send(m).catch((e) => log('ERR-http-send', String(e))) }
|
|
199
|
-
upstream.onmessage = (m) => {
|
|
253
|
+
upstream.onmessage = (m) => {
|
|
254
|
+
// footprint-0 (#1756): our injected execute/canvas_edit responses carry a `gemus-bf-*` string id.
|
|
255
|
+
// Self-consume them here (settle the pending injection) and DO NOT forward to Codex — Codex never
|
|
256
|
+
// issued these calls, so it must never see the responses.
|
|
257
|
+
if (typeof m?.id === 'string' && m.id.startsWith('gemus-bf-')) {
|
|
258
|
+
const p = pendingInjections.get(m.id)
|
|
259
|
+
if (p) p.settle(interpretInjection(m))
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
// #1756 liveness heartbeat: our own `ping` pongs (gemus-ping-*) are self-consumed — Codex never
|
|
263
|
+
// issued them, so it must never see the responses.
|
|
264
|
+
if (typeof m?.id === 'string' && m.id.startsWith('gemus-ping-')) return
|
|
265
|
+
tapServerToClient(m); steerOnInit(m); log('G->C', m); stdio.send(m).catch((e) => log('ERR-stdio-send', String(e)))
|
|
266
|
+
}
|
|
200
267
|
|
|
201
268
|
// ── session hygiene (spike: KEY_ONLY_SESSION_CAP=3) ────────────────────────────────────────────
|
|
202
269
|
// We must DELETE the upstream session or we leak a key-only slot when codex ends the conversation.
|
|
203
270
|
// NOTE: transport.close() does NOT issue DELETE (it only aborts + fires onclose, streamableHttp.js:280);
|
|
204
271
|
// terminateSession() sends the DELETE (:431). Call it first, then close(). Also clean up local artifacts.
|
|
272
|
+
// #1756 proxy-death detection: send an MCP `ping` every 30s so the platform can tell a live proxy
|
|
273
|
+
// (keeps pinging) from a dead one (hard-killed → no graceful DELETE → leaked session). This is
|
|
274
|
+
// **independent of Codex tool activity**, so a proxy mid-imagegen (silent to Codex for minutes) still
|
|
275
|
+
// proves liveness → the platform reclaims only genuinely-dead sessions (short floor), never a live one.
|
|
276
|
+
const HEARTBEAT_MS = Number(process.env.PROXY_HEARTBEAT_MS) || 30_000
|
|
277
|
+
let pingSeq = 0
|
|
278
|
+
let heartbeatTimer
|
|
279
|
+
function startHeartbeat() {
|
|
280
|
+
heartbeatTimer = setInterval(() => {
|
|
281
|
+
upstream.send({ jsonrpc: '2.0', id: `gemus-ping-${++pingSeq}`, method: 'ping' }).catch((e) => log('PING-ERR', String(e)))
|
|
282
|
+
}, HEARTBEAT_MS)
|
|
283
|
+
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref() // don't keep the process alive on this alone
|
|
284
|
+
}
|
|
285
|
+
|
|
205
286
|
let shuttingDown = false
|
|
206
287
|
async function shutdown() {
|
|
207
288
|
if (shuttingDown) return
|
|
208
289
|
shuttingDown = true
|
|
290
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
|
209
291
|
for (const t of idleTimers.values()) clearTimeout(t)
|
|
210
292
|
idleTimers.clear()
|
|
293
|
+
// Settle any in-flight footprint-0 injections so a mid-delivery shutdown doesn't hang their awaits.
|
|
294
|
+
for (const p of pendingInjections.values()) p.settle({ ok: false, error: 'proxy shutting down' })
|
|
295
|
+
pendingInjections.clear()
|
|
211
296
|
try { hookServer.close() } catch { /* ignore */ }
|
|
212
297
|
if (discoveryFile) { try { fs.unlinkSync(discoveryFile) } catch { /* ignore */ } }
|
|
213
298
|
try { await upstream.terminateSession() } catch { /* ignore */ }
|
|
@@ -223,4 +308,5 @@ process.on('SIGINT', shutdown)
|
|
|
223
308
|
|
|
224
309
|
await upstream.start()
|
|
225
310
|
await stdio.start()
|
|
226
|
-
|
|
311
|
+
startHeartbeat()
|
|
312
|
+
log('STARTED', { GEMUS_URL, SERVER_ORIGIN, hasKey: Boolean(KEY), pid: process.pid, idleMs: IDLE_MS, heartbeatMs: HEARTBEAT_MS })
|