diff --git a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts index c045bc57e92f..e9540b1ee782 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts @@ -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[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"); diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 34c097bb1773..29bda11386fa 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -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, ); 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 0d350d7b0243..b1a3617f8034 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 @@ -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 }> }; + }; + 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 }; + }; + 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");