Serve an installer that refuses what it cannot verify

9e996135da32 · AtlantisPleb · · parent 7701e1d7fc74

Serve an installer that refuses what it cannot verify

The installer is a published entry point: `curl | bash` hands a shell script
the right to write an executable onto a reader's machine and put it on their
PATH. As written it verified nothing, so three things had to change before it
could be served under that name.

It downloaded a binary and made it executable with no integrity check. It now
fetches `SHA256SUMS-<version>` separately from the artifact, refuses when the
sums file is missing, when it names no entry for the platform, when neither
shasum nor sha256sum is available, and when the digest does not match — in
every case before the bytes are made executable.

It fell back to `./target/release/oa`, `./target/debug/oa`, or an
already-installed binary when the download failed, so running it from any
directory near a stale or foreign build installed that build while printing
the version it meant to fetch. The download is now the only source; a failure
is a failure.

And it hardcoded `version=0.1.0` while reading a channel it never used, which
made the channel a decoration. It now resolves the channel to a version
through a pointer file and refuses a response that is not a version.

The `/coder` page linked an `/assets/css/webtui.css` that was never added, and
the root layout had grown an `@extra_css` hatch to let it. Both are gone: the
page asked for a 404 and rendered on its literal fallbacks, and this
repository has one component system, so a page that needs a second one needs a
decision rather than an escape hatch.

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.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/js/app.js
  • added assets/js/coder_copy.js
  • modified lib/openagents_web.ex
  • added lib/openagents_web/live/coder_live.ex
  • modified lib/openagents_web/router.ex
  • added priv/static/install.sh
  • added test/openagents_web/install_script_test.exs
  • added test/openagents_web/live/coder_live_test.exs

Diff

8 files changed, +511 -3

assets/js/app.js modified +2 -1

@@ -1,3 +1,4 @@

1
import CoderCopy from "./coder_copy"
1 2
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
2 3
// to get started and then uncomment the line below.
3 4
// import "./user_socket.js"

@@ -74,7 +75,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute

74 75
const liveSocket = new LiveSocket("/live", Socket, {
75 76
  longPollFallbackMs: 2500,
76 77
  params: {_csrf_token: csrfToken},
77
  hooks: {...colocatedHooks, VoiceController, PacedTranscript, DocsSidebar},
78
  hooks: {...colocatedHooks, VoiceController, PacedTranscript, DocsSidebar, CoderCopy},
78 79
})
79 80
80 81
// Show progress bar on live navigation and form submits
assets/js/coder_copy.js added +54

@@ -0,0 +1,54 @@

1
const CoderCopy = {
2
  mounted() {
3
    this.onClick = () => this.copy()
4
    this.el.addEventListener("click", this.onClick)
5
  },
6
7
  destroyed() {
8
    this.el.removeEventListener("click", this.onClick)
9
  },
10
11
  copy() {
12
    const text = this.el.dataset.copyText || "npm i -g @openagentsinc/cli"
13
    const hintEl = document.getElementById("copy-hint")
14
15
    const flashCopied = () => {
16
      if (hintEl) {
17
        hintEl.textContent = "    copied to clipboard!     "
18
        clearTimeout(this._timeout)
19
        this._timeout = setTimeout(() => {
20
          hintEl.textContent = " " + text + " "
21
        }, 2000)
22
      }
23
    }
24
25
    if (navigator.clipboard && window.isSecureContext) {
26
      navigator.clipboard.writeText(text).then(flashCopied).catch(() => {
27
        this.fallbackCopy(text, flashCopied)
28
      })
29
    } else {
30
      this.fallbackCopy(text, flashCopied)
31
    }
32
  },
33
34
  fallbackCopy(text, cb) {
35
    const ta = document.createElement("textarea")
36
    ta.value = text
37
    ta.style.position = "fixed"
38
    ta.style.top = "0"
39
    ta.style.left = "0"
40
    ta.style.opacity = "0"
41
    document.body.appendChild(ta)
42
    ta.focus()
43
    ta.select()
44
    try {
45
      document.execCommand("copy")
46
      cb()
47
    } catch (e) {
48
      console.error("Copy failed", e)
49
    }
50
    document.body.removeChild(ta)
51
  }
52
}
53
54
export default CoderCopy
lib/openagents_web.ex modified +2 -2

