diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index a049a6b14863..1a487e9b9d67 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -40,7 +40,10 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( group: "orchestration", method: "GET", url: (httpBaseUrl) => - environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}/bounded`), + environmentEndpointUrl( + httpBaseUrl, + `/api/orchestration/threads/${encodeURIComponent(input.threadId)}/bounded`, + ), timeoutMs: input.timeoutMs ?? DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS, request: ({ client, headers }) => client.threadBoundedSnapshot({ diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 61504668427e..0a2df6f936a5 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -4,6 +4,7 @@ import { ORCHESTRATION_PROTOCOL_HEADER, ORCHESTRATION_PROTOCOL_VERSION_TEXT, ProjectId, + ThreadId, type AuthSessionState, type OrchestrationV2ShellSnapshot, OrchestrationV2ThreadDetailSnapshot, @@ -307,6 +308,50 @@ describe("authenticated environment HTTP requests", () => { }), ); + // MCP-created thread ids contain ":", which the request path percent-encodes. + // The DPoP proof must sign the URL that is actually sent, or the environment + // rejects it as a URL mismatch. + const MCP_THREAD_ID = ThreadId.make("mcp:3534bc83-1c17-4a1e-9118-601c2766d355"); + const MCP_THREAD_LOADERS: ReadonlyArray< + Pick<(typeof LOADERS)[number], "name" | "response" | "load"> + > = [ + { + name: "thread snapshot", + response: encodeThreadSnapshot(THREAD), + load: (input: HttpInput) => + ThreadSnapshotLoader.fetchEnvironmentThreadSnapshot({ ...input, threadId: MCP_THREAD_ID }), + }, + { + name: "bounded thread snapshot", + response: encodeBoundedSnapshot(BOUNDED_THREAD), + load: (input: HttpInput) => + fetchEnvironmentBoundedThreadSnapshot({ ...input, threadId: MCP_THREAD_ID }), + }, + { + name: "older thread history", + response: THREAD_HISTORY, + load: (input: HttpInput) => + fetchEnvironmentThreadHistoryPage({ + ...input, + threadId: MCP_THREAD_ID, + cursor: "older-page", + }), + }, + ]; + it.effect.each(MCP_THREAD_LOADERS)( + "signs the sent URL for a $name of a thread id that needs encoding", + (loader) => + Effect.gen(function* () { + const harness = makeHarness(() => Response.json(loader.response)); + yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer)); + + const sent = new URL(harness.calls[0]!.url); + expect(sent.pathname).toContain("/mcp%3A3534bc83-"); + sent.search = ""; + expect(harness.proofs.map((proof) => proof.url)).toEqual([sent.toString()]); + }), + ); + it.effect("retries a rejected diff once with a new token, endpoint, and proof", () => Effect.gen(function* () { const harness = makeHarness((requestNumber) => diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index b83cb6f20c35..316e0567305a 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -30,7 +30,10 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( group: "orchestration", method: "GET", url: (httpBaseUrl) => - environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}/history`), + environmentEndpointUrl( + httpBaseUrl, + `/api/orchestration/threads/${encodeURIComponent(input.threadId)}/history`, + ), timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_HISTORY_TIMEOUT_MS, request: ({ client, headers }) => client.threadHistoryPage({ diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 8b31e4c60687..905d8872d589 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -66,7 +66,10 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( group: "orchestration", method: "GET", url: (httpBaseUrl) => - environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}`), + environmentEndpointUrl( + httpBaseUrl, + `/api/orchestration/threads/${encodeURIComponent(input.threadId)}`, + ), timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, request: ({ client, headers }) => client.threadSnapshot({