@gemus/mcp-proxy 0.1.4 → 0.1.6
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/package.json +2 -2
- package/src/__tests__/backfill.test.ts +39 -1
- package/src/__tests__/mcpHeaders.test.ts +22 -1
- package/src/backfill.mjs +20 -4
- package/src/mcpHeaders.mjs +28 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemus/mcp-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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.3"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=18"
|
|
@@ -74,7 +74,7 @@ beforeEach(() => {
|
|
|
74
74
|
mocks.readRollout.mockReset().mockReturnValue('')
|
|
75
75
|
mocks.findRolloutFile.mockReset().mockReturnValue('/fake/rollout.jsonl')
|
|
76
76
|
// Default: generated_images unavailable → backfill falls back to the rollout source (existing tests).
|
|
77
|
-
mocks.readGeneratedImageFiles.mockReset().mockReturnValue({ available: false, images: [] })
|
|
77
|
+
mocks.readGeneratedImageFiles.mockReset().mockReturnValue({ available: false, images: [], failedCallIds: [] })
|
|
78
78
|
})
|
|
79
79
|
|
|
80
80
|
describe('normalizeToolName (tap → tracker)', () => {
|
|
@@ -353,4 +353,42 @@ describe('backfillTurn — Layer 2 file byte source (#1756)', () => {
|
|
|
353
353
|
expect(mocks.readRollout).toHaveBeenCalledWith('/x')
|
|
354
354
|
expect(r.delivered).toBe(1)
|
|
355
355
|
})
|
|
356
|
+
|
|
357
|
+
it('exec-* file-source image (opaque call-id) is delivered exactly once (#1968)', async () => {
|
|
358
|
+
const bf = makeBackfiller()
|
|
359
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
360
|
+
mocks.readGeneratedImageFiles.mockReturnValue({
|
|
361
|
+
available: true,
|
|
362
|
+
images: [{ callId: 'exec-3eb2a29c', base64: 'iVBORw0KGgoAAAA1', mimeType: 'image/png' }],
|
|
363
|
+
failedCallIds: [],
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
367
|
+
|
|
368
|
+
expect(mocks.readRollout).not.toHaveBeenCalled()
|
|
369
|
+
expect(r.delivered).toBe(1)
|
|
370
|
+
expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(1)
|
|
371
|
+
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({
|
|
372
|
+
nodeId: 'nodeA',
|
|
373
|
+
image: { callId: 'exec-3eb2a29c' },
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('unreadable fresh candidate is recovered from rollout by EXACT call-id only — no history bypass (#1968)', async () => {
|
|
378
|
+
const bf = makeBackfiller()
|
|
379
|
+
observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
|
|
380
|
+
// File source: nothing readable, but one fresh candidate failed to read (torn write).
|
|
381
|
+
mocks.readGeneratedImageFiles.mockReturnValue({ available: true, images: [], failedCallIds: ['exec-torn'] })
|
|
382
|
+
// Rollout holds the failed id AND an unrelated historical image that must NOT be surfaced.
|
|
383
|
+
mocks.readRollout.mockReturnValue(rollout([['exec-torn', 'iVBORw0KGgoAAAA9'], ['ig_history', 'iVBORw0KGgoAAAA8']]))
|
|
384
|
+
|
|
385
|
+
const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
|
|
386
|
+
|
|
387
|
+
expect(mocks.readRollout).toHaveBeenCalledWith('/x')
|
|
388
|
+
expect(r.delivered).toBe(1)
|
|
389
|
+
expect(mocks.deliverImageToNode).toHaveBeenCalledTimes(1)
|
|
390
|
+
expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ image: { callId: 'exec-torn' } })
|
|
391
|
+
// The historical image was in the rollout but is NOT in failedCallIds → never delivered or orphaned.
|
|
392
|
+
expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
|
|
393
|
+
})
|
|
356
394
|
})
|
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { CLIENT_CAPABILITIES } from '@gemus/codex-backfill-core'
|
|
2
3
|
import { buildProxyUpstreamHeaders } from '../mcpHeaders.mjs'
|
|
3
4
|
|
|
4
5
|
describe('buildProxyUpstreamHeaders', () => {
|
|
5
|
-
it('advertises the
|
|
6
|
+
it('advertises only the capabilities the proxy can actually fulfil', () => {
|
|
6
7
|
expect(buildProxyUpstreamHeaders('mak_test123')).toEqual({
|
|
7
8
|
Authorization: 'Bearer mak_test123',
|
|
8
9
|
'X-Gemus-Client-Capabilities': 'agent-authored-prompts,codex-imagen',
|
|
9
10
|
})
|
|
10
11
|
})
|
|
12
|
+
|
|
13
|
+
it('never advertises codex-instant-delivery — the proxy has no ImageScheduler to renew the lease (#2049)', () => {
|
|
14
|
+
// 该能力门控服务端的 AWAITING_CLIENT execute 分支,要求客户端有 scheduler 每 60s renewLease。
|
|
15
|
+
// 只有 bridge 有(且 probe 门控,见 bridge/src/cli.ts)。proxy 没有 —— 发了它就等于承诺一个
|
|
16
|
+
// 永不到来的续租 → 5min 后 CLIENT_LEASE_EXPIRED → 图 orphan 成孤立 image-upload 节点。
|
|
17
|
+
// absent → 服务端回退经典两阶段 client-delivered 协议,proxy 完全支持(backfill.mjs)。
|
|
18
|
+
const header = buildProxyUpstreamHeaders('mak_test123')['X-Gemus-Client-Capabilities']
|
|
19
|
+
expect(header).not.toContain('codex-instant-delivery')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('stays a strict subset of the shared allowlist — a new capability must not auto-inherit (#2049)', () => {
|
|
23
|
+
// 回归门:本 bug 的成因正是 proxy 发「整个 CLIENT_CAPABILITIES 数组」—— #1969 往数组里加了
|
|
24
|
+
// codex-instant-delivery,proxy 就自动继承了一个它做不到的承诺,而 #1990 把红掉的断言改成
|
|
25
|
+
// 迎合它。此断言让「加能力」与「proxy 声称支持它」变成两个独立决策,不再自动传染。
|
|
26
|
+
const advertised = buildProxyUpstreamHeaders('mak_test123')['X-Gemus-Client-Capabilities'].split(',')
|
|
27
|
+
for (const capability of advertised) {
|
|
28
|
+
expect(CLIENT_CAPABILITIES).toContain(capability)
|
|
29
|
+
}
|
|
30
|
+
expect(advertised.length).toBeLessThan(CLIENT_CAPABILITIES.length)
|
|
31
|
+
})
|
|
11
32
|
})
|
package/src/backfill.mjs
CHANGED
|
@@ -137,17 +137,33 @@ export function createBackfiller({ server, apiKey, codexHome, call, log = () =>
|
|
|
137
137
|
async function backfillTurnInner({ turnKey, sessionId, rolloutPath, salvageOnly }) {
|
|
138
138
|
const t = turns.get(turnKey)
|
|
139
139
|
const sid = sessionId || t?.sessionId
|
|
140
|
-
// #1756 Layer 2: 优先读 generated_images 落盘文件(去 rollout-scrape
|
|
141
|
-
//
|
|
142
|
-
//
|
|
140
|
+
// #1756 Layer 2: 优先读 generated_images 落盘文件(去 rollout-scrape 脆化)。仅目录整体不可用
|
|
141
|
+
// (旧 codex 不落盘)才整源回退 rollout;目录可用即锁定文件源,否则会绕过 mtime 守卫把历史图从
|
|
142
|
+
// rollout 重新捞出重复交付。#1968:文件源里「已确认 fresh 但读失败」的候选(torn/locked write)
|
|
143
|
+
// 按精确 call-id 定向从 rollout 恢复——只捞这几个 id,不换整源、不越 mtime 守卫。
|
|
143
144
|
const fileResult = sid
|
|
144
145
|
? core.readGeneratedImageFiles(codexHome, sid, procStartMs)
|
|
145
|
-
: { available: false, images: [] }
|
|
146
|
+
: { available: false, images: [], failedCallIds: [] }
|
|
146
147
|
let all
|
|
147
148
|
let source
|
|
148
149
|
if (fileResult.available) {
|
|
149
150
|
all = fileResult.images
|
|
150
151
|
source = 'generated_images'
|
|
152
|
+
const failedCallIds = fileResult.failedCallIds || []
|
|
153
|
+
if (failedCallIds.length) {
|
|
154
|
+
const path = rolloutPath || core.findRolloutFile(codexHome, sid)
|
|
155
|
+
const want = new Set(failedCallIds)
|
|
156
|
+
const recovered = core.extractImageOutputs(core.readRollout(path)).filter((img) => want.has(img.callId))
|
|
157
|
+
if (recovered.length) {
|
|
158
|
+
all = all.concat(recovered)
|
|
159
|
+
source = `${source}+rollout`
|
|
160
|
+
}
|
|
161
|
+
log('backfill: unreadable generated_images candidates', {
|
|
162
|
+
turnKey,
|
|
163
|
+
failed: failedCallIds.length,
|
|
164
|
+
recovered: recovered.length,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
151
167
|
} else {
|
|
152
168
|
const path = rolloutPath || core.findRolloutFile(codexHome, sid)
|
|
153
169
|
all = core.extractImageOutputs(core.readRollout(path))
|
package/src/mcpHeaders.mjs
CHANGED
|
@@ -4,9 +4,36 @@ import {
|
|
|
4
4
|
serializeClientCapabilities,
|
|
5
5
|
} from '@gemus/codex-backfill-core'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* #2049:proxy 只 advertise **它自己能履行**的能力 —— 不是 `CLIENT_CAPABILITIES` 全集。
|
|
9
|
+
*
|
|
10
|
+
* `CLIENT_CAPABILITIES` 是**所有已知能力的 allowlist**(共享 SSOT),不是「每个客户端都支持」的清单。
|
|
11
|
+
* 各客户端各自声明自己能兑现的子集:bridge 见 `bridge/src/cli.ts`(`imageScheduler` 与
|
|
12
|
+
* `activeCapabilities` **成对注入** —— 有 scheduler 才发 capability,且 probe 门控)。
|
|
13
|
+
*
|
|
14
|
+
* **`codex-instant-delivery` 必须排除**:它门控服务端的 AWAITING_CLIENT execute 分支,
|
|
15
|
+
* 要求客户端有 ImageScheduler 每 60s `renewLease`。**proxy 没有 scheduler**(本包与
|
|
16
|
+
* `@gemus/codex-backfill-core` 里 `renew`/`lease` 零命中)。发了它 = 承诺一个永不到来的续租:
|
|
17
|
+
* execute 建 AWAITING_CLIENT job(lease=+5min)→ 无人续 → `CLIENT_LEASE_EXPIRED` → 节点 FAILED
|
|
18
|
+
* → Codex 的图回来时目标已死 → `backfill.mjs` orphan 兜底 → 孤立 `image-upload` 节点。
|
|
19
|
+
* 真机实测见 #2049。
|
|
20
|
+
* **不发它 → 服务端回退经典两阶段 client-delivered 协议,proxy 完全支持**(`backfill.mjs`
|
|
21
|
+
* tap execute + 注入交付)—— 这正是 `codex-backfill-core/src/mcp.ts` 那条注释写的
|
|
22
|
+
* "absent → the classic dryRun/Phase-2 two-phase client-delivered protocol is used unchanged"。
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ **往 `CLIENT_CAPABILITIES` 加新能力时**:默认**不会**、也**不该**自动出现在这里。先问
|
|
25
|
+
* 「proxy 拿什么兑现它?」—— 本 bug 的成因正是「发全集」让新能力自动传染(#1969 加,proxy 白捡,
|
|
26
|
+
* #1990 把红掉的断言改成迎合)。`__tests__/mcpHeaders.test.ts` 有严格子集断言守着这条。
|
|
27
|
+
*/
|
|
28
|
+
const PROXY_UNSUPPORTED_CAPABILITIES = new Set(['codex-instant-delivery'])
|
|
29
|
+
|
|
30
|
+
const PROXY_CAPABILITIES = Object.freeze(
|
|
31
|
+
CLIENT_CAPABILITIES.filter((capability) => !PROXY_UNSUPPORTED_CAPABILITIES.has(capability)),
|
|
32
|
+
)
|
|
33
|
+
|
|
7
34
|
export function buildProxyUpstreamHeaders(apiKey) {
|
|
8
35
|
return {
|
|
9
36
|
Authorization: `Bearer ${apiKey}`,
|
|
10
|
-
[CLIENT_CAPABILITIES_HEADER]: serializeClientCapabilities(
|
|
37
|
+
[CLIENT_CAPABILITIES_HEADER]: serializeClientCapabilities(PROXY_CAPABILITIES),
|
|
11
38
|
}
|
|
12
39
|
}
|