From 78f275a2eb9b30c8ce66cd29ef379733d7786b63 Mon Sep 17 00:00:00 2001 From: RabbITCybErSeC Date: Thu, 21 May 2026 18:46:53 +0200 Subject: [PATCH] fix: publish binary installer assets --- .github/workflows/release.yml | 73 ++++++++ README.md | 4 +- docs/network-policy-implementation-report.md | 13 +- docs/quickstart.md | 22 ++- docs/troubleshooting.md | 2 +- install.sh | 171 +++++++++++++++---- internal/cli/uninstall.go | 2 +- internal/cli/uninstall_test.go | 49 ++++++ internal/containercmd/builder.go | 1 - internal/containercmd/builder_test.go | 4 +- 10 files changed, 292 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e62d3af --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: Publish Binaries + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Build and publish CLI binaries + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: arm64 + - goos: darwin + goarch: amd64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build archive + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + set -eu + asset="opencode-sandbox_${GOOS}_${GOARCH}" + mkdir -p "dist/${asset}" + go build -trimpath -ldflags="-s -w" -o "dist/${asset}/opencode-sandbox" ./cmd/opencode-sandbox + tar -C "dist/${asset}" -czf "dist/${asset}.tar.gz" opencode-sandbox + + - name: Upload archive artifact + uses: actions/upload-artifact@v4 + with: + name: opencode-sandbox_${{ matrix.goos }}_${{ matrix.goarch }} + path: dist/opencode-sandbox_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz + if-no-files-found: error + + publish: + name: Attach release assets + runs-on: ubuntu-latest + needs: release + steps: + - name: Download archives + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Generate checksums + working-directory: dist + run: sha256sum opencode-sandbox_*.tar.gz > checksums.txt + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + files: | + dist/opencode-sandbox_*.tar.gz + dist/checksums.txt diff --git a/README.md b/README.md index 93984a1..19c06b8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Go CLI wrapper that runs [OpenCode](https://opencode.ai) inside Apple's native ## Quickstart ```bash -# Use install the wrapper +# Install the prebuilt wrapper for your Mac architecture curl -fsSL https://raw.githubusercontent.com/RabbITCybErSeC/opencode-sandbox/main/install.sh | bash # Add the alias printed by the installer, then check your environment @@ -178,6 +178,8 @@ opencode-sandbox image pull --strict-init Local source builds remain supported: ```bash +git clone https://github.com/RabbITCybErSeC/opencode-sandbox.git +cd opencode-sandbox opencode-sandbox image build opencode-sandbox image build --strict-init ``` diff --git a/docs/network-policy-implementation-report.md b/docs/network-policy-implementation-report.md index 5963ee0..29d200f 100644 --- a/docs/network-policy-implementation-report.md +++ b/docs/network-policy-implementation-report.md @@ -39,10 +39,15 @@ When `localhostAccess.enabled` is true: ### 5. Container Command Builder (`internal/containercmd/builder.go`) When `LocalhostAccess.Enabled` is true, appends to `container run` argv: -- `--localhost 203.0.113.113` (the Apple container flag for host DNS) - `--env OPENCODE_SANDBOX_HOST_DOMAIN=host.container.internal` - `--env OPENCODE_SANDBOX_HOST_IP=203.0.113.113` +Host DNS itself is configured outside `container run` with: + +```bash +sudo container system dns create host.container.internal --localhost 203.0.113.113 +``` + ### 6. CLI Flag (`internal/cli/run.go`, `internal/cli/run_plan.go`) Added `--allow-host-access` flag parsing: @@ -75,7 +80,7 @@ localhostAccess: ### 9. Tests (`internal/containercmd/builder_test.go`, `internal/cli/init_config_test.go`) -- `TestBuildArgvLocalhostAccessEnabled`: verifies `--localhost` flag and both env vars are present +- `TestBuildArgvLocalhostAccessEnabled`: verifies no invalid `container run --localhost` flag is present and both env vars are present - `TestBuildArgvLocalhostAccessDisabled`: verifies no `--localhost` flag when disabled - `TestInitProjectCreatesConfig`: verifies generated config contains `localhostAccess:` @@ -123,7 +128,7 @@ sudo container system dns create host.container.internal --localhost 203.0.113.1 | `internal/config/defaults.go` | +5 | Default `localhostAccess` values | | `internal/config/load.go` | +17 | `applyLocalhostAccess()` merge function | | `internal/config/validate.go` | +12 | IP and domain validation | -| `internal/containercmd/builder.go` | +6 | `--localhost` flag and env vars | +| `internal/containercmd/builder.go` | +5 | Host access env vars | | `internal/containercmd/builder_test.go` | +48 | Two new test cases | | `internal/cli/run.go` | +13/-2 | `--allow-host-access` flag parsing and override | | `internal/cli/run_plan.go` | +5/-2 | `AllowHostAccess` field on `RunPlan` | @@ -139,6 +144,6 @@ sudo container system dns create host.container.internal --localhost 203.0.113.1 - [ ] `go build ./cmd/opencode-sandbox` succeeds - [ ] `opencode-sandbox init` generates config with `localhostAccess` section - [ ] `opencode-sandbox init --global` generates config with `localhostAccess` section -- [ ] `opencode-sandbox run . --allow-host-access --print-command` shows `--localhost 203.0.113.113` +- [ ] `opencode-sandbox run . --allow-host-access --print-command` shows host access env vars and no `--localhost` run flag - [ ] `opencode-sandbox doctor` shows `host.dns` check when `localhostAccess.enabled: true` - [ ] Config validation rejects invalid IP or missing domain when enabled diff --git a/docs/quickstart.md b/docs/quickstart.md index 04c0eaf..1db8e08 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -5,14 +5,13 @@ This guide gets opencode-sandbox from install to a first sandboxed OpenCode run. ## Prerequisites - macOS with Apple's `container` CLI installed and available on `PATH`. -- Go installed for building the wrapper CLI. -- Network access to pull the published OpenCode container image, or to build it locally from source. +- `curl` and `tar` for the installer. +- Network access to download the wrapper binary and pull the published OpenCode container image. Check the environment first: ```bash container system version -go version ``` ## 1. Install the wrapper CLI @@ -23,7 +22,13 @@ Install from GitHub: curl -fsSL https://raw.githubusercontent.com/RabbITCybErSeC/opencode-sandbox/main/install.sh | bash ``` -The installer clones or updates the repo, builds the wrapper CLI, pulls the published runtime image, and prints a ready-to-add shell alias. In an interactive shell it also asks whether to pull the optional strict eBPF init image. +The installer downloads the latest prebuilt macOS binary for your architecture, pulls the published runtime image, and prints a ready-to-add shell alias. In an interactive shell it also asks whether to pull the optional strict eBPF init image. + +To install a specific release instead of the latest one: + +```bash +curl -fsSL https://raw.githubusercontent.com/RabbITCybErSeC/opencode-sandbox/main/install.sh | OPENCODE_SANDBOX_VERSION=v0.0.2 bash +``` ```bash alias sopencode="$HOME/.local/bin/opencode-sandbox" @@ -39,6 +44,7 @@ If you want a development checkout, clone the repo: git clone https://github.com/RabbITCybErSeC/opencode-sandbox.git cd opencode-sandbox git pull --ff-only +go version ``` From the repo root: @@ -305,6 +311,14 @@ sopencode run . sopencode run . --dry-run ``` +Uninstall global wrapper artifacts and container resources: + +```bash +sopencode uninstall +``` + +Uninstall intentionally preserves project-local files such as `.opencode-sandbox.yaml` and `.opencode-sandbox/`. + ## Optional Shell Alias During active development, keep the wrapper command explicit: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 30c086e..bdb0362 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -139,7 +139,7 @@ For normal installs, prefer pulling the published image: opencode-sandbox image pull ``` -For local source builds, ensure the installer source checkout still exists at `~/.local/share/opencode-sandbox-src`, set `OPENCODE_SANDBOX_DIR`, or pass the source explicitly: +For local source builds, use a development checkout and pass the source explicitly. The installer downloads a prebuilt wrapper binary and no longer creates `~/.local/share/opencode-sandbox-src`. ```bash opencode-sandbox image build --context /path/to/opencode-sandbox diff --git a/install.sh b/install.sh index d291cb7..760ba8f 100755 --- a/install.sh +++ b/install.sh @@ -1,8 +1,18 @@ #!/usr/bin/env sh set -eu -repo_url="${OPENCODE_SANDBOX_REPO:-https://github.com/RabbITCybErSeC/opencode-sandbox.git}" -src_dir="${OPENCODE_SANDBOX_DIR:-"$HOME/.local/share/opencode-sandbox-src"}" +repo="${OPENCODE_SANDBOX_REPO:-RabbITCybErSeC/opencode-sandbox}" +case "$repo" in + https://github.com/*) + repo="${repo#https://github.com/}" + repo="${repo%.git}" + ;; + git@github.com:*) + repo="${repo#git@github.com:}" + repo="${repo%.git}" + ;; +esac +version="${OPENCODE_SANDBOX_VERSION:-}" bin_path="${OPENCODE_SANDBOX_BIN:-"$HOME/.local/bin/opencode-sandbox"}" runtime_image="${OPENCODE_SANDBOX_IMAGE:-ghcr.io/rabbitcybersec/opencode-sandbox:latest}" strict_init_image="${OPENCODE_SANDBOX_INIT_IMAGE:-ghcr.io/rabbitcybersec/opencode-sandbox-init:latest}" @@ -16,12 +26,63 @@ need_cmd() { fi } -check_container_available() { - if [ "$(uname -s)" != "Darwin" ]; then +is_disabled() { + case "$1" in + 0|false|False|FALSE|no|No|NO) return 0 ;; + *) return 1 ;; + esac +} + +is_enabled() { + case "$1" in + 1|true|True|TRUE|yes|Yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +detect_asset() { + os_name="$(uname -s)" + machine="$(uname -m)" + + if [ "$os_name" != "Darwin" ]; then printf 'Error: Apple container requires macOS.\n' >&2 return 1 fi + case "$machine" in + arm64|aarch64) + arch="arm64" + ;; + x86_64|amd64) + arch="amd64" + ;; + *) + printf 'Error: unsupported macOS architecture: %s\n' "$machine" >&2 + return 1 + ;; + esac + + printf 'opencode-sandbox_darwin_%s.tar.gz\n' "$arch" +} + +release_url() { + asset="$1" + if [ -n "$version" ]; then + printf 'https://github.com/%s/releases/download/%s/%s\n' "$repo" "$version" "$asset" + else + printf 'https://github.com/%s/releases/latest/download/%s\n' "$repo" "$asset" + fi +} + +checksum_url() { + if [ -n "$version" ]; then + printf 'https://github.com/%s/releases/download/%s/checksums.txt\n' "$repo" "$version" + else + printf 'https://github.com/%s/releases/latest/download/checksums.txt\n' "$repo" + fi +} + +check_container_available() { if ! command -v container >/dev/null 2>&1; then printf 'Error: Apple container binary not found in PATH. Install Apple container and ensure it is available on PATH.\n' >&2 return 1 @@ -37,18 +98,72 @@ check_container_available() { fi } -is_disabled() { - case "$1" in - 0|false|False|FALSE|no|No|NO) return 0 ;; - *) return 1 ;; - esac +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + return 1 + fi } -is_enabled() { - case "$1" in - 1|true|True|TRUE|yes|Yes|YES) return 0 ;; - *) return 1 ;; - esac +verify_checksum() { + archive="$1" + asset="$2" + checksums="$3" + + expected="$(awk -v file="$asset" '$2 == file { print $1 }' "$checksums" | head -n 1)" + if [ -z "$expected" ]; then + printf 'Warning: no checksum entry for %s; skipping checksum verification.\n' "$asset" >&2 + return 0 + fi + + if ! actual="$(sha256_file "$archive")"; then + printf 'Warning: shasum or sha256sum not found; skipping checksum verification.\n' >&2 + return 0 + fi + + if [ "$actual" != "$expected" ]; then + printf 'Error: checksum mismatch for %s\n' "$asset" >&2 + printf 'Expected: %s\n' "$expected" >&2 + printf 'Actual: %s\n' "$actual" >&2 + return 1 + fi + + printf 'Verified checksum for %s\n' "$asset" +} + +download_binary() { + asset="$1" + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/opencode-sandbox-install.XXXXXX")" + trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM + + archive="$tmpdir/$asset" + extract_dir="$tmpdir/extract" + checksums="$tmpdir/checksums.txt" + mkdir -p "$extract_dir" + + url="$(release_url "$asset")" + printf 'Downloading %s\n' "$url" + curl -fL "$url" -o "$archive" + + if curl -fL "$(checksum_url)" -o "$checksums"; then + verify_checksum "$archive" "$asset" "$checksums" + else + printf 'Warning: checksums.txt not available; skipping checksum verification.\n' >&2 + fi + + tar -xzf "$archive" -C "$extract_dir" + if [ ! -f "$extract_dir/opencode-sandbox" ]; then + printf 'Error: release archive did not contain opencode-sandbox binary.\n' >&2 + return 1 + fi + + printf 'Installing opencode-sandbox to %s\n' "$bin_path" + mkdir -p "$(dirname "$bin_path")" + cp -f "$extract_dir/opencode-sandbox" "$bin_path" + chmod 0755 "$bin_path" } pull_image_ref() { @@ -56,27 +171,12 @@ pull_image_ref() { "$bin_path" image pull --tag "$1" } -need_cmd git -need_cmd go -check_container_available +need_cmd curl +need_cmd tar -if [ -d "$src_dir/.git" ]; then - printf 'Updating opencode-sandbox in %s\n' "$src_dir" - git -C "$src_dir" pull --ff-only -elif [ -e "$src_dir" ]; then - printf 'Error: %s exists but is not a git checkout.\n' "$src_dir" >&2 - printf 'Set OPENCODE_SANDBOX_DIR to another path or move the existing path aside.\n' >&2 - exit 1 -else - printf 'Cloning opencode-sandbox into %s\n' "$src_dir" - mkdir -p "$(dirname "$src_dir")" - git clone "$repo_url" "$src_dir" -fi - -printf 'Building opencode-sandbox to %s\n' "$bin_path" -mkdir -p "$(dirname "$bin_path")" -ldflags="-X github.com/RabbITCybErSeC/opencode-sandbox/internal/cli.installedSourceDir=$src_dir" -(cd "$src_dir" && go build -ldflags "$ldflags" -o "$bin_path" ./cmd/opencode-sandbox) +asset="$(detect_asset)" +check_container_available +download_binary "$asset" if is_disabled "$pull_image"; then printf 'Skipping runtime image pull because OPENCODE_SANDBOX_PULL_IMAGE=%s\n' "$pull_image" @@ -118,6 +218,7 @@ Then open a new shell or run the alias command above, and try: For local source builds or custom OpenCode versions: - sopencode image build + git clone https://github.com/$repo.git + sopencode image build --context ./opencode-sandbox EOF diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go index 683a009..e7563a6 100644 --- a/internal/cli/uninstall.go +++ b/internal/cli/uninstall.go @@ -75,7 +75,7 @@ func runUninstall(args []string) error { return err } - fmt.Println("Preserved project artifacts such as .opencode-sandbox.yaml and .opencode-sandbox/.") + fmt.Println("Project-local artifacts are intentionally preserved: .opencode-sandbox.yaml and .opencode-sandbox/.") return nil } diff --git a/internal/cli/uninstall_test.go b/internal/cli/uninstall_test.go index 1c1c4fb..d5fd0b5 100644 --- a/internal/cli/uninstall_test.go +++ b/internal/cli/uninstall_test.go @@ -2,6 +2,7 @@ package cli import ( "context" + "io" "os" "os/exec" "path/filepath" @@ -75,6 +76,30 @@ func TestRunUninstallRemovesGlobalArtifactsAndPreservesProjectArtifacts(t *testi assertCalls(t, calls, wantCalls) } +func TestRunUninstallExplainsProjectArtifactsArePreserved(t *testing.T) { + home := t.TempDir() + project := t.TempDir() + setupUninstallHome(t, home) + createUninstallArtifacts(t, home, project) + + captureUninstallCommands(t, &[][]string{}, map[string][]byte{ + "container list --all --format json": []byte(`[]`), + }) + + output := captureStdout(t, func() { + if err := runUninstall(nil); err != nil { + t.Fatalf("runUninstall failed: %v", err) + } + }) + + if !strings.Contains(output, "Project-local artifacts are intentionally preserved") { + t.Fatalf("expected project artifact preservation guidance, got:\n%s", output) + } + if !strings.Contains(output, ".opencode-sandbox.yaml") { + t.Fatalf("expected project config path in guidance, got:\n%s", output) + } +} + func TestRunUninstallRejectsUnknownOption(t *testing.T) { if err := runUninstall([]string{"--surprise"}); err == nil { t.Fatal("expected unknown option error") @@ -215,3 +240,27 @@ func userConfigRoot(t *testing.T) string { } return configRoot } + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("creating stdout pipe: %v", err) + } + os.Stdout = writer + + fn() + + if err := writer.Close(); err != nil { + t.Fatalf("closing stdout writer: %v", err) + } + os.Stdout = oldStdout + + out, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("reading stdout: %v", err) + } + return string(out) +} diff --git a/internal/containercmd/builder.go b/internal/containercmd/builder.go index b0e67bd..1a51882 100644 --- a/internal/containercmd/builder.go +++ b/internal/containercmd/builder.go @@ -105,7 +105,6 @@ func BuildArgv(plan Plan) []string { } if cfg.Network.LocalhostAccess.Enabled { - argv = append(argv, "--localhost", cfg.Network.LocalhostAccess.IP) argv = append(argv, "--env", fmt.Sprintf("OPENCODE_SANDBOX_HOST_DOMAIN=%s", cfg.Network.LocalhostAccess.Domain)) argv = append(argv, "--env", fmt.Sprintf("OPENCODE_SANDBOX_HOST_IP=%s", cfg.Network.LocalhostAccess.IP)) } diff --git a/internal/containercmd/builder_test.go b/internal/containercmd/builder_test.go index 092911c..f769aa8 100644 --- a/internal/containercmd/builder_test.go +++ b/internal/containercmd/builder_test.go @@ -366,8 +366,8 @@ func TestBuildArgvLocalhostAccessEnabled(t *testing.T) { argv := BuildArgv(plan) joined := strings.Join(argv, " ") - if !strings.Contains(joined, "--localhost 203.0.113.113") { - t.Error("expected --localhost flag") + if strings.Contains(joined, "--localhost") { + t.Error("expected no --localhost flag; host DNS is configured with `container system dns create`") } if !strings.Contains(joined, "OPENCODE_SANDBOX_HOST_DOMAIN=host.container.internal") { t.Error("expected OPENCODE_SANDBOX_HOST_DOMAIN env var")