Serve the CLI binaries from the bucket that holds them

1a88db3666b9 · AtlantisPleb · · parent 9e996135da32

Serve the CLI binaries from the bucket that holds them

`https://openagents.com/releases/<name>` now answers with CLI release
artifacts. The binaries stay out of the repository: a 40 MB executable per
platform per version turns a clone into a download, and a forge whose own
history is the product cannot pay that. They live in a Cloud Storage bucket
that grants every object to `allUsers`, and this route is the one durable name
in front of them, so the install URL keeps working when the bucket behind it is
renamed or replaced.

## The proxy adds a name, not an authority

Every object it serves is already readable by anyone holding the bucket URL, so
there is nothing here to authenticate and nothing to withhold. No credential,
no token provider, no service account — a plain `Req` GET is the whole client.
`RouteAuthority` classifies it `:public_read`, which is what the enumeration
test reads, and the scope carries no pipeline: `:browser` accepts only HTML and
would refuse an installer asking for `application/octet-stream`, while `:api`
accepts only JSON and would refuse a browser.

What the route does add is the object's content type, the cache lifetime it
deserves, and a strict allowlist on the one path segment a caller controls.
Letters, digits, dot, underscore, and hyphen; no leading dot, no `..`, no
slash. The segment is interpolated into an outbound URL, so the allowlist is
the boundary that keeps the request inside the bucket, and it is checked before
anything is sent.

## Ranges are a correctness requirement, not an optimization

`priv/static/install.sh` reads `Content-Length` with a `HEAD` for any artifact
of 16 MiB or more, then fetches eight ranges concurrently and concatenates
them. A ranged request answered `200` with the whole body produces eight
complete copies in one file — a corrupt binary that still looks like a
successful download, and one the checksum would then reject with no clue why.
So `Range` is forwarded to the bucket and `206` and `Content-Range` come back
untouched.

`HEAD` needs the same care from the other side. `Plug.Head` rewrites the method
to `GET` before the router sees it, so the controller reads the method the
client actually sent from the adapter, and answers a size probe with a `HEAD`
upstream rather than pulling 40 MB out of the bucket to throw away. A `GET`
declares `content-length` before `send_chunked`, which is what makes Bandit
stream a length-delimited body instead of a chunked one — the artifact crosses
the process one chunk at a time and a 40 MB binary is never a 40 MB message.

## The cache lifetimes point opposite ways on purpose

A version-pinned artifact and its sums file are the same bytes forever and are
served `immutable` for a year. A channel pointer is the opposite: `stable` is a
name that moves, and caching it the same way would pin every installer that
read it to the release it named that day, with nothing able to tell it
otherwise. It gets sixty seconds.

## Elsewhere

`releases` joins the reserved slugs so no namespace can shadow the route, which
also gives every deeper path the site's own 404 — the bucket is flat, so a
deeper path names no object anyway. The install documentation now leads with
the one-line installer and keeps npm as the alternative, and the landing page
and the repository index name that command where a reader will meet it first.

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 431 · 2026-08-26T00:43:34.764940Z
built
18 modules in 119.7 s
deployed
live · 18 modules on 3 nodes · push→live —
deployed
needs_rolling_replace · 18 modules on 0 nodes · push→live —

Changed files

  • modified config/config.exs
  • modified config/runtime.exs
  • modified lib/openagents/repositories/namespace.ex
  • added lib/openagents_web/controllers/release_controller.ex
  • modified lib/openagents_web/live/home_live.ex
  • modified lib/openagents_web/live/repository_index_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified ops/deploy/fleet-startup.template.sh
  • modified priv/docs/install-cli.md
  • modified test/openagents_web/controllers/page_controller_test.exs
  • added test/openagents_web/controllers/release_controller_test.exs
  • modified test/openagents_web/live/repository_live_test.exs

Diff

13 files changed, +607 -11

config/config.exs modified +9

@@ -478,6 +478,15 @@ config :openagents,

478 478
  forge_wal_anchor_interval_ms: 3_600_000,
479 479
  forge_wal_dir: nil,
480 480
  forge_wal_bucket: nil,
481
  # The bucket behind `/releases/<name>`. It grants every object to `allUsers`,
482
  # so the proxy needs no credential — the name is deployment policy, not a
483
  # secret, and it has a default so a development server serves the same
484
  # artifacts production does.
485
  releases_bucket: "openagentsgemini-cli-releases",
486
  # The seam `test/openagents_web/controllers/release_controller_test.exs`
487
  # stubs. Empty everywhere else, so the request options the controller builds
488
  # are the ones that reach the bucket.
489
  releases_request_options: [],
481 490
  forge_gcs_token_provider: nil,
482 491
  forge_rolling_provider: nil,
483 492
  # Hot-load allowlist: MODULE names, not repo paths. An entry ending in `.`
