Make the pairing form fillable, and let a link carry the code

16b0b813cd08 · AtlantisPleb · · parent 6ecafbabbebe

Make the pairing form fillable, and let a link carry the code

`/computers` is where a person approves a computer pairing, and it is the only
place that can. Three pairings expired unapproved during one working session,
partly because of this form.

`computers-pairing__form` was a two-column grid with three children, so the
checkbox landed beside the code label and the button wrapped underneath into
the same cell — the words "Approve pairing" rendered through the checkbox text.
The mobile breakpoint already stacked it, which is why this only appeared at
desktop widths. It is one row per control now, and the checkbox sits on a line
with its own words.

The page also told the reader to run `sarah-computer-controller pair`, which
does not exist. Someone following the instructions could not obtain a code at
all. It is `oa computer pair`.

And a pairing link may now carry its code, as `/device` already does. That
difference was not cosmetic: a device authorization was approved in seconds
because the link carried the code, while pairing codes had to be read off the
agent's terminal and retyped inside a ten-minute window. Only a code-shaped
value is accepted, so a crafted link cannot put arbitrary text in front of the
reader as though the server had issued it.

Five tests: the command named, a code prefilled, lower case accepted, a
script-shaped value refused, and no link meaning no prefill.

Refs openagents#112.

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 445 · 2026-08-26T10:53:01.656747Z

Changed files

  • modified assets/css/openagents.css
  • modified lib/openagents_web/live/computers_live.ex
  • modified test/openagents_web/live/computers_live_test.exs

Diff

3 files changed, +97 -7

assets/css/openagents.css modified +28 -3

@@ -352,11 +352,36 @@

352 352
    font-size: 1.25rem;
353 353
  }
354 354
355
  /* One row per control.
356
   *
357
   * This was `minmax(0, 1fr) auto` — two columns for three children, so the
358
   * checkbox landed beside the code label and the button wrapped underneath
359
   * into the same cell, rendering "Approve pairing" through the checkbox text.
360
   * The form was hard to fill and the button hard to hit, on the one page
361
   * where a person approves a computer. */
355 362
  .computers-pairing__form {
356 363
    display: grid;
357
    grid-template-columns: minmax(0, 1fr) auto;
358
    align-items: end;
359
    gap: 10px;
364
    grid-template-columns: minmax(0, 1fr);
365
    align-items: start;
366
    gap: 14px;
367
  }
368
369
  .computers-pairing__form .btn {
370
    justify-self: start;
371
  }
372
373
  /* The checkbox and its words on one line, aligned to each other rather than
374
     stretched across the row. */
375
  .computers-pairing__field label:has(input[type="checkbox"]) {
376
    display: flex;
377
    align-items: center;
378
    gap: 8px;
379
    line-height: 1.35;
380
  }
381
382
  .computers-pairing__field input[type="checkbox"] {
383
    flex: 0 0 auto;
384
    margin: 0;
360 385
  }
