diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 853303323d27..6c8d685edb8e 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -380,6 +380,8 @@ export async function startOrResumeThread(params: { : buildCodexUserMcpServersThreadConfigPatch(params.params.config, { agentId: params.agentId ?? params.params.agentId, }); + const legacyUserMcpServersFingerprint = + legacyFingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch); const userMcpServersFingerprint = fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch); const environmentSelectionFingerprint = fingerprintEnvironmentSelection( @@ -581,7 +583,14 @@ export async function startOrResumeThread(params: { rotatedContextEngineBinding = true; } } - if (binding?.threadId && binding.userMcpServersFingerprint !== userMcpServersFingerprint) { + if ( + binding?.threadId && + !areUserMcpServersFingerprintsCompatible({ + previous: binding.userMcpServersFingerprint, + next: userMcpServersFingerprint, + nextLegacy: legacyUserMcpServersFingerprint, + }) + ) { embeddedAgentLog.debug("codex app-server user MCP config changed; starting a new thread", { threadId: binding.threadId, }); @@ -1691,6 +1700,12 @@ function legacyFingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): st ); } +function legacyFingerprintUserMcpServersConfigPatch( + configPatch: JsonObject | undefined, +): string | undefined { + return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined; +} + function fingerprintUserMcpServersConfigPatch( configPatch: JsonObject | undefined, ): string | undefined { @@ -1770,6 +1785,21 @@ function areDynamicToolFingerprintsCompatible( return !previous || previous === next || previous === nextLegacy; } +function areUserMcpServersFingerprintsCompatible(params: { + previous?: string; + next?: string; + nextLegacy?: string; +}): boolean { + // Beta 5 stored raw stabilized JSON, while doctor hashes those exact bytes. + // A successful resume rewrites either legacy form to the current redacted hash. + return ( + params.previous === params.next || + params.previous === params.nextLegacy || + (params.nextLegacy !== undefined && + params.previous === hashCodexAppServerBindingFingerprint(params.nextLegacy)) + ); +} + function shouldStartTransientNoToolThread(params: { previous: string | undefined; nextHasDynamicTools: boolean; diff --git a/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts b/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts index 3af0719abc6c..e5f966c7812d 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts @@ -6,9 +6,11 @@ import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CodexAppServerRuntimeOptions } from "./config.js"; import { + hashCodexAppServerBindingFingerprint, readCodexAppServerBinding, registerCodexTestSessionIdentity, resetCodexTestBindingStore, + seedCodexTestBinding, testCodexAppServerBindingStore, writeCodexAppServerBinding, } from "./session-binding.test-helpers.js"; @@ -194,6 +196,85 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081 expect(binding?.userMcpServersFingerprint).not.toContain("server_119"); }); + it.each(["raw", "doctor-hashed"] as const)( + "resumes beta5 user MCP bindings stored as %s fingerprints", + async (legacyForm) => { + const sessionFile = path.join(tempDir, "session.jsonl"); + registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1"); + const workspaceDir = path.join(tempDir, "workspace"); + const authorization = "Bearer beta5-access-token"; + const config = { + mcp: { + servers: { + ducktape: { + transport: "streamable-http", + url: "https://agents.ducktape.xyz/mcp", + headers: { + Authorization: authorization, + "x-tenant": "keep", + }, + }, + }, + }, + } as unknown as EmbeddedRunAttemptParams["config"]; + const request = vi.fn(async (method: string, _params: unknown) => { + if (method === "thread/start") { + return threadStartResult("thread-beta5"); + } + if (method === "thread/resume") { + return threadResumeResult("thread-beta5"); + } + throw new Error(`unexpected method: ${method}`); + }); + const run = () => + startOrResumeThread({ + client: { request } as never, + params: createParams(sessionFile, workspaceDir, config), + cwd: workspaceDir, + dynamicTools: [], + appServer: createAppServerOptions(), + }); + + await run(); + const currentBinding = await readCodexAppServerBinding(sessionFile); + expect(currentBinding).toBeDefined(); + + const legacyFingerprint = JSON.stringify({ + mcp_servers: { + ducktape: { + http_headers: { + Authorization: authorization, + "x-tenant": "keep", + }, + url: "https://agents.ducktape.xyz/mcp", + }, + }, + }); + seedCodexTestBinding(sessionFile, { + ...currentBinding!, + userMcpServersFingerprint: + legacyForm === "raw" + ? legacyFingerprint + : hashCodexAppServerBindingFingerprint(legacyFingerprint), + }); + + request.mockClear(); + await run(); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]); + const convergedBinding = await readCodexAppServerBinding(sessionFile); + expect(convergedBinding?.userMcpServersFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(convergedBinding?.userMcpServersFingerprint).not.toContain("beta5-access-token"); + expect(convergedBinding?.userMcpServersFingerprint).not.toBe(legacyFingerprint); + expect(convergedBinding?.userMcpServersFingerprint).not.toBe( + hashCodexAppServerBindingFingerprint(legacyFingerprint), + ); + + request.mockClear(); + await run(); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]); + }, + ); + it("projects only Codex user MCP servers scoped to the current agent", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace");