From 9079b52c8496a8ac4d2cf5ec2fdb113fd8f70c0f Mon Sep 17 00:00:00 2001 From: Christer Barreholm Date: Wed, 5 Aug 2026 15:25:05 +0200 Subject: [PATCH] feat: switch to opencode, installed from pinned release tarball with sha256 verify Replace the claude.ai curl|bash installer with a pinned opencode release tarball (v1.18.13) from anomalyco/opencode, verified at build time against sha256 checksums committed in devops/build-image.sh. The binary installs to /usr/local/bin, root-owned, so the container user cannot replace it. Autoupdate is disabled via OPENCODE_DISABLE_AUTOUPDATE=true baked into the image, plus "autoupdate": false in opencode.json (baked in, and written to the host config by the wrapper if none exists). - rename bin/claude to bin/opencode; mount opencode's XDG dirs (~/.config/opencode, ~/.local/share/opencode, ~/.local/state/opencode) - rename container user claude -> opencode; add git for snapshots - add tests for the autoupdate env var, baked config, and root-owned binary - fix CI passing -repo-name instead of --repo-name (was silently ignored); skip registry push on pull requests --- .github/workflows/build-and-push.yml | 13 ++-- README.md | 73 +++++++++++++++------- bin/{claude => opencode} | 55 +++++++++++------ devops/build-image.sh | 91 +++++++++++++++++++++------- tests/test-image.sh | 64 +++++++++++++------ 5 files changed, 210 insertions(+), 86 deletions(-) rename bin/{claude => opencode} (55%) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index a892e55..5b75dcf 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -34,12 +34,12 @@ jobs: REPO_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') # Run the build script - VERSION=$(./devops/build-image.sh -repo-name $REPO_NAME | tail -n2 | head -n1 | cut -d: -f2) + VERSION=$(./devops/build-image.sh --repo-name $REPO_NAME | tail -n2 | head -n1 | cut -d: -f2) # Tag the image for GHCR - buildah tag claude-code \ - "ghcr.io/$REPO_NAME/claude-code:$VERSION" \ - "ghcr.io/$REPO_NAME/claude-code:latest" + buildah tag opencode \ + "ghcr.io/$REPO_NAME/opencode:$VERSION" \ + "ghcr.io/$REPO_NAME/opencode:latest" echo "repo_name=$REPO_NAME" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -48,6 +48,7 @@ jobs: run: ./tests/test-image.sh "${{ steps.build.outputs.version }}" - name: Push to GitHub Container Registry + if: github.event_name != 'pull_request' run: | - buildah push "ghcr.io/${{ steps.build.outputs.repo_name }}/claude-code:${{ steps.build.outputs.version }}" - buildah push "ghcr.io/${{ steps.build.outputs.repo_name }}/claude-code:latest" + buildah push "ghcr.io/${{ steps.build.outputs.repo_name }}/opencode:${{ steps.build.outputs.version }}" + buildah push "ghcr.io/${{ steps.build.outputs.repo_name }}/opencode:latest" diff --git a/README.md b/README.md index e0d7f67..912b365 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,66 @@ -claude-podman +opencode-podman ==== -Claude for the security-conscious: run [claude-code, the claude cli tool](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview), in a rootless [podman](https://podman.io/) container. +[opencode](https://opencode.ai/) for the security-conscious: run the opencode +terminal agent in a rootless [podman](https://podman.io/) container. + +This is a fork of [claude-podman](https://github.com/EvanCarroll/claude-podman) +adapted for opencode. Installation ---- First, download and install podman. Then install the script with curl. Set -`REPO` to match your fork (e.g. `?????/claude-podman`): +`REPO` to match your fork (e.g. `?????/opencode-podman`): ```sh -REPO=evancarroll/claude-podman +REPO=ingby/opencode-podman curl --proto '=https' --tlsv1.2 -sSf \ - "https://raw.githubusercontent.com/$REPO/refs/heads/main/bin/claude" | + "https://raw.githubusercontent.com/$REPO/refs/heads/main/bin/opencode" | sed "s|^REPO_NAME=.*|REPO_NAME=$REPO|" | - sudo tee /usr/local/bin/claude-podman > /dev/null -sudo chmod a+x /usr/local/bin/claude-podman + sudo tee /usr/local/bin/opencode-podman > /dev/null +sudo chmod a+x /usr/local/bin/opencode-podman ``` -Now you can just run `claude-podman`. +Now you can just run `opencode-podman`. Benefits ---- This provides the following benefits: -* Claude only gets file access to +* opencode only gets file access to * Files in the present working directory - * `$HOME/.claude.json` - * `$HOME/.claude` -* Claude can only execute the files that exist in the image. + * `$HOME/.config/opencode` (config) + * `$HOME/.local/share/opencode` (auth, sessions) + * `$HOME/.local/state/opencode` (state) +* opencode can only execute the files that exist in the image. This image runs in rootless podman, and even inside rootless podman it runs as -a non-root user inside the container. Claude code is maximally locked down and -can't even update itself! +a non-root user inside the container. + +Supply chain hardening +---- + +The image does **not** use opencode's `curl | bash` installer, and opencode +cannot update itself: + +* The build downloads a single, explicitly pinned release tarball + (`opencode-linux-*-musl.tar.gz`) from GitHub. The version **and** its + sha256 checksum are committed in `devops/build-image.sh`; the build fails + if the downloaded artifact does not match. +* Autoupdate is disabled three ways: + * `OPENCODE_DISABLE_AUTOUPDATE=true` is baked into the image (authoritative + — it wins even if a mounted config says otherwise). + * The image ships `~/.config/opencode/opencode.json` with + `"autoupdate": false`, and the wrapper writes the same config on the + host if none exists yet. + * The binary lives at `/usr/local/bin/opencode`, owned by root, so the + container user could not overwrite it anyway. + +To upgrade opencode: edit `OPENCODE_VERSION` and the two `OPENCODE_SHA256_*` +values in `devops/build-image.sh` (download the new tarballs and run +`sha256sum` on them), then rebuild. Customizing the runtime ---- @@ -46,12 +73,13 @@ Need to add packages to the container, or run an init script? no problem ``` -For example, let's say you're using kubernetes and you do want claude to be able to troubleshoot it. +For example, let's say you're using kubernetes and you do want opencode to be +able to troubleshoot it. ```sh -claude-podman \ +opencode-podman \ --apk-packages kubectl \ - --podman-arg "-v $HOME/.kube/config:/home/claude/.kube/config" + --podman-arg "-v $HOME/.kube/config:/home/opencode/.kube/config" ``` Sharing a network with another container @@ -64,11 +92,12 @@ container on the same network is then reachable from inside by its container name, thanks to podman's built-in DNS. ```sh -# Start the service Claude should reach, on a shared network -podman run -d --name myservice --network claude-net some/image +# Start the service opencode should reach, on a shared network +podman run -d --name myservice --network opencode-net some/image -# Run Claude on the same network (creates claude-net if needed) -claude-podman --network claude-net +# Run opencode on the same network (creates opencode-net if needed) +opencode-podman --network opencode-net ``` -Inside the container, Claude can now reach the service at `http://myservice:`. +Inside the container, opencode can now reach the service at +`http://myservice:`. diff --git a/bin/claude b/bin/opencode similarity index 55% rename from bin/claude rename to bin/opencode index a244249..46f05fa 100755 --- a/bin/claude +++ b/bin/opencode @@ -1,10 +1,10 @@ #!/bin/bash # GH Repo - Replace to run a fork -REPO_NAME=evancarroll/claude-podman +REPO_NAME=ingby/opencode-podman # Default image -IMAGE="ghcr.io/$REPO_NAME/claude-code:latest" -NAME="claudecode-$(uuidgen -r | cut -d- -f1)" +IMAGE="ghcr.io/$REPO_NAME/opencode:latest" +NAME="opencode-$(uuidgen -r | cut -d- -f1)" INIT_SCRIPT="" APK_PACKAGES="" PODMAN_ARGS="" @@ -15,33 +15,33 @@ while [[ $# -gt 0 ]]; do case "$1" in --help) cat <<-'EOT' - Usage: claude [OPTIONS] + Usage: opencode-podman [OPTIONS] - Run Claude Code in a Podman container + Run opencode in a Podman container Options: - --local Use local image 'claude-code:latest' instead of remote + --local Use local image 'opencode:latest' instead of remote --init-script FILE Execute initialization script --apk-packages LIST Install additional Alpine packages (comma or space separated) --network NAME Join a dedicated podman network (created if missing) so - Claude can reach other containers on it by name via DNS + opencode can reach other containers on it by name via DNS --podman-arg ARG Pass additional argument to podman - --version Show the Claude Code version in the image + --version Show the opencode version in the image --help Display this help message EOT exit 0 ;; --self-update) curl --proto '=https' --tlsv1.2 -sSf \ - "https://raw.githubusercontent.com/$REPO_NAME/refs/heads/main/bin/claude" | + "https://raw.githubusercontent.com/$REPO_NAME/refs/heads/main/bin/opencode" | sed "s|^REPO_NAME=.*|REPO_NAME=$REPO_NAME|" | - sudo tee /usr/local/bin/claude-podman >/dev/null - sudo chmod a+x /usr/local/bin/claude-podman + sudo tee /usr/local/bin/opencode-podman >/dev/null + sudo chmod a+x /usr/local/bin/opencode-podman echo "Updated" exit 0 ;; --local) - IMAGE="localhost/claude-code:latest" + IMAGE="localhost/opencode:latest" shift ;; --init-script) @@ -63,8 +63,8 @@ while [[ $# -gt 0 ]]; do --version) podman run \ --rm \ - --user claude \ - --userns=keep-id:uid=1001,gid=1001 \ + --user opencode \ + --userns=keep-id:uid=1000,gid=1000 \ $PODMAN_ARGS \ "$IMAGE" --version exit 0 @@ -84,16 +84,35 @@ if [ -n "$NETWORK" ]; then NETWORK_ARG="--network $NETWORK" fi +# opencode keeps auth/sessions, config, and state in XDG directories; +# create them on the host so podman doesn't have to +mkdir -p \ + "${HOME}/.config/opencode" \ + "${HOME}/.local/share/opencode" \ + "${HOME}/.local/state/opencode" + +# Disable autoupdate in the host config unless one already exists (the +# image also sets OPENCODE_DISABLE_AUTOUPDATE, which wins regardless) +if [ ! -f "${HOME}/.config/opencode/opencode.json" ]; then + cat > "${HOME}/.config/opencode/opencode.json" <<-'EOT' + { + "$schema": "https://opencode.ai/config.json", + "autoupdate": false + } + EOT +fi + podman run \ - -v "${HOME}/.claude:/home/claude/.claude" \ - -v "${HOME}/.claude.json:/home/claude/.claude.json" \ + -v "${HOME}/.config/opencode:/home/opencode/.config/opencode" \ + -v "${HOME}/.local/share/opencode:/home/opencode/.local/share/opencode" \ + -v "${HOME}/.local/state/opencode:/home/opencode/.local/state/opencode" \ -v "${PWD}:${PWD}" \ -w "$PWD" \ --rm \ --name "$NAME" \ -ti \ --detach \ - --user claude \ + --user opencode \ --userns=keep-id:uid=1000,gid=1000 \ $NETWORK_ARG \ $PODMAN_ARGS \ @@ -108,4 +127,4 @@ if [ -n "$INIT_SCRIPT" ]; then podman exec --user root:root "$NAME" bash /root/init.sh fi -podman container attach "$NAME" \ No newline at end of file +podman container attach "$NAME" diff --git a/devops/build-image.sh b/devops/build-image.sh index 1819f51..57c5864 100755 --- a/devops/build-image.sh +++ b/devops/build-image.sh @@ -1,8 +1,15 @@ #!/bin/sh +set -eu -CONTAINER=$(buildah from docker.io/alpine:latest) -IMAGE=claude-code -REPO_NAME="EvanCarroll/claude-podman" +# opencode release pin. To upgrade: bump the version, then update both +# checksums from the new release assets (compute with sha256sum after +# downloading from https://github.com/anomalyco/opencode/releases). +OPENCODE_VERSION="1.18.13" +OPENCODE_SHA256_X86_64="93f1506f26ad8b6e867754d144bea43e6c707ec518bac227fdf959048d020d74" +OPENCODE_SHA256_AARCH64="a5b90d6111d2d826fe14952c769d0d4bcf00db7810c0c92d9f4541d3768f03ef" + +IMAGE=opencode +REPO_NAME="ingby/opencode-podman" # Process arguments while [ "$#" -gt 0 ]; do @@ -11,7 +18,7 @@ while [ "$#" -gt 0 ]; do cat <<-'EOT' Usage: $0 [OPTIONS] - Build image to run Claude Code in a Podman container + Build image to run opencode in a Podman container Options: --repo-name REPO_NAME Use the specified repo name instead of the default "REPO_NAME" @@ -29,30 +36,70 @@ while [ "$#" -gt 0 ]; do esac done +case "$(uname -m)" in +x86_64) + OPENCODE_ASSET="opencode-linux-x64-musl.tar.gz" + OPENCODE_SHA256="$OPENCODE_SHA256_X86_64" + ;; +aarch64 | arm64) + OPENCODE_ASSET="opencode-linux-arm64-musl.tar.gz" + OPENCODE_SHA256="$OPENCODE_SHA256_AARCH64" + ;; +*) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +CONTAINER=$(buildah from docker.io/alpine:latest) + # Install dependencies and create user buildah run "$CONTAINER" sh </dev/null | head -1 | awk '{print $1}') +# Bake a default config that disables autoupdate. If the config dir is +# bind-mounted at runtime this file is shadowed, so the +# OPENCODE_DISABLE_AUTOUPDATE env var below is the authoritative switch. +buildah run --user opencode "$CONTAINER" sh <<'EOT' + set -eu + mkdir -p /home/opencode/.config/opencode + cat > /home/opencode/.config/opencode/opencode.json <<'JSON' +{ + "$schema": "https://opencode.ai/config.json", + "autoupdate": false +} +JSON +EOT buildah config \ - --author "Evan Carroll" \ - --env "PATH=/home/claude/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + --author "Christer Barreholm" \ + --env "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ --env "SHELL=/bin/bash" \ - --env "DISABLE_TELEMETRY=1" \ - --env "DISABLE_AUTOUPDATER=1" \ - --env "USE_BUILTIN_RIPGREP=0" \ + --env "OPENCODE_DISABLE_AUTOUPDATE=true" \ --cmd "" \ - --entrypoint '[ "/home/claude/.local/bin/claude" ]' \ - --annotation "org.anthropic.claudecode.version=$CLAUDE_VERSION" \ - --annotation "org.opencontainers.image.title=claude-code" \ - --annotation "org.opencontainers.image.description=Claude Code on Alpine ready for rootless podman" \ + --entrypoint '[ "/usr/local/bin/opencode" ]' \ + --annotation "ai.opencode.version=$OPENCODE_VERSION" \ + --annotation "org.opencontainers.image.title=opencode" \ + --annotation "org.opencontainers.image.description=opencode on Alpine ready for rootless podman" \ --annotation "org.opencontainers.image.url=https://github.com/$REPO_NAME" \ --annotation "org.opencontainers.image.source=https://github.com/$REPO_NAME" \ --annotation "org.opencontainers.image.documentation=https://github.com/$REPO_NAME/blob/main/README.md" \ @@ -64,8 +111,8 @@ buildah commit \ --rm \ "$CONTAINER" "$IMAGE" -buildah tag "$IMAGE" "$IMAGE:$CLAUDE_VERSION" +buildah tag "$IMAGE" "$IMAGE:$OPENCODE_VERSION" echo Done! -echo ${IMAGE}:${CLAUDE_VERSION} -echo To use this image run /bin/claude +echo ${IMAGE}:${OPENCODE_VERSION} +echo To use this image run /bin/opencode diff --git a/tests/test-image.sh b/tests/test-image.sh index 822779e..2ec1af6 100755 --- a/tests/test-image.sh +++ b/tests/test-image.sh @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail -IMAGE="localhost/claude-code:${1:-latest}" +IMAGE="localhost/opencode:${1:-latest}" PASS=0 FAIL=0 @@ -22,41 +22,69 @@ run_test() { run_test "Image exists" \ podman image exists "$IMAGE" -# 2. Claude binary runs and outputs a version -test_claude_version() { +# 2. opencode binary runs and outputs a version +test_opencode_version() { local output output=$(podman run --rm --entrypoint "" "$IMAGE" \ - /home/claude/.local/bin/claude --version 2>&1) - echo "$output" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+' + /usr/local/bin/opencode --version 2>&1) + echo "$output" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+' } -run_test "Claude binary outputs version" \ - test_claude_version +run_test "opencode binary outputs version" \ + test_opencode_version # 3. Entrypoint is set correctly test_entrypoint() { local entrypoint entrypoint=$(podman inspect --format '{{range .Config.Entrypoint}}{{.}}{{end}}' "$IMAGE") - [ "$entrypoint" = "/home/claude/.local/bin/claude" ] + [ "$entrypoint" = "/usr/local/bin/opencode" ] } -run_test "Entrypoint is /home/claude/.local/bin/claude" \ +run_test "Entrypoint is /usr/local/bin/opencode" \ test_entrypoint -# 4. Claude user exists with UID 1000 -test_claude_uid() { +# 4. opencode user exists with UID 1000 +test_opencode_uid() { local output - output=$(podman run --rm --entrypoint "" "$IMAGE" id claude 2>&1) + output=$(podman run --rm --entrypoint "" "$IMAGE" id opencode 2>&1) echo "$output" | grep -q "uid=1000" } -run_test "Claude user has UID 1000" \ - test_claude_uid +run_test "opencode user has UID 1000" \ + test_opencode_uid -# 5. Wrapper script starts claude and it responds +# 5. Autoupdate is disabled via environment +test_autoupdate_env() { + podman inspect --format '{{range .Config.Env}}{{.}}{{"\n"}}{{end}}' "$IMAGE" | + grep -q '^OPENCODE_DISABLE_AUTOUPDATE=true$' +} +run_test "OPENCODE_DISABLE_AUTOUPDATE is set" \ + test_autoupdate_env + +# 6. Baked-in config disables autoupdate +test_autoupdate_config() { + local output + output=$(podman run --rm --entrypoint "" "$IMAGE" \ + cat /home/opencode/.config/opencode/opencode.json 2>&1) + echo "$output" | grep -q '"autoupdate": false' +} +run_test "Image config sets autoupdate false" \ + test_autoupdate_config + +# 7. Binary is root-owned so it cannot replace itself +test_binary_owner() { + local output + output=$(podman run --rm --entrypoint "" "$IMAGE" \ + stat -c '%U' /usr/local/bin/opencode 2>&1) + [ "$output" = "root" ] +} +run_test "opencode binary is owned by root" \ + test_binary_owner + +# 8. Wrapper script starts opencode and it responds test_wrapper() { local output - output=$(timeout 30 ./bin/claude --local --version 2>&1) - echo "$output" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+' + output=$(timeout 30 ./bin/opencode --local --version 2>&1) + echo "$output" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+' } -run_test "Wrapper script starts claude" \ +run_test "Wrapper script starts opencode" \ test_wrapper # Summary