mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7353aad8dc | ||
|
|
17fcd26602 | ||
|
|
15ff1704a4 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.13.0"}
|
||||
{".":"0.13.1"}
|
||||
@@ -6,6 +6,17 @@ 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,6 +75,31 @@ 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,11 +1596,15 @@ 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", "ZERO_UPDATE_RELEASE_URL"} {
|
||||
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,6 +207,13 @@ 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
|
||||
}
|
||||
@@ -212,15 +212,34 @@ type errorPayload struct {
|
||||
// response.output_item.added, the argument deltas, and the final
|
||||
// response.output_item.done.
|
||||
type toolCallBuilder struct {
|
||||
ID string
|
||||
CallID string
|
||||
Name string
|
||||
ID string
|
||||
CallID string
|
||||
Name string
|
||||
// Arguments holds exactly the input already forwarded to the runtime as
|
||||
// tool-call deltas: every write goes through forwardInput, so an empty
|
||||
// builder means the runtime has received nothing for this call yet. The
|
||||
// done handler relies on that to forward input that only arrived whole
|
||||
// (on the added or done item) without duplicating streamed fragments.
|
||||
Arguments strings.Builder
|
||||
Freeform bool
|
||||
started bool
|
||||
ended bool
|
||||
}
|
||||
|
||||
// forwardInput appends fragment to the builder and emits the matching
|
||||
// tool-call delta, keeping the builder and the runtime in step.
|
||||
func (builder *toolCallBuilder) forwardInput(ctx context.Context, events chan<- zeroruntime.StreamEvent, fragment string) {
|
||||
if fragment == "" {
|
||||
return
|
||||
}
|
||||
builder.Arguments.WriteString(fragment)
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallDelta,
|
||||
ToolCallID: builder.ID,
|
||||
ArgumentsFragment: fragment,
|
||||
})
|
||||
}
|
||||
|
||||
// responsesState tracks in-flight tool calls and whether the terminal
|
||||
// event has been emitted. It is the Responses-API equivalent of the
|
||||
// chat-completions toolState in provider.go.
|
||||
@@ -726,9 +745,6 @@ func (p *CodexProvider) handleOutputItemAdded(
|
||||
builder.Name = event.Item.Name
|
||||
}
|
||||
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
|
||||
if builder.Freeform && event.Item.Input != "" && builder.Arguments.Len() == 0 {
|
||||
builder.Arguments.WriteString(event.Item.Input)
|
||||
}
|
||||
state.emitted = true
|
||||
if !builder.started {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
@@ -740,6 +756,11 @@ func (p *CodexProvider) handleOutputItemAdded(
|
||||
})
|
||||
builder.started = true
|
||||
}
|
||||
// A custom tool call may arrive with its whole input on the added item
|
||||
// instead of input deltas; the runtime needs it as a delta either way.
|
||||
if builder.Freeform && builder.Arguments.Len() == 0 {
|
||||
builder.forwardInput(ctx, events, event.Item.Input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,13 +794,8 @@ func (p *CodexProvider) handleToolInputDelta(
|
||||
if event.CallID != "" && builder.CallID == "" {
|
||||
builder.CallID = event.CallID
|
||||
}
|
||||
builder.Arguments.WriteString(event.Delta)
|
||||
state.emitted = true
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallDelta,
|
||||
ToolCallID: key,
|
||||
ArgumentsFragment: event.Delta,
|
||||
})
|
||||
builder.forwardInput(ctx, events, event.Delta)
|
||||
}
|
||||
|
||||
// handleOutputItemDone finalizes a tool call when the item type is
|
||||
@@ -813,13 +829,6 @@ func (p *CodexProvider) handleOutputItemDone(
|
||||
builder.CallID = event.Item.CallID
|
||||
}
|
||||
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
|
||||
finalInput := event.Item.Arguments
|
||||
if builder.Freeform {
|
||||
finalInput = event.Item.Input
|
||||
}
|
||||
if finalInput != "" && builder.Arguments.Len() == 0 {
|
||||
builder.Arguments.WriteString(finalInput)
|
||||
}
|
||||
if !builder.started {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallStart,
|
||||
@@ -830,6 +839,16 @@ func (p *CodexProvider) handleOutputItemDone(
|
||||
})
|
||||
builder.started = true
|
||||
}
|
||||
// The done item repeats the complete input. When nothing reached the
|
||||
// runtime through deltas (or the added item), this is the only copy, so
|
||||
// forward it before closing the call; otherwise the streamed copy stands.
|
||||
if builder.Arguments.Len() == 0 {
|
||||
finalInput := event.Item.Arguments
|
||||
if builder.Freeform {
|
||||
finalInput = event.Item.Input
|
||||
}
|
||||
builder.forwardInput(ctx, events, finalInput)
|
||||
}
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventToolCallEnd,
|
||||
ToolCallID: key,
|
||||
|
||||
@@ -903,6 +903,145 @@ func TestCodexProviderParsesResponsesToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime only sees tool input through tool-call deltas. The Codex
|
||||
// backend may deliver that input incrementally, whole on the done item, or
|
||||
// (for custom tool calls) whole on the added item; whichever way it arrives,
|
||||
// the deltas before tool-call-end must carry it exactly once, and the replayed
|
||||
// history item must hold the same input.
|
||||
func TestCodexOutputItemDoneForwardsUnstreamedArguments(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
functionArguments = `{"path":"README.md"}`
|
||||
customInput = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
|
||||
)
|
||||
cases := []struct {
|
||||
name string
|
||||
freeform bool
|
||||
added bool // response.output_item.added precedes the input
|
||||
addedInput bool // the added item already carries the whole input
|
||||
streamed bool // input arrives through *_delta events
|
||||
}{
|
||||
{name: "function/added/streamed", added: true, streamed: true},
|
||||
{name: "function/added/final-only", added: true},
|
||||
{name: "function/no-added/streamed", streamed: true},
|
||||
{name: "function/no-added/final-only"},
|
||||
{name: "custom/added/streamed", freeform: true, added: true, streamed: true},
|
||||
{name: "custom/added/final-only", freeform: true, added: true},
|
||||
{name: "custom/no-added/streamed", freeform: true, streamed: true},
|
||||
{name: "custom/no-added/final-only", freeform: true},
|
||||
{name: "custom/input-on-added", freeform: true, added: true, addedInput: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
state := newResponsesState()
|
||||
events := make(chan zeroruntime.StreamEvent, 10)
|
||||
provider := &CodexProvider{}
|
||||
item := &itemPayload{Type: "function_call", ID: "item-1", CallID: "call-1", Name: "read_file", Arguments: functionArguments}
|
||||
want := functionArguments
|
||||
if tc.freeform {
|
||||
item.Type = "custom_tool_call"
|
||||
item.Name = "apply_patch"
|
||||
item.Arguments = ""
|
||||
item.Input = customInput
|
||||
want = customInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
if tc.added {
|
||||
initial := *item
|
||||
if !tc.addedInput {
|
||||
initial.Arguments = ""
|
||||
initial.Input = ""
|
||||
}
|
||||
provider.handleOutputItemAdded(ctx, &responsesEvent{Item: &initial}, state, events)
|
||||
}
|
||||
if tc.streamed {
|
||||
provider.handleToolInputDelta(ctx, &responsesEvent{ItemID: item.ID, Delta: want}, state, events, tc.freeform)
|
||||
}
|
||||
provider.handleOutputItemDone(ctx, &responsesEvent{Item: item}, state, events)
|
||||
close(events)
|
||||
var arguments strings.Builder
|
||||
var starts, ends int
|
||||
for event := range events {
|
||||
switch event.Type {
|
||||
case zeroruntime.StreamEventToolCallStart:
|
||||
starts++
|
||||
case zeroruntime.StreamEventToolCallDelta:
|
||||
arguments.WriteString(event.ArgumentsFragment)
|
||||
case zeroruntime.StreamEventToolCallEnd:
|
||||
ends++
|
||||
if arguments.String() != want {
|
||||
t.Errorf("arguments at tool-call-end = %q, want %q", arguments.String(), want)
|
||||
}
|
||||
}
|
||||
if event.ToolCallID != item.ID {
|
||||
t.Errorf("tool call ID = %q, want %q", event.ToolCallID, item.ID)
|
||||
}
|
||||
}
|
||||
if starts != 1 || ends != 1 {
|
||||
t.Errorf("starts=%d ends=%d, want 1 each", starts, ends)
|
||||
}
|
||||
got := state.outputInputItems()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("output items = %v, want one tool call", got)
|
||||
}
|
||||
replayed := got[0].Arguments
|
||||
if tc.freeform {
|
||||
replayed = got[0].Input
|
||||
}
|
||||
if replayed != want {
|
||||
t.Errorf("replayed history input = %q, want %q", replayed, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The same cases through the runtime's collector, which is what the agent
|
||||
// loop consumes: a function call whose arguments exist only on the done item
|
||||
// and a custom tool call whose input exists only on the added item both reach
|
||||
// the runtime with their input intact.
|
||||
func TestCodexStreamCollectsToolInputDeliveredWhole(t *testing.T) {
|
||||
t.Parallel()
|
||||
var rec codexRequest
|
||||
srv := newCodexResponsesServer(t, &rec,
|
||||
`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"read_file"}}`,
|
||||
`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"read_file","arguments":"{\"path\":\"README.md\"}"}}`,
|
||||
`{"type":"response.output_item.added","output_index":1,"item":{"type":"custom_tool_call","id":"ct-1","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch\n"}}`,
|
||||
`{"type":"response.output_item.done","output_index":1,"item":{"type":"custom_tool_call","id":"ct-1","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch\n"}}`,
|
||||
`{"type":"response.completed","response":{"id":"resp-1","status":"completed","usage":{"input_tokens":3,"output_tokens":2}}}`,
|
||||
)
|
||||
defer srv.Close()
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "read the readme"}},
|
||||
Tools: []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
|
||||
{Name: "apply_patch", Type: zeroruntime.ToolDefinitionFreeform},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
collected := zeroruntime.CollectStream(context.Background(), stream)
|
||||
if collected.Error != "" {
|
||||
t.Fatalf("unexpected stream error: %q", collected.Error)
|
||||
}
|
||||
if len(collected.ToolCalls) != 2 {
|
||||
t.Fatalf("tool calls = %+v, want read_file and apply_patch", collected.ToolCalls)
|
||||
}
|
||||
if call := collected.ToolCalls[0]; call.Name != "read_file" || call.Arguments != `{"path":"README.md"}` || call.Freeform {
|
||||
t.Errorf("function call = %+v, want read_file with the done item's arguments", call)
|
||||
}
|
||||
if call := collected.ToolCalls[1]; call.Name != "apply_patch" || call.Arguments != "*** Begin Patch\n*** End Patch\n" || !call.Freeform {
|
||||
t.Errorf("custom call = %+v, want apply_patch with the added item's input", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderSendsAssistantToolCallsAsInputItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
// When the runtime replays a prior assistant turn that issued a tool
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
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,6 +3,7 @@ package update
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -17,8 +18,19 @@ 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"`
|
||||
@@ -129,7 +141,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("ZERO_UPDATE_RELEASE_URL")), repository)
|
||||
endpoint, err := resolveEndpoint(firstNonEmpty(options.Endpoint, os.Getenv(EnvUpdateReleaseURL)), repository)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
@@ -203,7 +215,7 @@ func upgradeSourceFlag(options Options) string {
|
||||
if endpoint := strings.TrimSpace(options.Endpoint); endpoint != "" {
|
||||
return "--endpoint " + shellQuote(endpoint)
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("ZERO_UPDATE_RELEASE_URL")) != "" {
|
||||
if strings.TrimSpace(os.Getenv(EnvUpdateReleaseURL)) != "" {
|
||||
return ""
|
||||
}
|
||||
if repository := strings.TrimSpace(options.Repository); repository != "" && repository != DefaultRepository {
|
||||
@@ -340,6 +352,14 @@ 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)
|
||||
@@ -350,7 +370,30 @@ 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")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
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)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@twigpine/zero",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@twigpine/zero",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@twigpine/zero",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "Zero — a fast, multi-provider terminal coding agent.",
|
||||
"type": "module",
|
||||
"module": "bin/zero.js",
|
||||
|
||||
Reference in new issue
Block a user