diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs
index 228b11560..16c56f63f 100644
--- a/dotnet/src/Client.cs
+++ b/dotnet/src/Client.cs
@@ -167,7 +167,7 @@ public CopilotClient(CopilotClientOptions? options = null)
throw new ArgumentException("GitHubToken and UseLoggedInUser cannot be combined with RuntimeConnection.ForUri (the existing runtime manages its own auth).", nameof(options));
}
var parsed = ParseRuntimeUrl(uri.Url);
- _optionsHost = parsed.Host;
+ _optionsHost = parsed.Host.Trim('[', ']');
_optionsPort = parsed.Port;
break;
@@ -298,7 +298,7 @@ private static RuntimeConnection ResolveDefaultConnection(CopilotClientOptions o
///
/// Parses a runtime URL into a URI with host and port.
///
- /// The URL to parse. Supports formats: "port", "host:port", "http://host:port".
+ /// The URL to parse. Supports formats: "port", "host:port", "[ipv6]:port", "http://host:port".
private static Uri ParseRuntimeUrl(string url)
{
// If it's just a port number, treat as localhost
diff --git a/dotnet/test/Unit/RuntimeConnectionUrlParsingTests.cs b/dotnet/test/Unit/RuntimeConnectionUrlParsingTests.cs
new file mode 100644
index 000000000..1b16456b0
--- /dev/null
+++ b/dotnet/test/Unit/RuntimeConnectionUrlParsingTests.cs
@@ -0,0 +1,42 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+using Xunit;
+using System.Reflection;
+
+namespace GitHub.Copilot.Test.Unit;
+
+public class RuntimeConnectionUrlParsingTests
+{
+ [Fact]
+ public void ForUri_ParsesBracketedIpv6HostPort()
+ {
+ var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForUri("[::1]:9000")
+ });
+
+ Assert.Equal("::1", GetPrivateField(client, "_optionsHost"));
+ Assert.Equal(9000, GetPrivateField(client, "_optionsPort"));
+ }
+
+ [Fact]
+ public void ForUri_ParsesHttpIpv6HostPort()
+ {
+ var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForUri("http://[::1]:7000")
+ });
+
+ Assert.Equal("::1", GetPrivateField(client, "_optionsHost"));
+ Assert.Equal(7000, GetPrivateField(client, "_optionsPort"));
+ }
+
+ private static T? GetPrivateField(object instance, string name)
+ {
+ var field = instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(field);
+ return (T?)field.GetValue(instance);
+ }
+}
diff --git a/go/client.go b/go/client.go
index d2c43c26b..f6d746bc4 100644
--- a/go/client.go
+++ b/go/client.go
@@ -36,6 +36,7 @@ import (
"fmt"
"log"
"net"
+ "net/netip"
"os"
"os/exec"
"regexp"
@@ -372,13 +373,31 @@ func setEnvValue(env []string, key string, value string) []string {
// parseCLIURL parses a CLI URL into host and port components.
//
-// Supports formats: "host:port", "http://host:port", "https://host:port", or just "port".
+// Supports formats: "host:port", "[ipv6]:port", "http://host:port", "https://host:port", or just "port".
// Panics if the URL format is invalid or the port is out of range.
func parseCLIURL(url string) (string, int) {
// Remove protocol if present
cleanURL, _ := strings.CutPrefix(url, "https://")
cleanURL, _ = strings.CutPrefix(cleanURL, "http://")
+ // Use the standard parser only for the bracketed IPv6 form. Keep the
+ // existing host:port parsing behavior for all other inputs.
+ if strings.HasPrefix(cleanURL, "[") {
+ host, portStr, err := net.SplitHostPort(cleanURL)
+ if err != nil {
+ panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))
+ }
+ addr, err := netip.ParseAddr(host)
+ if err != nil || !addr.Is6() {
+ panic(fmt.Sprintf("Invalid URIConnection format: %s", url))
+ }
+ port, err := strconv.Atoi(portStr)
+ if err != nil || port <= 0 || port > 65535 {
+ panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))
+ }
+ return host, port
+ }
+
// Parse host:port or port format
var host string
var portStr string
@@ -386,15 +405,13 @@ func parseCLIURL(url string) (string, int) {
host = before
portStr = after
} else {
- // Only port provided
- portStr = before
+ portStr = cleanURL
}
if host == "" {
host = "localhost"
}
- // Validate port
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))
diff --git a/go/client_test.go b/go/client_test.go
index b5274cfda..12112595f 100644
--- a/go/client_test.go
+++ b/go/client_test.go
@@ -49,6 +49,15 @@ func TestClient_URLParsing(t *testing.T) {
}
})
+ t.Run("should parse bracketed IPv6 host:port URL format", func(t *testing.T) {
+ client := NewClient(&ClientOptions{
+ Connection: URIConnection{URL: "[::1]:9000"},
+ })
+ if client.actualPort != 9000 || client.actualHost != "::1" {
+ t.Errorf("Expected [::1]:9000, got %s:%d", client.actualHost, client.actualPort)
+ }
+ })
+
t.Run("should parse http://host:port URL format", func(t *testing.T) {
client := NewClient(&ClientOptions{
Connection: URIConnection{URL: "http://localhost:7000"},
@@ -58,6 +67,24 @@ func TestClient_URLParsing(t *testing.T) {
}
})
+ t.Run("should parse http://[ipv6]:port URL format", func(t *testing.T) {
+ client := NewClient(&ClientOptions{
+ Connection: URIConnection{URL: "http://[::1]:7000"},
+ })
+ if client.actualPort != 7000 || client.actualHost != "::1" {
+ t.Errorf("Expected [::1]:7000, got %s:%d", client.actualHost, client.actualPort)
+ }
+ })
+
+ t.Run("should panic for bracketed non-IPv6 host", func(t *testing.T) {
+ defer func() {
+ if r := recover(); r == nil {
+ t.Error("Expected panic for invalid bracketed host")
+ }
+ }()
+ NewClient(&ClientOptions{Connection: URIConnection{URL: "[not-ipv6]:1234"}})
+ })
+
t.Run("should parse https://host:port URL format", func(t *testing.T) {
client := NewClient(&ClientOptions{
Connection: URIConnection{URL: "https://example.com:443"},
diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java
index 44878b87e..5a4e6659a 100644
--- a/java/src/main/java/com/github/copilot/CopilotClient.java
+++ b/java/src/main/java/com/github/copilot/CopilotClient.java
@@ -184,7 +184,10 @@ public CopilotClient(CopilotClientOptions options) {
// Parse CliUrl if provided
if (this.options.getCliUrl() != null && !this.options.getCliUrl().isEmpty()) {
URI uri = CliServerManager.parseCliUrl(this.options.getCliUrl());
- this.optionsHost = uri.getHost();
+ String host = uri.getHost();
+ this.optionsHost = host != null && host.startsWith("[") && host.endsWith("]")
+ ? host.substring(1, host.length() - 1)
+ : host;
this.optionsPort = uri.getPort();
} else {
this.optionsHost = null;
diff --git a/java/src/test/java/com/github/copilot/CliServerManagerTest.java b/java/src/test/java/com/github/copilot/CliServerManagerTest.java
index b445d6153..141e658e4 100644
--- a/java/src/test/java/com/github/copilot/CliServerManagerTest.java
+++ b/java/src/test/java/com/github/copilot/CliServerManagerTest.java
@@ -48,6 +48,13 @@ void parseCliUrlWithHttpsPrefix() {
assertEquals("https://secure.host:443", uri.toString());
}
+ @Test
+ void parseCliUrlWithBracketedIpv6() {
+ URI uri = CliServerManager.parseCliUrl("[::1]:4321");
+ assertNotNull(uri.getHost());
+ assertEquals(4321, uri.getPort());
+ }
+
@Test
void parseCliUrlWithHostOnly() {
URI uri = CliServerManager.parseCliUrl("copilot.example.com");
diff --git a/java/src/test/java/com/github/copilot/CopilotClientTest.java b/java/src/test/java/com/github/copilot/CopilotClientTest.java
index d977563ae..6bbb26c14 100644
--- a/java/src/test/java/com/github/copilot/CopilotClientTest.java
+++ b/java/src/test/java/com/github/copilot/CopilotClientTest.java
@@ -148,6 +148,15 @@ void testCliUrlOnlyConstruction() {
client.close();
}
+ @Test
+ void testBracketedIpv6CliUrlNormalizesHost() throws Exception {
+ try (var client = new CopilotClient(new CopilotClientOptions().setCliUrl("[::1]:4321"))) {
+ Field hostField = CopilotClient.class.getDeclaredField("optionsHost");
+ hostField.setAccessible(true);
+ assertEquals("::1", hostField.get(client));
+ }
+ }
+
@Test
void testCliUrlMutualExclusionWithCliPath() {
var options = new CopilotClientOptions().setCliUrl("localhost:3000").setCliPath("/path/to/cli");
diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts
index 4ed139be7..bc48bfbd5 100644
--- a/nodejs/src/client.ts
+++ b/nodejs/src/client.ts
@@ -15,7 +15,7 @@ import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
-import { Socket } from "node:net";
+import { isIPv6, Socket } from "node:net";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
@@ -765,22 +765,38 @@ export class CopilotClient {
/**
* Parse CLI URL into host and port
- * Supports formats: "host:port", "http://host:port", "https://host:port", or just "port"
+ * Supports formats: "host:port", "[ipv6]:port", "http://host:port", "https://host:port", or just "port"
*/
private parseCliUrl(url: string): { host: string; port: number } {
// Remove protocol if present
- let cleanUrl = url.replace(/^https?:\/\//, "");
+ const cleanUrl = url.replace(/^https?:\/\//, "");
// Check if it's just a port number
if (/^\d+$/.test(cleanUrl)) {
return { host: "localhost", port: parseInt(cleanUrl, 10) };
}
+ // Handle the canonical bracketed IPv6 host:port form without changing
+ // the existing parser behavior for other inputs.
+ const ipv6Match = cleanUrl.match(/^\[([^\]]+)\]:(\d+)$/);
+ if (ipv6Match) {
+ const host = ipv6Match[1];
+ if (!isIPv6(host)) {
+ throw new Error(`Invalid cliUrl format: ${url}`);
+ }
+
+ const port = parseInt(ipv6Match[2], 10);
+ if (isNaN(port) || port <= 0 || port > 65535) {
+ throw new Error(`Invalid port in cliUrl: ${url}`);
+ }
+ return { host, port };
+ }
+
// Parse host:port format
const parts = cleanUrl.split(":");
if (parts.length !== 2) {
throw new Error(
- `Invalid cliUrl format: ${url}. Expected "host:port", "http://host:port", or "port"`
+ `Invalid cliUrl format: ${url}. Expected "host:port", "[ipv6]:port", "http://host:port", or "port"`
);
}
diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts
index 962d90970..a1dd590cc 100644
--- a/nodejs/test/client.test.ts
+++ b/nodejs/test/client.test.ts
@@ -2166,6 +2166,17 @@ describe("CopilotClient", () => {
expect((client as any).isExternalServer).toBe(true);
});
+ it("should parse bracketed IPv6 host:port URL format", () => {
+ const client = new CopilotClient({
+ connection: RuntimeConnection.forUri("[::1]:9000"),
+ logLevel: "error",
+ });
+
+ expect((client as any).runtimePort).toBe(9000);
+ expect((client as any).actualHost).toBe("::1");
+ expect((client as any).isExternalServer).toBe(true);
+ });
+
it("should parse http://host:port URL format", () => {
const client = new CopilotClient({
connection: RuntimeConnection.forUri("http://localhost:7000"),
@@ -2177,6 +2188,26 @@ describe("CopilotClient", () => {
expect((client as any).isExternalServer).toBe(true);
});
+ it("should parse http://[ipv6]:port URL format", () => {
+ const client = new CopilotClient({
+ connection: RuntimeConnection.forUri("http://[::1]:7000"),
+ logLevel: "error",
+ });
+
+ expect((client as any).runtimePort).toBe(7000);
+ expect((client as any).actualHost).toBe("::1");
+ expect((client as any).isExternalServer).toBe(true);
+ });
+
+ it("should reject a bracketed non-IPv6 host", () => {
+ expect(() => {
+ new CopilotClient({
+ connection: RuntimeConnection.forUri("[not-ipv6]:1234"),
+ logLevel: "error",
+ });
+ }).toThrow(/Invalid cliUrl format/);
+ });
+
it("should parse https://host:port URL format", () => {
const client = new CopilotClient({
connection: RuntimeConnection.forUri("https://example.com:443"),
diff --git a/python/copilot/client.py b/python/copilot/client.py
index 7c273bd01..a4383d218 100644
--- a/python/copilot/client.py
+++ b/python/copilot/client.py
@@ -15,6 +15,7 @@
from __future__ import annotations
import asyncio
+import ipaddress
import inspect
import logging
import os
@@ -1630,8 +1631,8 @@ def _parse_cli_url(self, url: str) -> tuple[str, int]:
"""
Parse CLI URL into host and port.
- Supports formats: "host:port", "http://host:port", "https://host:port",
- or just "port".
+ Supports formats: "host:port", "[ipv6]:port", "http://host:port",
+ "https://host:port", or just "port".
Args:
url: The CLI URL to parse.
@@ -1642,9 +1643,6 @@ def _parse_cli_url(self, url: str) -> tuple[str, int]:
Raises:
ValueError: If the URL format is invalid or the port is out of range.
"""
- import re
-
- # Remove protocol if present
clean_url = re.sub(r"^https?://", "", url)
# Check if it's just a port number
@@ -1654,14 +1652,24 @@ def _parse_cli_url(self, url: str) -> tuple[str, int]:
raise ValueError(f"Invalid port in cli_url: {url}")
return ("localhost", port)
- # Parse host:port format
- parts = clean_url.split(":")
- if len(parts) != 2:
- raise ValueError(f"Invalid cli_url format: {url}")
+ ipv6_match = re.match(r"^\[([^\]]+)\]:(.*)$", clean_url)
+ if ipv6_match:
+ host = ipv6_match.group(1)
+ port_text = ipv6_match.group(2)
+ try:
+ ipaddress.IPv6Address(host)
+ except ValueError as e:
+ raise ValueError(f"Invalid cli_url format: {url}") from e
+ else:
+ # Parse host:port format
+ parts = clean_url.split(":")
+ if len(parts) != 2:
+ raise ValueError(f"Invalid cli_url format: {url}")
+ host = parts[0] if parts[0] else "localhost"
+ port_text = parts[1]
- host = parts[0] if parts[0] else "localhost"
try:
- port = int(parts[1])
+ port = int(port_text)
except ValueError as e:
raise ValueError(f"Invalid port in cli_url: {url}") from e
@@ -4273,22 +4281,22 @@ async def _connect_via_tcp(self) -> None:
if not self._runtime_port:
raise RuntimeError("Server port not available")
- # Create a TCP socket connection with timeout
+ # Create a TCP socket connection with timeout. create_connection resolves
+ # both IPv4 and IPv6 addresses instead of forcing AF_INET.
import socket
# Connection timeout constant
TCP_CONNECTION_TIMEOUT = 10 # seconds
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(TCP_CONNECTION_TIMEOUT)
-
try:
tcp_connect_start = time.perf_counter()
logger.info(
"CopilotClient._connect_via_tcp connecting to CLI server",
extra={"host": self._actual_host, "port": self._runtime_port},
)
- sock.connect((self._actual_host, self._runtime_port))
+ sock = socket.create_connection(
+ (self._actual_host, self._runtime_port), timeout=TCP_CONNECTION_TIMEOUT
+ )
sock.settimeout(None) # Remove timeout after connection
log_timing(
logger,
diff --git a/python/test_client.py b/python/test_client.py
index f101fc396..092a2d97f 100644
--- a/python/test_client.py
+++ b/python/test_client.py
@@ -1196,12 +1196,28 @@ def test_parse_host_port_url(self):
assert client._actual_host == "127.0.0.1"
assert client._is_external_server
+ def test_parse_bracketed_ipv6_host_port_url(self):
+ client = CopilotClient(connection=RuntimeConnection.for_uri("[::1]:9000"))
+ assert client._runtime_port == 9000
+ assert client._actual_host == "::1"
+ assert client._is_external_server
+
def test_parse_http_url(self):
client = CopilotClient(connection=RuntimeConnection.for_uri("http://localhost:7000"))
assert client._runtime_port == 7000
assert client._actual_host == "localhost"
assert client._is_external_server
+ def test_parse_http_ipv6_url(self):
+ client = CopilotClient(connection=RuntimeConnection.for_uri("http://[::1]:7000"))
+ assert client._runtime_port == 7000
+ assert client._actual_host == "::1"
+ assert client._is_external_server
+
+ def test_reject_bracketed_non_ipv6_host(self):
+ with pytest.raises(ValueError, match="Invalid cli_url format"):
+ CopilotClient(connection=RuntimeConnection.for_uri("[not-ipv6]:1234"))
+
def test_parse_https_url(self):
client = CopilotClient(connection=RuntimeConnection.for_uri("https://example.com:443"))
assert client._runtime_port == 443
@@ -1228,6 +1244,18 @@ def test_is_external_server_true(self):
client = CopilotClient(connection=RuntimeConnection.for_uri("localhost:8080"))
assert client._is_external_server
+ @pytest.mark.asyncio
+ async def test_connect_via_tcp_uses_family_independent_resolution(self):
+ client = CopilotClient(connection=RuntimeConnection.for_uri("[::1]:9000"))
+ fake_socket = Mock()
+ fake_socket.makefile.return_value = Mock()
+
+ with patch("socket.create_connection", return_value=fake_socket) as create_connection:
+ await client._connect_via_tcp()
+
+ create_connection.assert_called_once_with(("::1", 9000), timeout=10)
+ client._process.terminate()
+
class TestSessionFsConfig:
def test_missing_initial_cwd(self):