authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 14:29:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 14:29:04-07:00
log0e7abea7c98060db537fe81f31ce5ba7c6d376d0
tree63dfd3d797acab2226d57e8e1b253394893c906d
parenta1db449fe4fecafcb473340771163e791b7fb3d6
signature Signed by PGP key B5690EEEBB952194

refactor(client): sign relay request URLs built from the HttpApi contract (#17602)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

9 files changed, 41 insertions(+), 52 deletions(-)

packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts+6-8
...@@ -8,7 +8,6 @@ import { HttpClient } from "effect/http";...@@ -8,7 +8,6 @@ import { HttpClient } from "effect/http";
88
9import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";9import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
10import type { PreparedConnection } from "../connection/model.ts";10import type { PreparedConnection } from "../connection/model.ts";
11import { environmentEndpointUrl } from "../environment/endpoint.ts";
12import * as ManagedRelay from "../relay/managedRelay.ts";11import * as ManagedRelay from "../relay/managedRelay.ts";
13import {12import {
14 executeAuthenticatedEnvironmentHttpRequest,13 executeAuthenticatedEnvironmentHttpRequest,
...@@ -35,20 +34,19 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn(...@@ -35,20 +34,19 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn(
35 >;34 >;
36 readonly timeoutMs?: number;35 readonly timeoutMs?: number;
37}) {36}) {
37 const endpoint = {
38 params: { threadId: input.threadId },
39 query: { compactTurnItems: "1" },
40 };
38 return yield* executeAuthenticatedEnvironmentHttpRequest({41 return yield* executeAuthenticatedEnvironmentHttpRequest({
39 ...input,42 ...input,
40 group: "orchestration",43 group: "orchestration",
41 method: "GET",44 method: "GET",
42 url: (httpBaseUrl) =>45 url: (urls) => urls.threadBoundedSnapshot(endpoint),
43 environmentEndpointUrl(
44 httpBaseUrl,
45 `/api/orchestration/threads/${encodeURIComponent(input.threadId)}/bounded`,
46 ),
47 timeoutMs: input.timeoutMs ?? DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS,46 timeoutMs: input.timeoutMs ?? DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS,
48 request: ({ client, headers }) =>47 request: ({ client, headers }) =>
49 client.threadBoundedSnapshot({48 client.threadBoundedSnapshot({
50 params: { threadId: input.threadId },49 ...endpoint,
51 query: { compactTurnItems: "1" },
52 headers: withOrchestrationProtocolHeader(headers),50 headers: withOrchestrationProtocolHeader(headers),
53 }),51 }),
54 }).pipe(52 }).pipe(
packages/client-runtime/src/state/deviceHubAccess.ts+1-1
...@@ -46,7 +46,7 @@ export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDevi...@@ -46,7 +46,7 @@ export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDevi
46 remoteAuthorization,46 remoteAuthorization,
47 group: "auth",47 group: "auth",
48 method: "POST",48 method: "POST",
49 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/websocket-ticket"),49 url: (urls) => urls.webSocketTicket(),
50 timeoutMs: TICKET_TIMEOUT_MS,50 timeoutMs: TICKET_TIMEOUT_MS,
51 request: ({ client, headers }) => client.webSocketTicket({ headers }),51 request: ({ client, headers }) => client.webSocketTicket({ headers }),
52 });52 });
packages/client-runtime/src/state/environmentHttpAuth.test.ts+6-10
...@@ -294,12 +294,9 @@ describe("authenticated environment HTTP requests", () => {...@@ -294,12 +294,9 @@ describe("authenticated environment HTTP requests", () => {
294 );294 );
295 }295 }
296 expect(harness.authorizations).toEqual([{ expectedEnvironmentId: TARGET.environmentId }]);296 expect(harness.authorizations).toEqual([{ expectedEnvironmentId: TARGET.environmentId }]);
297 // The proof signs exactly the URL sent; signers drop the query for `htu`.
297 expect(harness.proofs).toEqual([298 expect(harness.proofs).toEqual([
298 {299 { method: loader.method, url: call.url, accessToken: "current-token" },
299 method: loader.method,
300 url: `${CURRENT_ORIGIN}${loader.path}`,
301 accessToken: "current-token",
302 },
303 ]);300 ]);
304 if (loader.name === "older thread history") {301 if (loader.name === "older thread history") {
305 expect(url.searchParams.get("cursor")).toBe("older-page");302 expect(url.searchParams.get("cursor")).toBe("older-page");
...@@ -345,10 +342,9 @@ describe("authenticated environment HTTP requests", () => {...@@ -345,10 +342,9 @@ describe("authenticated environment HTTP requests", () => {
345 const harness = makeHarness(() => Response.json(loader.response));342 const harness = makeHarness(() => Response.json(loader.response));
346 yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer));343 yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer));
347344
348 const sent = new URL(harness.calls[0]!.url);345 const sent = harness.calls[0]!.url;
349 expect(sent.pathname).toContain("/mcp%3A3534bc83-");346 expect(new URL(sent).pathname).toContain("/mcp%3A3534bc83-");
350 sent.search = "";347 expect(harness.proofs.map((proof) => proof.url)).toEqual([sent]);
351 expect(harness.proofs.map((proof) => proof.url)).toEqual([sent.toString()]);
352 }),348 }),
353 );349 );
354350
...@@ -408,7 +404,7 @@ describe("authenticated environment HTTP requests", () => {...@@ -408,7 +404,7 @@ describe("authenticated environment HTTP requests", () => {
408 );404 );
409 expect(harness.proofs[1]).toEqual({405 expect(harness.proofs[1]).toEqual({
410 method: "GET",406 method: "GET",
411 url: `${RENEWED_ORIGIN}${loader.path}`,407 url: retried.url,
412 accessToken: "renewed-token",408 accessToken: "renewed-token",
413 });409 });
414 if (loader.name === "older thread history") {410 if (loader.name === "older thread history") {
packages/client-runtime/src/state/environmentHttpAuth.ts+13-3
...@@ -14,6 +14,7 @@ import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";...@@ -14,6 +14,7 @@ import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
14import {14import {
15 executeEnvironmentHttpRequest,15 executeEnvironmentHttpRequest,
16 makeEnvironmentHttpApiGroupClient,16 makeEnvironmentHttpApiGroupClient,
17 makeEnvironmentHttpApiUrlBuilder,
17 RemoteEnvironmentAuthFetchError,18 RemoteEnvironmentAuthFetchError,
18 RemoteEnvironmentAuthTimeoutError,19 RemoteEnvironmentAuthTimeoutError,
19 type RemoteEnvironmentRequestError,20 type RemoteEnvironmentRequestError,
...@@ -131,7 +132,12 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <...@@ -131,7 +132,12 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <
131 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;132 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
132 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;133 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
133 readonly method: HttpMethod.HttpMethod;134 readonly method: HttpMethod.HttpMethod;
134 readonly url: (httpBaseUrl: string) => string;135 /**
136 * The URL to sign, from the group's contract URL builder. It encodes path
137 * params exactly like the request client, so the DPoP proof matches the
138 * URL actually sent.
139 */
140 readonly url: (urls: ReturnType<typeof makeEnvironmentHttpApiUrlBuilder>[Group]) => string;
135 readonly timeoutMs: number;141 readonly timeoutMs: number;
136 readonly group: Group;142 readonly group: Group;
137 readonly request: (input: {143 readonly request: (input: {
...@@ -146,6 +152,8 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <...@@ -146,6 +152,8 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <
146 Effect.Services<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>> | R152 Effect.Services<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>> | R
147> {153> {
148 let httpBaseUrl = input.prepared.httpBaseUrl;154 let httpBaseUrl = input.prepared.httpBaseUrl;
155 const requestUrlFor = (baseUrl: string) =>
156 input.url(makeEnvironmentHttpApiUrlBuilder(baseUrl)[input.group]);
149 return yield* Effect.gen(function* () {157 return yield* Effect.gen(function* () {
150 let rejectedAccessToken: string | undefined;158 let rejectedAccessToken: string | undefined;
151 for (;;) {159 for (;;) {
...@@ -179,7 +187,7 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <...@@ -179,7 +187,7 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <
179 authorization = current.httpAuthorization;187 authorization = current.httpAuthorization;
180 }188 }
181189
182 const requestUrl = input.url(httpBaseUrl);190 const requestUrl = requestUrlFor(httpBaseUrl);
183 const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group);191 const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group);
184 const headers = yield* buildEnvironmentAuthHeaders(192 const headers = yield* buildEnvironmentAuthHeaders(
185 authorization,193 authorization,
...@@ -225,7 +233,9 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <...@@ -225,7 +233,9 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <
225 Effect.timeoutOrElse({233 Effect.timeoutOrElse({
226 duration: input.timeoutMs,234 duration: input.timeoutMs,
227 orElse: () =>235 orElse: () =>
228 Effect.fail(new RemoteEnvironmentAuthTimeoutError(input.url(httpBaseUrl), input.timeoutMs)),236 Effect.fail(
237 new RemoteEnvironmentAuthTimeoutError(requestUrlFor(httpBaseUrl), input.timeoutMs),
238 ),
229 }),239 }),
230 );240 );
231});241});
packages/client-runtime/src/state/pullRequestDiffHttp.ts+2-5
...@@ -13,10 +13,7 @@ import { HttpClient } from "effect/http";...@@ -13,10 +13,7 @@ import { HttpClient } from "effect/http";
13import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";13import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
14import type { PreparedConnection } from "../connection/model.ts";14import type { PreparedConnection } from "../connection/model.ts";
15import * as ManagedRelay from "../relay/managedRelay.ts";15import * as ManagedRelay from "../relay/managedRelay.ts";
16import {16import { type RemoteEnvironmentRequestError } from "../rpc/http.ts";
17 makeEnvironmentHttpApiUrlBuilder,
18 type RemoteEnvironmentRequestError,
19} from "../rpc/http.ts";
20import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";17import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
2118
22const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000;19const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000;
...@@ -54,7 +51,7 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn(...@@ -54,7 +51,7 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn(
54 ...input,51 ...input,
55 group: "pullRequests",52 group: "pullRequests",
56 method: "POST",53 method: "POST",
57 url: (httpBaseUrl) => makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).pullRequests.diff(),54 url: (urls) => urls.diff(),
58 timeoutMs: input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS,55 timeoutMs: input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS,
59 request: ({ client, headers }) => client.diff({ payload: input.diff, headers }),56 request: ({ client, headers }) => client.diff({ payload: input.diff, headers }),
60 }).pipe(57 }).pipe(
packages/client-runtime/src/state/session.ts+1-2
...@@ -11,7 +11,6 @@ import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";...@@ -11,7 +11,6 @@ import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
11import * as EnvironmentRegistry from "../connection/registry.ts";11import * as EnvironmentRegistry from "../connection/registry.ts";
12import type { PreparedConnection } from "../connection/model.ts";12import type { PreparedConnection } from "../connection/model.ts";
13import * as EnvironmentSupervisor from "../connection/supervisor.ts";13import * as EnvironmentSupervisor from "../connection/supervisor.ts";
14import { environmentEndpointUrl } from "../environment/endpoint.ts";
15import * as ManagedRelay from "../relay/managedRelay.ts";14import * as ManagedRelay from "../relay/managedRelay.ts";
16import { safeErrorLogAttributes } from "../errors/safeLog.ts";15import { safeErrorLogAttributes } from "../errors/safeLog.ts";
17import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";16import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
...@@ -59,7 +58,7 @@ export const fetchEnvironmentSessionState = Effect.fn(...@@ -59,7 +58,7 @@ export const fetchEnvironmentSessionState = Effect.fn(
59 ...input,58 ...input,
60 group: "auth",59 group: "auth",
61 method: "GET",60 method: "GET",
62 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/session"),61 url: (urls) => urls.session(),
63 timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,62 timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
64 request: ({ client, headers }) => client.session({ headers }),63 request: ({ client, headers }) => client.session({ headers }),
65 // This endpoint returns 200 with authenticated:false for expired credentials.64 // This endpoint returns 200 with authenticated:false for expired credentials.
packages/client-runtime/src/state/shellSnapshotHttp.ts+1-2
...@@ -9,7 +9,6 @@ import { HttpClient, HttpClientError, type HttpClientResponse } from "effect/htt...@@ -9,7 +9,6 @@ import { HttpClient, HttpClientError, type HttpClientResponse } from "effect/htt
99
10import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";10import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
11import type { PreparedConnection } from "../connection/model.ts";11import type { PreparedConnection } from "../connection/model.ts";
12import { environmentEndpointUrl } from "../environment/endpoint.ts";
13import * as ManagedRelay from "../relay/managedRelay.ts";12import * as ManagedRelay from "../relay/managedRelay.ts";
14import {13import {
15 executeAuthenticatedEnvironmentHttpRequest,14 executeAuthenticatedEnvironmentHttpRequest,
...@@ -48,7 +47,7 @@ export const fetchEnvironmentShellSnapshot = Effect.fn(...@@ -48,7 +47,7 @@ export const fetchEnvironmentShellSnapshot = Effect.fn(
48 ...input,47 ...input,
49 group: "orchestration",48 group: "orchestration",
50 method: "GET",49 method: "GET",
51 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"),50 url: (urls) => urls.shellSnapshot(),
52 timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,51 timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,
53 // The body is decoded here so each thread's pull request links can decode after its52 // The body is decoded here so each thread's pull request links can decode after its
54 // rows. Every declared error is an `EnvironmentHttpCommonError`, so decoding one keeps53 // rows. Every declared error is an `EnvironmentHttpCommonError`, so decoding one keeps
packages/client-runtime/src/state/threadHistoryHttp.ts+10-15
...@@ -4,7 +4,6 @@ import * as Option from "effect/Option";...@@ -4,7 +4,6 @@ import * as Option from "effect/Option";
44
5import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts";5import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
6import type { PreparedConnection } from "../connection/model.ts";6import type { PreparedConnection } from "../connection/model.ts";
7import { environmentEndpointUrl } from "../environment/endpoint.ts";
8import * as ManagedRelay from "../relay/managedRelay.ts";7import * as ManagedRelay from "../relay/managedRelay.ts";
9import {8import {
10 executeAuthenticatedEnvironmentHttpRequest,9 executeAuthenticatedEnvironmentHttpRequest,
...@@ -25,25 +24,21 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn(...@@ -25,25 +24,21 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn(
25 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;24 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
26 readonly timeoutMs?: number;25 readonly timeoutMs?: number;
27}) {26}) {
27 const endpoint = {
28 params: { threadId: input.threadId },
29 query: {
30 cursor: input.cursor,
31 ...(input.view === undefined ? {} : { view: input.view }),
32 ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }),
33 },
34 };
28 return yield* executeAuthenticatedEnvironmentHttpRequest({35 return yield* executeAuthenticatedEnvironmentHttpRequest({
29 ...input,36 ...input,
30 group: "orchestration",37 group: "orchestration",
31 method: "GET",38 method: "GET",
32 url: (httpBaseUrl) =>39 url: (urls) => urls.threadHistoryPage(endpoint),
33 environmentEndpointUrl(
34 httpBaseUrl,
35 `/api/orchestration/threads/${encodeURIComponent(input.threadId)}/history`,
36 ),
37 timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_HISTORY_TIMEOUT_MS,40 timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_HISTORY_TIMEOUT_MS,
38 request: ({ client, headers }) =>41 request: ({ client, headers }) =>
39 client.threadHistoryPage({42 client.threadHistoryPage({ ...endpoint, headers: withOrchestrationProtocolHeader(headers) }),
40 params: { threadId: input.threadId },
41 query: {
42 cursor: input.cursor,
43 ...(input.view === undefined ? {} : { view: input.view }),
44 ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }),
45 },
46 headers: withOrchestrationProtocolHeader(headers),
47 }),
48 });43 });
49});44});
packages/client-runtime/src/state/threadSnapshotHttp.ts+1-6
...@@ -5,7 +5,6 @@ import * as Option from "effect/Option";...@@ -5,7 +5,6 @@ import * as Option from "effect/Option";
55
6import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";6import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
7import type { PreparedConnection } from "../connection/model.ts";7import type { PreparedConnection } from "../connection/model.ts";
8import { environmentEndpointUrl } from "../environment/endpoint.ts";
9import * as ManagedRelay from "../relay/managedRelay.ts";8import * as ManagedRelay from "../relay/managedRelay.ts";
10import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";9import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
11import {10import {
...@@ -65,11 +64,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(...@@ -65,11 +64,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
65 ...input,64 ...input,
66 group: "orchestration",65 group: "orchestration",
67 method: "GET",66 method: "GET",
68 url: (httpBaseUrl) =>67 url: (urls) => urls.threadSnapshot({ params: { threadId: input.threadId } }),
69 environmentEndpointUrl(
70 httpBaseUrl,
71 `/api/orchestration/threads/${encodeURIComponent(input.threadId)}`,
72 ),
73 timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS,68 timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS,
74 request: ({ client, headers }) =>69 request: ({ client, headers }) =>
75 client.threadSnapshot({70 client.threadSnapshot({