@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 CHANGED
@@ -1,4 +1,4 @@
1
- # @gemus/mcp-proxy (Issue #1751, P1 — WIP)
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: minimal skeleton, behavioral checkpoint PASSED
9
+ ## Status: implemented
10
10
 
11
- `src/proxy.mjs` is the validated passthrough+tap skeleton. The backfill delivery layer (Stop-hook
12
- endpoint read rollout via `transcript_path` `ExecuteClaimTracker` → key-only `deliverImageToNode`)
13
- is the next build increment (plan Steps 2–4).
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>/ig_<call_id>.png` + rollout `saved_path`+base64.
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 30000 — turn-idle fallback before salvage-orphan).
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.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.0"
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 companion steering to the initialize response instructions', () => {
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('auto-backfilled')
95
- expect(msg.result.instructions).toContain('NEVER') // don't self-forward
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
- // Companion steering (Mode B has no bridge to inject CLIENT_DELIVERED_GUIDANCE into the system prompt).
34
- // This proxy IS the backfiller, so it tells codex via the MCP initialize `instructions` field — not to
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 — codex-imagen is auto-backfilled',
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
- 'NEVER locate, read, host, upload, or submit the imagegen file yourself — you cannot access it and it',
46
- 'is delivered for you. NEVER pass `content` for a codex-imagen node, and NEVER spin up a local HTTP',
47
- 'server or upload the image to a public host to "deliver" it. A codex-imagen node showing no output /',
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
- const path = rolloutPath || core.findRolloutFile(codexHome, sid)
147
- const jsonl = core.readRollout(path)
148
- const all = core.extractImageOutputs(jsonl)
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, path: path || '(none)' })
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 / 其它失败 → orphan 保底(镜像 bridge:新图未落节点则至少 image-upload)。
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: { Authorization: `Bearer ${KEY}` } },
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) => { tapServerToClient(m); steerOnInit(m); log('G->C', m); stdio.send(m).catch((e) => log('ERR-stdio-send', String(e))) }
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
- log('STARTED', { GEMUS_URL, SERVER_ORIGIN, hasKey: Boolean(KEY), pid: process.pid, idleMs: IDLE_MS })
311
+ startHeartbeat()
312
+ log('STARTED', { GEMUS_URL, SERVER_ORIGIN, hasKey: Boolean(KEY), pid: process.pid, idleMs: IDLE_MS, heartbeatMs: HEARTBEAT_MS })