Install on musl Linux, and on a shell Alpine actually ships

960983853961 · AtlantisPleb · · parent 8c65caa0fd7f

Install on musl Linux, and on a shell Alpine actually ships

Two Linux builds now exist per architecture, so the installer has to choose
between them. It does not ask which distribution this is: that needs files a
minimal image may not carry, and `ldd` disagrees with itself across
implementations -- GNU's prints a banner to stdout and exits 0, musl's prints
to stderr, BusyBox's does neither. It asks the question that actually decides
the answer, which is whether the glibc dynamic loader for this architecture
exists. The gnu artifact names that loader in its PT_INTERP; the musl artifact
is static and names none. A system with the loader can run either and gets gnu;
a system without it can only run musl. Every way that test can be wrong sends
the reader to the artifact that still runs.

The script is now POSIX shell rather than bash, and the difference is the
point: the musl builds exist for Alpine above all, and Alpine ships no bash, so
piping this into `bash` there failed before the first line ran with an error
about the shell rather than about anything the reader did. `bash` still works
and every published form of the command keeps working. What it costs is `[[ ]]`,
`=~`, and one array, which is a fair price for `curl … | sh` working on the
platform the feature is for.

The tests extract `linux_libc` from the served script and run it against
fixture roots -- a glibc loader, a musl loader, neither, both, and each
architecture judged by its own -- so they assert the code readers receive
rather than a copy of it. The release route gains a case covering every
platform name the installer can ask for, because the musl artifacts carry two
more hyphens than any name that existed when the allowlist was written.

Proven end to end against production: `sh install.sh` on stock Alpine with no
bash resolves musl, downloads `openagents-0.1.0-rc.3-linux-x86_64-musl`,
verifies its digest, and runs it; the same script under dash on Debian, with
the chunked parallel path forced, reassembles eight ranges into a binary whose
checksum still matches; and on macOS it installs a notarized arm64 build that
`spctl` accepts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 434 · 2026-08-26T01:57:57.572648Z
built
13 modules in 120.9 s
deployed
live · 13 modules on 3 nodes · push→live —
deployed
needs_rolling_replace · 13 modules on 0 nodes · push→live —

Changed files

  • modified priv/docs/install-cli.md
  • modified priv/static/install.sh
  • modified test/openagents_web/controllers/release_controller_test.exs
  • modified test/openagents_web/install_script_test.exs

Diff

4 files changed, +310 -14

priv/docs/install-cli.md modified +57 -4

@@ -15,8 +15,36 @@ configuration file. Open a new shell, then confirm the installation:

