Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
.superpowers/
docs/superpowers/
zero-linux-sandbox
16 changes: 16 additions & 0 deletions docs/UPDATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,19 @@ Endpoint resolution order:
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
before reinstalling.

## Authentication

Unauthenticated GitHub API requests are subject to strict
[rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api).
If you see `403 Forbidden` during update checks, set a GitHub personal access token:

| Environment variable | Role |
|---|---|
| `ZERO_GITHUB_TOKEN` | Used for update checks (takes precedence) |
| `GITHUB_TOKEN` | Fallback when `ZERO_GITHUB_TOKEN` is not set |

Tokens are **only** sent to `https://api.github.com`. Custom endpoints (set via
`--endpoint` or `ZERO_UPDATE_RELEASE_URL`) and plain HTTP URLs never receive
credentials, so you can safely point Zero at a private mirror without leaking
your token.
2 changes: 1 addition & 1 deletion internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1291,7 +1291,7 @@ func TestRunUpdateHelpDocumentsCheckFlag(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
for _, want := range []string{"--check", "--repo", "--endpoint", "--timeout", "--target"} {
for _, want := range []string{"--check", "--repo", "--endpoint", "--timeout", "--target", "ZERO_GITHUB_TOKEN", "GITHUB_TOKEN"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("expected update help to document %s, got %q", want, stdout.String())
}
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ Flags:
--timeout <duration> Release check timeout (default 5s)
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
-h, --help Show this help
Environment:
ZERO_GITHUB_TOKEN Token for update checks (takes precedence over GITHUB_TOKEN)
Only sent to https://api.github.com; never sent to custom endpoints
ZERO_UPDATE_RELEASE_URL Override the release API URL (same as --endpoint)
`)
return err
}
128 changes: 128 additions & 0 deletions internal/update/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package update

import (
"context"
"io"
"net/http"
"strings"
"testing"
)

// authTransport intercepts HTTP requests and records the Authorization header
// so tests can verify whether fetchRelease sent credentials.
type authTransport struct {
t *testing.T
receivedAuth string
responseBody string
responseCode int
}

func (at *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
at.receivedAuth = req.Header.Get("Authorization")
at.t.Logf("authTransport: %s %s → Authorization: %q", req.Method, req.URL.String(), at.receivedAuth)
if at.responseBody == "" {
at.responseBody = `{"tag_name":"v0.2.0","html_url":"https://example.test/release","assets":[{"name":"zero-v0.2.0-linux-x64.tar.gz","browser_download_url":"https://example.test/zero-v0.2.0-linux-x64.tar.gz"},{"name":"zero-v0.2.0-linux-x64.tar.gz.sha256","browser_download_url":"https://example.test/zero-v0.2.0-linux-x64.tar.gz.sha256"}]}`
}
if at.responseCode == 0 {
at.responseCode = 200
}
return &http.Response{
StatusCode: at.responseCode,
Body: io.NopCloser(strings.NewReader(at.responseBody)),
Header: make(http.Header),
}, nil
}

func TestFetchReleaseSendsAuthToHttpsGithub(t *testing.T) {
// ZERO_GITHUB_TOKEN → sent to https://api.github.com
at := &authTransport{t: t}
oldClient := http.DefaultClient
http.DefaultClient = &http.Client{Transport: at}
t.Cleanup(func() { http.DefaultClient = oldClient })

t.Setenv("ZERO_GITHUB_TOKEN", "zero_token")
t.Setenv("GITHUB_TOKEN", "github_fallback")

_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Logf("fetchRelease returned error (expected for fake response): %v", err)
}
if at.receivedAuth != "Bearer zero_token" {
t.Fatalf("ZERO_GITHUB_TOKEN should take precedence: got Authorization %q, want %q", at.receivedAuth, "Bearer zero_token")
}
}

func TestFetchReleaseFallsBackToGithubToken(t *testing.T) {
// Only GITHUB_TOKEN set → sent to https://api.github.com
at := &authTransport{t: t}
oldClient := http.DefaultClient
http.DefaultClient = &http.Client{Transport: at}
t.Cleanup(func() { http.DefaultClient = oldClient })

t.Setenv("GITHUB_TOKEN", "fallback_token")
t.Setenv("ZERO_GITHUB_TOKEN", "") // clear ambient precedence var

_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Logf("fetchRelease returned error (expected for fake response): %v", err)
}
if at.receivedAuth != "Bearer fallback_token" {
t.Fatalf("GITHUB_TOKEN should be used as fallback: got Authorization %q, want %q", at.receivedAuth, "Bearer fallback_token")
}
}

func TestFetchReleaseNoAuthToCustomEndpoint(t *testing.T) {
// Token set but endpoint is custom host → no auth sent
at := &authTransport{t: t}
oldClient := http.DefaultClient
http.DefaultClient = &http.Client{Transport: at}
t.Cleanup(func() { http.DefaultClient = oldClient })

t.Setenv("ZERO_GITHUB_TOKEN", "secret")
t.Setenv("GITHUB_TOKEN", "fallback")

_, err := fetchRelease(context.Background(), "https://internal.mirror.example.com/releases/latest")
if err != nil {
t.Logf("fetchRelease returned error (expected for fake response): %v", err)
}
if at.receivedAuth != "" {
t.Fatalf("auth should not be sent to custom endpoint: got Authorization %q", at.receivedAuth)
}
}

func TestFetchReleaseNoAuthToHttpGithub(t *testing.T) {
// Token set but scheme is HTTP → no auth sent even to api.github.com
at := &authTransport{t: t}
oldClient := http.DefaultClient
http.DefaultClient = &http.Client{Transport: at}
t.Cleanup(func() { http.DefaultClient = oldClient })

t.Setenv("ZERO_GITHUB_TOKEN", "secret")

_, err := fetchRelease(context.Background(), "http://api.github.com/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Logf("fetchRelease returned error (expected for fake response): %v", err)
}
if at.receivedAuth != "" {
t.Fatalf("auth should not be sent over HTTP: got Authorization %q", at.receivedAuth)
}
}

func TestFetchReleaseNoAuthWhenTokensNotSet(t *testing.T) {
// No env vars set → no auth sent (authenticated fallback)
at := &authTransport{t: t}
oldClient := http.DefaultClient
http.DefaultClient = &http.Client{Transport: at}
t.Cleanup(func() { http.DefaultClient = oldClient })

t.Setenv("ZERO_GITHUB_TOKEN", "")
t.Setenv("GITHUB_TOKEN", "")

_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
if err != nil {
t.Logf("fetchRelease returned error (expected for fake response): %v", err)
}
if at.receivedAuth != "" {
t.Fatalf("no auth should be sent when no tokens set: got Authorization %q", at.receivedAuth)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
8 changes: 8 additions & 0 deletions internal/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import (
const (
DefaultRepository = "Gitlawb/zero"
DefaultTimeout = 5 * time.Second
// EnvUpdateToken is read by fetchRelease to authenticate GitHub API
// requests. ZERO_GITHUB_TOKEN takes precedence over GITHUB_TOKEN.
EnvUpdateToken = "ZERO_GITHUB_TOKEN"
)

type Release struct {
Expand Down Expand Up @@ -251,6 +254,11 @@ func fetchRelease(ctx context.Context, endpoint string) (release Release, err er
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("User-Agent", "zero/update")
if token := os.Getenv(EnvUpdateToken); token != "" && request.URL.Scheme == "https" && strings.EqualFold(request.URL.Hostname(), "api.github.com") {
request.Header.Set("Authorization", "Bearer "+token)
} else if token := os.Getenv("GITHUB_TOKEN"); token != "" && request.URL.Scheme == "https" && strings.EqualFold(request.URL.Hostname(), "api.github.com") {
request.Header.Set("Authorization", "Bearer "+token)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return Release{}, err
Expand Down
Loading