Skip to content

Commit 0ef16fe

Browse files
committed
fix: support bracketed IPv6 runtime URLs
1 parent f324f72 commit 0ef16fe

8 files changed

Lines changed: 216 additions & 43 deletions

File tree

dotnet/src/Client.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ public CopilotClient(CopilotClientOptions? options = null)
167167
throw new ArgumentException("GitHubToken and UseLoggedInUser cannot be combined with RuntimeConnection.ForUri (the existing runtime manages its own auth).", nameof(options));
168168
}
169169
var parsed = ParseRuntimeUrl(uri.Url);
170-
_optionsHost = parsed.Host;
170+
_optionsHost = parsed.Host.Trim('[', ']');
171171
_optionsPort = parsed.Port;
172172
break;
173173

@@ -298,12 +298,18 @@ private static RuntimeConnection ResolveDefaultConnection(CopilotClientOptions o
298298
/// <summary>
299299
/// Parses a runtime URL into a URI with host and port.
300300
/// </summary>
301-
/// <param name="url">The URL to parse. Supports formats: "port", "host:port", "http://host:port".</param>
301+
/// <param name="url">The URL to parse. Supports formats: "port", "host:port", "[ipv6]:port", "http://host:port".</param>
302302
private static Uri ParseRuntimeUrl(string url)
303303
{
304+
url = url.Trim();
305+
304306
// If it's just a port number, treat as localhost
305307
if (int.TryParse(url, out var port))
306308
{
309+
if (port <= 0 || port > 65535)
310+
{
311+
throw new ArgumentException($"Invalid runtime URL port: {url}");
312+
}
307313
return new Uri($"http://localhost:{port}");
308314
}
309315

@@ -314,7 +320,18 @@ private static Uri ParseRuntimeUrl(string url)
314320
url = "https://" + url;
315321
}
316322

317-
return new Uri(url);
323+
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
324+
string.IsNullOrEmpty(uri.Host) ||
325+
uri.Port <= 0 ||
326+
uri.Port > 65535 ||
327+
(!string.IsNullOrEmpty(uri.AbsolutePath) && uri.AbsolutePath != "/") ||
328+
!string.IsNullOrEmpty(uri.Query) ||
329+
!string.IsNullOrEmpty(uri.Fragment))
330+
{
331+
throw new ArgumentException($"Invalid runtime URL: {url}");
332+
}
333+
334+
return uri;
318335
}
319336

320337
/// <summary>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
[Fact]
37+
public void ForUri_RejectsUrlPath()
38+
{
39+
Assert.Throws<ArgumentException>(() => new CopilotClient(new CopilotClientOptions
40+
{
41+
Connection = RuntimeConnection.ForUri("http://localhost:8080/path")
42+
}));
43+
}
44+
45+
private static T? GetPrivateField<T>(object instance, string name)
46+
{
47+
var field = instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
48+
Assert.NotNull(field);
49+
return (T?)field.GetValue(instance);
50+
}
51+
}

go/client.go

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
"fmt"
3737
"log"
3838
"net"
39+
neturl "net/url"
3940
"os"
4041
"os/exec"
4142
"regexp"
@@ -372,35 +373,47 @@ func setEnvValue(env []string, key string, value string) []string {
372373

373374
// parseCLIURL parses a CLI URL into host and port components.
374375
//
375-
// Supports formats: "host:port", "http://host:port", "https://host:port", or just "port".
376+
// Supports formats: "host:port", "[ipv6]:port", "http://host:port", "https://host:port", or just "port".
376377
// Panics if the URL format is invalid or the port is out of range.
377378
func parseCLIURL(url string) (string, int) {
378-
// Remove protocol if present
379-
cleanURL, _ := strings.CutPrefix(url, "https://")
380-
cleanURL, _ = strings.CutPrefix(cleanURL, "http://")
381-
382-
// Parse host:port or port format
383-
var host string
384-
var portStr string
385-
if before, after, found := strings.Cut(cleanURL, ":"); found {
386-
host = before
387-
portStr = after
388-
} else {
389-
// Only port provided
390-
portStr = before
379+
cleanURL := strings.TrimSpace(url)
380+
if cleanURL == "" {
381+
panic(fmt.Sprintf("Invalid URIConnection format: %s", url))
382+
}
383+
384+
if _, err := strconv.Atoi(cleanURL); err == nil {
385+
port := parseCLIPort(url, cleanURL)
386+
return "localhost", port
387+
}
388+
389+
parseURL := cleanURL
390+
if !strings.Contains(parseURL, "://") {
391+
parseURL = "tcp://" + parseURL
392+
}
393+
394+
parsed, err := neturl.Parse(parseURL)
395+
if err != nil {
396+
panic(fmt.Sprintf("Invalid URIConnection format: %s", url))
397+
}
398+
if parsed.Host == "" || parsed.Port() == "" || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
399+
panic(fmt.Sprintf("Invalid URIConnection format: %s", url))
391400
}
392401

402+
port := parseCLIPort(url, parsed.Port())
403+
host := parsed.Hostname()
393404
if host == "" {
394405
host = "localhost"
395406
}
396407

397-
// Validate port
408+
return host, port
409+
}
410+
411+
func parseCLIPort(url string, portStr string) int {
398412
port, err := strconv.Atoi(portStr)
399413
if err != nil || port <= 0 || port > 65535 {
400414
panic(fmt.Sprintf("Invalid port in URIConnection: %s", url))
401415
}
402-
403-
return host, port
416+
return port
404417
}
405418

406419
// Start starts the CLI server (if not using an external server) and establishes

go/client_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ func TestClient_URLParsing(t *testing.T) {
4949
}
5050
})
5151

