diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5f48a86..85d125d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,7 +18,7 @@ body: attributes: label: CLI version description: Output of `agora --version` - placeholder: "e.g. agora-cli-go 0.1.7 (commit abc1234, built 2026-04-29)" + placeholder: "e.g. agora-cli-go 0.1.8 (commit abc1234, built 2026-04-30)" validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a425b4..38081d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,8 +190,32 @@ jobs: $hash = (Get-FileHash -Path (Join-Path $downloadDir $archive) -Algorithm SHA256).Hash.ToLowerInvariant() Set-Content -Path (Join-Path $downloadDir 'checksums.txt') -Value "$hash $archive" - $server = Start-Process -FilePath python -ArgumentList '-m', 'http.server', '18081', '--directory', $fixtureRoot -PassThru - Start-Sleep -Seconds 2 + $serverOutLog = Join-Path $fixtureRoot 'http-server.out.log' + $serverErrLog = Join-Path $fixtureRoot 'http-server.err.log' + $server = Start-Process -FilePath python -ArgumentList '-m', 'http.server', '18081', '--directory', $fixtureRoot -RedirectStandardOutput $serverOutLog -RedirectStandardError $serverErrLog -PassThru + $archiveUrl = "http://127.0.0.1:18081/download/v$version/$archive" + $serverReady = $false + for ($attempt = 1; $attempt -le 20; $attempt++) { + if ($server.HasExited) { + if (Test-Path -LiteralPath $serverOutLog) { Get-Content -Path $serverOutLog | ForEach-Object { Write-Host $_ } } + if (Test-Path -LiteralPath $serverErrLog) { Get-Content -Path $serverErrLog | ForEach-Object { Write-Host $_ } } + throw "Fixture HTTP server exited before serving $archiveUrl." + } + try { + $response = Invoke-WebRequest -Uri $archiveUrl -Method Head -UseBasicParsing + if ($response.StatusCode -eq 200) { + $serverReady = $true + break + } + } catch { + Start-Sleep -Milliseconds 500 + } + } + if (-not $serverReady) { + if (Test-Path -LiteralPath $serverOutLog) { Get-Content -Path $serverOutLog | ForEach-Object { Write-Host $_ } } + if (Test-Path -LiteralPath $serverErrLog) { Get-Content -Path $serverErrLog | ForEach-Object { Write-Host $_ } } + throw "Fixture HTTP server did not serve $archiveUrl." + } try { $env:VERSION = $version @@ -199,6 +223,9 @@ jobs: $env:RELEASES_PAGE_URL = 'http://127.0.0.1:18081' & ./install.ps1 -InstallDir $installDir + if ($LASTEXITCODE -ne 0) { + throw "install.ps1 failed with exit code $LASTEXITCODE." + } & (Join-Path $installDir 'agora.exe') --help *> $null Set-Content -Path (Join-Path $downloadDir 'checksums.txt') -Value ('0' * 64 + " $archive") diff --git a/CHANGELOG.md b/CHANGELOG.md index c99b466..94efdb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). When tagging a new release, rename the `[Unreleased]` section to the new version -(e.g. `[0.1.7] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top, +(e.g. `[0.1.8] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top, and update the link references at the bottom of this file. 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 ## [Unreleased] +## [0.1.8] - 2026-04-30 + +### Fixed + +- Preserve OAuth PKCE query parameters on Windows by opening browser login URLs through `rundll32 url.dll,FileProtocolHandler` instead of `cmd /c start`. +- Accept OAuth callbacks on both IPv4 and IPv6 localhost loopback addresses so Windows `localhost` resolution does not strand successful browser sign-ins. +- Update the release workflow output wiring to avoid self-referencing step outputs during dry-run and publish-mode setup. + ## [0.1.7] - 2026-04-30 ### Added @@ -105,7 +113,8 @@ Earlier entries pre-date this convention and only carry their version's compare - Support machine-readable JSON output for automation and agent workflows. - Ship automated release packaging through GoReleaser, including cross-platform archives, Linux packages, Homebrew, Scoop, npm wrapper packages, Docker images, and install scripts. -[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.1.7...HEAD +[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.1.8...HEAD +[0.1.8]: https://github.com/AgoraIO/cli/compare/v0.1.7...v0.1.8 [0.1.7]: https://github.com/AgoraIO/cli/compare/v0.1.6...v0.1.7 [0.1.6]: https://github.com/AgoraIO/cli/compare/v0.1.5...v0.1.6 [0.1.5]: https://github.com/AgoraIO/cli/compare/v0.1.4...v0.1.5 diff --git a/RELEASING.md b/RELEASING.md index 09ab499..dcabebb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,8 +5,8 @@ Releases are fully automated via GoReleaser. Pushing a `v*` tag is the only manu ## Release ```bash -git tag v0.1.7 -git push origin v0.1.7 +git tag v0.1.8 +git push origin v0.1.8 ``` The release workflow (`.github/workflows/release.yml`) then: diff --git a/docs/install.md b/docs/install.md index fd4f6e6..c9387b8 100644 --- a/docs/install.md +++ b/docs/install.md @@ -16,7 +16,7 @@ agora --help Install a pinned version: ```bash -curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.1.7 --add-to-path +curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.1.8 --add-to-path agora --help ``` @@ -50,7 +50,7 @@ agora --help Install a pinned version and add the default install directory to your user PATH: ```powershell -$env:VERSION = "0.1.7" +$env:VERSION = "0.1.8" & ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) -AddToPath agora --help ``` @@ -86,7 +86,7 @@ If another managed `agora` install is detected, the installer refuses by default Both direct installers support these core overrides: - `GITHUB_REPO`: install from a fork or alternate repository. -- `VERSION`: install a specific version. Both `0.1.7` and `v0.1.7` are accepted. +- `VERSION`: install a specific version. Both `0.1.8` and `v0.1.8` are accepted. - `INSTALL_DIR`: install to a custom directory. - `GITHUB_TOKEN` or `GH_TOKEN`: optional GitHub token to avoid API rate limits when resolving the latest release. @@ -172,7 +172,7 @@ agora --help npx agoraio-cli --help # Pin a specific version -npm install -g agoraio-cli@0.1.7 +npm install -g agoraio-cli@0.1.8 # Update to the latest published version npm update -g agoraio-cli @@ -200,12 +200,12 @@ For one-off shell sessions, source the generated script according to your shell' If latest-version resolution fails, retry with a pinned version or provide `GITHUB_TOKEN` / `GH_TOKEN`: ```bash -GITHUB_TOKEN=your-token-here VERSION=0.1.7 sh install.sh +GITHUB_TOKEN=your-token-here VERSION=0.1.8 sh install.sh ``` ```powershell $env:GITHUB_TOKEN = "your-token-here" -$env:VERSION = "0.1.7" +$env:VERSION = "0.1.8" & ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) ``` @@ -251,7 +251,7 @@ For CI, automation, and reproducible environments, pin `VERSION` explicitly inst 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: ```bash -TAG=v0.1.7 +TAG=v0.1.8 ASSET_BASE="https://github.com/AgoraIO/cli/releases/download/${TAG}" curl -fsSLO "${ASSET_BASE}/checksums.txt" curl -fsSLO "${ASSET_BASE}/checksums.txt.sig" @@ -275,8 +275,8 @@ cosign verify "ghcr.io/agoraio/agora-cli:${TAG#v}" \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' ``` -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): +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): ```bash -grype sbom:agora-cli-go_v0.1.7_linux_amd64.tar.gz.spdx.json +grype sbom:agora-cli-go_v0.1.8_linux_amd64.tar.gz.spdx.json ``` diff --git a/install.ps1 b/install.ps1 index 4f6c743..8db1ecf 100644 --- a/install.ps1 +++ b/install.ps1 @@ -12,7 +12,7 @@ # irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex # # Pin a version: -# $env:VERSION = '0.1.7'; & ([scriptblock]::Create((irm .../install.ps1))) +# $env:VERSION = '0.1.8'; & ([scriptblock]::Create((irm .../install.ps1))) # [CmdletBinding()] param( diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 992f93c..ca65bc6 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "regexp" @@ -561,6 +562,36 @@ func TestWaitForOAuthCallbackMismatchAndTimeout(t *testing.T) { } } +func TestWaitForOAuthCallbackAcceptsIPv4WhenRedirectUsesLocalhost(t *testing.T) { + server, err := waitForOAuthCallback("expected-state", time.Second) + if err != nil { + t.Fatal(err) + } + defer server.Close() + if !strings.HasPrefix(server.RedirectURI, "http://localhost:") { + t.Fatalf("expected localhost redirect URI for OAuth compatibility, got %s", server.RedirectURI) + } + if len(server.listeners) == 0 { + t.Fatal("expected at least one loopback listener") + } + parsed, err := url.Parse(server.RedirectURI) + if err != nil { + t.Fatal(err) + } + resp, err := http.Get("http://127.0.0.1:" + parsed.Port() + "/oauth/callback?code=test-code&state=expected-state") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + payload, err := server.Wait() + if err != nil { + t.Fatal(err) + } + if payload.Code != "test-code" || payload.State != "expected-state" { + t.Fatalf("unexpected callback payload: %+v", payload) + } +} + func TestExchangeAuthorizationCodeFailureAndScopeArray(t *testing.T) { failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index eabd7cb..1da1da6 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -158,16 +158,21 @@ func randomToken(n int) (string, error) { } func openBrowser(target string) bool { - var cmd *exec.Cmd - switch runtime.GOOS { + name, args := browserOpenCommand(runtime.GOOS, target) + return exec.Command(name, args...).Start() == nil +} + +func browserOpenCommand(goos, target string) (string, []string) { + switch goos { case "darwin": - cmd = exec.Command("open", target) + return "open", []string{target} case "windows": - cmd = exec.Command("cmd", "/c", "start", "", target) + // Avoid `cmd /c start` because unescaped '&' in OAuth query strings can + // be interpreted by cmd.exe, truncating the URL before PKCE parameters. + return "rundll32", []string{"url.dll,FileProtocolHandler", target} default: - cmd = exec.Command("xdg-open", target) + return "xdg-open", []string{target} } - return cmd.Start() == nil } type callbackServer struct { @@ -175,7 +180,7 @@ type callbackServer struct { wait chan callbackPayload errs chan error server *http.Server - listener net.Listener + listeners []net.Listener } type callbackPayload struct { @@ -190,19 +195,24 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac srv := &http.Server{ Handler: mux, // Set ReadHeaderTimeout to mitigate Slowloris attacks (gosec G112). - // Even though this listens only on 127.0.0.1, we still bound it. + // Even though this listens only on loopback interfaces, we still bound it. ReadHeaderTimeout: 10 * time.Second, } - ln, err := net.Listen("tcp", "127.0.0.1:0") + ln4, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { return nil, err } + port := ln4.Addr().(*net.TCPAddr).Port + listeners := []net.Listener{ln4} + if ln6, err := net.Listen("tcp6", fmt.Sprintf("[::1]:%d", port)); err == nil { + listeners = append(listeners, ln6) + } cs := &callbackServer{ - RedirectURI: fmt.Sprintf("http://localhost:%d/oauth/callback", ln.Addr().(*net.TCPAddr).Port), + RedirectURI: fmt.Sprintf("http://localhost:%d/oauth/callback", port), wait: wait, errs: errs, server: srv, - listener: ln, + listeners: listeners, } mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") @@ -223,9 +233,11 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac wait <- callbackPayload{Code: code, State: state} } }) - go func() { - _ = srv.Serve(ln) - }() + for _, listener := range listeners { + go func(ln net.Listener) { + _ = srv.Serve(ln) + }(listener) + } go func() { <-time.After(timeout) 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) { } func (c *callbackServer) Close() error { - return c.server.Close() + var firstErr error + if err := c.server.Close(); err != nil { + firstErr = err + } + for _, listener := range c.listeners { + if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) && firstErr == nil { + firstErr = err + } + } + return firstErr } type tokenResponse struct { diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index ce7b51b..d1a883c 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "reflect" "strings" "testing" ) @@ -56,3 +57,20 @@ func TestEnsureValidAccessTokenSkipsPromptInJSONMode(t *testing.T) { t.Fatalf("expected missing session error, got %v", err) } } + +func TestBrowserOpenCommandWindowsPreservesOAuthQuery(t *testing.T) { + target := "https://sso.example/authorize?response_type=code&code_challenge=abc&code_challenge_method=S256&state=xyz" + name, args := browserOpenCommand("windows", target) + if name != "rundll32" { + t.Fatalf("expected rundll32 opener, got %s", name) + } + expected := []string{"url.dll,FileProtocolHandler", target} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("unexpected args: %#v", args) + } + for _, arg := range args { + if arg == "cmd" || arg == "/c" || arg == "start" { + t.Fatalf("windows opener must not shell through cmd.exe, got %#v", args) + } + } +} diff --git a/internal/cli/integration_auth_test.go b/internal/cli/integration_auth_test.go index 000c5ea..ebba9a3 100644 --- a/internal/cli/integration_auth_test.go +++ b/internal/cli/integration_auth_test.go @@ -55,6 +55,12 @@ func TestCLILoginAndWhoAmIParity(t *testing.T) { if len(oauth.authorizeRedirectURIs) != 1 || !strings.Contains(oauth.authorizeRedirectURIs[0], "http://localhost:") { t.Fatalf("expected localhost redirect URI, got %+v", oauth.authorizeRedirectURIs) } + if len(oauth.authorizeRawQueries) != 1 || !strings.Contains(oauth.authorizeRawQueries[0], "code_challenge=") || !strings.Contains(oauth.authorizeRawQueries[0], "code_challenge_method=S256") { + t.Fatalf("expected authorize URL to include PKCE challenge, got %+v", oauth.authorizeRawQueries) + } + if len(oauth.tokenRequests) != 1 || !strings.Contains(oauth.tokenRequests[0], "code_verifier=") { + t.Fatalf("expected token request to include PKCE verifier, got %+v", oauth.tokenRequests) + } var envelope map[string]any if err := json.Unmarshal([]byte(status.stdout), &envelope); err != nil { t.Fatal(err) diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 296b973..9e5a40f 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -287,6 +287,7 @@ type fakeOAuthServer struct { server *http.Server baseURL string authorizeRedirectURIs []string + authorizeRawQueries []string tokenRequests []string } @@ -302,6 +303,7 @@ func newFakeOAuthServer() *fakeOAuthServer { return } oauth.authorizeRedirectURIs = append(oauth.authorizeRedirectURIs, redirectURI) + oauth.authorizeRawQueries = append(oauth.authorizeRawQueries, r.URL.RawQuery) http.Redirect(w, r, redirectURI+"?code=test-auth-code&state="+state, http.StatusFound) case r.Method == http.MethodPost && r.URL.Path == "/api/v0/oauth/token": body, _ := io.ReadAll(r.Body) diff --git a/internal/cli/version.go b/internal/cli/version.go index 5b6a3c3..e3407cc 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -5,9 +5,9 @@ import "fmt" // Build-time injected version variables. These are populated by ldflags at // release time: // -// go build -ldflags '-X github.com/.../internal/cli.version=v0.1.7 +// go build -ldflags '-X github.com/.../internal/cli.version=v0.1.8 // -X github.com/.../internal/cli.commit=abc1234 -// -X github.com/.../internal/cli.date=2026-04-29' +// -X github.com/.../internal/cli.date=2026-04-30' // // Snapshot/local builds keep the placeholder values below. var (