diff --git a/README.md b/README.md index 11c7769..23772ae 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,16 @@ make build cp bin/bwai ~/.local/bin/ ``` +## Update + +To update `bwai` to the latest release: + +```sh +bwai update +``` + +This downloads the latest binary from [GitHub releases](https://github.com/umago/bubblewrap-ai/releases), verifies its SHA-256 digest, and replaces the running binary in-place. + ## Usage Run `bwai` from inside the project directory you want to give the agent access to: diff --git a/cmd/bwai/main.go b/cmd/bwai/main.go index 9e5e3cd..3692228 100644 --- a/cmd/bwai/main.go +++ b/cmd/bwai/main.go @@ -11,6 +11,16 @@ import ( ) func main() { + // Subcommand dispatch (before flag parsing to avoid flag conflicts) + if len(os.Args) > 1 && os.Args[1] == "update" { + fs := flag.NewFlagSet("update", flag.ExitOnError) + if err := fs.Parse(os.Args[2:]); err != nil { + os.Exit(1) + } + runUpdate() + return + } + versionFlag := flag.Bool("version", false, "Print version and exit") dumpConfig := flag.Bool("dump-config", false, "Print the default configuration JSON and exit") configFlag := flag.String("config", "", "Path to a config file (overrides ~/.bwai.json)") diff --git a/cmd/bwai/update.go b/cmd/bwai/update.go new file mode 100644 index 0000000..13cea43 --- /dev/null +++ b/cmd/bwai/update.go @@ -0,0 +1,198 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "syscall" + "time" +) + +const releasesAPI = "https://api.github.com/repos/umago/bubblewrap-ai/releases/latest" + +const maxBinarySize = 10 << 20 // 10 MB + +var ( + apiClient = &http.Client{Timeout: 30 * time.Second} + downloadClient = &http.Client{Timeout: 120 * time.Second} +) + +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Digest string `json:"digest"` +} + +// runUpdate downloads the latest bwai binary from GitHub releases, +// verifies its SHA-256 digest, and replaces the current binary in-place. +func runUpdate() { + currentExe, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "bwai: cannot determine current executable: %v\n", err) + os.Exit(1) + } + + fmt.Println("Checking for latest release...") + + release, err := fetchLatestRelease() + if err != nil { + fmt.Fprintf(os.Stderr, "bwai: %v\n", err) + os.Exit(1) + } + + asset := findAsset(release.Assets, "bwai") + if asset == nil { + fmt.Fprintf(os.Stderr, "bwai: no 'bwai' asset found in release %s\n", release.TagName) + os.Exit(1) + } + + fmt.Printf("Downloading bwai %s...\n", release.TagName) + + data, err := downloadAsset(asset.BrowserDownloadURL) + if err != nil { + fmt.Fprintf(os.Stderr, "bwai: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Downloaded %d bytes.\n", len(data)) + + if asset.Digest == "" { + fmt.Fprintln(os.Stderr, "bwai: no digest available — refusing to install unverified binary") + os.Exit(1) + } + if err := verifyDigest(data, asset.Digest); err != nil { + fmt.Fprintf(os.Stderr, "bwai: %v\n", err) + os.Exit(1) + } + fmt.Println("SHA-256 digest verified.") + + if err := replaceBinary(data, currentExe); err != nil { + fmt.Fprintf(os.Stderr, "bwai: %v\n", err) + os.Exit(1) + } + + fmt.Printf("bwai updated successfully (%s → %s).\n", version, release.TagName) +} + +func fetchLatestRelease() (*githubRelease, error) { + resp, err := apiClient.Get(releasesAPI) + if err != nil { + return nil, fmt.Errorf("failed to fetch latest release: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to parse release response: %w", err) + } + return &release, nil +} + +func findAsset(assets []githubAsset, name string) *githubAsset { + for i := range assets { + if assets[i].Name == name { + return &assets[i] + } + } + return nil +} + +func downloadAsset(url string) ([]byte, error) { + resp, err := downloadClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to download binary: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download failed with HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBinarySize+1)) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + if int64(len(data)) > maxBinarySize { + return nil, fmt.Errorf("downloaded binary exceeds max size of %d bytes", maxBinarySize) + } + return data, nil +} + +// verifyDigest compares the SHA-256 of data against the expected digest. +// The digest from GitHub is in the format "sha256:hex...". +func verifyDigest(data []byte, expected string) error { + colon := strings.Index(expected, ":") + if colon >= 0 { + expected = expected[colon+1:] + } + + hash := sha256.Sum256(data) + actual := hex.EncodeToString(hash[:]) + + if actual != expected { + return fmt.Errorf("digest mismatch: expected %s, got %s", expected, actual) + } + return nil +} + +// replaceBinary writes the new binary data to the current executable path. +// The current binary is renamed to .old first to avoid ETXTBSY on Linux. +func replaceBinary(data []byte, currentExe string) error { + oldExe := currentExe + ".old" + + // Clean up leftover from a previous failed update + if _, err := os.Stat(oldExe); err == nil { + if err := os.Remove(oldExe); err != nil { + return fmt.Errorf("failed to remove stale backup %s: %w", oldExe, err) + } + } + + // Rename current binary out of the way + if _, err := os.Stat(currentExe); err == nil { + if err := os.Rename(currentExe, oldExe); err != nil { + return fmt.Errorf("failed to rename %s: %w", currentExe, err) + } + } + + // Write the new binary + if err := os.WriteFile(currentExe, data, 0o755); err != nil { + // Try to restore the old binary + if _, statErr := os.Stat(oldExe); statErr == nil { + if renameErr := os.Rename(oldExe, currentExe); renameErr != nil { + fmt.Fprintf(os.Stderr, "bwai: CRITICAL — failed to restore backup after write error: %v\n", renameErr) + } + } + return fmt.Errorf("failed to write new binary to %s: %w", currentExe, err) + } + + // Preserve ownership from the old binary + if info, err := os.Stat(oldExe); err == nil { + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + if err := os.Chown(currentExe, int(stat.Uid), int(stat.Gid)); err != nil { + if renameErr := os.Rename(oldExe, currentExe); renameErr != nil { + fmt.Fprintf(os.Stderr, "bwai: CRITICAL — failed to restore backup after chown error: %v\n", renameErr) + } + return fmt.Errorf("failed to chown new binary: %w", err) + } + } + } + + // Delete the old backup + _ = os.Remove(oldExe) + + return nil +} diff --git a/cmd/bwai/update_test.go b/cmd/bwai/update_test.go new file mode 100644 index 0000000..7e4c41e --- /dev/null +++ b/cmd/bwai/update_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestVerifyDigest_MatchingSHA256(t *testing.T) { + data := []byte("hello world") + hash := sha256.Sum256(data) + expected := "sha256:" + hex.EncodeToString(hash[:]) + + if err := verifyDigest(data, expected); err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestVerifyDigest_BareHex(t *testing.T) { + data := []byte("hello world") + hash := sha256.Sum256(data) + expected := hex.EncodeToString(hash[:]) + + if err := verifyDigest(data, expected); err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestVerifyDigest_Mismatch(t *testing.T) { + data := []byte("hello world") + expected := "sha256:0000000000000000000000000000000000000000000000000000000000000000" + + err := verifyDigest(data, expected) + if err == nil { + t.Fatal("expected error on digest mismatch, got nil") + } + if !strings.Contains(err.Error(), "digest mismatch") { + t.Errorf("error should mention 'digest mismatch', got: %v", err) + } +} + +func TestFindAsset_Found(t *testing.T) { + assets := []githubAsset{ + {Name: "checksums.txt"}, + {Name: "bwai", BrowserDownloadURL: "https://example.com/bwai"}, + } + a := findAsset(assets, "bwai") + if a == nil { + t.Fatal("expected to find 'bwai' asset") + } + if a.BrowserDownloadURL != "https://example.com/bwai" { + t.Errorf("unexpected URL: %s", a.BrowserDownloadURL) + } +} + +func TestFindAsset_NotFound(t *testing.T) { + assets := []githubAsset{ + {Name: "checksums.txt"}, + } + if findAsset(assets, "bwai") != nil { + t.Fatal("expected nil for missing asset") + } +} + +func TestReplaceBinary_Basic(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "bwai") + + if err := os.WriteFile(current, []byte("old version"), 0o755); err != nil { + t.Fatal(err) + } + + if err := replaceBinary([]byte("new version"), current); err != nil { + t.Fatalf("replaceBinary failed: %v", err) + } + + content, err := os.ReadFile(current) + if err != nil { + t.Fatal(err) + } + if string(content) != "new version" { + t.Errorf("expected 'new version', got %q", content) + } + + // Old backup should be cleaned up + if _, err := os.Stat(current + ".old"); !os.IsNotExist(err) { + t.Error("old backup should have been removed") + } +} + +func TestReplaceBinary_NoExistingFile(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "bwai") + + if err := replaceBinary([]byte("fresh install"), current); err != nil { + t.Fatalf("replaceBinary failed: %v", err) + } + + content, err := os.ReadFile(current) + if err != nil { + t.Fatal(err) + } + if string(content) != "fresh install" { + t.Errorf("expected 'fresh install', got %q", content) + } +} + +func TestReplaceBinary_Permissions(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "bwai") + + if err := os.WriteFile(current, []byte("old version"), 0o644); err != nil { + t.Fatal(err) + } + + if err := replaceBinary([]byte("new version"), current); err != nil { + t.Fatalf("replaceBinary failed: %v", err) + } + + info, err := os.Stat(current) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Errorf("expected 0o755 permissions, got %o", info.Mode().Perm()) + } +} + +func TestReplaceBinary_CleansStaleBackup(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "bwai") + + if err := os.WriteFile(current, []byte("current"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(current+".old", []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + + if err := replaceBinary([]byte("new"), current); err != nil { + t.Fatalf("replaceBinary failed: %v", err) + } + + if _, err := os.Stat(current + ".old"); !os.IsNotExist(err) { + t.Error("stale backup should have been removed before update") + } +}