15 15
openagents --help
16 16
```
17 17
18
Run the same command again to update. The CLI does not include an
19
`openagents update` command.
18
## Update
19
20
```sh
21
openagents update
22
```
23
24
The command resolves the same channel the installer resolves, compares the
25
version it names against the running binary, and stops there when they agree.
26
Otherwise it downloads the new artifact, fetches `SHA256SUMS-<version>` over a
27
separate request, refuses anything it cannot verify, and replaces the binary in
28
place. `openagents self-update` is the same command.
29
30
Ask what the channel names without installing anything:
31
32
```sh
33
openagents update --check
34
```
35
36
Follow a different channel, or install one exact version:
37
38
```sh
39
openagents update --channel beta
40
openagents update --version 0.1.0-rc.2
41
```
42
43
`--force` reinstalls the version already running, which is how you repair a
44
binary you suspect is damaged.
45
46
Running the installer again does the same job and is the right choice when the
47
binary cannot start at all.
20 48
21 49
## Install a specific version
22 50

@@ -44,6 +72,11 @@ A channel is a pointer that moves, so the version you get today is not the

44 72
version you get next month. Pass an explicit version when you need the answer
45 73
to stay the same.
46 74
75
`beta` names the current release candidate. `stable` does not resolve yet:
76
it starts naming a version when the first release is cut, and until then the
77
installer says so and stops rather than guessing. Until that happens, pass a
78
version or follow `beta`.
79
47 80
## Choose where the binary lands
48 81
49 82
The installer links `openagents` and `oa` into `~/.openagents/bin`. Set

@@ -61,13 +94,33 @@ The downloaded binary itself always lands in `~/.openagents/downloads`.

61 94
| --- | --- |
62 95
| macOS on Apple silicon | `macos-aarch64` |
63 96
| macOS on Intel | `macos-x86_64` |
64
| Linux on x86-64 | `linux-x86_64` |
65
| Linux on ARM64 | `linux-aarch64` |
97
| Linux on x86-64, glibc | `linux-x86_64` |
98
| Linux on x86-64, musl | `linux-x86_64-musl` |
99
| Linux on ARM64, glibc | `linux-aarch64` |
100
| Linux on ARM64, musl | `linux-aarch64-musl` |
66 101
| Windows on x86-64 | `windows-x86_64` |
67 102
68 103
On Apple silicon, a shell running under Rosetta reports an Intel processor. The
69 104
installer detects that and installs the native `macos-aarch64` build anyway.
70 105
106
On Linux, the installer picks between the two C library builds by looking for
107
the glibc dynamic loader for your architecture. A system that has it can run
108
the dynamically linked build and receives it. A system that does not — Alpine,
109
a distroless or BusyBox image, NixOS — receives the statically linked musl
110
build, which depends on no loader at all. Distributions are never named or
111
guessed at, and the check needs no tools beyond the shell, so it holds on
112
images that carry neither `ldd` nor a release file.
113
114
On Alpine and other minimal Linux images, pipe the installer into `sh`. The
115
script is POSIX shell, and those images ship no `bash`:
116
117
```sh
118
curl -fsSL https://openagents.com/install.sh | sh
119
```
120
121
`bash` works everywhere it exists, so either form is fine on a system that has
122
it.
123
71 124
On Windows, run the installer under Git for Windows or MSYS2 Bash. It installs
72 125
`openagents.exe` and `oa.exe`. Under WSL, use the Linux build: WSL is Linux, and
73 126
`uname -s` reports it as such.
priv/static/install.sh modified +107 -10

@@ -1,18 +1,36 @@

1
#!/bin/bash
1
#!/bin/sh
2 2
#
3 3
# OpenAgents CLI installer — https://openagents.com/install.sh
4 4
#
5 5
# Usage:
6
#   curl -fsSL https://openagents.com/install.sh | bash            # latest stable
7
#   curl -fsSL https://openagents.com/install.sh | bash -s 0.1.0   # specific version
6
#   curl -fsSL https://openagents.com/install.sh | sh            # latest stable
7
#   curl -fsSL https://openagents.com/install.sh | sh -s 0.1.0   # specific version
8 8
#
9 9
# Windows: run under Git for Windows / MSYS2 Bash; WSL uses the Linux binary.
10
#
11
# This is POSIX shell, not bash, and the difference is the point. The musl
12
# builds exist for Alpine above all, and Alpine ships no bash: piping this into
13
# `bash` there fails before the first line runs, with an error about the shell
14
# rather than about anything the reader did. `sh` is on every system this
15
# installs to. Piping into `bash` still works and every published form of the
16
# command keeps working, so nothing that already ran stops running.
17
#
18
# What that costs: no `[[ ]]`, no `=~`, and no arrays. Version matching goes
19
# through `is_version` below, and the parallel download tracks its background
20
# jobs in a space-separated string.
10 21
11 22
set -e
12 23
13 24
TARGET="$1"
14 25
15
if [[ -n "$TARGET" ]] && [[ ! "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
26
# The grammar the release publishes under, and the same one `ops/release-cli.sh`
27
# and `oa update` apply. A version one of them accepts and another rejects is a
28
# release nobody can ask for.
29
is_version() {
30
    printf '%s' "$1" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$'
31
}
32
33
if [ -n "$TARGET" ] && ! is_version "$TARGET"; then
16 34
    echo "Invalid version format: $TARGET (expected X.Y.Z or X.Y.Z-suffix)" >&2
17 35
    exit 1
18 36
fi

@@ -60,16 +78,19 @@ download_file_parallel() {

60 78
    local chunk_size=$(( (size + n - 1) / n ))
61 79
    local tmpdir
62 80
    tmpdir=$(mktemp -d 2>/dev/null) || { download_file "$url" "$output"; return; }
63
    local pids=() i start end
64
    for i in $(seq 0 $((n - 1))); do
81
    # A space-separated list rather than an array: `wait` takes one pid at a
82
    # time either way, and this is the part of bash the script does without.
83
    local pids="" i=0 start end
84
    while [ "$i" -lt "$n" ]; do
65 85
        start=$((i * chunk_size))
66 86
        end=$((start + chunk_size - 1))
67 87
        [ $end -ge $size ] && end=$((size - 1))
68 88
        curl -fsSL -r "${start}-${end}" -o "${tmpdir}/$(printf 'chunk.%03d' "$i")" "$url" &
69
        pids+=($!)
89
        pids="$pids $!"
90
        i=$((i + 1))
70 91
    done
71 92
    local all_ok=true pid
72
    for pid in "${pids[@]}"; do
93
    for pid in $pids; do
73 94
        wait "$pid" || all_ok=false
74 95
    done
75 96
    if [ "$all_ok" = true ] && cat "${tmpdir}"/chunk.* > "$output" 2>/dev/null; then

@@ -112,12 +133,87 @@ if [ "$os" = "macos" ] && [ "$arch" = "x86_64" ]; then

112 133
    fi
113 134
fi
114 135
136
# Which C library, on Linux. Two Linux builds exist per architecture because a
137
# glibc-linked executable does not run on a musl system: the kernel reports the
138
# missing interpreter as "no such file or directory" against a file that
139
# plainly exists, which is one of the least legible errors a first install can
140
# produce.
141
#
142
# The test is not "which distribution is this". Distribution detection needs
143
# files a minimal image may not carry, and `ldd` disagrees with itself across
144
# implementations -- GNU's prints a version banner to stdout and exits 0, musl's
145
# prints "musl libc" to stderr, and BusyBox's does neither. The question that
146
# actually decides the answer is narrower: is the glibc dynamic loader this
147
# artifact would ask for present on the system?
148
#
149
# The gnu artifact is dynamically linked and names that loader in its
150
# PT_INTERP. The musl artifact is statically linked and names no interpreter at
151
# all, so it runs anywhere. A system with the loader can run either and gets
152
# gnu; a system without it can only run musl. That reads correctly on Alpine,
153
# on a distroless or BusyBox-only image with no `ldd` to ask, on a Debian host
154
# that happens to have the `musl` package installed, and on NixOS, where the
155
# loader lives in the Nix store and the static build is genuinely the right
156
# answer. Every way this test can be wrong sends the reader to the artifact
157
# that still runs.
158
#
159
# $1 is the architecture; $2 is a filesystem root to search under, empty for
160
# the real one, so the choice can be exercised against a fixture.
161
linux_libc() {
162
    libc_arch="$1"
163
    libc_root="$2"
164
165
    case "$libc_arch" in
166
        x86_64)
167
            loaders="/lib64/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2"
168
            ;;
169
        aarch64)
170
            loaders="/lib/ld-linux-aarch64.so.1 /lib64/ld-linux-aarch64.so.1 /lib/aarch64-linux-gnu/ld-linux-aarch64.so.1"
171
            ;;
172
        *)
173
            echo "gnu"
174
            return
175
            ;;
176
    esac
177
178
    for loader in $loaders; do
179
        if [ -e "${libc_root}${loader}" ]; then
180
            # A system whose own `ldd` identifies as musl is musl even with a
181
            # glibc compatibility loader installed beside it, and that is the
182
            # one case where the loader alone would answer wrongly in the
183
            # direction that does not run.
184
            if [ -z "$libc_root" ] && command -v ldd >/dev/null 2>&1; then
185
                case "$(ldd --version 2>&1 || true)" in
186
                    *musl*) echo "musl"; return ;;
187
                esac
188
            fi
189
            echo "gnu"
190
            return
191
        fi
192
    done
193
194
    echo "musl"
195
}
196
197
libc=""
198
if [ "$os" = "linux" ]; then
199
    libc="$(linux_libc "$arch" "")"
200
fi
201
115 202
BASE_URL_PRIMARY="https://openagents.com/releases"
116 203
DOWNLOAD_DIR="$HOME/.openagents/downloads"
117 204
BIN_DIR="${OPENAGENTS_BIN_DIR:-$HOME/.openagents/bin}"
118 205
mkdir -p "$DOWNLOAD_DIR" "$BIN_DIR"
119 206
120 207
platform="${os}-${arch}"
208
209
# The glibc Linux artifact keeps the unsuffixed name it has always had, so
210
# every installer already in circulation keeps resolving. musl is the addition
211
# and carries the suffix.
212
if [ "$libc" = "musl" ]; then
213
    platform="${platform}-musl"
214
    echo "musl libc detected; installing the statically linked build." >&2
215
fi
216
121 217
CHANNEL="${OPENAGENTS_CHANNEL:-stable}"
122 218
123 219
# A channel is a pointer file naming the version it currently means, so

@@ -129,10 +225,11 @@ else

129 225
    version="$(download_file "${BASE_URL_PRIMARY}/${CHANNEL}" "" 2>/dev/null | tr -d '[:space:]')" || version=""
130 226
    if [ -z "$version" ]; then
131 227
        echo "Could not resolve the '${CHANNEL}' channel from ${BASE_URL_PRIMARY}/${CHANNEL}." >&2
132
        echo "Pass a version explicitly: curl -fsSL https://openagents.com/install.sh | bash -s X.Y.Z" >&2
228
        echo "Pass a version explicitly: curl -fsSL https://openagents.com/install.sh | sh -s X.Y.Z" >&2
229
        echo "Or follow another channel: OPENAGENTS_CHANNEL=beta" >&2
133 230
        exit 1
134 231
    fi
135
    if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
232
    if ! is_version "$version"; then
136 233
        echo "The '${CHANNEL}' channel returned something that is not a version: ${version}" >&2
137 234
        exit 1
138 235
    fi
test/openagents_web/controllers/release_controller_test.exs modified +23

@@ -179,6 +179,29 @@ defmodule OpenAgentsWeb.ReleaseControllerTest do

179 179
    end
180 180
  end
181 181
182
  test "every platform the installer can ask for is admitted", %{conn: conn} do
183
    Req.Test.stub(__MODULE__, fn upstream ->
184
      Plug.Conn.send_resp(upstream, 200, "bytes")
185
    end)
186
187
    # The musl artifacts carry two more hyphens than any name that existed when
188
    # the allowlist was written. A pattern tightened later without them in mind
189
    # would 404 every Alpine install while every other platform kept working.
190
    for platform <- [
191
          "macos-aarch64",
192
          "macos-x86_64",
193
          "linux-x86_64",
194
          "linux-x86_64-musl",
195
          "linux-aarch64",
196
          "linux-aarch64-musl",
197
          "windows-x86_64"
198
        ] do
199
      name = "openagents-0.1.0-rc.2-#{platform}"
200
201
      assert get(conn, ~p"/releases/#{name}").status == 200, "#{name} was refused"
202
    end
203
  end
204
182 205
  test "a deeper path names no object, and the reserved slug is what answers", %{conn: conn} do
183 206
    Req.Test.stub(__MODULE__, fn _upstream ->
184 207
      flunk("a multi-segment path reached the bucket")
test/openagents_web/install_script_test.exs modified +123

@@ -18,6 +18,28 @@ defmodule OpenAgentsWeb.InstallScriptTest do

18 18
19 19
  @script Path.join([File.cwd!(), "priv", "static", "install.sh"])
20 20
21
  defp libc(source, arch, root) do
22
    {output, 0} =
23
      System.cmd("bash", ["-c", ". #{source}; linux_libc #{arch} #{root}"],
24
        stderr_to_stdout: true
25
      )
26
27
    String.trim(output)
28
  end
29
30
  defp fixture(dir, name, paths) do
31
    root = Path.join(dir, name)
32
    File.mkdir_p!(root)
33
34
    Enum.each(paths, fn path ->
35
      absolute = Path.join(root, path)
36
      File.mkdir_p!(Path.dirname(absolute))
37
      File.write!(absolute, "")
38
    end)
39
40
    root
41
  end
42
21 43
  test "the installer is served under the name the published command uses" do
22 44
    assert "install.sh" in OpenAgentsWeb.static_paths(),
23 45
           "`install.sh` is not in static_paths/0, so /install.sh is a 404"

@@ -30,6 +52,22 @@ defmodule OpenAgentsWeb.InstallScriptTest do

30 52
    assert {_output, 0} = System.cmd("bash", ["-n", @script], stderr_to_stdout: true)
31 53
  end
32 54
55
  test "the script needs no shell Alpine does not ship" do
56
    # The musl builds exist for Alpine above all, and Alpine ships no bash.
57
    # `curl … | bash` there fails before the first line runs, with an error
58
    # about the shell rather than about anything the reader did. So this is
59
    # POSIX shell, and these are the three things that would quietly make it
60
    # bash again.
61
    assert {_output, 0} = System.cmd("sh", ["-n", @script], stderr_to_stdout: true)
62
63
    script = File.read!(@script)
64
    body = String.replace(script, ~r/^#.*$/m, "")
65
66
    refute body =~ "[[", "`[[ ]]` is a bash conditional"
67
    refute body =~ "=~", "`=~` is a bash regex match"
68
    refute body =~ ~r/\[@\]/, "an array expansion is bash-only"
69
  end
70
33 71
  test "nothing is installed without a checksum that matches" do
34 72
    script = File.read!(@script)
35 73

@@ -57,6 +95,91 @@ defmodule OpenAgentsWeb.InstallScriptTest do

57 95
           "the installer reinstalls whatever is already on disk"
58 96
  end
59 97
98
  describe "libc detection" do
99
    # `linux_libc` is extracted from the served script and run against fixture
100
    # roots, so these assert the code readers actually receive rather than a
101
    # copy of it. The choice matters more than most: a glibc binary on a musl
102
    # system fails at exec with "no such file or directory" naming a file that
103
    # is plainly there, and nothing in that message points at the installer.
104
    setup do
105
      script = File.read!(@script)
106
107
      [function] = Regex.run(~r/^linux_libc\(\).*?^\}/ms, script)
108
109
      dir = Path.join(System.tmp_dir!(), "install-libc-#{System.unique_integer([:positive])}")
110
      File.mkdir_p!(dir)
111
      on_exit(fn -> File.rm_rf(dir) end)
112
113
      source = Path.join(dir, "linux_libc.sh")
114
      File.write!(source, function)
115
116
      {:ok, source: source, dir: dir}
117
    end
118
119
    test "a present glibc loader means the dynamically linked build runs", context do
120
      root = fixture(context.dir, "glibc", ["lib64/ld-linux-x86-64.so.2"])
121
122
      assert libc(context.source, "x86_64", root) == "gnu"
123
    end
124
125
    test "a multiarch glibc loader counts too", context do
126
      root = fixture(context.dir, "multiarch", ["lib/x86_64-linux-gnu/ld-linux-x86-64.so.2"])
127
128
      assert libc(context.source, "x86_64", root) == "gnu"
129
    end
130
131
    test "no glibc loader means only the static build runs", context do
132
      root = fixture(context.dir, "alpine", ["lib/ld-musl-x86_64.so.1"])
133
134
      assert libc(context.source, "x86_64", root) == "musl"
135
    end
136
137
    test "an image carrying no libc at all still resolves to the static build", context do
138
      root = fixture(context.dir, "bare", [])
139
140
      assert libc(context.source, "x86_64", root) == "musl"
141
      assert libc(context.source, "aarch64", root) == "musl"
142
    end
143
144
    test "a glibc host with the musl package installed is still glibc", context do
145
      # Debian's `musl` package drops a musl loader onto a glibc system. The
146
      # question is which artifact runs, not which loaders exist, and both do.
147
      root =
148
        fixture(context.dir, "both", [
149
          "lib64/ld-linux-x86-64.so.2",
150
          "lib/ld-musl-x86_64.so.1"
151
        ])
152
153
      assert libc(context.source, "x86_64", root) == "gnu"
154
    end
155
156
    test "each architecture is judged by its own loader", context do
157
      root = fixture(context.dir, "arm", ["lib/ld-linux-aarch64.so.1"])
158
159
      assert libc(context.source, "aarch64", root) == "gnu"
160
161
      # The x86_64 loader is absent from this root, and an architecture's
162
      # verdict must not be borrowed from another's.
163
      assert libc(context.source, "x86_64", root) == "musl"
164
    end
165
166
    test "an architecture with no musl build asks for the glibc one", context do
167
      root = fixture(context.dir, "riscv", [])
168
169
      assert libc(context.source, "riscv64", root) == "gnu"
170
    end
171
  end
172
173
  test "the musl platform is named the way the release publishes it" do
174
    script = File.read!(@script)
175
176
    assert script =~ ~s(platform="${platform}-musl"),
177
           "the installer detects musl and then asks for the same artifact anyway"
178
179
    refute script =~ ~s(platform="${os}-${arch}-${libc}"),
180
           "the glibc artifact is renamed, which breaks every installer already in circulation"
181
  end
182
60 183
  test "the channel resolves a version rather than hardcoding one" do
61 184
    script = File.read!(@script)
62 185

This page updates live while a promote is in flight · changelog