config/runtime.exs modified +3

@@ -574,6 +574,9 @@ if config_env() == :prod and runtime_role == :web do

574 574
    forge_wal_adapter: forge_wal_adapter,
575 575
    forge_wal_dir: required_text.("OPENAGENTS_FORGE_WAL_DIR"),
576 576
    forge_wal_bucket: optional_text.("OPENAGENTS_FORGE_WAL_BUCKET"),
577
    releases_bucket:
578
      optional_text.("OPENAGENTS_RELEASES_BUCKET") ||
579
        Application.fetch_env!(:openagents, :releases_bucket),
577 580
    ra_enabled: ra_enabled,
578 581
    ra_data_dir: required_text.("OPENAGENTS_RA_DATA_DIR"),
579 582
    ra_expected_size: parse_integer.("OPENAGENTS_RA_EXPECTED_SIZE", 1..100),
lib/openagents/repositories/namespace.ex modified +1 -1

@@ -9,7 +9,7 @@ defmodule OpenAgents.Repositories.Namespace do

9 9
  @timestamps_opts [type: :utc_datetime_usec]
10 10
  @reserved_slugs ~w(
11 11
    admin api assets auth changelog chat components computers controller data dev device docs git og
12
    health healthz leaderboard machines memory repositories sarah settings status voice
12
    health healthz leaderboard machines memory releases repositories sarah settings status voice
13 13
  )
14 14
15 15
  def reserved_slugs, do: @reserved_slugs
lib/openagents_web/controllers/release_controller.ex added +249

@@ -0,0 +1,249 @@

1
defmodule OpenAgentsWeb.ReleaseController do
2
  @moduledoc """
3
  Serves CLI release artifacts out of the public release bucket.
4
5
  The binaries are not in the repository and never will be: a 40 MB executable
6
  per platform per version turns a clone into a download. They live in a
7
  world-readable Cloud Storage bucket, and this route is the one durable name
8
  in front of them, so `https://openagents.com/releases/<name>` keeps working
9
  when the bucket behind it is renamed or replaced.
10
11
  The proxy therefore adds no authority. Every object it serves is already
12
  readable by anyone who knows the bucket URL, so there is nothing here to
13
  authenticate and nothing to withhold. It adds a stable URL, the content type
14
  and cache lifetime the object deserves, and a strict allowlist on the object
15
  name so that the one path segment a caller controls cannot address anything
16
  outside the bucket.
17
18
  `priv/static/install.sh` is the client this contract exists for, and it is
19
  the reason `Range` is honoured rather than ignored. For an artifact of 16 MiB
20
  or more the installer reads `Content-Length` with a `HEAD`, then fetches
21
  eight ranges concurrently and concatenates them. A ranged request answered
22
  with `200` and the whole body would produce eight complete copies
23
  concatenated into one corrupt binary that still passes as a download, so the
24
  `206` is a correctness requirement rather than an optimization.
25
  """
26
27
  use OpenAgentsWeb, :controller
28
29
  import Plug.Conn
30
31
  require Logger
32
33
  @storage_host "https://storage.googleapis.com"
34
35
  # One segment, and a conservative one. Letters, digits, dot, underscore, and
36
  # hyphen cover every object we publish (`openagents-0.1.0-rc.1-macos-aarch64`,
37
  # `SHA256SUMS-0.1.0-rc.1`, `stable`) and admit no slash, no percent escape,
38
  # and no leading dot. The caller controls this segment and it is interpolated
39
  # into an outbound URL, so the allowlist is the boundary that keeps the
40
  # request inside the bucket.
41
  @name_pattern ~r/\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
42
  @name_max_bytes 128
43
44
  # A ranged request is passed through verbatim, so its shape is checked here
45
  # rather than trusted. Only the byte-range syntax the installer sends is
46
  # forwarded; anything else is dropped and the whole object is served.
47
  @range_pattern ~r/\Abytes=[0-9,\- ]+\z/
48
49
  # A version-pinned artifact and its sums file are the same bytes forever, so
50
  # a client that has one never needs to ask again. A channel pointer is the
51
  # opposite: `stable` is a name that moves, and caching it for a year would
52
  # pin every installer that read it to the release it named that day. Getting
53
  # these two the wrong way round is how a fleet ends up stuck on an old
54
  # version with no way to tell it otherwise.
55
  @immutable_cache "public, max-age=31536000, immutable"
56
  @pointer_cache "public, max-age=60"
57
58
  @streamable_statuses [200, 206]
59
60
  def show(conn, %{"name" => name}) when is_binary(name) do
61
    if admitted_name?(name) do
62
      proxy(conn, name)
63
    else
64
      not_found(conn)
65
    end
66
  end
67
68
  # Anything that does not arrive as one string names no object. The bucket is
