diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift index 08b714f4298fc..04335640f6a24 100644 --- a/apps/swift-ios/Core/HTTP.swift +++ b/apps/swift-ios/Core/HTTP.swift @@ -368,6 +368,8 @@ public actor EnvironmentAPI { ) } + /// `path` is percent-encoded: callers escape dynamic segments themselves so + /// an ID containing `%` or `/` stays one literal segment. func authorized( environment: Environment, path: String, @@ -504,7 +506,7 @@ public actor EnvironmentAPI { headers: [String: String] = [:] ) -> URLRequest { var request = URLRequest( - url: endpoint(environment.httpBaseURL, path: path, queryItems: queryItems) + url: endpoint(environment.httpBaseURL, percentEncodedPath: path, queryItems: queryItems) ) request.httpMethod = method request.httpBody = body @@ -723,3 +725,11 @@ func endpoint(_ baseURL: URL, path: String, queryItems: [URLQueryItem] = []) -> components.fragment = nil return components.url! } + +func endpoint(_ baseURL: URL, percentEncodedPath: String, queryItems: [URLQueryItem] = []) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.percentEncodedPath = percentEncodedPath + components.queryItems = queryItems.isEmpty ? nil : queryItems + components.fragment = nil + return components.url! +} diff --git a/apps/swift-ios/Tests/CoreTests/DualOrchestrationClientTests.swift b/apps/swift-ios/Tests/CoreTests/DualOrchestrationClientTests.swift index 7e3ae42b18f7c..949c1f9eac2ec 100644 --- a/apps/swift-ios/Tests/CoreTests/DualOrchestrationClientTests.swift +++ b/apps/swift-ios/Tests/CoreTests/DualOrchestrationClientTests.swift @@ -94,6 +94,18 @@ final class V2OrchestrationClientTests: XCTestCase { XCTAssertEqual(history.value(forHTTPHeaderField: "x-t3-orchestration-protocol"), "2") } + func testThreadReadEncodesLiteralPercentInThreadIDOnce() async throws { + let transport = try V2ClientHTTPTransport() + let client = makeClient(transport: transport) + _ = try? await client.threadSnapshot(id: "thread:delegated-task:command%3Amcp%3Aissue-17643") + let requests = await transport.requests + let read = try XCTUnwrap(requests.last { $0.url?.path.hasSuffix("/bounded") == true }) + XCTAssertEqual( + URLComponents(url: try XCTUnwrap(read.url), resolvingAgainstBaseURL: false)?.percentEncodedPath, + "/api/orchestration/threads/thread:delegated-task:command%253Amcp%253Aissue-17643/bounded" + ) + } + func testV2ConnectionSendsCanonicalLaunchCommand() async throws { let transport = try V2ClientHTTPTransport() let socket = V2ClientCommandSocket()