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):