diff --git a/AGENTS.md b/AGENTS.md index b73666f..03ad123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ cmd/odek/ schedule.go `odek schedule` command and scheduler wiring memory_cmd.go `odek memory` command cleanup.go `odek cleanup [--dry-run]` one-shot storage sweep + janitor wiring (telegram/serve/schedule daemon) + upgrade.go `odek upgrade [--check]` self-upgrade from GitHub Releases (OS/arch autodetection, checksums.txt SHA-256 verification, atomic binary replace) parallel.go Parallelism helpers toolctx.go Tool-call context plumbing security_report_validation_test.go Regression bar for every documented mitigation diff --git a/cmd/odek/dispatch.go b/cmd/odek/dispatch.go index 6ece722..2c8b882 100644 --- a/cmd/odek/dispatch.go +++ b/cmd/odek/dispatch.go @@ -57,6 +57,8 @@ func dispatch(args []string) int { return cliExit(memoryCmd(rest)) case "cleanup": return cliExit(cleanupCmd(rest)) + case "upgrade": + return cliExit(upgradeCmd(rest)) default: fmt.Fprintf(os.Stderr, "odek: unknown command %q\n", cmd) printUsage() diff --git a/cmd/odek/main.go b/cmd/odek/main.go index f112bd7..18d2ef6 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -848,6 +848,7 @@ func printUsage() { odek schedule odek memory > odek cleanup [--dry-run] + odek upgrade [--check] odek version Commands: @@ -880,6 +881,11 @@ Commands: cleanup One-shot storage sweep of ~/.odek (sessions, audit, plans, skill skips, log rotation). --dry-run previews. init Create a config file (default: ./odek.json) + upgrade Self-upgrade to the latest GitHub release + Downloads the asset for the current OS/arch, verifies + it against the release checksums.txt (SHA-256), and + installs it atomically. --check reports without + installing. version Print version and exit Init flags: diff --git a/cmd/odek/upgrade.go b/cmd/odek/upgrade.go new file mode 100644 index 0000000..b843949 --- /dev/null +++ b/cmd/odek/upgrade.go @@ -0,0 +1,319 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/BackendStack21/odek/internal/transport" +) + +// upgradeCmd implements `odek upgrade [--check]`: a self-upgrade from +// GitHub Releases. The platform asset is selected by OS/arch autodetection +// (runtime.GOOS/GOARCH → odek--), the download is verified +// against the release's checksums.txt, and the current binary is replaced +// atomically (temp file + rename in the same directory). +func upgradeCmd(args []string) error { + checkOnly := false + for _, a := range args { + switch a { + case "--check": + checkOnly = true + case "--help", "-h": + fmt.Println(`Usage: odek upgrade [--check] + +Upgrade the odek binary to the latest GitHub release. The asset matching +the current OS/architecture is downloaded, verified against the release's +checksums.txt (SHA-256), and installed atomically over the running binary. + + --check Report the latest version without installing anything.`) + return nil + default: + return fmt.Errorf("unknown flag %q for upgrade", a) + } + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("upgrade: cannot locate current executable: %w", err) + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return fmt.Errorf("upgrade: cannot resolve current executable: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + client := transport.NewPooledClient(60 * time.Second) + + _, err = performUpgrade(ctx, client, githubAPIBase, exe, getVersion(), checkOnly, os.Stdout) + return err +} + +const ( + // githubAPIBase is the GitHub API root used to resolve the latest + // release. Asset downloads follow the browser_download_url returned by + // the API. Kept as a variable so tests can point at a fake server. + githubAPIBase = "https://api.github.com" + githubRepo = "BackendStack21/odek" +) + +// ghRelease is the subset of the GitHub release payload upgrade needs. +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +// assetURL returns the download URL for the named asset, or "". +func (r *ghRelease) assetURL(name string) string { + for _, a := range r.Assets { + if a.Name == name { + return a.BrowserDownloadURL + } + } + return "" +} + +// fetchLatestRelease queries the GitHub API for the repository's latest +// published release. +func fetchLatestRelease(ctx context.Context, client *http.Client, apiBase string) (*ghRelease, error) { + url := strings.TrimSuffix(apiBase, "/") + "/repos/" + githubRepo + "/releases/latest" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot reach GitHub: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned %s", resp.Status) + } + var rel ghRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&rel); err != nil { + return nil, fmt.Errorf("cannot parse GitHub release payload: %w", err) + } + if rel.TagName == "" { + return nil, fmt.Errorf("GitHub release payload has no tag_name") + } + return &rel, nil +} + +// parseChecksums parses a checksums.txt body (" " per line) +// into a name→hash map. +func parseChecksums(data []byte) map[string]string { + out := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && len(fields[0]) == 64 { + out[fields[1]] = fields[0] + } + } + return out +} + +// compareVersions compares two "vX.Y.Z" versions, returning -1, 0, or 1. +// A current version that does not parse as semver (e.g. "dev" or a commit +// hash from a local build) is treated as older than any release. +func compareVersions(current, latest string) int { + c, cok := parseSemver(current) + l, lok := parseSemver(latest) + if !lok { + return 0 // unknown latest — caller errors out before comparing + } + if !cok { + return -1 + } + for i := 0; i < 3; i++ { + if c[i] != l[i] { + if c[i] < l[i] { + return -1 + } + return 1 + } + } + return 0 +} + +func parseSemver(v string) ([3]int, bool) { + var out [3]int + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + parts := strings.Split(v, ".") + if len(parts) != 3 { + return out, false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return out, false + } + out[i] = n + } + return out, true +} + +// upgradeAssetName maps the running platform to its release asset name. +func upgradeAssetName() (string, error) { + name := "odek-" + runtime.GOOS + "-" + runtime.GOARCH + switch name { + case "odek-darwin-amd64", "odek-darwin-arm64", "odek-linux-amd64", "odek-linux-arm64": + return name, nil + } + return "", fmt.Errorf("no prebuilt release for %s/%s — build from source instead", runtime.GOOS, runtime.GOARCH) +} + +// performUpgrade runs the full upgrade flow against apiBase, replacing the +// binary at exePath. It returns the installed (or available, with +// checkOnly) version. The empty string with nil error means "already up to +// date". +func performUpgrade(ctx context.Context, client *http.Client, apiBase, exePath, current string, checkOnly bool, out io.Writer) (string, error) { + asset, err := upgradeAssetName() + if err != nil { + return "", err + } + + rel, err := fetchLatestRelease(ctx, client, apiBase) + if err != nil { + return "", err + } + latest := rel.TagName + if _, ok := parseSemver(latest); !ok { + return "", fmt.Errorf("latest release tag %q is not a semantic version", latest) + } + + cmp := compareVersions(current, latest) + if cmp >= 0 { + fmt.Fprintf(out, "odek %s is up to date (latest: %s)\n", current, latest) + return "", nil + } + fmt.Fprintf(out, "upgrade available: %s → %s (%s/%s)\n", current, latest, runtime.GOOS, runtime.GOARCH) + if checkOnly { + return latest, nil + } + + assetURL := rel.assetURL(asset) + if assetURL == "" { + return "", fmt.Errorf("release %s has no asset %q", latest, asset) + } + checksumsURL := rel.assetURL("checksums.txt") + if checksumsURL == "" { + return "", fmt.Errorf("release %s has no checksums.txt — refusing to install an unverifiable binary", latest) + } + + // Download into a temp dir next to the target so the final rename stays + // on the same filesystem (atomic). + tmpDir, err := os.MkdirTemp(filepath.Dir(exePath), ".odek-upgrade-*") + if err != nil { + return "", fmt.Errorf("cannot create temp dir next to %s: %w", exePath, err) + } + defer os.RemoveAll(tmpDir) + + checksumData, err := downloadBytes(ctx, client, checksumsURL) + if err != nil { + return "", fmt.Errorf("cannot download checksums.txt: %w", err) + } + wantSum := parseChecksums(checksumData)[asset] + if wantSum == "" { + return "", fmt.Errorf("checksums.txt has no entry for %q — refusing to install", asset) + } + + tmpBin := filepath.Join(tmpDir, asset) + if err := downloadToFile(ctx, client, assetURL, tmpBin); err != nil { + return "", fmt.Errorf("cannot download %s: %w", asset, err) + } + gotSum, err := fileSHA256(tmpBin) + if err != nil { + return "", err + } + if !strings.EqualFold(gotSum, wantSum) { + return "", fmt.Errorf("checksum mismatch for %s (got %s, want %s) — refusing to install", asset, gotSum, wantSum) + } + + // Preserve the installed binary's permission bits. + fi, err := os.Stat(exePath) + if err != nil { + return "", fmt.Errorf("cannot stat %s: %w", exePath, err) + } + if err := os.Chmod(tmpBin, fi.Mode()); err != nil { + return "", fmt.Errorf("cannot set permissions: %w", err) + } + if err := os.Rename(tmpBin, exePath); err != nil { + if os.IsPermission(err) { + return "", fmt.Errorf("cannot replace %s: permission denied — retry with elevated privileges (e.g. sudo odek upgrade)", exePath) + } + return "", fmt.Errorf("cannot replace %s: %w", exePath, err) + } + + fmt.Fprintf(out, "upgraded %s → %s (%s)\n", current, latest, exePath) + return latest, nil +} + +func downloadBytes(ctx context.Context, client *http.Client, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", url, resp.Status) + } + return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) +} + +func downloadToFile(ctx context.Context, client *http.Client, url, dst string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: %s", url, resp.Status) + } + // Cap the download at 256 MiB — release binaries are ~12 MB. + f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o755) + if err != nil { + return err + } + _, copyErr := io.Copy(f, io.LimitReader(resp.Body, 256<<20)) + closeErr := f.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/cmd/odek/upgrade_test.go b/cmd/odek/upgrade_test.go new file mode 100644 index 0000000..161c91c --- /dev/null +++ b/cmd/odek/upgrade_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestCompareVersions(t *testing.T) { + cases := []struct { + current, latest string + want int + }{ + {"v1.0.0", "v1.0.0", 0}, + {"v1.0.0", "v1.0.1", -1}, + {"v1.0.1", "v1.0.0", 1}, + {"v1.15.10", "v1.16.0", -1}, + {"v2.0.0", "v1.99.99", 1}, + {"1.2.3", "v1.2.3", 0}, // missing v prefix tolerated + {"dev", "v1.16.0", -1}, // local dev build is always older + {"c7699de", "v1.16.0", -1}, // commit-hash version is always older + {"v1.0", "v1.0.0", -1}, // unparseable current treated as older + } + for _, tc := range cases { + if got := compareVersions(tc.current, tc.latest); got != tc.want { + t.Errorf("compareVersions(%q, %q) = %d, want %d", tc.current, tc.latest, got, tc.want) + } + } +} + +func TestParseChecksums(t *testing.T) { + data := []byte("e04c760815c30bfe320482bee1e718abaa3088f8726d6cd313b677ec59313bfe odek-darwin-amd64\n" + + "36bdb1490e48aa35a4fc104190b5bc1e64e1f99c565b46a8f44f61c575493778 odek-darwin-arm64\n" + + "garbage line\n") + got := parseChecksums(data) + if len(got) != 2 { + t.Fatalf("parseChecksums returned %d entries, want 2: %v", len(got), got) + } + if got["odek-darwin-amd64"] != "e04c760815c30bfe320482bee1e718abaa3088f8726d6cd313b677ec59313bfe" { + t.Errorf("wrong hash for odek-darwin-amd64: %q", got["odek-darwin-amd64"]) + } +} + +func TestUpgradeAssetName(t *testing.T) { + name, err := upgradeAssetName() + switch runtime.GOOS { + case "darwin", "linux": + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "odek-" + runtime.GOOS + "-" + runtime.GOARCH + if name != want { + t.Errorf("upgradeAssetName() = %q, want %q", name, want) + } + default: + if err == nil { + t.Errorf("expected unsupported-platform error on %s/%s, got asset %q", runtime.GOOS, runtime.GOARCH, name) + } + } +} + +// fakeReleaseServer serves a latest-release JSON payload plus asset and +// checksums.txt downloads for the current platform's asset name. +func fakeReleaseServer(t *testing.T, tag string, binContent []byte, sumOverride string) *httptest.Server { + t.Helper() + asset, err := upgradeAssetName() + if err != nil { + t.Skipf("no prebuilt asset for this platform: %v", err) + } + sum := sumOverride + if sum == "" { + h := sha256.Sum256(binContent) + sum = hex.EncodeToString(h[:]) + } + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + mux.HandleFunc("/repos/"+githubRepo+"/releases/latest", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"tag_name":%q,"assets":[`+ + `{"name":%q,"browser_download_url":%q},`+ + `{"name":"checksums.txt","browser_download_url":%q}]}`, + tag, asset, srv.URL+"/dl/"+asset, srv.URL+"/dl/checksums.txt") + }) + mux.HandleFunc("/dl/"+asset, func(w http.ResponseWriter, r *http.Request) { + w.Write(binContent) + }) + mux.HandleFunc("/dl/checksums.txt", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "%s %s\n", sum, asset) + }) + t.Cleanup(srv.Close) + return srv +} + +func TestPerformUpgrade_EndToEnd(t *testing.T) { + newBin := []byte("#!/bin/sh\necho odek v9.9.9\n") + srv := fakeReleaseServer(t, "v9.9.9", newBin, "") + + dir := t.TempDir() + exe := filepath.Join(dir, "odek") + if err := os.WriteFile(exe, []byte("old binary"), 0o755); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + got, err := performUpgrade(context.Background(), srv.Client(), srv.URL, exe, "v1.0.0", false, &out) + if err != nil { + t.Fatalf("performUpgrade: %v", err) + } + if got != "v9.9.9" { + t.Errorf("installed version = %q, want v9.9.9", got) + } + content, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(content, newBin) { + t.Errorf("binary not replaced; got %q", content) + } + fi, err := os.Stat(exe) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o755 { + t.Errorf("mode = %o, want 755", fi.Mode().Perm()) + } + if !strings.Contains(out.String(), "upgraded v1.0.0 → v9.9.9") { + t.Errorf("unexpected output: %q", out.String()) + } +} + +func TestPerformUpgrade_UpToDate(t *testing.T) { + srv := fakeReleaseServer(t, "v1.16.0", []byte("bin"), "") + exe := filepath.Join(t.TempDir(), "odek") + if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + got, err := performUpgrade(context.Background(), srv.Client(), srv.URL, exe, "v1.16.0", false, &out) + if err != nil { + t.Fatalf("performUpgrade: %v", err) + } + if got != "" { + t.Errorf("returned %q, want empty (no upgrade)", got) + } + if !strings.Contains(out.String(), "up to date") { + t.Errorf("unexpected output: %q", out.String()) + } +} + +func TestPerformUpgrade_CheckOnly(t *testing.T) { + srv := fakeReleaseServer(t, "v9.9.9", []byte("bin"), "") + exe := filepath.Join(t.TempDir(), "odek") + if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + got, err := performUpgrade(context.Background(), srv.Client(), srv.URL, exe, "v1.0.0", true, &out) + if err != nil { + t.Fatalf("performUpgrade: %v", err) + } + if got != "v9.9.9" { + t.Errorf("returned %q, want v9.9.9", got) + } + content, _ := os.ReadFile(exe) + if string(content) != "old" { + t.Errorf("--check modified the binary: %q", content) + } + if !strings.Contains(out.String(), "upgrade available: v1.0.0 → v9.9.9") { + t.Errorf("unexpected output: %q", out.String()) + } +} + +func TestPerformUpgrade_ChecksumMismatch(t *testing.T) { + bad := strings.Repeat("0", 64) + srv := fakeReleaseServer(t, "v9.9.9", []byte("bin"), bad) + exe := filepath.Join(t.TempDir(), "odek") + if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + _, err := performUpgrade(context.Background(), srv.Client(), srv.URL, exe, "v1.0.0", false, &out) + if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("expected checksum mismatch error, got %v", err) + } + content, _ := os.ReadFile(exe) + if string(content) != "old" { + t.Errorf("binary modified despite checksum mismatch: %q", content) + } +} + +func TestFetchLatestRelease(t *testing.T) { + srv := fakeReleaseServer(t, "v1.16.0", []byte("bin"), "") + rel, err := fetchLatestRelease(context.Background(), srv.Client(), srv.URL) + if err != nil { + t.Fatalf("fetchLatestRelease: %v", err) + } + if rel.TagName != "v1.16.0" { + t.Errorf("tag = %q, want v1.16.0", rel.TagName) + } + if rel.assetURL("checksums.txt") == "" { + t.Error("checksums.txt asset URL missing") + } +} diff --git a/docs/CLI.md b/docs/CLI.md index 286797a..fe20ede 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -32,6 +32,7 @@ | `odek mcp [--sandbox]` | Start MCP server (expose tools to Claude Code) or connect to external MCP servers (via `mcp_servers` config) | | `odek telegram` | Start the Telegram bot (long-polling). Hosts the embedded scheduler unless `schedules.enabled=false` | | `odek schedule ` | Manage native in-process scheduled tasks (cron): `list`, `add`, `rm`, `enable`, `disable`, `run`, `next`, `daemon`. See [Schedules](SCHEDULES.md) | +| `odek upgrade [--check]` | Self-upgrade to the latest GitHub release. Auto-detects OS/arch (`odek--` asset), verifies the download against the release `checksums.txt` (SHA-256), and installs it atomically over the current binary. `--check` reports the latest version without installing | | `odek version` | Print version and exit | ## Run flags