361 386
362 387
  .computers-pairing__field {
lib/openagents_web/live/computers_live.ex modified +21 -4

@@ -13,13 +13,13 @@ defmodule OpenAgentsWeb.ComputersLive do

13 13
  @presence_refresh_ms 15_000
14 14
15 15
  @impl true
16
  def mount(_params, _session, socket) do
16
  def mount(params, _session, socket) do
17 17
    socket =
18 18
      socket
19 19
      |> stream_configure(:computers, dom_id: &"computer-#{&1.id}")
20 20
      |> assign(:page_title, "Computers · Sarah")
21 21
      |> assign(:controller_enabled?, Computer.enabled?())
22
      |> assign(:pairing_form, to_form(%{"code" => ""}, as: :pairing))
22
      |> assign(:pairing_form, to_form(%{"code" => prefilled_code(params)}, as: :pairing))
23 23
      |> assign(:pairing_error, nil)
24 24
      |> assign(:operation_success, nil)
25 25
      |> assign(:subscribed_computer_ids, MapSet.new())

@@ -34,6 +34,23 @@ defmodule OpenAgentsWeb.ComputersLive do

34 34
    {:ok, socket}
35 35
  end
36 36
37
  # A pairing link may carry its code, the way `/device` does. Without this the
38
  # code has to be read off the agent's terminal and retyped, and three pairing
39
  # attempts expired unapproved because of it. Only the shape a code can have is
40
  # accepted, so a crafted link cannot put arbitrary text in the field.
41
  defp prefilled_code(params) when is_map(params) do
42
    case params["user_code"] || params["code"] do
43
      code when is_binary(code) ->
44
        normalized = code |> String.trim() |> String.upcase()
45
        if Regex.match?(~r/\A[A-Z0-9]{4}-[A-Z0-9]{4}\z/, normalized), do: normalized, else: ""
46
47
      _ ->
48
        ""
49
    end
50
  end
51
52
  defp prefilled_code(_), do: ""
53
37 54
  @impl true
38 55
  def handle_event("approve_pairing", _params, %{assigns: %{controller_enabled?: false}} = socket) do
39 56
    {:noreply,

@@ -352,8 +369,8 @@ defmodule OpenAgentsWeb.ComputersLive do

352 369
                <div>
353 370
                  <h2>Pair a computer</h2>
354 371
                  <p>
355
                    Run <code>sarah-computer-controller pair</code> on the computer, then enter
356
                    its one-time code. Codes expire after ten minutes.
372
                    Run <code>oa computer pair</code> on the computer, then enter its one-time
373
                    code. Codes expire after ten minutes.
357 374
                  </p>
358 375
                </div>
359 376
              </div>
test/openagents_web/live/computers_live_test.exs modified +48

@@ -173,4 +173,52 @@ defmodule OpenAgentsWeb.ComputersLiveTest do

173 173
174 174
    pairing
175 175
  end
176
177
  describe "the pairing form is usable" do
178
    # Three attempts to pair expired unapproved during one working session,
179
    # partly because this form was hard to fill: `computers-pairing__form` was a
180
    # two-column grid with three children, so the checkbox landed beside the
181
    # code label and the button wrapped underneath into the same cell — the
182
    # words "Approve pairing" rendered through the checkbox text. See #112.
183
    test "names the command that actually produces a code", %{conn: conn} do
184
      conn = log_in_github_user(conn, "computers-command")
185
      {:ok, _view, html} = live(conn, ~p"/computers")
186
187
      assert html =~ "oa computer pair"
188
189
      refute html =~ "sarah-computer-controller",
190
             "the page tells the reader to run a command that does not exist"
191
    end
192
193
    test "prefills a code carried in the link, the way /device does", %{conn: conn} do
194
      conn = log_in_github_user(conn, "computers-prefill")
195
      {:ok, view, _html} = live(conn, ~p"/computers?user_code=M6YF-BP8Q")
196
197
      assert has_element?(view, ~s(#pairing-form input[value="M6YF-BP8Q"])),
198
             "the code in the link did not reach the field, so it has to be retyped"
199
    end
200
201
    test "lower case in the link still fills the field", %{conn: conn} do
202
      conn = log_in_github_user(conn, "computers-prefill-case")
203
      {:ok, view, _html} = live(conn, ~p"/computers?user_code=m6yf-bp8q")
204
205
      assert has_element?(view, ~s(#pairing-form input[value="M6YF-BP8Q"]))
206
    end
207
208
    test "only a code-shaped value is prefilled", %{conn: conn} do
209
      conn = log_in_github_user(conn, "computers-prefill-junk")
210
      {:ok, view, _html} = live(conn, ~p"/computers?user_code=<script>alert(1)</script>")
211
212
      # A crafted link must not put arbitrary text in front of the reader as
213
      # though the server had issued it.
214
      assert has_element?(view, ~s(#pairing-form input[value=""]))
215
    end
216
217
    test "no link, no prefill", %{conn: conn} do
218
      conn = log_in_github_user(conn, "computers-prefill-none")
219
      {:ok, view, _html} = live(conn, ~p"/computers")
220
221
      assert has_element?(view, ~s(#pairing-form input[value=""]))
222
    end
223
  end
176 224
end

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