fix(codex): bound app-server binding fingerprints

(cherry picked from commit 54995af3ae)
This commit is contained in:
Eva
2026-07-11 21:11:42 +07:00
committed by Vincent Koc
parent 305174845f
commit 491e42e6d5
3 changed files with 200 additions and 5 deletions
@@ -1452,6 +1452,54 @@ describe("Codex app-server thread lifecycle bindings", () => {
]);
});
it("stores large dynamic tool fingerprints as bounded hashes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-large-tools");
}
throw new Error(`unexpected method: ${method}`);
});
const largeDynamicTools = [
{
type: "namespace",
name: "openclaw",
description: "",
tools: Array.from({ length: 200 }, (_, index) => ({
...createNamedDynamicTool(`tool_${index}`),
inputSchema: {
type: "object",
properties: Object.fromEntries(
Array.from({ length: 20 }, (__, propertyIndex) => [
`property_${propertyIndex}`,
{
type: "string",
description: "x".repeat(200),
},
]),
),
additionalProperties: false,
},
})),
},
] satisfies Parameters<typeof startOrResumeThread>[0]["dynamicTools"];
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: largeDynamicTools,
appServer: createThreadLifecycleAppServerOptions(),
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.dynamicToolsFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(binding?.dynamicToolsFingerprint).toHaveLength(71);
expect(binding?.dynamicToolsFingerprint).not.toContain("tool_199");
});
it("keeps plugin app bindings across transient native-tool-disabled turns", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -355,7 +355,7 @@ export async function startOrResumeThread(params: {
() => legacyFingerprintDynamicTools(params.dynamicTools),
);
const dynamicToolsFingerprint = lifecycleTiming.measureSync("dynamic-tools-fingerprint", () =>
hashDynamicToolFingerprint(legacyDynamicToolsFingerprint),
hashCanonicalFingerprint(legacyDynamicToolsFingerprint),
);
const dynamicToolsContainDeferred = flattenCodexDynamicToolFunctions(params.dynamicTools).some(
(tool) => tool.deferLoading === true,
@@ -1682,7 +1682,7 @@ export function areCodexDynamicToolFingerprintsCompatible(params: {
}
function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string {
return hashDynamicToolFingerprint(legacyFingerprintDynamicTools(dynamicTools));
return hashCanonicalFingerprint(legacyFingerprintDynamicTools(dynamicTools));
}
function legacyFingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string {
@@ -1691,14 +1691,16 @@ function legacyFingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): st
);
}
function hashDynamicToolFingerprint(canonical: string): string {
function hashCanonicalFingerprint(canonical: string): string {
return "sha256:" + crypto.createHash("sha256").update(canonical).digest("hex");
}
function fingerprintUserMcpServersConfigPatch(
configPatch: JsonObject | undefined,
): string | undefined {
return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined;
return configPatch
? hashCanonicalFingerprint(JSON.stringify(stabilizeJsonValue(configPatch)))
: undefined;
}
function fingerprintJsonObject(value: JsonObject): string {
@@ -1760,7 +1762,7 @@ function readActiveCodexTurnIds(thread: unknown): string[] {
}
const LEGACY_EMPTY_DYNAMIC_TOOLS_FINGERPRINT = legacyFingerprintDynamicTools([]);
const EMPTY_DYNAMIC_TOOLS_FINGERPRINT = hashDynamicToolFingerprint(
const EMPTY_DYNAMIC_TOOLS_FINGERPRINT = hashCanonicalFingerprint(
LEGACY_EMPTY_DYNAMIC_TOOLS_FINGERPRINT,
);
@@ -151,6 +151,48 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
});
});
it("stores large user MCP server fingerprints as bounded hashes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult();
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, {
mcp: {
servers: Object.fromEntries(
Array.from({ length: 120 }, (_, index) => [
`server_${index}`,
{
transport: "stdio",
command: "node",
args: [
`/opt/openclaw/mcp/server-${index}/dist/index.js`,
"--description",
"x".repeat(400),
],
},
]),
),
},
} as unknown as EmbeddedRunAttemptParams["config"]),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.userMcpServersFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(binding?.userMcpServersFingerprint?.length).toBe(71);
expect(binding?.userMcpServersFingerprint).not.toContain("server_119");
});
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");
@@ -485,6 +527,109 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
expect(startParams?.config?.mcp_servers).toBeUndefined();
});
it("starts a new thread when a user MCP Authorization bearer changes without storing the bearer", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
const workspaceDir = path.join(tempDir, "workspace");
const createConfig = (authorization: string) =>
({
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-with-current-bearer");
}
if (method === "thread/resume") {
return threadResumeResult("thread-with-stale-bearer");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, createConfig("Bearer access-token-one")),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
const firstBinding = await readCodexAppServerBinding(sessionFile);
expect(firstBinding?.userMcpServersFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(firstBinding?.userMcpServersFingerprint).not.toContain("access-token-one");
request.mockClear();
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, createConfig("Bearer access-token-two")),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const startParams = request.mock.calls[0]?.[1] as {
config?: { mcp_servers?: Record<string, { http_headers?: Record<string, string> }> };
};
expect(startParams?.config?.mcp_servers?.ducktape?.http_headers?.Authorization).toBe(
"Bearer access-token-two",
);
const secondBinding = await readCodexAppServerBinding(sessionFile);
expect(secondBinding?.userMcpServersFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(secondBinding?.userMcpServersFingerprint).not.toContain("access-token-two");
expect(secondBinding?.userMcpServersFingerprint).not.toBe(
firstBinding?.userMcpServersFingerprint,
);
});
it("omits MCP OAuth servers instead of sending bearers to a remote app-server", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-without-oauth-mcp");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, {
mcp: {
servers: {
ducktape: {
transport: "streamable-http",
url: "https://agents.ducktape.xyz/mcp",
auth: "oauth",
oauth: { authProfileId: "ducktape:mcp" },
},
},
},
} as unknown as EmbeddedRunAttemptParams["config"]),
cwd: workspaceDir,
dynamicTools: [],
appServer: {
...createAppServerOptions(),
connectionClass: "remote",
},
});
const startParams = request.mock.calls[0]?.[1] as {
config?: { mcp_servers?: Record<string, unknown> };
};
expect(startParams?.config?.mcp_servers).toBeUndefined();
});
it("resends user MCP config when resuming a thread with the matching fingerprint", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");