Skip to content
4 changes: 2 additions & 2 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,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;

Expand Down Expand Up @@ -308,7 +308,7 @@ private static RuntimeConnection ResolveDefaultConnection(CopilotClientOptions o
/// <summary>
/// Parses a runtime URL into a URI with host and port.
/// </summary>
/// <param name="url">The URL to parse. Supports formats: "port", "host:port", "http://host:port".</param>
/// <param name="url">The URL to parse. Supports formats: "port", "host:port", "[ipv6]:port", "http://host:port".</param>
private static Uri ParseRuntimeUrl(string url)
{
// If it's just a port number, treat as localhost
Expand Down
42 changes: 42 additions & 0 deletions dotnet/test/Unit/RuntimeConnectionUrlParsingTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>(client, "_optionsHost"));
Assert.Equal(9000, GetPrivateField<int?>(client, "_optionsPort"));
}

[Fact]
public void ForUri_ParsesHttpIpv6HostPort()
{
var client = new CopilotClient(new CopilotClientOptions
{
Connection = RuntimeConnection.ForUri("http://[::1]:7000")
});

Assert.Equal("::1", GetPrivateField<string>(client, "_optionsHost"));
Assert.Equal(7000, GetPrivateField<int?>(client, "_optionsPort"));
}

private static T? GetPrivateField<T>(object instance, string name)
{
var field = instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
return (T?)field.GetValue(instance);
}
}
25 changes: 21 additions & 4 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"fmt"
"log"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -401,29 +402,45 @@ 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
Comment thread
xianjianlf2 marked this conversation as resolved.
}

// Parse host:port or port format
var host string
var portStr string
if before, after, found := strings.Cut(cleanURL, ":"); found {
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))
Expand Down
27 changes: 27 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,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"},
Expand All @@ -87,6 +96,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"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,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");
Expand Down
24 changes: 20 additions & 4 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { Socket } from "node:net";
import { isIPv6, Socket } from "node:net";
import { dirname, isAbsolute, join, resolve } from "node:path";
import {
createMessageConnection,
Expand Down Expand Up @@ -748,22 +748,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"`
);
}

Expand Down
31 changes: 31 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2627,6 +2627,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"),
Expand All @@ -2638,6 +2649,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"),
Expand Down
40 changes: 24 additions & 16 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import asyncio
import inspect
import ipaddress
import logging
import os
import re
Expand Down Expand Up @@ -1838,8 +1839,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.
Expand All @@ -1850,9 +1851,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
Expand All @@ -1862,14 +1860,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)
Comment thread
xianjianlf2 marked this conversation as resolved.
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

Expand Down Expand Up @@ -4631,22 +4639,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,
Expand Down
Loading
Loading