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";
88
99import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
1010import type { PreparedConnection } from "../connection/model.ts";
11import { environmentEndpointUrl } from "../environment/endpoint.ts";
1211import * as ManagedRelay from "../relay/managedRelay.ts";
1312import {
1413 executeAuthenticatedEnvironmentHttpRequest,
......@@ -35,20 +34,19 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn(
3534 >;
3635 readonly timeoutMs?: number;
3736}) {
37 const endpoint = {
38 params: { threadId: input.threadId },
39 query: { compactTurnItems: "1" },
40 };
3841 return yield* executeAuthenticatedEnvironmentHttpRequest({
3942 ...input,
4043 group: "orchestration",
4144 method: "GET",
42 url: (httpBaseUrl) =>
43 environmentEndpointUrl(
44 httpBaseUrl,
45 `/api/orchestration/threads/${encodeURIComponent(input.threadId)}/bounded`,
46 ),
45 url: (urls) => urls.threadBoundedSnapshot(endpoint),
4746 timeoutMs: input.timeoutMs ?? DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS,
4847 request: ({ client, headers }) =>
4948 client.threadBoundedSnapshot({
50 params: { threadId: input.threadId },
51 query: { compactTurnItems: "1" },
49 ...endpoint,
5250 headers: withOrchestrationProtocolHeader(headers),
5351 }),
5452 }).pipe(
packages/client-runtime/src/state/deviceHubAccess.ts+1-1
......@@ -46,7 +46,7 @@ export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDevi
4646 remoteAuthorization,
4747 group: "auth",
4848 method: "POST",
49 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/websocket-ticket"),
49 url: (urls) => urls.webSocketTicket(),
5050 timeoutMs: TICKET_TIMEOUT_MS,
5151 request: ({ client, headers }) => client.webSocketTicket({ headers }),
5252 });
packages/client-runtime/src/state/environmentHttpAuth.test.ts+6-10
......@@ -294,12 +294,9 @@ describe("authenticated environment HTTP requests", () => {
294294 );
295295 }
296296 expect(harness.authorizations).toEqual([{ expectedEnvironmentId: TARGET.environmentId }]);
297 // The proof signs exactly the URL sent; signers drop the query for `htu`.
297298 expect(harness.proofs).toEqual([
298 {
299 method: loader.method,
300 url: `${CURRENT_ORIGIN}${loader.path}`,
301 accessToken: "current-token",
302 },
299 { method: loader.method, url: call.url, accessToken: "current-token" },
303300 ]);
304301 if (loader.name === "older thread history") {
305302 expect(url.searchParams.get("cursor")).toBe("older-page");
......@@ -345,10 +342,9 @@ describe("authenticated environment HTTP requests", () => {
345342 const harness = makeHarness(() => Response.json(loader.response));
346343 yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer));
347344
348 const sent = new URL(harness.calls[0]!.url);
349 expect(sent.pathname).toContain("/mcp%3A3534bc83-");
350 sent.search = "";
351 expect(harness.proofs.map((proof) => proof.url)).toEqual([sent.toString()]);
345 const sent = harness.calls[0]!.url;
346 expect(new URL(sent).pathname).toContain("/mcp%3A3534bc83-");
347 expect(harness.proofs.map((proof) => proof.url)).toEqual([sent]);
352348 }),
353349 );
354350
......@@ -408,7 +404,7 @@ describe("authenticated environment HTTP requests", () => {
408404 );
409405 expect(harness.proofs[1]).toEqual({
410406 method: "GET",
411 url: `${RENEWED_ORIGIN}${loader.path}`,
407 url: retried.url,
412408 accessToken: "renewed-token",
413409 });
414410 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";
1414import {
1515 executeEnvironmentHttpRequest,
1616 makeEnvironmentHttpApiGroupClient,
17 makeEnvironmentHttpApiUrlBuilder,
1718 RemoteEnvironmentAuthFetchError,
1819 RemoteEnvironmentAuthTimeoutError,
1920 type RemoteEnvironmentRequestError,
......@@ -131,7 +132,12 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <
131132 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
132133 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
133134 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;
135141 readonly timeoutMs: number;
136142 readonly group: Group;
137143 readonly request: (input: {
......@@ -146,6 +152,8 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <
146152 Effect.Services<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>> | R
147153> {
148154 let httpBaseUrl = input.prepared.httpBaseUrl;
155 const requestUrlFor = (baseUrl: string) =>
156 input.url(makeEnvironmentHttpApiUrlBuilder(baseUrl)[input.group]);
149157 return yield* Effect.gen(function* () {
150158 let rejectedAccessToken: string | undefined;
151159 for (;;) {
......@@ -179,7 +187,7 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <
179187 authorization = current.httpAuthorization;
180188 }
181189
182 const requestUrl = input.url(httpBaseUrl);
190 const requestUrl = requestUrlFor(httpBaseUrl);
183191 const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group);
184192 const headers = yield* buildEnvironmentAuthHeaders(
185193 authorization,
......@@ -225,7 +233,9 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* <
225233 Effect.timeoutOrElse({
226234 duration: input.timeoutMs,
227235 orElse: () =>
228 Effect.fail(new RemoteEnvironmentAuthTimeoutError(input.url(httpBaseUrl), input.timeoutMs)),
236 Effect.fail(
237 new RemoteEnvironmentAuthTimeoutError(requestUrlFor(httpBaseUrl), input.timeoutMs),
238 ),
229239 }),
230240 );
231241});
packages/client-runtime/src/state/pullRequestDiffHttp.ts+2-5
......@@ -13,10 +13,7 @@ import { HttpClient } from "effect/http";
1313import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
1414import type { PreparedConnection } from "../connection/model.ts";
1515import * as ManagedRelay from "../relay/managedRelay.ts";
16import {
17 makeEnvironmentHttpApiUrlBuilder,
18 type RemoteEnvironmentRequestError,
19} from "../rpc/http.ts";
16import { type RemoteEnvironmentRequestError } from "../rpc/http.ts";
2017import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
2118
2219const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000;
......@@ -54,7 +51,7 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn(
5451 ...input,
5552 group: "pullRequests",
5653 method: "POST",
57 url: (httpBaseUrl) => makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).pullRequests.diff(),
54 url: (urls) => urls.diff(),
5855 timeoutMs: input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS,
5956 request: ({ client, headers }) => client.diff({ payload: input.diff, headers }),
6057 }).pipe(
packages/client-runtime/src/state/session.ts+1-2
......@@ -11,7 +11,6 @@ import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
1111import * as EnvironmentRegistry from "../connection/registry.ts";
1212import type { PreparedConnection } from "../connection/model.ts";
1313import * as EnvironmentSupervisor from "../connection/supervisor.ts";
14import { environmentEndpointUrl } from "../environment/endpoint.ts";
1514import * as ManagedRelay from "../relay/managedRelay.ts";
1615import { safeErrorLogAttributes } from "../errors/safeLog.ts";
1716import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
......@@ -59,7 +58,7 @@ export const fetchEnvironmentSessionState = Effect.fn(
5958 ...input,
6059 group: "auth",
6160 method: "GET",
62 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/session"),
61 url: (urls) => urls.session(),
6362 timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
6463 request: ({ client, headers }) => client.session({ headers }),
6564 // 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
99
1010import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
1111import type { PreparedConnection } from "../connection/model.ts";
12import { environmentEndpointUrl } from "../environment/endpoint.ts";
1312import * as ManagedRelay from "../relay/managedRelay.ts";
1413import {
1514 executeAuthenticatedEnvironmentHttpRequest,
......@@ -48,7 +47,7 @@ export const fetchEnvironmentShellSnapshot = Effect.fn(
4847 ...input,
4948 group: "orchestration",
5049 method: "GET",
51 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"),
50 url: (urls) => urls.shellSnapshot(),
5251 timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,
5352 // The body is decoded here so each thread's pull request links can decode after its
5453 // 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";
44
55import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
66import type { PreparedConnection } from "../connection/model.ts";
7import { environmentEndpointUrl } from "../environment/endpoint.ts";
87import * as ManagedRelay from "../relay/managedRelay.ts";
98import {
109 executeAuthenticatedEnvironmentHttpRequest,
......@@ -25,25 +24,21 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn(
2524 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
2625 readonly timeoutMs?: number;
2726}) {
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 };
2835 return yield* executeAuthenticatedEnvironmentHttpRequest({
2936 ...input,
3037 group: "orchestration",
3138 method: "GET",
32 url: (httpBaseUrl) =>
33 environmentEndpointUrl(
34 httpBaseUrl,
35 `/api/orchestration/threads/${encodeURIComponent(input.threadId)}/history`,
36 ),
39 url: (urls) => urls.threadHistoryPage(endpoint),
3740 timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_HISTORY_TIMEOUT_MS,
3841 request: ({ client, headers }) =>
39 client.threadHistoryPage({
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 }),
42 client.threadHistoryPage({ ...endpoint, headers: withOrchestrationProtocolHeader(headers) }),
4843 });
4944});
packages/client-runtime/src/state/threadSnapshotHttp.ts+1-6
......@@ -5,7 +5,6 @@ import * as Option from "effect/Option";
55
66import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
77import type { PreparedConnection } from "../connection/model.ts";
8import { environmentEndpointUrl } from "../environment/endpoint.ts";
98import * as ManagedRelay from "../relay/managedRelay.ts";
109import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
1110import {
......@@ -65,11 +64,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
6564 ...input,
6665 group: "orchestration",
6766 method: "GET",
68 url: (httpBaseUrl) =>
69 environmentEndpointUrl(
70 httpBaseUrl,
71 `/api/orchestration/threads/${encodeURIComponent(input.threadId)}`,
72 ),
67 url: (urls) => urls.threadSnapshot({ params: { threadId: input.threadId } }),
7368 timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS,
7469 request: ({ client, headers }) =>
7570 client.threadSnapshot({