69
  # flat, and a deeper path never reaches here: `releases` is a reserved slug,
70
  # so the router answers `/releases/a/b` with the site's own 404 page.
71
  def show(conn, _params), do: not_found(conn)
72
73
  defp admitted_name?(name) do
74
    byte_size(name) <= @name_max_bytes and
75
      Regex.match?(@name_pattern, name) and
76
      not String.contains?(name, "..")
77
  end
78
79
  defp proxy(conn, name) do
80
    url = object_url(name)
81
    headers = upstream_headers(conn)
82
83
    if head_request?(conn) do
84
      case Req.head(url, request_options(headers)) do
85
        {:ok, response} -> answer_head(conn, name, response)
86
        {:error, reason} -> unavailable(conn, name, reason)
87
      end
88
    else
89
      case Req.get(url, request_options(headers) ++ [into: :self]) do
90
        {:ok, response} -> answer_get(conn, name, response)
91
        {:error, reason} -> unavailable(conn, name, reason)
92
      end
93
    end
94
  end
95
96
  defp answer_head(conn, name, %Req.Response{status: status} = response)
97
       when status in @streamable_statuses do
98
    conn
99
    |> put_artifact_headers(name, response)
100
    |> send_resp(status, "")
101
  end
102
103
  defp answer_head(conn, name, response), do: refuse(conn, name, response)
104
105
  defp answer_get(conn, name, %Req.Response{status: status, body: body} = response)
106
       when status in @streamable_statuses do
107
    conn =
108
      conn
109
      |> put_artifact_headers(name, response)
110
      |> send_chunked(status)
111
112
    # `body` is a `Req.Response.Async`, so the artifact crosses this process one
113
    # chunk at a time and a 40 MB binary is never a 40 MB message. A client that
114
    # walks away mid-download closes the socket; halting cancels the upstream
115
    # read rather than draining the rest of the object into a closed connection.
116
    Enum.reduce_while(body, conn, fn data, current_conn ->
117
      case chunk(current_conn, data) do
118
        {:ok, next_conn} -> {:cont, next_conn}
119
        {:error, :closed} -> {:halt, current_conn}
120
      end
121
    end)
122
  end
123
124
  defp answer_get(conn, name, %Req.Response{body: %Req.Response.Async{}} = response) do
125
    Req.cancel_async_response(response)
126
    refuse(conn, name, response)
127
  end
128
129
  defp answer_get(conn, name, response), do: refuse(conn, name, response)
130
131
  defp refuse(conn, _name, %Req.Response{status: 404}), do: not_found(conn)
132
133
  # The bucket rejected the range the client asked for. Say so, rather than
134
  # reporting it as a fault of ours: the client chose the range and is the only
135
  # party that can choose a different one.
136
  defp refuse(conn, _name, %Req.Response{status: 416}) do
137
    conn
138
    |> put_resp_header("accept-ranges", "bytes")
139
    |> put_resp_header("x-content-type-options", "nosniff")
140
    |> send_resp(416, "")
141
  end
142
143
  defp refuse(conn, name, %Req.Response{status: status}) do
144
    Logger.warning("release object #{name} answered #{status} from the bucket")
145
146
    conn
147
    |> put_resp_content_type("text/plain", "utf-8")
148
    |> put_resp_header("x-content-type-options", "nosniff")
149
    |> send_resp(502, "The release store did not answer.\n")
150
  end
151
152
  defp unavailable(conn, name, reason) do
153
    Logger.warning("release object #{name} could not be read: #{failure_kind(reason)}")
154
155
    conn
156
    |> put_resp_content_type("text/plain", "utf-8")
157
    |> put_resp_header("x-content-type-options", "nosniff")
158
    |> send_resp(502, "The release store did not answer.\n")
159
  end
160
161
  # The kind of failure, never the payload that carried it. A log line records
162
  # that the bucket was unreachable and how; the exception itself may quote a
163
  # URL or a header, and `test/openagents/log_safety_test.exs` refuses the shape
164
  # that would put one in a log.
165
  defp failure_kind(%Req.TransportError{reason: reason}), do: "transport #{reason}"
166
  defp failure_kind(%{__struct__: module}), do: inspect(module)
167
  defp failure_kind(_other), do: "unknown"
168
169
  defp not_found(conn) do
170
    conn
171
    |> put_resp_content_type("text/plain", "utf-8")
172
    |> put_resp_header("x-content-type-options", "nosniff")
173
    |> send_resp(404, "Not found.\n")
174
  end
175
176
  defp put_artifact_headers(conn, name, response) do
177
    {type, charset} = content_type(name)
178
179
    conn
180
    |> put_resp_content_type(type, charset)
181
    |> put_resp_header("cache-control", cache_control(name))
182
    |> put_resp_header("x-content-type-options", "nosniff")