@@ -19,7 +19,7 @@ defmodule OpenAgentsWeb do

19 19
20 20
  def static_paths,
21 21
    do:
22
      ~w(assets fonts images favicon.ico favicon-32x32.png favicon-16x16.png apple-touch-icon.png robots.txt)
22
      ~w(assets fonts images favicon.ico favicon-32x32.png favicon-16x16.png apple-touch-icon.png robots.txt install.sh)
23 23
24 24
  @doc """
25 25
  Prefixes for static files that are served under a digested name.

@@ -34,7 +34,7 @@ defmodule OpenAgentsWeb do

34 34
  It only widens what may be served to names that begin this way; a file still
35 35
  has to exist in `priv/static` to be sent.
36 36
  """
37
  def static_prefixes, do: ~w(favicon apple-touch-icon robots)
37
  def static_prefixes, do: ~w(favicon apple-touch-icon robots install)
38 38
39 39
  @doc """
40 40
  `Plug.Static` options for this application's endpoint.
lib/openagents_web/live/coder_live.ex added +60

@@ -0,0 +1,60 @@

1
defmodule OpenAgentsWeb.CoderLive do
2
  @moduledoc """
3
  The install command, on a terminal-shaped page that copies it when clicked.
4
5
  It carried a link to a `webtui.css` that was never added, so the page asked
6
  for a stylesheet that 404s and the root layout grew an `@extra_css` hatch to
7
  let it. Both are gone: this repository has one component system, and a page
8
  that needs a second one needs a decision rather than an escape hatch. The
9
  colours come from the CSS variables that system already defines, with literal
10
  fallbacks, which is what the page was really relying on.
11
  """
12
  use OpenAgentsWeb, :live_view
13
14
  @cmd "curl -fsSL https://openagents.com/install.sh | bash"
15
16
  @impl true
17
  def mount(_params, _session, socket) do
18
    {:ok,
19
     socket
20
     |> assign(:page_title, "OpenAgents Coder")
21
     |> assign(:cmd, @cmd)}
22
  end
23
24
  @impl true
25
  def render(assigns) do
26
    ~H"""
27
    <div
28
      id="coder-viewport"
29
      phx-hook="CoderCopy"
30
      data-copy-text={@cmd}
31
      class="min-h-screen w-full flex flex-col items-center justify-center p-4 select-none cursor-pointer"
32
      style="background-color: var(--background0, #000); color: var(--foreground0, #fff); font-family: var(--font-family, monospace); -webkit-user-select: none; user-select: none;"
33
    >
34
      <div
35
        class="inline-block text-center select-none font-mono text-sm sm:text-base leading-tight"
36
        style="-webkit-user-select: none; user-select: none; color: var(--foreground0, #fff);"
37
      >
38
        <pre
39
          class="font-mono text-xs sm:text-sm md:text-base leading-none select-none pointer-events-none"
40
          style="margin: 0; color: var(--foreground0, #fff); -webkit-user-select: none; user-select: none;"
41
        >
42
          <span style="color: var(--foreground2, #666);">┌──────────────────</span> OpenAgents <span style="color: var(--foreground2, #666);">───────────────────┐</span>
43
          <span style="color: var(--foreground2, #666);">│</span>0001110110010001100001010010001010000100001011000<span style="color: var(--foreground2, #666);">│</span>
44
          <span style="color: var(--foreground2, #666);">│                                                 │</span>
45
          <span style="color: var(--foreground2, #666);">│</span>     <span class="font-bold text-cyan-400" style="color: #56b6c2;">██████╗ ██████╗ ██████╗ ███████╗██████╗</span>     <span style="color: var(--foreground2, #666);">│</span>
46
          <span style="color: var(--foreground2, #666);">│</span>    <span class="font-bold text-cyan-400" style="color: #56b6c2;">██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗</span>    <span style="color: var(--foreground2, #666);">│</span>
47
          <span style="color: var(--foreground2, #666);">│</span>    <span class="font-bold text-cyan-400" style="color: #56b6c2;">██║     ██║   ██║██║  ██║█████╗  ██████╔╝</span>    <span style="color: var(--foreground2, #666);">│</span>
48
          <span style="color: var(--foreground2, #666);">│</span>    <span class="font-bold text-cyan-400" style="color: #56b6c2;">██║     ██║   ██║██║  ██║██╔══╝  ██╔══██╗</span>    <span style="color: var(--foreground2, #666);">│</span>
49
          <span style="color: var(--foreground2, #666);">│</span>    <span class="font-bold text-cyan-400" style="color: #56b6c2;">╚██████╗╚██████╔╝██████╔╝███████╗██║  ██║</span>    <span style="color: var(--foreground2, #666);">│</span>
50
          <span style="color: var(--foreground2, #666);">│</span>     <span class="font-bold text-cyan-400" style="color: #56b6c2;">╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝  ╚═╝</span>    <span style="color: var(--foreground2, #666);">│</span>
51
          <span style="color: var(--foreground2, #666);">│                                                 │</span>
52
          <span style="color: var(--foreground2, #666);">│</span>   <span id="copy-hint" style="color: var(--foreground1, #ccc);"> <%= @cmd %> </span>   <span style="color: var(--foreground2, #666);">│</span>
53
          <span style="color: var(--foreground2, #666);">│</span>1111100010110000010100000000000010001001100011101<span style="color: var(--foreground2, #666);">│</span>
54
          <span style="color: var(--foreground2, #666);">└─────────────────────────────────────────────────┘</span>
55
        </pre>
56
      </div>
57
    </div>
