Skip to content

Commit 67f4f12

Browse files
xianjianlf2冼健聪SteveSandersonMSCopilot
authored
fix: support bracketed IPv6 runtime URLs (#2200)
* fix: support bracketed IPv6 runtime URLs * fix: support bracketed IPv6 runtime URLs across SDKs * fix: validate bracketed IPv6 runtime hosts * fix: order Python imports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: 冼健聪 <mark.xian@evenrealities.com> Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 08543ee commit 67f4f12

11 files changed

Lines changed: 215 additions & 27 deletions

File tree

dotnet/src/Client.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ public CopilotClient(CopilotClientOptions? options = null)
177177
throw new ArgumentException("GitHubToken and UseLoggedInUser cannot be combined with RuntimeConnection.ForUri (the existing runtime manages its own auth).", nameof(options));
178178
}
179179
var parsed = ParseRuntimeUrl(uri.Url);
180-
_optionsHost = parsed.Host;
180+
_optionsHost = parsed.Host.Trim('[', ']');
181181
_optionsPort = parsed.Port;
182182
break;
183183

@@ -308,7 +308,7 @@ private static RuntimeConnection ResolveDefaultConnection(CopilotClientOptions o
308308
/// <summary>
309309
/// Parses a runtime URL into a URI with host and port.
310310
/// </summary>
311-
/// <param name="url">The URL to parse. Supports formats: "port", "host:port", "http://host:port".</param>
311+
/// <param name="url">The URL to parse. Supports formats: "port", "host:port", "[ipv6]:port", "http://host:port".</param>
312312
private static Uri ParseRuntimeUrl(string url)
313313
{
314314
// If it's just a port number, treat as localhost
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using Xunit;
6+
using System.Reflection;
7+
8+
namespace GitHub.Copilot.Test.Unit;
9+
10+
public class RuntimeConnectionUrlParsingTests
11+
{
12+
[Fact]
13+
public void ForUri_ParsesBracketedIpv6HostPort()
14+
{
15+
var client = new CopilotClient(new CopilotClientOptions
16+
{
17+
Connection = RuntimeConnection.ForUri("[::1]:9000")
18+
});
19+
20+
Assert.Equal("::1", GetPrivateField<string>(client, "_optionsHost"));
21+
Assert.Equal(9000, GetPrivateField<int?>(client, "_optionsPort"));
22+
}
23+
24+
[Fact]
25+
public void ForUri_ParsesHttpIpv6HostPort()
26+
{
27+
var client = new CopilotClient(new CopilotClientOptions
28+
{
29+
Connection = RuntimeConnection.ForUri("http://[::1]:7000")
30+
});
31+
32+
Assert.Equal("::1", GetPrivateField<string>(client, "_optionsHost"));
33+
Assert.Equal(7000, GetPrivateField<int?>(client, "_optionsPort"));
34+
}
35+
36+
private static T? GetPrivateField<T>(object instance, string name)
37+
{
38+
var field = instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
39+
Assert.NotNull(field);
40+
return (T?)field.GetValue(instance);
41+
}
42+
}

go/client.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
"fmt"
3737
"log"
3838
"net"
39+
"net/netip"
3940
"os"
4041
"os/exec"
4142
"path/filepath"
@@ -401,29 +402,45 @@ func setEnvValue(env []string, key string, value string) []string {
401402

402403
// parseCLIURL parses a CLI URL into host and port components.
403404
//
404-
// Supports formats: "host:port", "http://host:port", "https://host:port", or just "port".
405+
// Supports formats: "host:port", "[ipv6]:port", "http://host:port", "https://host:port", or just "port".
405406
// Panics if the URL format is invalid or the port is out of range.
406407
func parseCLIURL(url string) (string, int) {
407408
// Remove protocol if present
408409
cleanURL, _ := strings.CutPrefix(url, "https://")
409410
cleanURL, _ = strings.CutPrefix(cleanURL, "http://")
410411

412+
// Use the standard parser only for the bracketed IPv6 form. Keep the
413+
// existing host:port parsing behavior for all other inputs.
414+
if strings.HasPrefix(cleanURL, "[") {
415+
host, portStr, err := net.SplitHostPort(cleanURL)
416+
if err != nil {
417+
panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))
418+
}
419+
addr, err := netip.ParseAddr(host)
420+
if err != nil || !addr.Is6() {
421+
panic(fmt.Sprintf("Invalid URIConnection format: %s", url))
422+
}
423+
port, err := strconv.Atoi(portStr)
424+
if err != nil || port <= 0 || port > 65535 {
425+
panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))
426+
}
427+
return host, port
428+
}
429+
411430
// Parse host:port or port format
412431
var host string
413432
var portStr string
414433
if before, after, found := strings.Cut(cleanURL, ":"); found {
415434
host = before
416435
portStr = after
417436
} else {
418-
// Only port provided
419-
portStr = before
437+
portStr = cleanURL
420438
}
421439

