Skip to content

Commit 2988f94

Browse files
Merge pull request #2 from AgoraIO/hotfix/v0.1.8
Hotfix/v0.1.8
2 parents 4523bed + 25b71e7 commit 2988f94

12 files changed

Lines changed: 148 additions & 34 deletions

File tree

.github/ISSUE_TEMPLATE/bug_report.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ body:
1818
attributes:
1919
label: CLI version
2020
description: Output of `agora --version`
21-
placeholder: "e.g. agora-cli-go 0.1.7 (commit abc1234, built 2026-04-29)"
21+
placeholder: "e.g. agora-cli-go 0.1.8 (commit abc1234, built 2026-04-30)"
2222
validations:
2323
required: true
2424

.github/workflows/ci.yml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,15 +190,42 @@ jobs:
190190
$hash = (Get-FileHash -Path (Join-Path $downloadDir $archive) -Algorithm SHA256).Hash.ToLowerInvariant()
191191
Set-Content -Path (Join-Path $downloadDir 'checksums.txt') -Value "$hash $archive"
192192
193-
$server = Start-Process -FilePath python -ArgumentList '-m', 'http.server', '18081', '--directory', $fixtureRoot -PassThru
194-
Start-Sleep -Seconds 2
193+
$serverOutLog = Join-Path $fixtureRoot 'http-server.out.log'
194+
$serverErrLog = Join-Path $fixtureRoot 'http-server.err.log'
195+
$server = Start-Process -FilePath python -ArgumentList '-m', 'http.server', '18081', '--directory', $fixtureRoot -RedirectStandardOutput $serverOutLog -RedirectStandardError $serverErrLog -PassThru
196+
$archiveUrl = "http://127.0.0.1:18081/download/v$version/$archive"
197+
$serverReady = $false
198+
for ($attempt = 1; $attempt -le 20; $attempt++) {
199+
if ($server.HasExited) {
200+
if (Test-Path -LiteralPath $serverOutLog) { Get-Content -Path $serverOutLog | ForEach-Object { Write-Host $_ } }
201+
if (Test-Path -LiteralPath $serverErrLog) { Get-Content -Path $serverErrLog | ForEach-Object { Write-Host $_ } }
202+
throw "Fixture HTTP server exited before serving $archiveUrl."
203+
}
204+
try {
205+
$response = Invoke-WebRequest -Uri $archiveUrl -Method Head -UseBasicParsing
206+
if ($response.StatusCode -eq 200) {
207+
$serverReady = $true
208+
break
209+
}
210+
} catch {
211+
Start-Sleep -Milliseconds 500
212+
}
213+
}
214+
if (-not $serverReady) {
215+
if (Test-Path -LiteralPath $serverOutLog) { Get-Content -Path $serverOutLog | ForEach-Object { Write-Host $_ } }
216+
if (Test-Path -LiteralPath $serverErrLog) { Get-Content -Path $serverErrLog | ForEach-Object { Write-Host $_ } }
217+
throw "Fixture HTTP server did not serve $archiveUrl."
218+
}
195219
196220
try {
197221
$env:VERSION = $version
198222
$env:RELEASES_DOWNLOAD_BASE_URL = 'http://127.0.0.1:18081/download'
199223
$env:RELEASES_PAGE_URL = 'http://127.0.0.1:18081'
200224
201225
& ./install.ps1 -InstallDir $installDir
226+
if ($LASTEXITCODE -ne 0) {
227+
throw "install.ps1 failed with exit code $LASTEXITCODE."
228+
}
202229
& (Join-Path $installDir 'agora.exe') --help *> $null
203230
204231
Set-Content -Path (Join-Path $downloadDir 'checksums.txt') -Value ('0' * 64 + " $archive")

CHANGELOG.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
When tagging a new release, rename the `[Unreleased]` section to the new version
9-
(e.g. `[0.1.7] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top,
9+
(e.g. `[0.1.8] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top,
1010
and update the link references at the bottom of this file.
1111

1212
When adding a new entry, link the change to the PR or commit that introduced it
@@ -15,6 +15,14 @@ Earlier entries pre-date this convention and only carry their version's compare
1515

1616
## [Unreleased]
1717

18+
## [0.1.8] - 2026-04-30
19+
20+
### Fixed
21+
22+
- Preserve OAuth PKCE query parameters on Windows by opening browser login URLs through `rundll32 url.dll,FileProtocolHandler` instead of `cmd /c start`.
23+
- Accept OAuth callbacks on both IPv4 and IPv6 localhost loopback addresses so Windows `localhost` resolution does not strand successful browser sign-ins.
24+
- Update the release workflow output wiring to avoid self-referencing step outputs during dry-run and publish-mode setup.
25+
1826
## [0.1.7] - 2026-04-30
1927

2028
### Added
@@ -105,7 +113,8 @@ Earlier entries pre-date this convention and only carry their version's compare
105113
- Support machine-readable JSON output for automation and agent workflows.
106114
- Ship automated release packaging through GoReleaser, including cross-platform archives, Linux packages, Homebrew, Scoop, npm wrapper packages, Docker images, and install scripts.
107115

108-
[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.1.7...HEAD
116+
[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.1.8...HEAD
117+
[0.1.8]: https://github.com/AgoraIO/cli/compare/v0.1.7...v0.1.8
109118
[0.1.7]: https://github.com/AgoraIO/cli/compare/v0.1.6...v0.1.7
110119
[0.1.6]: https://github.com/AgoraIO/cli/compare/v0.1.5...v0.1.6
111120
[0.1.5]: https://github.com/AgoraIO/cli/compare/v0.1.4...v0.1.5

RELEASING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ Releases are fully automated via GoReleaser. Pushing a `v*` tag is the only manu
55
## Release
66

77
```bash
8-
git tag v0.1.7
9-
git push origin v0.1.7
8+
git tag v0.1.8
9+
git push origin v0.1.8
1010
```
1111

1212
The release workflow (`.github/workflows/release.yml`) then:

docs/install.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ agora --help
1616
Install a pinned version:
1717

1818
```bash
19-
curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.1.7 --add-to-path
19+
curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.1.8 --add-to-path
2020
agora --help
2121
```
2222

@@ -50,7 +50,7 @@ agora --help
5050
Install a pinned version and add the default install directory to your user PATH:
5151

5252
```powershell
53-
$env:VERSION = "0.1.7"
53+
$env:VERSION = "0.1.8"
5454
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) -AddToPath
5555
agora --help
5656
```
@@ -86,7 +86,7 @@ If another managed `agora` install is detected, the installer refuses by default
8686
Both direct installers support these core overrides:
8787

8888
- `GITHUB_REPO`: install from a fork or alternate repository.
89-
- `VERSION`: install a specific version. Both `0.1.7` and `v0.1.7` are accepted.
89+
- `VERSION`: install a specific version. Both `0.1.8` and `v0.1.8` are accepted.
9090
- `INSTALL_DIR`: install to a custom directory.
9191
- `GITHUB_TOKEN` or `GH_TOKEN`: optional GitHub token to avoid API rate limits when resolving the latest release.
9292

@@ -172,7 +172,7 @@ agora --help
172172
npx agoraio-cli --help
173173

174174
# Pin a specific version
175-
npm install -g agoraio-cli@0.1.7
175+
npm install -g agoraio-cli@0.1.8
176176

177177
# Update to the latest published version
178178
npm update -g agoraio-cli
@@ -200,12 +200,12 @@ For one-off shell sessions, source the generated script according to your shell'
200200
If latest-version resolution fails, retry with a pinned version or provide `GITHUB_TOKEN` / `GH_TOKEN`:
201201

202202
```bash
203-
GITHUB_TOKEN=your-token-here VERSION=0.1.7 sh install.sh
203+
GITHUB_TOKEN=your-token-here VERSION=0.1.8 sh install.sh
204204
```
205205

206206
```powershell
207207
$env:GITHUB_TOKEN = "your-token-here"
208-
$env:VERSION = "0.1.7"
208+
$env:VERSION = "0.1.8"
209209
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1)))
210210
```
211211

@@ -251,7 +251,7 @@ For CI, automation, and reproducible environments, pin `VERSION` explicitly inst
251251
Every release is signed with [Cosign](https://docs.sigstore.dev/cosign/overview/) using GitHub Actions OIDC (keyless mode) and ships an [SPDX 2.3](https://spdx.dev/) SBOM per archive and per Linux package. To verify the `checksums.txt` file before trusting any artifact:
252252

253253
```bash
254-
TAG=v0.1.7
254+
TAG=v0.1.8
255255
ASSET_BASE="https://github.com/AgoraIO/cli/releases/download/${TAG}"
256256
curl -fsSLO "${ASSET_BASE}/checksums.txt"
257257
curl -fsSLO "${ASSET_BASE}/checksums.txt.sig"
@@ -275,8 +275,8 @@ cosign verify "ghcr.io/agoraio/agora-cli:${TAG#v}" \
275275
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
276276
```
277277

278-
To audit dependencies, download the `*.spdx.json` SBOM that ships next to each archive (e.g. `agora-cli-go_v0.1.7_linux_amd64.tar.gz.spdx.json`) and feed it to a scanner such as [Grype](https://github.com/anchore/grype):
278+
To audit dependencies, download the `*.spdx.json` SBOM that ships next to each archive (e.g. `agora-cli-go_v0.1.8_linux_amd64.tar.gz.spdx.json`) and feed it to a scanner such as [Grype](https://github.com/anchore/grype):
279279

280280
```bash
281-
grype sbom:agora-cli-go_v0.1.7_linux_amd64.tar.gz.spdx.json
281+
grype sbom:agora-cli-go_v0.1.8_linux_amd64.tar.gz.spdx.json
282282
```

install.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex
1313
#
1414
# Pin a version:
15-
# $env:VERSION = '0.1.7'; & ([scriptblock]::Create((irm .../install.ps1)))
15+
# $env:VERSION = '0.1.8'; & ([scriptblock]::Create((irm .../install.ps1)))
1616
#
1717
[CmdletBinding()]
1818
param(

internal/cli/app_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"net/http"
88
"net/http/httptest"
9+
"net/url"
910
"os"
1011
"path/filepath"
1112
"regexp"
@@ -561,6 +562,36 @@ func TestWaitForOAuthCallbackMismatchAndTimeout(t *testing.T) {
561562
}
562563
}
563564

565+
func TestWaitForOAuthCallbackAcceptsIPv4WhenRedirectUsesLocalhost(t *testing.T) {
566+
server, err := waitForOAuthCallback("expected-state", time.Second)
567+
if err != nil {
568+
t.Fatal(err)
569+
}
570+
defer server.Close()
571+
if !strings.HasPrefix(server.RedirectURI, "http://localhost:") {
572+
t.Fatalf("expected localhost redirect URI for OAuth compatibility, got %s", server.RedirectURI)
573+
}
574+
if len(server.listeners) == 0 {
575+
t.Fatal("expected at least one loopback listener")
576+
}
577+
parsed, err := url.Parse(server.RedirectURI)
578+
if err != nil {
579+
t.Fatal(err)
580+
}
581+
resp, err := http.Get("http://127.0.0.1:" + parsed.Port() + "/oauth/callback?code=test-code&state=expected-state")
582+
if err != nil {
583+
t.Fatal(err)
584+
}
585+
resp.Body.Close()
586+
payload, err := server.Wait()
587+
if err != nil {
588+
t.Fatal(err)
589+
}
590+
if payload.Code != "test-code" || payload.State != "expected-state" {
591+
t.Fatalf("unexpected callback payload: %+v", payload)
592+
}
593+
}
594+
564595
func TestExchangeAuthorizationCodeFailureAndScopeArray(t *testing.T) {
565596
failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
566597
w.WriteHeader(http.StatusBadRequest)

internal/cli/auth.go

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -158,24 +158,29 @@ func randomToken(n int) (string, error) {
158158
}
159159

160160
func openBrowser(target string) bool {
161-
var cmd *exec.Cmd
162-
switch runtime.GOOS {
161+
name, args := browserOpenCommand(runtime.GOOS, target)
162+
return exec.Command(name, args...).Start() == nil
163+
}
164+
165+
func browserOpenCommand(goos, target string) (string, []string) {
166+
switch goos {
163167
case "darwin":
164-
cmd = exec.Command("open", target)
168+
return "open", []string{target}
165169
case "windows":
166-
cmd = exec.Command("cmd", "/c", "start", "", target)
170+
// Avoid `cmd /c start` because unescaped '&' in OAuth query strings can
171+
// be interpreted by cmd.exe, truncating the URL before PKCE parameters.
172+
return "rundll32", []string{"url.dll,FileProtocolHandler", target}
167173
default:
168-
cmd = exec.Command("xdg-open", target)
174+
return "xdg-open", []string{target}
169175
}
170-
return cmd.Start() == nil
171176
}
172177

173178
type callbackServer struct {
174179
RedirectURI string
175180
wait chan callbackPayload
176181
errs chan error
177182
server *http.Server
178-
listener net.Listener
183+
listeners []net.Listener
179184
}
180185

181186
type callbackPayload struct {
@@ -190,19 +195,24 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac
190195
srv := &http.Server{
191196
Handler: mux,
192197
// Set ReadHeaderTimeout to mitigate Slowloris attacks (gosec G112).
193-
// Even though this listens only on 127.0.0.1, we still bound it.
198+
// Even though this listens only on loopback interfaces, we still bound it.
194199
ReadHeaderTimeout: 10 * time.Second,
195200
}
196-
ln, err := net.Listen("tcp", "127.0.0.1:0")
201+
ln4, err := net.Listen("tcp4", "127.0.0.1:0")
197202
if err != nil {
198203
return nil, err
199204
}
205+
port := ln4.Addr().(*net.TCPAddr).Port
206+
listeners := []net.Listener{ln4}
207+
if ln6, err := net.Listen("tcp6", fmt.Sprintf("[::1]:%d", port)); err == nil {
208+
listeners = append(listeners, ln6)
209+
}
200210
cs := &callbackServer{
201-
RedirectURI: fmt.Sprintf("http://localhost:%d/oauth/callback", ln.Addr().(*net.TCPAddr).Port),
211+
RedirectURI: fmt.Sprintf("http://localhost:%d/oauth/callback", port),
202212
wait: wait,
203213
errs: errs,
204214
server: srv,
205-
listener: ln,
215+
listeners: listeners,
206216
}
207217
mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
208218
code := r.URL.Query().Get("code")
@@ -223,9 +233,11 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac
223233
wait <- callbackPayload{Code: code, State: state}
224234
}
225235
})
226-
go func() {
227-
_ = srv.Serve(ln)
228-
}()
236+
for _, listener := range listeners {
237+
go func(ln net.Listener) {
238+
_ = srv.Serve(ln)
239+
}(listener)
240+
}
229241
go func() {
230242
<-time.After(timeout)
231243
errs <- errors.New("Timed out waiting for the OAuth callback. Re-run with --no-browser to copy the URL manually, or check that your browser completed the login flow.")
@@ -243,7 +255,16 @@ func (c *callbackServer) Wait() (callbackPayload, error) {
243255
}
244256

245257
func (c *callbackServer) Close() error {
246-
return c.server.Close()
258+
var firstErr error
259+
if err := c.server.Close(); err != nil {
260+
firstErr = err
261+
}
262+
for _, listener := range c.listeners {
263+
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) && firstErr == nil {
264+
firstErr = err
265+
}
266+
}
267+
return firstErr
247268
}
248269

249270
type tokenResponse struct {

internal/cli/auth_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"bytes"
5+
"reflect"
56
"strings"
67
"testing"
78
)
@@ -56,3 +57,20 @@ func TestEnsureValidAccessTokenSkipsPromptInJSONMode(t *testing.T) {
5657
t.Fatalf("expected missing session error, got %v", err)
5758
}
5859
}
60+
61+
func TestBrowserOpenCommandWindowsPreservesOAuthQuery(t *testing.T) {
62+
target := "https://sso.example/authorize?response_type=code&code_challenge=abc&code_challenge_method=S256&state=xyz"
63+
name, args := browserOpenCommand("windows", target)
64+
if name != "rundll32" {
65+
t.Fatalf("expected rundll32 opener, got %s", name)
66+
}
67+
expected := []string{"url.dll,FileProtocolHandler", target}
68+
if !reflect.DeepEqual(args, expected) {
69+
t.Fatalf("unexpected args: %#v", args)
70+
}
71+
for _, arg := range args {
72+
if arg == "cmd" || arg == "/c" || arg == "start" {
73+
t.Fatalf("windows opener must not shell through cmd.exe, got %#v", args)
74+
}
75+
}
76+
}

internal/cli/integration_auth_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ func TestCLILoginAndWhoAmIParity(t *testing.T) {
5555
if len(oauth.authorizeRedirectURIs) != 1 || !strings.Contains(oauth.authorizeRedirectURIs[0], "http://localhost:") {
5656
t.Fatalf("expected localhost redirect URI, got %+v", oauth.authorizeRedirectURIs)
5757
}
58+
if len(oauth.authorizeRawQueries) != 1 || !strings.Contains(oauth.authorizeRawQueries[0], "code_challenge=") || !strings.Contains(oauth.authorizeRawQueries[0], "code_challenge_method=S256") {
59+
t.Fatalf("expected authorize URL to include PKCE challenge, got %+v", oauth.authorizeRawQueries)
60+
}
61+
if len(oauth.tokenRequests) != 1 || !strings.Contains(oauth.tokenRequests[0], "code_verifier=") {
62+
t.Fatalf("expected token request to include PKCE verifier, got %+v", oauth.tokenRequests)
63+
}
5864
var envelope map[string]any
5965
if err := json.Unmarshal([]byte(status.stdout), &envelope); err != nil {
6066
t.Fatal(err)

0 commit comments

Comments
 (0)