diff --git a/.github/workflows/core-release.yml b/.github/workflows/core-release.yml index 1bc4b79..6c114a4 100644 --- a/.github/workflows/core-release.yml +++ b/.github/workflows/core-release.yml @@ -126,7 +126,7 @@ jobs: cp -r core/sdk "$STAGING/sdk" # Create tarball - ASSET="core-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" + ASSET="agent-sandbox-core-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" tar -czf "$ASSET" -C "$STAGING" . echo "asset=$ASSET" >> "$GITHUB_ENV" diff --git a/internal/release/fetcher.go b/internal/release/fetcher.go deleted file mode 100644 index dc133e1..0000000 --- a/internal/release/fetcher.go +++ /dev/null @@ -1,326 +0,0 @@ -// Package release implements core version fetching and caching. -package release - -import ( - "archive/tar" - "compress/gzip" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "time" -) - -const ( - // GitHubRepo is the repository containing core releases. - GitHubRepo = "donbader/agent-sandbox" - // AssetPrefix is the prefix for core tarball assets in GitHub Releases. - AssetPrefix = "agent-sandbox-core-" - // LatestCacheTTL is how long the "latest" resolution is cached before re-checking. - LatestCacheTTL = 1 * time.Hour -) - -// CacheDir returns the path where a specific core version is cached. -func CacheDir(version string) string { - base := cacheBase() - return filepath.Join(base, version) -} - -// IsCachedAt checks if a core version is fully downloaded at the given path. -func IsCachedAt(versionDir string) bool { - _, err := os.Stat(filepath.Join(versionDir, ".complete")) - return err == nil -} - -// Fetch downloads a core version if not already cached. Returns the path to the cached core. -func Fetch(version string) (string, error) { - dir := CacheDir(version) - if IsCachedAt(dir) { - return dir, nil - } - - if err := download(version, dir); err != nil { - _ = os.RemoveAll(dir) - return "", fmt.Errorf("fetch core %s: %w", version, err) - } - - if err := os.WriteFile(filepath.Join(dir, ".complete"), []byte(version), 0644); err != nil { - return "", fmt.Errorf("mark complete: %w", err) - } - - return dir, nil -} - -// FetchLatest queries GitHub for the latest core-v* release, downloads it, and returns -// the cache directory. Results are cached for LatestCacheTTL to avoid hitting the API -// on every generate. Old cached versions are automatically cleaned up when a new version -// is fetched. -func FetchLatest() (string, error) { - version, err := cachedLatestVersion() - if err == nil && version != "" { - dir := CacheDir(version) - if IsCachedAt(dir) { - return dir, nil - } - } - - previousVersion := version - version, err = resolveLatestVersion() - if err != nil { - return "", fmt.Errorf("resolve latest core version: %w", err) - } - - _ = saveLatestResolution(version) - - dir, err := Fetch(version) - if err != nil { - return "", err - } - - // Clean up old cached versions when a new one is fetched. - if previousVersion != "" && previousVersion != version { - cleanOldVersions(version) - } - - return dir, nil -} - -// resolveLatestVersion queries GitHub Releases API for the latest core-v* tag. -func resolveLatestVersion() (string, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/releases", GitHubRepo) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "", err - } - req.Header.Set("Accept", "application/vnd.github+json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("query releases: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode) - } - - var releases []struct { - TagName string `json:"tag_name"` - Draft bool `json:"draft"` - } - if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { - return "", fmt.Errorf("decode releases: %w", err) - } - - var versions []string - for _, r := range releases { - if r.Draft { - continue - } - if strings.HasPrefix(r.TagName, "core-") { - versions = append(versions, strings.TrimPrefix(r.TagName, "core-")) - } - } - - if len(versions) == 0 { - return "", fmt.Errorf("no core releases found in %s", GitHubRepo) - } - - sort.Slice(versions, func(i, j int) bool { - return compareSemver(versions[i], versions[j]) > 0 - }) - return versions[0], nil -} - -// compareSemver compares two semver strings (vX.Y.Z format). -// Returns positive if a > b, negative if a < b, 0 if equal. -func compareSemver(a, b string) int { - aParts := parseSemver(a) - bParts := parseSemver(b) - for i := 0; i < 3; i++ { - if aParts[i] != bParts[i] { - return aParts[i] - bParts[i] - } - } - return 0 -} - -func parseSemver(v string) [3]int { - v = strings.TrimPrefix(v, "v") - var parts [3]int - for i, s := range strings.SplitN(v, ".", 3) { - n := 0 - for _, c := range s { - if c >= '0' && c <= '9' { - n = n*10 + int(c-'0') - } - } - parts[i] = n - } - return parts -} - -type latestResolution struct { - Version string `json:"version"` - ResolvedAt time.Time `json:"resolved_at"` -} - -func latestCachePath() string { - return filepath.Join(cacheBase(), "latest.json") -} - -func cachedLatestVersion() (string, error) { - data, err := os.ReadFile(latestCachePath()) - if err != nil { - return "", err - } - var res latestResolution - if err := json.Unmarshal(data, &res); err != nil { - return "", err - } - if time.Since(res.ResolvedAt) > LatestCacheTTL { - return "", fmt.Errorf("cache expired") - } - return res.Version, nil -} - -func saveLatestResolution(version string) error { - res := latestResolution{Version: version, ResolvedAt: time.Now()} - data, err := json.Marshal(res) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(latestCachePath()), 0755); err != nil { - return err - } - return os.WriteFile(latestCachePath(), data, 0644) -} - -func cacheBase() string { - if override := os.Getenv("AGENT_SANDBOX_CACHE"); override != "" { - return filepath.Join(override, "core") - } - home, err := os.UserHomeDir() - if err != nil { - home = os.TempDir() - } - return filepath.Join(home, ".agent-sandbox", "core") -} - -// cleanOldVersions removes all cached version directories except the current one. -func cleanOldVersions(currentVersion string) { - base := cacheBase() - entries, err := os.ReadDir(base) - if err != nil { - return - } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - if entry.Name() == currentVersion { - continue - } - // Only remove directories that look like version dirs (have .complete sentinel). - if IsCachedAt(filepath.Join(base, entry.Name())) { - _ = os.RemoveAll(filepath.Join(base, entry.Name())) - } - } -} - -func download(version, destDir string) error { - tag := "core-" + version - asset := AssetPrefix + version + ".tar.gz" - url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", GitHubRepo, tag, asset) - - var resp *http.Response - var lastErr error - for attempt := 0; attempt < 3; attempt++ { - if attempt > 0 { - time.Sleep(time.Duration(attempt) * 2 * time.Second) - } - var err error - resp, err = http.Get(url) //nolint:gosec - if err != nil { - lastErr = fmt.Errorf("download %s: %w", url, err) - continue - } - if resp.StatusCode == http.StatusNotFound { - _ = resp.Body.Close() - return fmt.Errorf("core version %s not found (no release asset at %s)", version, url) - } - if resp.StatusCode >= 500 { - _ = resp.Body.Close() - lastErr = fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode) - continue - } - if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - return fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode) - } - lastErr = nil - break - } - if lastErr != nil { - return lastErr - } - defer func() { _ = resp.Body.Close() }() - - if err := os.MkdirAll(destDir, 0755); err != nil { - return fmt.Errorf("create cache dir: %w", err) - } - - return extractTarGz(resp.Body, destDir) -} - -func extractTarGz(r io.Reader, destDir string) error { - gz, err := gzip.NewReader(r) - if err != nil { - return fmt.Errorf("gzip reader: %w", err) - } - defer func() { _ = gz.Close() }() - - tr := tar.NewReader(gz) - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("tar read: %w", err) - } - - name := filepath.Clean(hdr.Name) - if strings.HasPrefix(name, "..") || filepath.IsAbs(name) { - continue - } - - target := filepath.Join(destDir, name) - - switch hdr.Typeflag { - case tar.TypeDir: - if err := os.MkdirAll(target, 0755); err != nil { - return err - } - case tar.TypeReg: - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0755) - if err != nil { - return err - } - if _, err := io.Copy(f, tr); err != nil { //nolint:gosec - _ = f.Close() - return err - } - _ = f.Close() - } - } - - return nil -} diff --git a/internal/release/fetcher_test.go b/internal/release/fetcher_test.go deleted file mode 100644 index 5d21232..0000000 --- a/internal/release/fetcher_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package release - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCacheDir(t *testing.T) { - dir := CacheDir("v1.0.0") - assert.Contains(t, dir, "agent-sandbox") - assert.Contains(t, dir, "v1.0.0") -} - -func TestIsCached(t *testing.T) { - cacheDir := t.TempDir() - version := "v1.0.0" - versionDir := filepath.Join(cacheDir, version) - - // Not cached yet - assert.False(t, IsCachedAt(versionDir)) - - // Create marker - require.NoError(t, os.MkdirAll(versionDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(versionDir, ".complete"), []byte(""), 0644)) - assert.True(t, IsCachedAt(versionDir)) -} - -func TestCacheDir_CustomEnv(t *testing.T) { - t.Setenv("AGENT_SANDBOX_CACHE", "/tmp/custom-cache") - dir := CacheDir("v2.0.0") - assert.Equal(t, "/tmp/custom-cache/core/v2.0.0", dir) -} - -func TestLatestResolutionCache(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("AGENT_SANDBOX_CACHE", tmpDir) - - // No cache file yet — should return error - _, err := cachedLatestVersion() - assert.Error(t, err) - - // Save a resolution - require.NoError(t, saveLatestResolution("v1.2.3")) - - // Should return cached version - version, err := cachedLatestVersion() - require.NoError(t, err) - assert.Equal(t, "v1.2.3", version) -} - -func TestLatestResolutionCache_Expired(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("AGENT_SANDBOX_CACHE", tmpDir) - - // Write an expired resolution (resolved_at in the past) - res := latestResolution{Version: "v0.9.0", ResolvedAt: time.Now().Add(-2 * LatestCacheTTL)} - data, err := json.Marshal(res) - require.NoError(t, err) - cachePath := latestCachePath() - require.NoError(t, os.MkdirAll(filepath.Dir(cachePath), 0755)) - require.NoError(t, os.WriteFile(cachePath, data, 0644)) - - // Should return error (expired) - _, err = cachedLatestVersion() - assert.Error(t, err) - assert.Contains(t, err.Error(), "expired") -} - -func TestCompareSemver(t *testing.T) { - // v0.10.0 > v0.9.0 (numeric comparison, not lexicographic) - assert.Greater(t, compareSemver("v0.10.0", "v0.9.0"), 0) - assert.Less(t, compareSemver("v0.9.0", "v0.10.0"), 0) - assert.Equal(t, 0, compareSemver("v1.2.3", "v1.2.3")) - assert.Greater(t, compareSemver("v2.0.0", "v1.99.99"), 0) - assert.Greater(t, compareSemver("v1.0.1", "v1.0.0"), 0) -} diff --git a/scripts/install.sh b/scripts/install.sh index fd2e921..cae56b4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -5,23 +5,37 @@ AGENT_SANDBOX_HOME="${AGENT_SANDBOX_HOME:-$HOME/.agent-sandbox}" BIN_DIR="$AGENT_SANDBOX_HOME/bin" SHIM_URL="https://raw.githubusercontent.com/donbader/agent-sandbox/main/scripts/shim.sh" -echo "Installing agent-sandbox shim..." +printf 'Installing agent-sandbox shim...\n' mkdir -p "$BIN_DIR" curl -fsSL "$SHIM_URL" -o "$BIN_DIR/agent-sandbox" || { printf 'Error: failed to download shim from %s\n' "$SHIM_URL" >&2; exit 1; } chmod +x "$BIN_DIR/agent-sandbox" -echo "Installed to $BIN_DIR/agent-sandbox" +# If an existing agent-sandbox binary is on PATH (and it's not our shim), +# replace it in-place so the user doesn't need to change PATH. +EXISTING=$(command -v agent-sandbox 2>/dev/null || true) +if [ -n "$EXISTING" ] && [ "$EXISTING" != "$BIN_DIR/agent-sandbox" ]; then + # Check it's not the shim already (shim has SHIM_VERSION near the top) + if ! grep -q 'SHIM_VERSION=' "$EXISTING" 2>/dev/null; then + printf 'Replacing existing binary at %s\n' "$EXISTING" + if cp "$BIN_DIR/agent-sandbox" "$EXISTING" 2>/dev/null; then + printf 'Done. agent-sandbox is now the shim.\n' + else + printf 'Permission denied. Trying with sudo...\n' + sudo cp "$BIN_DIR/agent-sandbox" "$EXISTING" + printf 'Done. agent-sandbox is now the shim.\n' + fi + fi +else + case ":$PATH:" in + *":$BIN_DIR:"*) + printf 'Already on PATH.\n' + ;; + *) + printf 'Add to your shell profile:\n' + printf ' export PATH="%s:$PATH"\n' "$BIN_DIR" + ;; + esac +fi -case ":$PATH:" in - *":$BIN_DIR:"*) - echo "Already on PATH" - ;; - *) - echo "Add to your shell profile:" - echo " export PATH=\"$BIN_DIR:\$PATH\"" - ;; -esac - -echo "" -echo "Run: agent-sandbox init" +printf '\nRun: agent-sandbox version\n'