422440
if host == "" {
423441
host = "localhost"
424442
}
425443

426-
// Validate port
427444
port, err := strconv.Atoi(portStr)
428445
if err != nil || port <= 0 || port > 65535 {
429446
panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))

go/client_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ func TestClient_URLParsing(t *testing.T) {
7878
}
7979
})
8080

81+
t.Run("should parse bracketed IPv6 host:port URL format", func(t *testing.T) {
82+
client := NewClient(&ClientOptions{
83+
Connection: URIConnection{URL: "[::1]:9000"},
84+
})
85+
if client.actualPort != 9000 || client.actualHost != "::1" {
86+
t.Errorf("Expected [::1]:9000, got %s:%d", client.actualHost, client.actualPort)
87+
}
88+
})
89+
8190
t.Run("should parse http://host:port URL format", func(t *testing.T) {
8291
client := NewClient(&ClientOptions{
8392
Connection: URIConnection{URL: "http://localhost:7000"},
@@ -87,6 +96,24 @@ func TestClient_URLParsing(t *testing.T) {
8796
}
8897
})
8998

99+
t.Run("should parse http://[ipv6]:port URL format", func(t *testing.T) {
100+
client := NewClient(&ClientOptions{
101+
Connection: URIConnection{URL: "http://[::1]:7000"},
102+
})
103+
if client.actualPort != 7000 || client.actualHost != "::1" {
104+
t.Errorf("Expected [::1]:7000, got %s:%d", client.actualHost, client.actualPort)
105+
}
106+
})
107+
108+
t.Run("should panic for bracketed non-IPv6 host", func(t *testing.T) {
109+
defer func() {
110+
if r := recover(); r == nil {
111+
t.Error("Expected panic for invalid bracketed host")
112+
}
113+
}()
114+
NewClient(&ClientOptions{Connection: URIConnection{URL: "[not-ipv6]:1234"}})
115+
})
116+
90117
t.Run("should parse https://host:port URL format", func(t *testing.T) {
91118
client := NewClient(&ClientOptions{
92119
Connection: URIConnection{URL: "https://example.com:443"},

java/sdk/src/main/java/com/github/copilot/CopilotClient.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,10 @@ public CopilotClient(CopilotClientOptions options) {
221221
// Parse CliUrl if provided
222222
if (this.options.getCliUrl() != null && !this.options.getCliUrl().isEmpty()) {
223223
URI uri = CliServerManager.parseCliUrl(this.options.getCliUrl());
224-
this.optionsHost = uri.getHost();
224+
String host = uri.getHost();
225+
this.optionsHost = host != null && host.startsWith("[") && host.endsWith("]")
226+
? host.substring(1, host.length() - 1)
227+
: host;
225228
this.optionsPort = uri.getPort();
226229
} else {
227230
this.optionsHost = null;

java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ void parseCliUrlWithHttpsPrefix() {
9393
assertEquals("https://secure.host:443", uri.toString());
9494
}
9595

96+
@Test
97+
void parseCliUrlWithBracketedIpv6() {
98+
URI uri = CliServerManager.parseCliUrl("[::1]:4321");
99+
assertNotNull(uri.getHost());
100+
assertEquals(4321, uri.getPort());
101+
}
102+
96103
@Test
97104
void parseCliUrlWithHostOnly() {
98105
URI uri = CliServerManager.parseCliUrl("copilot.example.com");

java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ void testCliUrlOnlyConstruction() {
179179
client.close();
180180
}
181181

182+
@Test
183+
void testBracketedIpv6CliUrlNormalizesHost() throws Exception {
184+
try (var client = new CopilotClient(new CopilotClientOptions().setCliUrl("[::1]:4321"))) {
185+
Field hostField = CopilotClient.class.getDeclaredField("optionsHost");
186+
hostField.setAccessible(true);
187+
assertEquals("::1", hostField.get(client));
188+
}
189+
}
190+
182191
@Test
183192
void testCliUrlMutualExclusionWithCliPath() {
184193
var options = new CopilotClientOptions().setCliUrl("localhost:3000").setCliPath("/path/to/cli");

nodejs/src/client.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import { spawn, type ChildProcess } from "node:child_process";
1515
import { randomUUID } from "node:crypto";
1616
import { existsSync } from "node:fs";
17-
import { Socket } from "node:net";
17+
import { isIPv6, Socket } from "node:net";
1818
import { dirname, isAbsolute, join, resolve } from "node:path";
1919
import {
2020
createMessageConnection,
@@ -748,22 +748,38 @@ export class CopilotClient {
748748

749749
/**
750750
* Parse CLI URL into host and port
751-
* Supports formats: "host:port", "http://host:port", "https://host:port", or just "port"
751+
* Supports formats: "host:port", "[ipv6]:port", "http://host:port", "https://host:port", or just "port"
752752
*/
753753
private parseCliUrl(url: string): { host: string; port: number } {
754754
// Remove protocol if present
755-
let cleanUrl = url.replace(/^https?:\/\//, "");
755+
const cleanUrl = url.replace(/^https?:\/\//, "");
756756

757757
// Check if it's just a port number
758758
if (/^\d+$/.test(cleanUrl)) {
759759
return { host: "localhost", port: parseInt(cleanUrl, 10) };
760760
}
761761

762+
// Handle the canonical bracketed IPv6 host:port form without changing
763+
// the existing parser behavior for other inputs.
764+
const ipv6Match = cleanUrl.match(/^\[([^\]]+)\]:(\d+)$/);
765+
if (ipv6Match) {
766+
const host = ipv6Match[1];
767+
if (!isIPv6(host)) {
768+
throw new Error(`Invalid cliUrl format: ${url}`);
769+
}
770+
771+
const port = parseInt(ipv6Match[2], 10);
772+
if (isNaN(port) || port <= 0 || port > 65535) {
773+
throw new Error(`Invalid port in cliUrl: ${url}`);
774+
}
775+
return { host, port };
776+
}
777+
762778
// Parse host:port format
763779
const parts = cleanUrl.split(":");
764780
if (parts.length !== 2) {
765781
throw new Error(
766-
`Invalid cliUrl format: ${url}. Expected "host:port", "http://host:port", or "port"`
782+
`Invalid cliUrl format: ${url}. Expected "host:port", "[ipv6]:port", "http://host:port", or "port"`
767783
);
768784
}
769785

nodejs/test/client.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2627,6 +2627,17 @@ describe("CopilotClient", () => {
26272627
expect((client as any).isExternalServer).toBe(true);
26282628
});
26292629

2630+
it("should parse bracketed IPv6 host:port URL format", () => {
2631+
const client = new CopilotClient({
2632+
connection: RuntimeConnection.forUri("[::1]:9000"),
2633+
logLevel: "error",
2634+
});
2635+
2636+
expect((client as any).runtimePort).toBe(9000);
2637+
expect((client as any).actualHost).toBe("::1");
2638+
expect((client as any).isExternalServer).toBe(true);
2639+
});
2640+
26302641
it("should parse http://host:port URL format", () => {
26312642
const client = new CopilotClient({
26322643
connection: RuntimeConnection.forUri("http://localhost:7000"),
@@ -2638,6 +2649,26 @@ describe("CopilotClient", () => {
26382649
expect((client as any).isExternalServer).toBe(true);
26392650
});
26402651

2652+
it("should parse http://[ipv6]:port URL format", () => {
2653+
const client = new CopilotClient({
2654+
connection: RuntimeConnection.forUri("http://[::1]:7000"),
2655+
logLevel: "error",
2656+
});
2657+
2658+
expect((client as any).runtimePort).toBe(7000);
2659+
expect((client as any).actualHost).toBe("::1");
2660+
expect((client as any).isExternalServer).toBe(true);
2661+
});
2662+
2663+
it("should reject a bracketed non-IPv6 host", () => {
2664+
expect(() => {
2665+
new CopilotClient({
2666+
connection: RuntimeConnection.forUri("[not-ipv6]:1234"),
2667+
logLevel: "error",
2668+
});
2669+
}).toThrow(/Invalid cliUrl format/);
2670+
});
2671+
26412672
it("should parse https://host:port URL format", () => {
26422673
const client = new CopilotClient({
26432674
connection: RuntimeConnection.forUri("https://example.com:443"),

python/copilot/client.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import asyncio
1818
import inspect
19+
import ipaddress
1920
import logging
2021
import os
2122
import re
@@ -1838,8 +1839,8 @@ def _parse_cli_url(self, url: str) -> tuple[str, int]:
18381839
"""
18391840
Parse CLI URL into host and port.
18401841
1841-
Supports formats: "host:port", "http://host:port", "https://host:port",
1842-
or just "port".
1842+
Supports formats: "host:port", "[ipv6]:port", "http://host:port",
1843+
"https://host:port", or just "port".
18431844
18441845
Args:
18451846
url: The CLI URL to parse.
@@ -1850,9 +1851,6 @@ def _parse_cli_url(self, url: str) -> tuple[str, int]:
18501851
Raises:
18511852
ValueError: If the URL format is invalid or the port is out of range.
18521853
"""
1853-
import re
1854-
1855-
# Remove protocol if present
18561854
clean_url = re.sub(r"^https?://", "", url)
18571855

18581856
# Check if it's just a port number
@@ -1862,14 +1860,24 @@ def _parse_cli_url(self, url: str) -> tuple[str, int]:
18621860
raise ValueError(f"Invalid port in cli_url: {url}")
18631861
return ("localhost", port)
18641862

1865-
# Parse host:port format
1866-
parts = clean_url.split(":")
1867-
if len(parts) != 2:
1868-
raise ValueError(f"Invalid cli_url format: {url}")
1863+
ipv6_match = re.match(r"^\[([^\]]+)\]:(.*)$", clean_url)
1864+
if ipv6_match:
1865+
host = ipv6_match.group(1)
1866+
port_text = ipv6_match.group(2)
1867+
try:
1868+
ipaddress.IPv6Address(host)
1869+
except ValueError as e:
1870+
raise ValueError(f"Invalid cli_url format: {url}") from e
1871+
else:
1872+
# Parse host:port format
1873+
parts = clean_url.split(":")
1874+
if len(parts) != 2:
1875+
raise ValueError(f"Invalid cli_url format: {url}")
1876+
host = parts[0] if parts[0] else "localhost"
1877+
port_text = parts[1]
18691878

1870-
host = parts[0] if parts[0] else "localhost"
18711879
try:
1872-
port = int(parts[1])
1880+
port = int(port_text)
18731881
except ValueError as e:
18741882
raise ValueError(f"Invalid port in cli_url: {url}") from e
18751883

@@ -4631,22 +4639,22 @@ async def _connect_via_tcp(self) -> None:
46314639
if not self._runtime_port:
46324640
raise RuntimeError("Server port not available")
46334641

4634-
# Create a TCP socket connection with timeout
4642+
# Create a TCP socket connection with timeout. create_connection resolves
4643+
# both IPv4 and IPv6 addresses instead of forcing AF_INET.
46354644
import socket
46364645

46374646
# Connection timeout constant
46384647
TCP_CONNECTION_TIMEOUT = 10 # seconds
46394648

4640-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
4641-
sock.settimeout(TCP_CONNECTION_TIMEOUT)
4642-
46434649
try:
46444650
tcp_connect_start = time.perf_counter()
46454651
logger.info(
46464652
"CopilotClient._connect_via_tcp connecting to CLI server",
46474653
extra={"host": self._actual_host, "port": self._runtime_port},
46484654
)
4649-
sock.connect((self._actual_host, self._runtime_port))
4655+
sock = socket.create_connection(
4656+
(self._actual_host, self._runtime_port), timeout=TCP_CONNECTION_TIMEOUT
4657+
)
46504658
sock.settimeout(None) # Remove timeout after connection
46514659
log_timing(
46524660
logger,

0 commit comments

Comments
 (0)