mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 05:01:36 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ebf5a6de |
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -1378,43 +1378,14 @@ func TestResolveAppliesOpenGatewayCatalogDefaults(t *testing.T) {
|
||||
if resolved.Provider.BaseURL != "https://api.twigpine.com/v1" {
|
||||
t.Fatalf("BaseURL = %q, want https://api.twigpine.com/v1", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Model != "auto" {
|
||||
t.Fatalf("Model = %q, want auto", resolved.Provider.Model)
|
||||
if resolved.Provider.Model != "xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("Model = %q, want xiaomi/mimo-v2.6-pro", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.APIKeyEnv != "GITLAWB_OPENGATEWAY_API_KEY" || resolved.Provider.APIKey != "ogw_live_test" {
|
||||
t.Fatalf("credential = (%q, %q), want GITLAWB_OPENGATEWAY_API_KEY env-resolved key", resolved.Provider.APIKeyEnv, resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalog default only fills a profile that names no model. A profile saved
|
||||
// while MiMo V2.6 Pro was the default persisted that id, and must keep it now
|
||||
// that the default is the smart router.
|
||||
func TestResolveKeepsSavedOpenGatewayModel(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "gitlawb-opengateway",
|
||||
"providers": [{
|
||||
"name": "gitlawb-opengateway",
|
||||
"catalogID": "gitlawb-opengateway",
|
||||
"providerKind": "openai-compatible",
|
||||
"baseURL": "https://api.twigpine.com/v1",
|
||||
"model": "xiaomi/mimo-v2.6-pro"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"GITLAWB_OPENGATEWAY_API_KEY": "ogw_live_test",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Provider.Model != "xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("Model = %q, want the saved model kept", resolved.Provider.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// Profiles saved before the Twigpine rename persist the catalog's old base URL
|
||||
// (https://opengateway.gitlawb.com/v1) explicitly. That host still serves the
|
||||
// gateway, so those configs must keep resolving exactly as before: same host,
|
||||
|
||||
@@ -111,14 +111,11 @@ var descriptors = []Descriptor{
|
||||
// Bearer ogw_live_… key; listed first and badged in every picker. The public
|
||||
// GET /v1/models lists only active models and is what pickers show when
|
||||
// reachable. DefaultModel must be an exact id from that list (not an alias)
|
||||
// so onboarding keeps it selected after live discovery. It is the gateway's
|
||||
// smart router, "auto": an account with no credits is routed to a free
|
||||
// model instead of failing with 402 on a paid one. Only new setups and
|
||||
// profiles that name no model get it; a saved model is kept. The id and env var
|
||||
// so onboarding keeps it selected after live discovery. The id and env var
|
||||
// keep their gitlawb names so saved configs keep resolving; the old host
|
||||
// (opengateway.gitlawb.com) still serves clients and stays a legacy catalog
|
||||
// endpoint so profiles saved against it resolve unchanged.
|
||||
recommended(withLegacyBaseURLs(openAICompat("gitlawb-opengateway", "Twigpine OpenGateway", "https://api.twigpine.com/v1", "auto", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway", "twigpine opengateway", "twigpine"), "https://opengateway.gitlawb.com/v1")),
|
||||
recommended(withLegacyBaseURLs(openAICompat("gitlawb-opengateway", "Twigpine OpenGateway", "https://api.twigpine.com/v1", "xiaomi/mimo-v2.6-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway", "twigpine opengateway", "twigpine"), "https://opengateway.gitlawb.com/v1")),
|
||||
// aimlapi.com — OpenAI-compatible aggregating gateway, also badged as
|
||||
// recommended (second, right after the OpenGateway default). The built-in TUI
|
||||
// onboarding can register/top up an account and save the issued key; the partner
|
||||
|
||||
@@ -115,11 +115,10 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
|
||||
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://api.twigpine.com/v1")
|
||||
}
|
||||
// The default must be an exact id from the live /v1/models list (not a
|
||||
// gateway alias such as "twigpine/auto"): onboarding keeps the default
|
||||
// selected after live discovery only on an exact id match. It is the smart
|
||||
// router so a new account with no credits is routed to a free model.
|
||||
if descriptor.DefaultModel != "auto" {
|
||||
t.Fatalf("OpenGateway default model = %q, want %q", descriptor.DefaultModel, "auto")
|
||||
// gateway alias such as the bare "mimo-v2.6-pro"): onboarding keeps the
|
||||
// default selected after live discovery only on an exact id match.
|
||||
if descriptor.DefaultModel != "xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("OpenGateway default model = %q, want %q", descriptor.DefaultModel, "xiaomi/mimo-v2.6-pro")
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
|
||||
@@ -229,15 +229,12 @@ var curatedModels = map[string][]Model{
|
||||
// GET /v1/models; pickers and onboarding show that list whenever it is
|
||||
// reachable (see providermodeldiscovery.DiscoverCatalog). This is only the
|
||||
// offline fallback, so keep it to ids the gateway is expected to keep
|
||||
// serving: the smart router (the catalog default), MiMo V2.6 Pro, and its
|
||||
// fast sibling. Do not add promo or time-limited models here. Users can
|
||||
// still type any id the gateway accepts. "auto" carries no context window on
|
||||
// purpose: the gateway reports none (the serving model varies per request),
|
||||
// so compaction sizes itself from modelregistry.FallbackContextWindow and
|
||||
// the context gauges show no guessed denominator.
|
||||
// serving: the smart router, the catalog default, and its fast sibling. Do
|
||||
// not add promo or time-limited models here. Users can still type any id
|
||||
// the gateway accepts.
|
||||
"gitlawb-opengateway": {
|
||||
{ID: "auto", Description: "catalog default"},
|
||||
{ID: "xiaomi/mimo-v2.6-pro", Description: "general model (Xiaomi MiMo V2.6 Pro)", ContextWindow: 262_144},
|
||||
{ID: "xiaomi/mimo-v2.6-pro", Description: "catalog default", ContextWindow: 262_144},
|
||||
{ID: "auto", Description: "smart routing (picks a capable model per request)"},
|
||||
{ID: "xiaomi/mimo-v2.6-flash", Description: "fast model (Xiaomi MiMo V2.6 Flash)", ContextWindow: 262_144},
|
||||
},
|
||||
"atomic-chat": {
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
{
|
||||
// Offline fallback only; the live /v1/models list is shown when reachable.
|
||||
provider: "gitlawb-opengateway",
|
||||
want: []string{"auto", "xiaomi/mimo-v2.6-pro", "xiaomi/mimo-v2.6-flash"},
|
||||
want: []string{"xiaomi/mimo-v2.6-pro", "auto", "xiaomi/mimo-v2.6-flash"},
|
||||
notWant: []string{
|
||||
"openai/gpt-4.1", "claude-sonnet-4.5",
|
||||
"mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro", "qwen-plus", "glm-4.6", "gemini-2.5-pro",
|
||||
@@ -116,24 +116,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The offline OpenGateway list leads with the smart router, the catalog
|
||||
// default. The gateway reports no context window for it (the serving model
|
||||
// varies per request), so the curated entry must not invent one: an unknown
|
||||
// window sizes compaction from modelregistry.FallbackContextWindow.
|
||||
func TestOpenGatewayModelsLeadWithAutoDefault(t *testing.T) {
|
||||
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
|
||||
if !ok {
|
||||
t.Fatal("provider gitlawb-opengateway missing from catalog")
|
||||
}
|
||||
models := Models(descriptor)
|
||||
if len(models) == 0 || models[0].ID != "auto" || models[0].ID != descriptor.DefaultModel {
|
||||
t.Fatalf("OpenGateway models = %#v, want the auto default first", modelIDs(models))
|
||||
}
|
||||
if models[0].ContextWindow != 0 {
|
||||
t.Fatalf("auto context window = %d, want unknown (0)", models[0].ContextWindow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsDoNotAliasMutableCatalogState(t *testing.T) {
|
||||
descriptor, ok := providercatalog.Get("minimax")
|
||||
if !ok {
|
||||
|
||||
@@ -69,23 +69,10 @@ func TestParseModelsDevProviderScopesAndMapsMetadata(t *testing.T) {
|
||||
|
||||
func TestParseOpenGatewayCatalogSkipsRetiredModels(t *testing.T) {
|
||||
// Mirrors GET https://api.twigpine.com/v1/models?status=all once the gateway
|
||||
// retires models: the smart router (listed with null metadata, since the
|
||||
// serving model varies per request), an active entry, plus a retired one
|
||||
// carrying a successor.
|
||||
// retires models: an active entry plus a retired one carrying a successor.
|
||||
body := []byte(`{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "auto",
|
||||
"object": "model",
|
||||
"name": "Auto (smart routing)",
|
||||
"description": "picks the cheapest capable model",
|
||||
"aliases": ["twigpine/auto", "gitlawb/auto"],
|
||||
"context_window": null,
|
||||
"pricing": null,
|
||||
"supports_tools": null,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "xiaomi/mimo-v2.6-pro",
|
||||
"object": "model",
|
||||
@@ -113,14 +100,8 @@ func TestParseOpenGatewayCatalogSkipsRetiredModels(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOpenGatewayCatalog returned error: %v", err)
|
||||
}
|
||||
// Sorted by description label (flagship…, picks…).
|
||||
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro,auto" {
|
||||
t.Fatalf("models = %q, want only the active entries", got)
|
||||
}
|
||||
// The router's null context window and pricing parse as unknown (0), not
|
||||
// as an error or a guessed value.
|
||||
if auto := models[1]; auto.ContextWindow != 0 || auto.InputCost != 0 || auto.OutputCost != 0 {
|
||||
t.Fatalf("auto metadata = %#v, want unknown context window and pricing", auto)
|
||||
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("models = %q, want only the active entry", got)
|
||||
}
|
||||
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
|
||||
if !ok {
|
||||
|
||||
@@ -347,13 +347,11 @@ func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDiscoverCatalogOpenGatewayDropsRetiredAndKeepsDefault(t *testing.T) {
|
||||
// Both the catalog fetch and the live /v1/models probe return the smart
|
||||
// router, one active and one retired entry. Users must only ever see the
|
||||
// active ones, and the catalog's default model must be among the ids
|
||||
// discovery returns (onboarding keeps the default selected only on an exact
|
||||
// id match).
|
||||
// Both the catalog fetch and the live /v1/models probe return one active and
|
||||
// one retired entry. Users must only ever see the active one, and the
|
||||
// catalog's default model must be among the ids discovery returns (onboarding
|
||||
// keeps the default selected only on an exact id match).
|
||||
const body = `{"object":"list","data":[
|
||||
{"id":"auto","name":"Auto (smart routing)","context_window":null,"pricing":null,"supports_tools":null,"status":"active"},
|
||||
{"id":"xiaomi/mimo-v2.6-pro","name":"MiMo V2.6-Pro","context_window":262144,"supports_tools":true,"status":"active"},
|
||||
{"id":"qwen/qwen-plus","name":"Qwen Plus","context_window":131072,"status":"retired","successor":"qwen/qwen3.7-max"}
|
||||
]}`
|
||||
@@ -387,8 +385,8 @@ func TestDiscoverCatalogOpenGatewayDropsRetiredAndKeepsDefault(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverCatalog: %v", err)
|
||||
}
|
||||
if got := strings.Join(modelIDs(models), ","); got != "auto,xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("models = %q, want only the active entries", got)
|
||||
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
|
||||
t.Fatalf("models = %q, want only the active entry", got)
|
||||
}
|
||||
if models[0].ID != descriptor.DefaultModel {
|
||||
t.Fatalf("default model %q missing from discovered %v", descriptor.DefaultModel, modelIDs(models))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user