52+
t.Run("should parse bracketed IPv6 host:port URL format", func(t *testing.T) {
53+
client := NewClient(&ClientOptions{
54+
Connection: URIConnection{URL: "[::1]:9000"},
55+
})
56+
if client.actualPort != 9000 || client.actualHost != "::1" {
57+
t.Errorf("Expected [::1]:9000, got %s:%d", client.actualHost, client.actualPort)
58+
}
59+
})
60+
5261
t.Run("should parse http://host:port URL format", func(t *testing.T) {
5362
client := NewClient(&ClientOptions{
5463
Connection: URIConnection{URL: "http://localhost:7000"},
@@ -58,6 +67,15 @@ func TestClient_URLParsing(t *testing.T) {
5867
}
5968
})
6069

70+
t.Run("should parse http://[ipv6]:port URL format", func(t *testing.T) {
71+
client := NewClient(&ClientOptions{
72+
Connection: URIConnection{URL: "http://[::1]:7000"},
73+
})
74+
if client.actualPort != 7000 || client.actualHost != "::1" {
75+
t.Errorf("Expected [::1]:7000, got %s:%d", client.actualHost, client.actualPort)
76+
}
77+
})
78+
6179
t.Run("should parse https://host:port URL format", func(t *testing.T) {
6280
client := NewClient(&ClientOptions{
6381
Connection: URIConnection{URL: "https://example.com:443"},
@@ -76,6 +94,15 @@ func TestClient_URLParsing(t *testing.T) {
7694
NewClient(&ClientOptions{Connection: URIConnection{URL: "invalid-url"}})
7795
})
7896

97+
t.Run("should panic for URL path", func(t *testing.T) {
98+
defer func() {
99+
if r := recover(); r == nil {
100+
t.Error("Expected panic for invalid URL path")
101+
}
102+
}()
103+
NewClient(&ClientOptions{Connection: URIConnection{URL: "http://localhost:8080/path"}})
104+
})
105+
79106
t.Run("should panic for invalid port - too high", func(t *testing.T) {
80107
defer func() {
81108
if r := recover(); r == nil {

nodejs/src/client.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -765,27 +765,46 @@ export class CopilotClient {
765765

766766
/**
767767
* Parse CLI URL into host and port
768-
* Supports formats: "host:port", "http://host:port", "https://host:port", or just "port"
768+
* Supports formats: "host:port", "[ipv6]:port", "http://host:port", "https://host:port", or just "port"
769769
*/
770770
private parseCliUrl(url: string): { host: string; port: number } {
771-
// Remove protocol if present
772-
let cleanUrl = url.replace(/^https?:\/\//, "");
771+
const trimmedUrl = url.trim();
773772

774773
// Check if it's just a port number
775-
if (/^\d+$/.test(cleanUrl)) {
776-
return { host: "localhost", port: parseInt(cleanUrl, 10) };
774+
if (/^\d+$/.test(trimmedUrl)) {
775+
return { host: "localhost", port: parseInt(trimmedUrl, 10) };
777776
}
778777

779-
// Parse host:port format
780-
const parts = cleanUrl.split(":");
781-
if (parts.length !== 2) {
778+
let parsed: URL;
779+
try {
780+
parsed = new URL(
781+
/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedUrl) ? trimmedUrl : `tcp://${trimmedUrl}`
782+
);
783+
} catch {
784+
if (trimmedUrl.includes(":")) {
785+
throw new Error(`Invalid port in cliUrl: ${url}`);
786+
}
787+
throw new Error(
788+
`Invalid cliUrl format: ${url}. Expected "host:port", "[ipv6]:port", "http://host:port", or "port"`
789+
);
790+
}
791+
792+
const explicitPort = trimmedUrl.match(/:(\d+)(?:[/?#]|$)/)?.[1];
793+
const portString = parsed.port || explicitPort;
794+
795+
if (
796+
!portString ||
797+
(parsed.pathname !== "" && parsed.pathname !== "/") ||
798+
parsed.search !== "" ||
799+
parsed.hash !== ""
800+
) {
782801
throw new Error(
783-
`Invalid cliUrl format: ${url}. Expected "host:port", "http://host:port", or "port"`
802+
`Invalid cliUrl format: ${url}. Expected "host:port", "[ipv6]:port", "http://host:port", or "port"`
784803
);
785804
}
786805

787-
const host = parts[0] || "localhost";
788-
const port = parseInt(parts[1], 10);
806+
const host = parsed.hostname.replace(/^\[(.*)\]$/, "$1") || "localhost";
807+
const port = parseInt(portString, 10);
789808

790809
if (isNaN(port) || port <= 0 || port > 65535) {
791810
throw new Error(`Invalid port in cliUrl: ${url}`);

nodejs/test/client.test.ts

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

2102+
it("should parse bracketed IPv6 host:port URL format", () => {
2103+
const client = new CopilotClient({
2104+
connection: RuntimeConnection.forUri("[::1]:9000"),
2105+
logLevel: "error",
2106+
});
2107+
2108+
expect((client as any).runtimePort).toBe(9000);
2109+
expect((client as any).actualHost).toBe("::1");
2110+
expect((client as any).isExternalServer).toBe(true);
2111+
});
2112+
21022113
it("should parse http://host:port URL format", () => {
21032114
const client = new CopilotClient({
21042115
connection: RuntimeConnection.forUri("http://localhost:7000"),
@@ -2110,6 +2121,17 @@ describe("CopilotClient", () => {
21102121
expect((client as any).isExternalServer).toBe(true);
21112122
});
21122123

2124+
it("should parse http://[ipv6]:port URL format", () => {
2125+
const client = new CopilotClient({
2126+
connection: RuntimeConnection.forUri("http://[::1]:7000"),
2127+
logLevel: "error",
2128+
});
2129+
2130+
expect((client as any).runtimePort).toBe(7000);
2131+
expect((client as any).actualHost).toBe("::1");
2132+
expect((client as any).isExternalServer).toBe(true);
2133+
});
2134+
21132135
it("should parse https://host:port URL format", () => {
21142136
const client = new CopilotClient({
21152137
connection: RuntimeConnection.forUri("https://example.com:443"),
@@ -2130,6 +2152,15 @@ describe("CopilotClient", () => {
21302152
}).toThrow(/Invalid cliUrl format/);
21312153
});
21322154

2155+
it("should throw error for URL path", () => {
2156+
expect(() => {
2157+
new CopilotClient({
2158+
connection: RuntimeConnection.forUri("http://localhost:8080/path"),
2159+
logLevel: "error",
2160+
});
2161+
}).toThrow(/Invalid cliUrl format/);
2162+
});
2163+
21332164
it("should throw error for invalid port - too high", () => {
21342165
expect(() => {
21352166
new CopilotClient({

python/copilot/client.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from datetime import UTC, datetime
3131
from types import TracebackType
3232
from typing import Any, ClassVar, Literal, TypedDict, cast, overload
33+
from urllib.parse import urlsplit
3334

3435
from ._diagnostics import log_timing
3536
from ._ffi_runtime_host import FfiRuntimeHost
@@ -1630,8 +1631,8 @@ def _parse_cli_url(self, url: str) -> tuple[str, int]:
16301631
"""
16311632
Parse CLI URL into host and port.
16321633
1633-
Supports formats: "host:port", "http://host:port", "https://host:port",
1634-
or just "port".
1634+
Supports formats: "host:port", "[ipv6]:port", "http://host:port",
1635+
"https://host:port", or just "port".
16351636
16361637
Args:
16371638
url: The CLI URL to parse.
@@ -1642,10 +1643,7 @@ def _parse_cli_url(self, url: str) -> tuple[str, int]:
16421643
Raises:
16431644
ValueError: If the URL format is invalid or the port is out of range.
16441645
"""
1645-
import re
1646-
1647-
# Remove protocol if present
1648-
clean_url = re.sub(r"^https?://", "", url)
1646+
clean_url = url.strip()
16491647

16501648
# Check if it's just a port number
16511649
if clean_url.isdigit():
@@ -1654,21 +1652,22 @@ def _parse_cli_url(self, url: str) -> tuple[str, int]:
16541652
raise ValueError(f"Invalid port in cli_url: {url}")
16551653
return ("localhost", port)
16561654

1657-
# Parse host:port format
1658-
parts = clean_url.split(":")
1659-
if len(parts) != 2:
1655+
parsed = urlsplit(clean_url if "://" in clean_url else f"tcp://{clean_url}")
1656+
if parsed.path not in ("", "/") or parsed.query or parsed.fragment:
16601657
raise ValueError(f"Invalid cli_url format: {url}")
16611658

1662-
host = parts[0] if parts[0] else "localhost"
16631659
try:
1664-
port = int(parts[1])
1660+
port = parsed.port
16651661
except ValueError as e:
16661662
raise ValueError(f"Invalid port in cli_url: {url}") from e
16671663

1664+
if port is None:
1665+
raise ValueError(f"Invalid cli_url format: {url}")
1666+
16681667
if port <= 0 or port > 65535:
16691668
raise ValueError(f"Invalid port in cli_url: {url}")
16701669

1671-
return (host, port)
1670+
return (parsed.hostname or "localhost", port)
16721671

16731672
async def __aenter__(self) -> CopilotClient:
16741673
"""

0 commit comments

Comments
 (0)