Summary
The cliUrl (Node, .NET) / cli_url (Python) / CLIUrl (Go) URL parser rejects IPv6 addresses in every standard form, including the canonical bracketed [::1]:8080 notation. The error message ("Invalid cli_url format") doesn't suggest IPv6 isn't supported, so users debugging it have no obvious next step.
This is a feature gap, not a security issue.
Affected versions
main at commit dd2dcbc439256acfb9feb2cff07c0b9c820091b8 (current HEAD as of 2026-04-26). All language SDKs share the same parsing logic and exhibit the same gap.
Affected source
python/copilot/client.py:944-986 — _parse_cli_url
nodejs/src/client.ts:351-376 — parseCliUrl
go/client.go:256-283 — parseCliUrl
Reproduction
from copilot.client import CopilotClient, ExternalServerConfig
c = CopilotClient(ExternalServerConfig(url="localhost:1"))
# IPv6 loopback with port — standard URL bracketed form
c._parse_cli_url("[::1]:8080")
# → ValueError: Invalid cli_url format: [::1]:8080
# IPv6 loopback alone
c._parse_cli_url("::1")
# → ValueError: Invalid cli_url format: ::1
# Link-local IPv6 with port
c._parse_cli_url("fe80::1:8080")
# → ValueError: Invalid cli_url format: fe80::1:8080
# Same with http:// prefix (which the parser otherwise accepts)
c._parse_cli_url("http://[::1]:8080")
# → ValueError: Invalid cli_url format: http://[::1]:8080
For comparison, IPv4 works as expected:
c._parse_cli_url("127.0.0.1:8080")
# → ('127.0.0.1', 8080)
Root cause
The Python parser splits on : and asserts exactly two parts:
# python/copilot/client.py:973-975
parts = clean_url.split(":")
if len(parts) != 2:
raise ValueError(f"Invalid cli_url format: {url}")
IPv6 addresses contain multiple colons, so any IPv6 form produces len(parts) > 2 and is rejected. Bracketed-form parsing (the standard way to disambiguate host:port when the host itself contains colons, per RFC 3986 §3.2.2) is not implemented.
The Node and Go parsers have the same shape (split(":") / strings.Cut) and the same gap.
Impact
- IPv6-only environments cannot use
cliUrl to connect to a backend CLI server.
- The error message does not hint that IPv6 is unsupported, so users hit a dead end debugging.
- The documented
backend-services and scaling deployment patterns implicitly require IPv4.
Suggested fix
Use the language's URL parser instead of hand-rolled splitting:
Python:
from urllib.parse import urlsplit
def _parse_cli_url(self, url: str) -> tuple[str, int]:
if "://" not in url:
url = "tcp://" + url
parts = urlsplit(url)
if parts.hostname is None or parts.port is None:
raise ValueError(f"Invalid cli_url format: {url}")
return (parts.hostname, parts.port)
urllib.parse.urlsplit handles bracketed IPv6 correctly, returns hostname already unbracketed, and rejects out-of-range ports.
Node has URL built in; Go has net/url and net.SplitHostPort — both handle IPv6 brackets natively.
Related minor issues found while investigating
These are smaller and could be folded into the same fix or filed separately:
-
Leading whitespace in host accepted silently. _parse_cli_url(" localhost:8080") returns (" localhost", 8080) — the literal space is preserved as part of the host, then fails at socket.connect() with a confusing DNS error. Should strip() or reject.
-
Misleading error category for URL paths. _parse_cli_url("http://localhost:8080/path") returns "Invalid port in cli_url" — the actual problem is the trailing /path, not the port.
-
Empty host with colon prefix accepted. _parse_cli_url(":8080") returns ("localhost", 8080). Possibly intentional, but undocumented and inconsistent with the spec.
Verification script
Self-contained reproducer covering all cases above. Save as probe.py inside a checkout of github/copilot-sdk and run from the repo root:
"""Demonstrates the IPv6 parsing gap in _parse_cli_url across all input forms."""
import os, sys
sys.path.insert(0, os.path.join(os.getcwd(), "python"))
from copilot.client import CopilotClient, ExternalServerConfig
cases = [
# IPv4 baseline (works)
("127.0.0.1:8080", "IPv4 baseline"),
("localhost:8080", "hostname baseline"),
# IPv6 — the gap
("[::1]:8080", "IPv6 loopback, bracketed (RFC 3986 standard form)"),
("::1", "IPv6 loopback bare"),
("fe80::1:8080", "IPv6 link-local with port"),
("http://[::1]:8080", "IPv6 with http:// prefix"),
("[2001:db8::1]:443", "IPv6 documentation prefix"),
# Minor adjacent issues
(" localhost:8080", "leading space in host (silently accepted, breaks later)"),
("http://localhost:8080/path", "URL with path (misleading error)"),
(":8080", "empty host (silently defaults to localhost)"),
]
c = CopilotClient(ExternalServerConfig(url="localhost:1"))
print(f"{'INPUT':<32s} {'RESULT':<55s} NOTE")
print(f"{'-'*32} {'-'*55} ----")
for url, note in cases:
try:
h, p = c._parse_cli_url(url)
result = f"({h!r}, {p})"
except Exception as e:
result = f"{type(e).__name__}: {e}"
print(f"{url!r:<32s} {result:<55s} {note}")
Expected output:
INPUT RESULT NOTE
-------------------------------- ------------------------------------------------------- ----
'127.0.0.1:8080' ('127.0.0.1', 8080) IPv4 baseline
'localhost:8080' ('localhost', 8080) hostname baseline
'[::1]:8080' ValueError: Invalid cli_url format: [::1]:8080 IPv6 loopback, bracketed (RFC 3986 standard form)
'::1' ValueError: Invalid cli_url format: ::1 IPv6 loopback bare
'fe80::1:8080' ValueError: Invalid cli_url format: fe80::1:8080 IPv6 link-local with port
'http://[::1]:8080' ValueError: Invalid cli_url format: http://[::1]:8080 IPv6 with http:// prefix
'[2001:db8::1]:443' ValueError: Invalid cli_url format: [2001:db8::1]:443 IPv6 documentation prefix
' localhost:8080' (' localhost', 8080) leading space in host (silently accepted, breaks later)
'http://localhost:8080/path' ValueError: Invalid port in cli_url: http://localhost:8080/path URL with path (misleading error)
':8080' ('localhost', 8080) empty host (silently defaults to localhost)
Summary
The
cliUrl(Node, .NET) /cli_url(Python) /CLIUrl(Go) URL parser rejects IPv6 addresses in every standard form, including the canonical bracketed[::1]:8080notation. The error message ("Invalid cli_url format") doesn't suggest IPv6 isn't supported, so users debugging it have no obvious next step.This is a feature gap, not a security issue.
Affected versions
mainat commitdd2dcbc439256acfb9feb2cff07c0b9c820091b8(current HEAD as of 2026-04-26). All language SDKs share the same parsing logic and exhibit the same gap.Affected source
python/copilot/client.py:944-986—_parse_cli_urlnodejs/src/client.ts:351-376—parseCliUrlgo/client.go:256-283—parseCliUrlReproduction
For comparison, IPv4 works as expected:
Root cause
The Python parser splits on
:and asserts exactly two parts:IPv6 addresses contain multiple colons, so any IPv6 form produces
len(parts) > 2and is rejected. Bracketed-form parsing (the standard way to disambiguatehost:portwhen the host itself contains colons, per RFC 3986 §3.2.2) is not implemented.The Node and Go parsers have the same shape (
split(":")/strings.Cut) and the same gap.Impact
cliUrlto connect to a backend CLI server.backend-servicesandscalingdeployment patterns implicitly require IPv4.Suggested fix
Use the language's URL parser instead of hand-rolled splitting:
Python:
urllib.parse.urlsplithandles bracketed IPv6 correctly, returnshostnamealready unbracketed, and rejects out-of-range ports.Node has
URLbuilt in; Go hasnet/urlandnet.SplitHostPort— both handle IPv6 brackets natively.Related minor issues found while investigating
These are smaller and could be folded into the same fix or filed separately:
Leading whitespace in host accepted silently.
_parse_cli_url(" localhost:8080")returns(" localhost", 8080)— the literal space is preserved as part of the host, then fails atsocket.connect()with a confusing DNS error. Shouldstrip()or reject.Misleading error category for URL paths.
_parse_cli_url("http://localhost:8080/path")returns"Invalid port in cli_url"— the actual problem is the trailing/path, not the port.Empty host with colon prefix accepted.
_parse_cli_url(":8080")returns("localhost", 8080). Possibly intentional, but undocumented and inconsistent with the spec.Verification script
Self-contained reproducer covering all cases above. Save as
probe.pyinside a checkout ofgithub/copilot-sdkand run from the repo root:Expected output: