mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ebf5a6de |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.13.1"}
|
||||
{".":"0.13.0"}
|
||||
@@ -6,17 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.13.1](https://github.com/Twigpine/zero/compare/v0.13.0...v0.13.1) (2026-11-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** stop a denied tool looping past the repeated-failure halt ([#866](https://github.com/Twigpine/zero/issues/866)) ([b32f7ce](https://github.com/Twigpine/zero/commit/b32f7ce4d6e21a5cae1fb4d63079cd0ff7b54886))
|
||||
* **codex:** forward tool input that only arrives whole on an output item ([#1215](https://github.com/Twigpine/zero/issues/1215)) ([15ff170](https://github.com/Twigpine/zero/commit/15ff1704a4c1807f6ad9fe68ef87c510cdb31e18)), closes [#1194](https://github.com/Twigpine/zero/issues/1194)
|
||||
* **mcp:** scrub the injected AI/ML API key from server responses ([#1197](https://github.com/Twigpine/zero/issues/1197)) ([8e9c3ec](https://github.com/Twigpine/zero/commit/8e9c3ec4ce385a69e1ae831df24981eee6dc5ebd))
|
||||
* **tools:** publish file writes atomically instead of truncating in place ([#1214](https://github.com/Twigpine/zero/issues/1214)) ([88ec97d](https://github.com/Twigpine/zero/commit/88ec97d5630be8721e0a3d956f5b198868d0da84))
|
||||
* **update:** authenticate GitHub release checks (supersedes [#504](https://github.com/Twigpine/zero/issues/504)) ([#1216](https://github.com/Twigpine/zero/issues/1216)) ([17fcd26](https://github.com/Twigpine/zero/commit/17fcd26602dc685fd1596739c4d718932d856d9e))
|
||||
|
||||
## [0.13.0](https://github.com/Twigpine/zero/compare/v0.12.0...v0.13.0) (2026-11-09)
|
||||
|
||||
|
||||
|
||||
@@ -75,31 +75,6 @@ Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
|
||||
instead of 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 empty after trimming |
|
||||
|
||||
Surrounding whitespace is removed from both token values before use.
|
||||
|
||||
Note: `GITHUB_TOKEN` is also used by the GitHub Models provider
|
||||
(the `github` descriptor in `internal/providercatalog/catalog.go`); both
|
||||
recipients are GitHub so no trust boundary is crossed, but a token set for
|
||||
the provider will also be sent on update checks.
|
||||
|
||||
Tokens are only sent to HTTPS URLs with the hostname `api.github.com`, including
|
||||
URLs selected through `--endpoint` or `ZERO_UPDATE_RELEASE_URL`. Other hosts
|
||||
and plain HTTP URLs never receive credentials. Redirects that downgrade to
|
||||
`http` after a token-eligible GitHub API request are refused, so the bearer
|
||||
token is never sent in cleartext (Go's standard library would otherwise copy
|
||||
`Authorization` on same-host redirects based on host alone).
|
||||
|
||||
## Windows recovery state (standalone installs)
|
||||
|
||||
This section describes Windows only. On Linux and macOS a standalone update
|
||||
|
||||
@@ -1596,15 +1596,11 @@ 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", "ZERO_GITHUB_TOKEN", "ZERO_UPDATE_RELEASE_URL"} {
|
||||
for _, want := range []string{"--check", "--repo", "--endpoint", "--timeout", "--target"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("expected update help to document %s, got %q", want, stdout.String())
|
||||
}
|
||||
}
|
||||
// The fallback entry must have its own line, apart from the precedence prose.
|
||||
if !strings.Contains(stdout.String(), "\n GITHUB_TOKEN ") {
|
||||
t.Fatalf("expected update help to document a standalone GITHUB_TOKEN entry, got %q", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
|
||||
@@ -207,13 +207,6 @@ 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 URLs on api.github.com, including endpoint overrides;
|
||||
never sent to other hosts or plain HTTP
|
||||
GITHUB_TOKEN Fallback token when ZERO_GITHUB_TOKEN is empty after trimming
|
||||
ZERO_UPDATE_RELEASE_URL Override the release API URL (same as --endpoint)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// releaseJSON returns a minimal GitHub release JSON body.
|
||||
func releaseJSON(tag string) string {
|
||||
return fmt.Sprintf(`{"tag_name":"%s","html_url":"https://example.test/release","assets":[{"name":"%s-linux-x64.tar.gz","browser_download_url":"https://example.test/%s-linux-x64.tar.gz"}]}`, tag, tag, tag)
|
||||
}
|
||||
|
||||
// fakeTransport records the Authorization header from the first request it receives
|
||||
// and returns a canned response. Used to verify auth is sent for GitHub URLs
|
||||
// without needing a real TLS server.
|
||||
type fakeTransport struct {
|
||||
gotAuth string
|
||||
statusCode int
|
||||
body string
|
||||
}
|
||||
|
||||
func (ft *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
ft.gotAuth = req.Header.Get("Authorization")
|
||||
if ft.statusCode == 0 {
|
||||
ft.statusCode = 200
|
||||
}
|
||||
if ft.body == "" {
|
||||
ft.body = releaseJSON("v0.2.0")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: ft.statusCode,
|
||||
Body: io.NopCloser(strings.NewReader(ft.body)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestFetchReleaseSendsAuthToHttpsGithub(t *testing.T) {
|
||||
ft := &fakeTransport{}
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: ft}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "zero_token")
|
||||
t.Setenv(EnvGitHubToken, "github_fallback")
|
||||
|
||||
_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRelease error: %v", err)
|
||||
}
|
||||
if ft.gotAuth != "Bearer zero_token" {
|
||||
t.Fatalf("expected Bearer zero_token, got %q", ft.gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseSendsAuthCaseInsensitive(t *testing.T) {
|
||||
ft := &fakeTransport{}
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: ft}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "zero_token")
|
||||
t.Setenv(EnvGitHubToken, "")
|
||||
|
||||
_, err := fetchRelease(context.Background(), "https://API.GITHUB.COM/repos/Gitlawb/zero/releases/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRelease error: %v", err)
|
||||
}
|
||||
if ft.gotAuth != "Bearer zero_token" {
|
||||
t.Fatalf("expected Bearer zero_token, got %q", ft.gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseFallsBackToGithubToken(t *testing.T) {
|
||||
ft := &fakeTransport{}
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: ft}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvGitHubToken, "fallback_token")
|
||||
t.Setenv(EnvUpdateToken, "")
|
||||
|
||||
_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRelease error: %v", err)
|
||||
}
|
||||
if ft.gotAuth != "Bearer fallback_token" {
|
||||
t.Fatalf("expected Bearer fallback_token, got %q", ft.gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseNormalizesTokens(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
updateToken string
|
||||
githubToken string
|
||||
wantAuth string
|
||||
}{
|
||||
{"blank preferred falls back", " \t\n", "fallback_token", "Bearer fallback_token"},
|
||||
{"trim preferred", " \tzero_token\r\n", "fallback_token", "Bearer zero_token"},
|
||||
{"trim fallback", "", " \tfallback_token\r\n", "Bearer fallback_token"},
|
||||
{"both blank", " \t\n", "\r\n ", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ft := &fakeTransport{}
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: ft}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
t.Setenv(EnvUpdateToken, tc.updateToken)
|
||||
t.Setenv(EnvGitHubToken, tc.githubToken)
|
||||
|
||||
_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRelease error: %v", err)
|
||||
}
|
||||
if ft.gotAuth != tc.wantAuth {
|
||||
t.Fatalf("Authorization = %q, want %q", ft.gotAuth, tc.wantAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseNoAuthToCustomEndpoint(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, releaseJSON("v0.2.0"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
old := httpClient
|
||||
httpClient = srv.Client()
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "secret")
|
||||
t.Setenv(EnvGitHubToken, "fallback")
|
||||
|
||||
_, err := fetchRelease(context.Background(), srv.URL+"/releases/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRelease error: %v", err)
|
||||
}
|
||||
if gotAuth != "" {
|
||||
t.Fatalf("expected no auth for non-GitHub endpoint, got %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseNoAuthToHttpGithub(t *testing.T) {
|
||||
// Verify the scheme guard independently of the hostname guard: an http://
|
||||
// api.github.com request must not carry a token even when tokens are
|
||||
// configured. Use the transport seam with the logical URL so the
|
||||
// githubAPIToken precondition (https + api.github.com) is exercised.
|
||||
calls := 0
|
||||
var firstAuth, firstURL string
|
||||
tr := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
firstAuth = req.Header.Get("Authorization")
|
||||
firstURL = req.URL.String()
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader(releaseJSON("v0.2.0"))),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: tr}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "secret")
|
||||
t.Setenv(EnvGitHubToken, "fallback")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := fetchRelease(ctx, "http://api.github.com/repos/Gitlawb/zero/releases/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRelease error: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected 1 request, got %d", calls)
|
||||
}
|
||||
if firstAuth != "" {
|
||||
t.Fatalf("auth should not be sent over HTTP even with tokens: got %q", firstAuth)
|
||||
}
|
||||
if firstURL != "http://api.github.com/repos/Gitlawb/zero/releases/latest" {
|
||||
t.Fatalf("unexpected URL %q", firstURL)
|
||||
}
|
||||
}
|
||||
|
||||
// roundTripperFunc adapts a function to http.RoundTripper.
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestFetchReleaseNoAuthWhenTokensNotSet(t *testing.T) {
|
||||
ft := &fakeTransport{}
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: ft}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "")
|
||||
t.Setenv(EnvGitHubToken, "")
|
||||
|
||||
_, err := fetchRelease(context.Background(), "https://api.github.com/repos/Gitlawb/zero/releases/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRelease error: %v", err)
|
||||
}
|
||||
if ft.gotAuth != "" {
|
||||
t.Fatalf("expected no auth when no tokens set, got %q", ft.gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseRefusesRedirectToHttp(t *testing.T) {
|
||||
calls := 0
|
||||
var firstAuth, firstURL string
|
||||
tr := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
firstAuth = req.Header.Get("Authorization")
|
||||
firstURL = req.URL.String()
|
||||
return &http.Response{
|
||||
StatusCode: 301,
|
||||
Header: http.Header{"Location": []string{"http://api.github.com/repos/Gitlawb/zero/releases/latest"}},
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
t.Fatalf("HTTP downgrade destination should not be contacted: %s %s calls=%d", req.Method, req.URL.String(), calls)
|
||||
return nil, fmt.Errorf("unexpected request to HTTP destination")
|
||||
})
|
||||
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: tr}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "secret")
|
||||
t.Setenv(EnvGitHubToken, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := fetchRelease(ctx, "https://api.github.com/repos/Gitlawb/zero/releases/latest")
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing redirect") {
|
||||
t.Fatalf("expected redirect refusal error, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected 1 transport call, HTTP destination should not be contacted: got %d", calls)
|
||||
}
|
||||
if firstAuth != "Bearer secret" {
|
||||
t.Fatalf("initial HTTPS request should carry Bearer token: got %q", firstAuth)
|
||||
}
|
||||
if firstURL != "https://api.github.com/repos/Gitlawb/zero/releases/latest" {
|
||||
t.Fatalf("unexpected initial URL %q", firstURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseRejectsUserinfoHostTrick(t *testing.T) {
|
||||
// Userinfo trick: "https://[email protected]/..." — the Go
|
||||
// URL parser treats "api.github.com" as userinfo, making Hostname() =
|
||||
// "evil.example.com". githubAPIToken sees a non-GitHub host and returns "",
|
||||
// so no auth is sent.
|
||||
ft := &fakeTransport{}
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: ft}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "secret")
|
||||
t.Setenv(EnvGitHubToken, "")
|
||||
|
||||
_, _ = fetchRelease(context.Background(), "https://[email protected]/repos/Gitlawb/zero/releases/latest")
|
||||
if strings.HasPrefix(ft.gotAuth, "Bearer ") {
|
||||
t.Fatalf("bearer auth should not be sent when userinfo tricks hostname, got %q", ft.gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Redirect regression tests (jatmn review) ---
|
||||
|
||||
func TestHTTPMirrorRedirectAllowedWithoutCredentials(t *testing.T) {
|
||||
// Mirror server redirects HTTP→HTTP (no credentials on original request).
|
||||
var gotAuth string
|
||||
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, releaseJSON("v0.2.0"))
|
||||
}))
|
||||
defer mirror.Close()
|
||||
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, mirror.URL+"/releases/latest", http.StatusFound)
|
||||
}))
|
||||
defer origin.Close()
|
||||
|
||||
old := httpClient
|
||||
httpClient = origin.Client()
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "")
|
||||
t.Setenv(EnvGitHubToken, "")
|
||||
|
||||
_, err := fetchRelease(context.Background(), origin.URL+"/releases/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP mirror redirect should succeed, got: %v", err)
|
||||
}
|
||||
if gotAuth != "" {
|
||||
t.Fatalf("expected no auth on mirror, got %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMirrorRedirectAllowedWithDummyTokens(t *testing.T) {
|
||||
// Mirror redirect works even with tokens set — tokens are not sent to non-GitHub hosts.
|
||||
var gotAuth string
|
||||
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, releaseJSON("v0.2.0"))
|
||||
}))
|
||||
defer mirror.Close()
|
||||
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, mirror.URL+"/releases/latest", http.StatusFound)
|
||||
}))
|
||||
defer origin.Close()
|
||||
|
||||
old := httpClient
|
||||
httpClient = origin.Client()
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "dummy_token")
|
||||
t.Setenv(EnvGitHubToken, "dummy_fallback")
|
||||
|
||||
_, err := fetchRelease(context.Background(), origin.URL+"/releases/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP mirror redirect should succeed even with tokens, got: %v", err)
|
||||
}
|
||||
if gotAuth != "" {
|
||||
t.Fatalf("expected no auth for non-GitHub mirror, got %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSDowngradeBlockedWhenCredentialsPresent(t *testing.T) {
|
||||
calls := 0
|
||||
var firstAuth string
|
||||
tr := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
firstAuth = req.Header.Get("Authorization")
|
||||
return &http.Response{
|
||||
StatusCode: 301,
|
||||
Header: http.Header{"Location": []string{"http://api.github.com/other"}},
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
t.Fatalf("HTTP downgrade destination should not be contacted: %s %s calls=%d", req.Method, req.URL.String(), calls)
|
||||
return nil, fmt.Errorf("unexpected request")
|
||||
})
|
||||
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: tr}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "secret")
|
||||
t.Setenv(EnvGitHubToken, "fallback_should_not_be_used")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := fetchRelease(ctx, "https://api.github.com/repos/Gitlawb/zero/releases/latest")
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing redirect") {
|
||||
t.Fatalf("expected redirect refusal, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", calls)
|
||||
}
|
||||
if firstAuth != "Bearer secret" {
|
||||
t.Fatalf("expected Bearer secret on first request, got %q", firstAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSSameHostRedirectAllowed(t *testing.T) {
|
||||
// HTTPS server redirects to same HTTPS host — should succeed.
|
||||
var gotPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
if r.URL.Path == "/repos/Gitlawb/zero/releases" {
|
||||
http.Redirect(w, r, "/repos/Gitlawb/zero/releases/latest", http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, releaseJSON("v0.2.0"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
old := httpClient
|
||||
httpClient = srv.Client()
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "token123")
|
||||
t.Setenv(EnvGitHubToken, "")
|
||||
|
||||
_, err := fetchRelease(context.Background(), srv.URL+"/repos/Gitlawb/zero/releases")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPS→HTTPS same-host redirect should succeed, got: %v", err)
|
||||
}
|
||||
if gotPath != "/repos/Gitlawb/zero/releases/latest" {
|
||||
t.Fatalf("expected final path /repos/Gitlawb/zero/releases/latest, got %q", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseAuthenticatedHTTPSRedirects(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
location string
|
||||
wantAuth string
|
||||
}{
|
||||
{"same host", "https://api.github.com/redirected", "Bearer secret"},
|
||||
{"other host", "https://mirror.example.test/redirected", ""},
|
||||
{"subdomain", "https://sub.api.github.com/redirected", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var urls, auth []string
|
||||
tr := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
urls = append(urls, req.URL.String())
|
||||
auth = append(auth, req.Header.Get("Authorization"))
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(releaseJSON("v0.2.0"))),
|
||||
Request: req,
|
||||
}
|
||||
if len(urls) == 1 {
|
||||
response.StatusCode = http.StatusFound
|
||||
response.Header.Set("Location", tc.location)
|
||||
}
|
||||
return response, nil
|
||||
})
|
||||
old := httpClient
|
||||
httpClient = &http.Client{Transport: tr}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
t.Setenv(EnvUpdateToken, "secret")
|
||||
t.Setenv(EnvGitHubToken, "")
|
||||
|
||||
const endpoint = "https://api.github.com/repos/Gitlawb/zero/releases/latest"
|
||||
release, err := fetchRelease(context.Background(), endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRelease error: %v", err)
|
||||
}
|
||||
if len(urls) != 2 || urls[0] != endpoint || urls[1] != tc.location {
|
||||
t.Fatalf("unexpected redirect route: %v", urls)
|
||||
}
|
||||
if auth[0] != "Bearer secret" || auth[1] != tc.wantAuth {
|
||||
t.Fatalf("Authorization per hop = %q, want [%q %q]", auth, "Bearer secret", tc.wantAuth)
|
||||
}
|
||||
if release.TagName != "v0.2.0" {
|
||||
t.Fatalf("release tag = %q, want v0.2.0", release.TagName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectLimitEnforced(t *testing.T) {
|
||||
count := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count++
|
||||
if count > 10 {
|
||||
http.Error(w, "too many", http.StatusLoopDetected)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/loop", http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
old := httpClient
|
||||
httpClient = srv.Client()
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
|
||||
t.Setenv(EnvUpdateToken, "")
|
||||
t.Setenv(EnvGitHubToken, "")
|
||||
|
||||
_, err := fetchRelease(context.Background(), srv.URL+"/loop")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for redirect loop")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "10 redirects") {
|
||||
t.Fatalf("expected redirect limit error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package update
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -18,19 +17,8 @@ import (
|
||||
const (
|
||||
DefaultRepository = "Twigpine/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"
|
||||
// EnvGitHubToken is the fallback token variable used for GitHub API requests.
|
||||
EnvGitHubToken = "GITHUB_TOKEN"
|
||||
// EnvUpdateReleaseURL overrides the release API endpoint.
|
||||
EnvUpdateReleaseURL = "ZERO_UPDATE_RELEASE_URL"
|
||||
)
|
||||
|
||||
// httpClient is the client used by fetchRelease. Overridden in tests to
|
||||
// intercept requests without mutating the global http.DefaultClient.
|
||||
var httpClient = http.DefaultClient
|
||||
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
@@ -141,7 +129,7 @@ func Check(ctx context.Context, options Options) (Result, error) {
|
||||
currentVersion = "0.0.0"
|
||||
}
|
||||
repository := strings.TrimSpace(firstNonEmpty(options.Repository, DefaultRepository))
|
||||
endpoint, err := resolveEndpoint(firstNonEmpty(options.Endpoint, os.Getenv(EnvUpdateReleaseURL)), repository)
|
||||
endpoint, err := resolveEndpoint(firstNonEmpty(options.Endpoint, os.Getenv("ZERO_UPDATE_RELEASE_URL")), repository)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
@@ -215,7 +203,7 @@ func upgradeSourceFlag(options Options) string {
|
||||
if endpoint := strings.TrimSpace(options.Endpoint); endpoint != "" {
|
||||
return "--endpoint " + shellQuote(endpoint)
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv(EnvUpdateReleaseURL)) != "" {
|
||||
if strings.TrimSpace(os.Getenv("ZERO_UPDATE_RELEASE_URL")) != "" {
|
||||
return ""
|
||||
}
|
||||
if repository := strings.TrimSpace(options.Repository); repository != "" && repository != DefaultRepository {
|
||||
@@ -352,14 +340,6 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallM
|
||||
return "Run `zero upgrade` to download, verify, and install the latest release."
|
||||
}
|
||||
|
||||
// githubAPIToken selects a trimmed token only for HTTPS GitHub API URLs.
|
||||
func githubAPIToken(u *url.URL) string {
|
||||
if u.Scheme != "https" || !strings.EqualFold(u.Hostname(), "api.github.com") {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmpty(os.Getenv(EnvUpdateToken), os.Getenv(EnvGitHubToken))
|
||||
}
|
||||
|
||||
func fetchRelease(ctx context.Context, endpoint string) (release Release, err error) {
|
||||
if strings.HasPrefix(endpoint, "data:") {
|
||||
return fetchDataRelease(endpoint)
|
||||
@@ -370,30 +350,7 @@ 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 := githubAPIToken(request.URL); token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: httpClient.Transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
if req.URL.Scheme != "https" {
|
||||
for _, prev := range via {
|
||||
if githubAPIToken(prev.URL) != "" {
|
||||
return errors.New("refusing redirect to non-HTTPS URL")
|
||||
}
|
||||
}
|
||||
}
|
||||
// net/http also forwards Authorization to subdomains; keep the exact-host boundary.
|
||||
if req.URL.Scheme != "https" || !strings.EqualFold(req.URL.Hostname(), "api.github.com") {
|
||||
req.Header.Del("Authorization")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@twigpine/zero",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@twigpine/zero",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@twigpine/zero",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"description": "Zero — a fast, multi-provider terminal coding agent.",
|
||||
"type": "module",
|
||||
"module": "bin/zero.js",
|
||||
|
||||
Reference in new issue
Block a user