58
    """
59
  end
60
end
lib/openagents_web/router.ex modified +1

@@ -185,6 +185,7 @@ defmodule OpenAgentsWeb.Router do

185 185
      live "/status", NetworkStatusLive, :index
186 186
      live "/changelog", ChangelogLive, :index
187 187
      live "/leaderboard", LeaderboardLive, :index
188
      live "/coder", CoderLive, :index
188 189
189 190
      # Forum reads are public: the context's readability predicates decide
190 191
      # what an anonymous reader sees, and posting still requires an account.
priv/static/install.sh added +287

@@ -0,0 +1,287 @@

1
#!/bin/bash
2
#
3
# OpenAgents CLI installer — https://openagents.com/install.sh
4
#
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
8
#
9
# Windows: run under Git for Windows / MSYS2 Bash; WSL uses the Linux binary.
10
11
set -e
12
13
TARGET="$1"
14
15
if [[ -n "$TARGET" ]] && [[ ! "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
16
    echo "Invalid version format: $TARGET (expected X.Y.Z or X.Y.Z-suffix)" >&2
17
    exit 1
18
fi
19
20
DOWNLOADER=""
21
if command -v curl >/dev/null 2>&1; then
22
    DOWNLOADER="curl"
23
elif command -v wget >/dev/null 2>&1; then
24
    DOWNLOADER="wget"
25
else
26
    echo "Either curl or wget is required but neither is installed" >&2
27
    exit 1
28
fi
29
30
download_file() {
31
    local url="$1" output="$2"
32
    if [ "$DOWNLOADER" = "curl" ]; then
33
        if [ -n "$output" ]; then
34
            curl -fsSL -o "$output" "$url"
35
        else
36
            curl -fsSL "$url"
37
        fi
38
    else
39
        if [ -n "$output" ]; then
40
            wget -q -O "$output" "$url"
41
        else
42
            wget -q -O - "$url"
43
        fi
44
    fi
45
}
46
47
download_file_parallel() {
48
    local url="$1" output="$2"
49
    if [ "$DOWNLOADER" != "curl" ]; then
50
        download_file "$url" "$output"
51
        return
52
    fi
53
    local size
54
    size=$(curl -fsSL --head "$url" 2>/dev/null | awk -F'[: \r\n]+' 'tolower($1)=="content-length"{print $2; exit}')
55
    if [ -z "$size" ] || ! [ "$size" -ge 16777216 ] 2>/dev/null; then
56
        download_file "$url" "$output"
57
        return
58
    fi
59
    local n=8
60
    local chunk_size=$(( (size + n - 1) / n ))
61
    local tmpdir
62
    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
65
        start=$((i * chunk_size))
66
        end=$((start + chunk_size - 1))
67
        [ $end -ge $size ] && end=$((size - 1))
68
        curl -fsSL -r "${start}-${end}" -o "${tmpdir}/$(printf 'chunk.%03d' "$i")" "$url" &
69
        pids+=($!)
70
    done
71
    local all_ok=true pid
72
    for pid in "${pids[@]}"; do
73
        wait "$pid" || all_ok=false
74
    done
75
    if [ "$all_ok" = true ] && cat "${tmpdir}"/chunk.* > "$output" 2>/dev/null; then
76
        rm -rf "$tmpdir"
77
        return 0
78
    fi
79
    rm -rf "$tmpdir"
80
    download_file "$url" "$output"
81
}
82
83
is_not_found() {
84
    local url="$1" code
85
    if [ "$DOWNLOADER" = "curl" ]; then
86
        code=$(curl -o /dev/null -sSL -w '%{http_code}' --head "$url" 2>/dev/null) || true
87
    else
88
        code=$(wget --server-response --spider "$url" 2>&1 | awk '/HTTP\//{print $2}' | tail -1) || true
89
    fi
90
    [ "$code" = "404" ]
91
}
92
93
case "$(uname -s)" in
94
    Darwin) os="macos" ;;
95
    Linux)  os="linux" ;;
96
    MINGW* | MSYS* | CYGWIN*) os="windows" ;;
97
    *)      echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
98
esac
99
100
case "$(uname -m)" in
101
    x86_64|amd64|AMD64) arch="x86_64" ;;
102
    arm64|aarch64|ARM64) arch="aarch64" ;;
103
    *)                    echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
104
esac
105
106
# Rosetta shell translation detection on Apple Silicon
107
if [ "$os" = "macos" ] && [ "$arch" = "x86_64" ]; then
108
    sysctl_bin="$(command -v sysctl || echo /usr/sbin/sysctl)"
109
    if [ "$("$sysctl_bin" -n hw.optional.arm64 2>/dev/null)" = "1" ]; then
110
        echo "Apple Silicon detected (Rosetta shell); installing the native arm64 build." >&2
111
        arch="aarch64"
112
    fi
113
fi
114
115
BASE_URL_PRIMARY="https://openagents.com/releases"
116
DOWNLOAD_DIR="$HOME/.openagents/downloads"
117
BIN_DIR="${OPENAGENTS_BIN_DIR:-$HOME/.openagents/bin}"
118
mkdir -p "$DOWNLOAD_DIR" "$BIN_DIR"
119
120
platform="${os}-${arch}"
121
CHANNEL="${OPENAGENTS_CHANNEL:-stable}"
122
123
# A channel is a pointer file naming the version it currently means, so
124
# `stable` can move without every installed script having to. A hardcoded
125
# default would make the channel a decoration: it was read and then ignored.
126
if [ -n "$TARGET" ]; then
127
    version="$TARGET"
128
else
129
    version="$(download_file "${BASE_URL_PRIMARY}/${CHANNEL}" "" 2>/dev/null | tr -d '[:space:]')" || version=""
130
    if [ -z "$version" ]; then
131
        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
133
        exit 1
134
    fi
135
    if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$ ]]; then
136
        echo "The '${CHANNEL}' channel returned something that is not a version: ${version}" >&2
137
        exit 1
138
    fi
139
fi
140
141
echo "Installing OpenAgents CLI $version ($platform)..." >&2
142
143
binary_path="$DOWNLOAD_DIR/openagents-$platform"
144
artifact_base="${BASE_URL_PRIMARY}/openagents-${version}-${platform}"
145
146
if [ "$os" = "windows" ]; then
147
    binary_path="${binary_path}.exe"
148
fi
149
150
binary_tmp="${binary_path}.tmp.$$"
151
rm -f "$binary_tmp" 2>/dev/null || true
152
153
# The download is the only source. This used to fall back to `./target/debug/oa`
154
# or an already-installed binary when the fetch failed, which meant running the
155
# installer from any directory holding a stale or foreign build silently
156
# installed that build while printing the version it meant to fetch. An
157
# installer people pipe into bash cannot have a path that installs something
158
# nobody named.
159
if ! download_file_parallel "$artifact_base" "$binary_tmp" 2>/dev/null; then
160
    echo "Could not download ${artifact_base}." >&2
161
    echo "No local fallback is used: an installer must install what it says it did." >&2
162
    exit 1
163
fi
164
echo "  Downloaded openagents ${version}." >&2
165
166
# Verify before the bytes are ever made executable. A checksum fetched over the
167
# same connection as the artifact proves only that they arrived together, which
168
# is why the sums file is fetched separately and the artifact is refused when it
169
# is absent rather than installed unverified.
170
checksum_tool=""
171
if command -v shasum >/dev/null 2>&1; then
172
    checksum_tool="shasum -a 256"
173
elif command -v sha256sum >/dev/null 2>&1; then
174
    checksum_tool="sha256sum"
175
else
176
    echo "Neither shasum nor sha256sum is available; refusing to install unverified bytes." >&2
177
    rm -f "$binary_tmp"
178
    exit 1
179
fi
180
181
sums_tmp="${binary_tmp}.sums"
182
if ! download_file "${BASE_URL_PRIMARY}/SHA256SUMS-${version}" "$sums_tmp" 2>/dev/null; then
183
    echo "Could not download SHA256SUMS-${version}; refusing to install unverified bytes." >&2
184
    rm -f "$binary_tmp" "$sums_tmp"
185
    exit 1
186
fi
187
188
artifact_name="openagents-${version}-${platform}"
189
[ "$os" = "windows" ] && artifact_name="${artifact_name}.exe"
190
191
expected="$(awk -v name="$artifact_name" '$2 == name || $2 == "*" name { print $1 }' "$sums_tmp" | head -1)"
192
if [ -z "$expected" ]; then
193
    echo "SHA256SUMS-${version} names no entry for ${artifact_name}; refusing to install." >&2
194
    rm -f "$binary_tmp" "$sums_tmp"
195
    exit 1
196
fi
197
198
actual="$($checksum_tool "$binary_tmp" | awk '{ print $1 }')"
199
if [ "$actual" != "$expected" ]; then
200
    echo "Checksum mismatch for ${artifact_name}." >&2
201
    echo "  expected ${expected}" >&2
202
    echo "  actual   ${actual}" >&2
203
    rm -f "$binary_tmp" "$sums_tmp"
204
    exit 1
205
fi
206
rm -f "$sums_tmp"
207
echo "  Verified sha256 ${actual}." >&2
208
209
if [ "$os" = "windows" ]; then
210
    mv -f "$binary_tmp" "$binary_path"
211
    for bin_name in openagents.exe oa.exe; do
212
        rm -f "$BIN_DIR/$bin_name.old" 2>/dev/null || true
213
        cp -f "$binary_path" "$BIN_DIR/$bin_name" 2>/dev/null || true
214
    done
215
    echo "  Binary installed to $BIN_DIR/openagents.exe and $BIN_DIR/oa.exe." >&2
216
else
217
    chmod +x "$binary_tmp"
218
    mv -f "$binary_tmp" "$binary_path"
219
220
    if [ "$(dirname "$BIN_DIR")" = "$(dirname "$DOWNLOAD_DIR")" ]; then
221
        link_target="../$(basename "$DOWNLOAD_DIR")/$(basename "$binary_path")"
222
    else
223
        link_target="$binary_path"
224
    fi
225
    ln -sf "$link_target" "$BIN_DIR/openagents"
226
    ln -sf "$link_target" "$BIN_DIR/oa"
227
    echo "  Binary linked to $BIN_DIR/openagents and $BIN_DIR/oa." >&2
228
fi
229
230
path_has_dir() {
231
    case ":$PATH:" in *":$1:"*) return 0 ;; *) return 1 ;; esac
232
}
233
234
SYMLINK_CREATED=""
235
if [ "$os" != "windows" ] && ! path_has_dir "$BIN_DIR"; then
236
    for candidate in "$HOME/.local/bin" "/usr/local/bin"; do
237
        if path_has_dir "$candidate" && [ -d "$candidate" ] && [ -w "$candidate" ]; then
238
            ln -sf "$BIN_DIR/openagents" "$candidate/openagents"
239
            ln -sf "$BIN_DIR/oa" "$candidate/oa"
240
            SYMLINK_CREATED="$candidate"
241
            echo "  Symlinked $candidate/openagents -> $BIN_DIR/openagents" >&2
242
            echo "  Symlinked $candidate/oa -> $BIN_DIR/oa" >&2
243
            break
244
        fi
245
    done
246
fi
247
248
user_shell="$(basename "${SHELL:-}")"
249
config_file=""
250
251
case "$user_shell" in
252
    bash) config_file="$HOME/.bashrc" ;;
253
    zsh)  config_file="$HOME/.zshrc" ;;
254
    fish) config_file="$HOME/.config/fish/config.fish" ;;
255
esac
256
257
if [ -n "$config_file" ]; then
258
    mkdir -p "$(dirname "$config_file")"
259
260
    if [ "$user_shell" = "fish" ]; then
261
        new_block='# >>> openagents installer >>>
262
fish_add_path $HOME/.openagents/bin
263
# <<< openagents installer <<<'
264
    else
265
        new_block='# >>> openagents installer >>>
266
export PATH="$HOME/.openagents/bin:$PATH"
267
# <<< openagents installer <<<'
268
    fi
269
270
    if grep -qs "openagents installer" "$config_file" 2>/dev/null; then
271
        tmp="$config_file.tmp.$$"
272
        awk '
273
            /# >>> openagents installer >>>/ { skip=1; next }
274
            /# <<< openagents installer <<</ { skip=0; next }
275
            !skip { print }
276
        ' "$config_file" > "$tmp" && mv "$tmp" "$config_file"
277
    else
278
        [ -f "$config_file" ] && cp "$config_file" "$config_file.bak.$(date +%s)"
279
    fi
280
281
    printf '\n%s\n' "$new_block" >> "$config_file"
282
    echo "  Updated $BIN_DIR in PATH in $config_file." >&2
283
fi
284
285
echo "" >&2
286
echo "OpenAgents CLI $version installation complete!" >&2
287
echo "Run 'openagents' or 'oa' to get started." >&2
test/openagents_web/install_script_test.exs added +69

@@ -0,0 +1,69 @@

1
defmodule OpenAgentsWeb.InstallScriptTest do
2
  @moduledoc """
