mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(codex): bound app-server binding fingerprints
(cherry picked from commit 54995af3ae)
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user