fix(codex): preserve beta5 MCP bindings

This commit is contained in:
Vincent Koc
2026-07-11 18:00:39 -07:00
parent a6db9e9909
commit 272750daed
2 changed files with 112 additions and 1 deletions
@@ -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;
@@ -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");