183
    |> put_resp_header("accept-ranges", "bytes")
184
    |> copy_upstream_header(response, "content-length")
185
    |> copy_upstream_header(response, "content-range")
186
  end
187
188
  # `content-length` is set before the response is sent, which is what makes
189
  # `HEAD` answer with the artifact's real size. Bandit streams a
190
  # length-delimited body rather than a chunked one once the length is
191
  # declared, and suppresses the body entirely for a `HEAD`, so the same code
192
  # path serves both the installer's size probe and its download.
193
  defp copy_upstream_header(conn, response, name) do
194
    case Req.Response.get_header(response, name) do
195
      [value | _rest] -> put_resp_header(conn, name, value)
196
      [] -> conn
197
    end
198
  end
199
200
  # `Plug.Head` rewrites `HEAD` to `GET` before the router sees the request, so
201
  # `conn.method` cannot answer this. The adapter still holds the method the
202
  # client actually sent. An adapter that stops carrying it falls through to
203
  # `false`, which costs a discarded body rather than a wrong answer.
204
  defp head_request?(%Plug.Conn{adapter: {_adapter, %{method: "HEAD"}}}), do: true
205
  defp head_request?(%Plug.Conn{}), do: false
206
207
  defp content_type("openagents-" <> _rest), do: {"application/octet-stream", nil}
208
  defp content_type(_name), do: {"text/plain", "utf-8"}
209
210
  defp cache_control("openagents-" <> _rest), do: @immutable_cache
211
  defp cache_control("SHA256SUMS-" <> _rest), do: @immutable_cache
212
  defp cache_control(_name), do: @pointer_cache
213
214
  defp upstream_headers(conn) do
215
    # Ask the bucket for the stored bytes. Req negotiates compression by
216
    # default, and a compressed transfer would make the `content-length` this
217
    # route forwards describe the encoded body rather than the artifact the
218
    # client is about to checksum.
219
    identity = [{"accept-encoding", "identity"}]
220
221
    case get_req_header(conn, "range") do
222
      [range | _rest] ->
223
        if Regex.match?(@range_pattern, range), do: [{"range", range} | identity], else: identity
224
225
      [] ->
226
        identity
227
    end
228
  end
229
230
  defp request_options(headers) do
231
    Keyword.merge(
232
      [
233
        headers: headers,
234
        decode_body: false,
235
        # The installer already retries by falling back to a serial download,
236
        # and a retry of a partially streamed body would restart it from the
237
        # top into a response that is already open.
238
        retry: false,
239
        receive_timeout: 60_000
240
      ],
241
      Application.get_env(:openagents, :releases_request_options, [])
242
    )
243
  end
244
245
  defp object_url(name) do
246
    bucket = Application.fetch_env!(:openagents, :releases_bucket)
247
    "#{@storage_host}/#{bucket}/#{name}"
248
  end
249
end
lib/openagents_web/live/home_live.ex modified +18

@@ -497,6 +497,24 @@ defmodule OpenAgentsWeb.HomeLive do

497 497
          --%>
498 498
        </Landing.hero>
499 499
500
        <%!-- The shortest path from reading about it to holding it. Deliberately
501
        quiet: one line under the hero rather than a third call to action
502
        competing with the two above it. --%>
503
        <Landing.section id="home-install">
504
          <div class="flex flex-col items-center gap-3 text-center">
505
            <p class="text-sm text-muted-foreground">Install the CLI</p>
506
            <code
507
              id="home-install-command"
508
              class="w-full max-w-xl overflow-x-auto rounded-md border border-border bg-card px-4 py-3 text-left font-mono text-sm whitespace-nowrap text-foreground"
509
            >
510
              curl -fsSL https://openagents.com/install.sh | bash
511
            </code>
512
            <.link navigate={~p"/docs/install-cli"} class="text-sm text-muted-foreground underline">
513
              Other ways to install
514
            </.link>
515
          </div>
516
        </Landing.section>
517
500 518
        <Landing.feature_grid title="Everything the work needs. Nothing it doesn't.">
501 519
          <:item title="Issues" icon="file-document">
502 520
            Plan, assign, label and close, over an API shaped after the one you already
lib/openagents_web/live/repository_index_live.ex modified +2 -2

@@ -29,8 +29,8 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

29 29
  # they are interpolated rather than written into the template, where the HEEx
30 30
  # parser would read them as tags.