3
  The installer is a published entry point, so what it refuses matters.
4
5
  `curl -fsSL https://openagents.com/install.sh | bash` hands a shell script
6
  the right to write an executable onto the reader's machine and put it on
7
  their PATH. Two properties keep that honest: the script has to actually be
8
  served under that name, and it has to refuse anything it cannot verify.
9
10
  The refusals are asserted against the script's own text rather than by
11
  running it, because running it means reaching the network. `bash -n` proves
12
  it parses; the behaviour these assert -- no unverified install, no
13
  unnamed source -- is proven end to end against a fixture release server in
14
  the issue's record.
15
  """
16
17
  use ExUnit.Case, async: true
18
19
  @script Path.join([File.cwd!(), "priv", "static", "install.sh"])
20
21
  test "the installer is served under the name the published command uses" do
22
    assert "install.sh" in OpenAgentsWeb.static_paths(),
23
           "`install.sh` is not in static_paths/0, so /install.sh is a 404"
24
25
    assert Enum.any?(OpenAgentsWeb.static_prefixes(), &String.starts_with?("install.sh", &1)),
26
           "no prefix in static_prefixes/0 covers install.sh, so it 404s once digested"
27
  end
28
29
  test "the script parses" do
30
    assert {_output, 0} = System.cmd("bash", ["-n", @script], stderr_to_stdout: true)
31
  end
32
33
  test "nothing is installed without a checksum that matches" do
34
    script = File.read!(@script)
35
36
    assert script =~ "SHA256SUMS-",
37
           "the installer does not fetch a sums file"
38
39
    assert script =~ "refusing to install unverified bytes",
40
           "the installer does not refuse when the sums file is missing"
41
42
    assert script =~ "Checksum mismatch",
43
           "the installer does not refuse a mismatched artifact"
44
  end
45
46
  test "there is no path that installs a binary nobody named" do
47
    script = File.read!(@script)
48
49
    # The removed fallback copied ./target/release/oa, ./target/debug/oa, or an
50
    # already-installed binary when the download failed, so running the
51
    # installer anywhere near a stale build installed that build while
52
    # reporting the version it meant to fetch.
53
    refute script =~ ~r/^\s*cp\s+"?\.\/target\//m,
54
           "the installer copies a local build when the download fails"
55
56
    refute script =~ ~r/^\s*cp\s+"\$HOME\/\.openagents\/bin/m,
57
           "the installer reinstalls whatever is already on disk"
58
  end
59
60
  test "the channel resolves a version rather than hardcoding one" do
61
    script = File.read!(@script)
62
63
    refute script =~ ~r/^\s*version="0\.1\.0"/m,
64
           "the installer still hardcodes a version, so the channel decides nothing"
65
66
    assert script =~ "${BASE_URL_PRIMARY}/${CHANNEL}",
67
           "the installer never reads the channel pointer"
68
  end
69
end
test/openagents_web/live/coder_live_test.exs added +36

@@ -0,0 +1,36 @@

1
defmodule OpenAgentsWeb.CoderLiveTest do
2
  @moduledoc """
3
  The page exists to hand a reader one command, so the command is the test.
4
  """
5
6
  use OpenAgentsWeb.ConnCase, async: true
7
8
  import Phoenix.LiveViewTest
9
10
  test "the page offers the published install command", %{conn: conn} do
11
    {:ok, _view, html} = live(conn, ~p"/coder")
12
13
    assert html =~ "curl -fsSL https://openagents.com/install.sh | bash"
14
  end
15
16
  test "the page adds no stylesheet of its own", %{conn: conn} do
17
    {:ok, _view, html} = live(conn, ~p"/coder")
18
19
    # It linked a `webtui.css` that was never added, through an `@extra_css`
20
    # hatch in the root layout, so the page asked for a 404 and rendered by
21
    # accident on its literal fallbacks. This repository has one component
22
    # system; a page that needs a second one needs a decision, not a hatch.
23
    refute html =~ "webtui"
24
25
    local_sheets =
26
      ~r/<link[^>]+rel="stylesheet"[^>]+href="(\/[^"]+)"/
27
      |> Regex.scan(html, capture: :all_but_first)
28
      |> List.flatten()
29
      |> Enum.map(&String.replace(&1, ~r/\?.*$/, ""))
30
      |> Enum.uniq()
31
32
    assert local_sheets == ["/assets/css/app.css"],
33
           "/coder links local stylesheets beyond the application bundle: " <>
34
             inspect(local_sheets)
35
  end
36
end

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