31 31
  @cli_steps [
32
    %{command: "npx --yes @openagentsinc/cli@latest --version", note: "try without installing"},
33
    %{command: "npm i -g @openagentsinc/cli", note: "install"},
32
    %{command: "curl -fsSL https://openagents.com/install.sh | bash", note: "install"},
33
    %{command: "npm i -g @openagentsinc/cli", note: "or install with npm"},
34 34
    %{command: "openagents auth login", note: "sign in"},
35 35
    %{command: "openagents repo create <name>", note: "create"},
36 36
    %{command: "openagents repo clone <owner>/<repo>", note: "clone"},
lib/openagents_web/route_authority.ex modified +8

@@ -21,6 +21,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

21 21
    "/",
22 22
    "/status",
23 23
    "/changelog",
24
    "/coder",
24 25
    "/leaderboard",
25 26
    "/components",
26 27
    "/components/icons",

@@ -637,6 +638,13 @@ defmodule OpenAgentsWeb.RouteAuthority do

637 638
  defp policy(%{plug: OpenAgentsWeb.NotFoundController, verb: verb}) when verb in [:get, :head],
638 639
    do: declaration(:public_read, "anonymous", "published:not-found", false)
639 640
641
  # CLI release downloads. Public by construction: the route proxies a bucket
642
  # that already grants every object to `allUsers`, so it withholds nothing a
643
  # direct storage URL would hand over, and it only ever reads. It exists to
644
  # give the artifacts one durable name, not to gate them.
645
  defp policy(%{path: "/releases" <> _rest, verb: verb}) when verb in [:get, :head],
646
    do: declaration(:public_read, "anonymous", "published:cli-release", false)
647
640 648
  defp policy(%{path: path, verb: verb}) do
641 649
    cond do
642 650
      String.starts_with?(path, "/og/") and verb in [:get, :head] ->
lib/openagents_web/router.ex modified +14

@@ -925,6 +925,20 @@ defmodule OpenAgentsWeb.Router do

925 925
    get "/v/:version/forum/t/:id", OgImageController, :forum_topic
926 926
  end
927 927
928
  # CLI release downloads. No pipeline: `:browser` accepts only HTML and would
929
  # refuse an installer asking for `application/octet-stream`, while `:api`
930
  # accepts only JSON and would refuse a browser. The route is public by
931
  # construction — it proxies a world-readable bucket — so there is no session
932
  # or bearer for a pipeline to establish.
933
  #
934
  # One segment, not `/*path`. The bucket is flat, so a deeper path names no
935
  # object; and `releases` is a reserved slug, which already gives every deeper
936
  # path a `NotFoundController` route below. A glob here would shadow that
937
  # route entirely and the compiler would say so.
938
  scope "/releases", OpenAgentsWeb do
939
    get "/:name", ReleaseController, :show
940
  end
941
928 942
  # Keep repository-shaped routes last. Every fixed product, API, operator,
929 943
  # Git, and development route above wins before a GitHub-backed namespace can
930 944
  # be interpreted from the first path segment.
ops/deploy/fleet-startup.template.sh modified +2

@@ -126,6 +126,7 @@ export OPENAGENTS_IMAGE_DIGEST="$IMAGE_DIGEST"

126 126
export OPENAGENTS_INFERENCE_PROXY_URL="https://openagents.com/api/inference/proxy"
127 127
export OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS="2592000"
128 128
export OPENAGENTS_MIGRATE_ON_BOOT="true"
129
export OPENAGENTS_RELEASES_BUCKET="openagentsgemini-cli-releases"
129 130
export OPENAGENTS_PRODUCTION_DEPLOY_ENABLED="true"
130 131
export OPENAGENTS_RA_DATA_DIR="/var/lib/openagents/ra"
131 132
export OPENAGENTS_RA_EXPECTED_SIZE="3"

@@ -183,6 +184,7 @@ ENV_NAMES=(

183 184
  OPENAGENTS_INFERENCE_PROXY_URL OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS
184 185
  OPENAGENTS_MIGRATE_ON_BOOT OPENAGENTS_PRODUCTION_DEPLOY_ENABLED
185 186
  OPENAGENTS_RA_DATA_DIR OPENAGENTS_RA_EXPECTED_SIZE
187
  OPENAGENTS_RELEASES_BUCKET
186 188
  OPENAGENTS_SECURE_COOKIES OPENAGENTS_STAGING_CLEANUP_ENABLED
187 189
  OPENAGENTS_STAGING_GATE PHX_HOST PHX_SERVER POOL_SIZE PORT GIT_SSH_COMMAND
188 190
)
priv/docs/install-cli.md modified +104 -7

@@ -1,11 +1,110 @@

1 1
# Install the OpenAgents CLI
2 2
3
The npm package is `@openagentsinc/cli`. It provides the `openagents` command
4
and requires Node.js 20 or later.
3
The CLI is a single native binary. Install it with the installer script:
5 4
6
## Install globally with npm
5
```sh
6
curl -fsSL https://openagents.com/install.sh | bash
7
```
8
9
The installer detects your operating system and processor, downloads the
10
matching build, verifies its SHA-256 checksum, and links `openagents` and `oa`
11
into `~/.openagents/bin`. It also adds that directory to `PATH` in your shell
12
configuration file. Open a new shell, then confirm the installation:
13
14
```sh
15
openagents --help
16
```
17
18
Run the same command again to update. The CLI does not include an
19
`openagents update` command.
20
21
## Install a specific version
22
23
Pass a version to the script when a script or qualification run must be
24
reproducible:
25
26
```sh
27
curl -fsSL https://openagents.com/install.sh | bash -s 0.1.0-rc.1
28
```
29
30
The version must read as `X.Y.Z` or `X.Y.Z-suffix`. The installer refuses
31
anything else before it downloads.
32
33
## Choose a channel
34
35
Without a version, the installer resolves a channel to the version that channel
36
currently names. `stable` is the default. Set `OPENAGENTS_CHANNEL` to follow a
37
different one:
38
39
```sh
40
curl -fsSL https://openagents.com/install.sh | OPENAGENTS_CHANNEL=beta bash
41
```
42
43
A channel is a pointer that moves, so the version you get today is not the
44
version you get next month. Pass an explicit version when you need the answer
45
to stay the same.
46
47
## Choose where the binary lands
7 48
8
Install the CLI globally when you use it regularly:
49
The installer links `openagents` and `oa` into `~/.openagents/bin`. Set
50
`OPENAGENTS_BIN_DIR` to link them somewhere already on your `PATH`:
51
52
```sh
53
curl -fsSL https://openagents.com/install.sh | OPENAGENTS_BIN_DIR="$HOME/.local/bin" bash
54
```
55
56
The downloaded binary itself always lands in `~/.openagents/downloads`.
57
58
## Supported platforms
59
60
| Platform | Build |
61
| --- | --- |
62
| macOS on Apple silicon | `macos-aarch64` |
63
| macOS on Intel | `macos-x86_64` |
64
| Linux on x86-64 | `linux-x86_64` |
65
| Linux on ARM64 | `linux-aarch64` |
66
| Windows on x86-64 | `windows-x86_64` |
67
68
On Apple silicon, a shell running under Rosetta reports an Intel processor. The
69
installer detects that and installs the native `macos-aarch64` build anyway.
70
71
On Windows, run the installer under Git for Windows or MSYS2 Bash. It installs
72
`openagents.exe` and `oa.exe`. Under WSL, use the Linux build: WSL is Linux, and
73
`uname -s` reports it as such.
74
75
## What the installer verifies
76
77
The installer downloads `SHA256SUMS-<version>` separately from the artifact and
78
compares the artifact against the entry that names it. It refuses to install
79
when the sums file is missing, when it names no entry for your platform, when
80
the checksums disagree, or when neither `shasum` nor `sha256sum` is available.
81
The bytes are never made executable before the comparison succeeds.
82
83
macOS builds are signed with an Apple Developer ID certificate and notarized by
84
Apple, so Gatekeeper admits them without a right-click override.
85
86
The installer has no local fallback. It installs what it downloaded or it fails
87
and says so.
88
89
## Verify a download by hand
90
91
Download the artifact and its sums file, then compare them yourself:
92
93
```sh
94
curl -fsSLO https://openagents.com/releases/openagents-0.1.0-rc.1-macos-aarch64
95
curl -fsSLO https://openagents.com/releases/SHA256SUMS-0.1.0-rc.1
96
shasum -a 256 openagents-0.1.0-rc.1-macos-aarch64
97
grep openagents-0.1.0-rc.1-macos-aarch64 SHA256SUMS-0.1.0-rc.1
98
```
99
100
The two hexadecimal digests must match exactly. On Linux, use `sha256sum` in
101
place of `shasum -a 256`.
102
103
## Install with npm instead
104
105
The npm package is `@openagentsinc/cli`. It provides the same `openagents`
106
command and requires Node.js 20 or later. Use it when you already manage your
107
tools with npm:
9 108
10 109
```sh
11 110
npm install --global @openagentsinc/cli

@@ -25,8 +124,6 @@ Install the latest release again when you want to update:

25 124
npm install --global @openagentsinc/cli@latest
26 125
```
27 126
28
The CLI does not include an `openagents update` command.
29
30 127
## Run one command with npx
31 128
32 129
Use `npx` when you want to run one CLI command without installing the package

@@ -187,7 +284,7 @@ accepts `profile` or `api_url` and never stores credentials.

187 284
188 285
## Configure Git authentication
189 286
190
After you install the CLI globally, configure only the current Git repository:
287
After you install the CLI, configure only the current Git repository:
191 288
192 289
```sh
193 290
openagents auth setup-git --local
test/openagents_web/controllers/page_controller_test.exs modified +7

@@ -5,4 +5,11 @@ defmodule OpenAgentsWeb.PageControllerTest do

5 5
    conn = get(conn, ~p"/")
6 6
    assert html_response(conn, 200) =~ "The Agent Forge"
7 7
  end
8
9
  test "the landing page names the one command that installs the CLI", %{conn: conn} do
10
    html = conn |> get(~p"/") |> html_response(200)
11
12
    assert html =~ ~s(id="home-install-command")
13
    assert html =~ "curl -fsSL https://openagents.com/install.sh | bash"
14
  end
8 15
end
test/openagents_web/controllers/release_controller_test.exs added +189

@@ -0,0 +1,189 @@

1
defmodule OpenAgentsWeb.ReleaseControllerTest do
2
  @moduledoc """
3
  The contract `priv/static/install.sh` reads.
4
5
  The installer resolves a channel, downloads an artifact, and verifies it
6
  against a sums file, all through `/releases/<name>`. Two parts of that are
7
  load-bearing beyond the usual "did it return the bytes": a `HEAD` must state
8
  the artifact's real size, and a ranged request must answer `206` with only
9
  the range. The installer fetches eight ranges concurrently for anything of
10
  16 MiB or more and concatenates them, so a `200` with the whole body in reply
11
  to a range produces eight complete copies in one file — a corrupt binary that
12
  still looks like a successful download.
13
14
  The bucket is stubbed rather than reached. The tests are about what this
15
  route does with an upstream answer, and a test that needs the network is a
16
  test that reports someone else's outage as our regression.
17
  """
18
19
  use OpenAgentsWeb.ConnCase, async: false
20
21
  @bucket "releases-test-bucket"
22
  @artifact "openagents-0.1.0-rc.1-macos-aarch64"
23
24
  setup do
25
    original_bucket = Application.get_env(:openagents, :releases_bucket)
26
    original_options = Application.get_env(:openagents, :releases_request_options)
27
28
    Application.put_env(:openagents, :releases_bucket, @bucket)
29
    Application.put_env(:openagents, :releases_request_options, plug: {Req.Test, __MODULE__})
30
31
    on_exit(fn ->
32
      Application.put_env(:openagents, :releases_bucket, original_bucket)
33
      Application.put_env(:openagents, :releases_request_options, original_options)
34
    end)
35
36
    :ok
37
  end
38
39
  test "an artifact is served as opaque bytes a client may keep forever", %{conn: conn} do
40
    body = :binary.copy("o", 64)
41
42
    Req.Test.stub(__MODULE__, fn upstream ->
43
      assert upstream.method == "GET"
44
      assert upstream.host == "storage.googleapis.com"
45
      assert upstream.request_path == "/#{@bucket}/#{@artifact}"
46
47
      # Compression would make the length this route forwards describe the
48
      # encoded body rather than the bytes the installer checksums.
49
      assert Plug.Conn.get_req_header(upstream, "accept-encoding") == ["identity"]
50
51
      upstream
52
      |> Plug.Conn.put_resp_header("content-length", "64")
53
      |> Plug.Conn.send_resp(200, body)
54
    end)
55
56
    conn = get(conn, ~p"/releases/#{@artifact}")
57
58
    assert conn.status == 200
59
    assert conn.resp_body == body
60
    assert get_resp_header(conn, "content-type") == ["application/octet-stream"]
61
    assert get_resp_header(conn, "content-length") == ["64"]
62
    assert get_resp_header(conn, "accept-ranges") == ["bytes"]
63
    assert get_resp_header(conn, "x-content-type-options") == ["nosniff"]
64
    assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
65
  end
66
67
  test "a sums file is readable text a client may keep forever", %{conn: conn} do
68
    sums = "#{String.duplicate("a", 64)}  #{@artifact}\n"
69
70
    Req.Test.stub(__MODULE__, fn upstream ->
71
      assert upstream.request_path == "/#{@bucket}/SHA256SUMS-0.1.0-rc.1"
72
73
      upstream
74
      |> Plug.Conn.put_resp_header("content-length", to_string(byte_size(sums)))
75
      |> Plug.Conn.send_resp(200, sums)
76
    end)
77
78
    conn = get(conn, ~p"/releases/SHA256SUMS-0.1.0-rc.1")
79
80
    assert conn.status == 200
81
    assert conn.resp_body == sums
82
    assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]
83
    assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
84
  end
85
86
  test "a channel pointer is text that expires quickly", %{conn: conn} do
87
    Req.Test.stub(__MODULE__, fn upstream ->
88
      assert upstream.request_path == "/#{@bucket}/stable"
89
90
      upstream
91
      |> Plug.Conn.put_resp_header("content-length", "11")
92
      |> Plug.Conn.send_resp(200, "0.1.0-rc.1\n")
93
    end)
94
95
    conn = get(conn, ~p"/releases/stable")
96
97
    assert conn.status == 200
98
    assert conn.resp_body == "0.1.0-rc.1\n"
99
    assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]
100
101
    # `stable` is a name that moves. Caching it the way an artifact is cached
102
    # would pin every installer that read it to the release it named that day.
103
    assert get_resp_header(conn, "cache-control") == ["public, max-age=60"]
104
  end
105
106
  test "a ranged request is answered with only the range that was asked for", %{conn: conn} do
107
    Req.Test.stub(__MODULE__, fn upstream ->
108
      assert Plug.Conn.get_req_header(upstream, "range") == ["bytes=8-15"]
109
110
      upstream
111
      |> Plug.Conn.put_resp_header("content-range", "bytes 8-15/64")
112
      |> Plug.Conn.put_resp_header("content-length", "8")
113
      |> Plug.Conn.send_resp(206, "34567890")
114
    end)
115
116
    conn =
117
      conn
118
      |> put_req_header("range", "bytes=8-15")
119
      |> get(~p"/releases/#{@artifact}")
120
121
    assert conn.status == 206
122
    assert conn.resp_body == "34567890"
123
    assert get_resp_header(conn, "content-range") == ["bytes 8-15/64"]
124
    assert get_resp_header(conn, "content-length") == ["8"]
125
    assert get_resp_header(conn, "accept-ranges") == ["bytes"]
126
  end
127
128
  test "a HEAD states the artifact's real size without sending it", %{conn: conn} do
129
    Req.Test.stub(__MODULE__, fn upstream ->
130
      # The size probe never asks for the body. Answering the client's `HEAD`
131
      # with a `GET` upstream would pull 40 MB out of the bucket to throw away.
132
      assert upstream.method == "HEAD"
133
134
      upstream
135
      |> Plug.Conn.put_resp_header("content-length", "41943040")
136
      |> Plug.Conn.send_resp(200, "")
137
    end)
138
139
    conn = head(conn, ~p"/releases/#{@artifact}")
140
141
    assert conn.status == 200
142
    assert conn.resp_body == ""
143
    assert get_resp_header(conn, "content-length") == ["41943040"]
144
    assert get_resp_header(conn, "accept-ranges") == ["bytes"]
145
    assert get_resp_header(conn, "content-type") == ["application/octet-stream"]
146
  end
147
148
  test "an object the bucket does not hold is a plain 404", %{conn: conn} do
149
    Req.Test.stub(__MODULE__, fn upstream ->
150
      Plug.Conn.send_resp(upstream, 404, "<?xml version='1.0'?><Error>NoSuchKey</Error>")
151
    end)
152
153
    conn = get(conn, ~p"/releases/openagents-9.9.9-linux-x86_64")
154
155
    assert conn.status == 404
156
    refute conn.resp_body =~ "NoSuchKey"
157
  end
158
159
  test "a bucket that answers with a fault is not reported as ours", %{conn: conn} do
160
    Req.Test.stub(__MODULE__, fn upstream ->
161
      Plug.Conn.send_resp(upstream, 503, "unavailable")
162
    end)
163
164
    conn = get(conn, ~p"/releases/stable")
165
166
    assert conn.status == 502
167
  end
168
169
  test "a name outside the allowlist is refused before the bucket is asked" do
170
    Req.Test.stub(__MODULE__, fn _upstream ->
171
      flunk("a rejected name reached the bucket")
172
    end)
173
174
    # `..` and a leading dot are refused by shape, and an encoded slash arrives
175
    # as one segment holding a slash, which the allowlist does not admit.
176
    for name <- ["..", ".ssh", "a%2Fb", "openagents%200.1.0", "under..dot"] do
177
      conn = get(build_conn(), "/releases/" <> name)
178
      assert conn.status == 404, "#{name} was not refused"
179
    end
180
  end
181
182
  test "a deeper path names no object, and the reserved slug is what answers", %{conn: conn} do
183
    Req.Test.stub(__MODULE__, fn _upstream ->
184
      flunk("a multi-segment path reached the bucket")
185
    end)
186
187
    assert get(conn, "/releases/openagents/0.1.0").status == 404
188
  end
189
end
test/openagents_web/live/repository_live_test.exs modified +1 -1

@@ -213,7 +213,7 @@ defmodule OpenAgentsWeb.RepositoryLiveTest do

213 213
    assert has_element?(view, "#repository-cli")
214 214
    assert has_element?(view, "#repository-cli-copy-0")
215 215
    assert has_element?(view, ~s(a[href="/docs/openagents-cli"]))
216
    assert html =~ "npx --yes @openagentsinc/cli@latest --version"
216
    assert html =~ "curl -fsSL https://openagents.com/install.sh | bash"
217 217
    assert html =~ "npm i -g @openagentsinc/cli"
218 218
    assert html =~ "openagents auth login"
219 219
    assert html =~ "openagents auth setup-git --local"

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