Name the computer requesting CLI sign-in

139debc05e69 · AtlantisPleb · · parent b259fde51b40

Name the computer requesting CLI sign-in

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 466 · 2026-08-27T00:51:37.585165Z

Changed files

  • modified lib/openagents/device_authorizations.ex
  • modified lib/openagents/device_authorizations/device_authorization.ex
  • modified lib/openagents_web/controllers/device_authorization_controller.ex
  • modified lib/openagents_web/live/device_authorization_live.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260827004027_add_device_name_to_device_authorizations.exs
  • modified test/openagents_web/controllers/device_authorization_controller_test.exs
  • modified test/openagents_web/live/device_authorization_live_test.exs

Diff

8 files changed, +85 -16

lib/openagents/device_authorizations.ex modified +25 -6

@@ -21,6 +21,7 @@ defmodule OpenAgents.DeviceAuthorizations do

21 21
  @ttl_seconds 600
22 22
  @interval_seconds 5
23 23
  @maximum_create_attempts 3
24
  @maximum_device_name_length 80
24 25
25 26
  # The alphabet `random_user_code/0` draws from. `I`, `O`, `0`, and `1` are
26 27
  # absent on purpose: a code is read off one screen and typed into another.

@@ -54,11 +55,12 @@ defmodule OpenAgents.DeviceAuthorizations do

54 55
55 56
  def cast_user_code(_code), do: :error
56 57
57
  def create(scopes \\ ApiTokens.default_scopes())
58
  def create(scopes \\ ApiTokens.default_scopes(), device_name \\ nil)
58 59
59
  def create(scopes) when is_list(scopes), do: create(scopes, @maximum_create_attempts)
60
  def create(scopes, device_name) when is_list(scopes),
61
    do: insert_authorization(scopes, normalize_device_name(device_name), @maximum_create_attempts)
60 62
61
  def create(_scopes), do: {:error, :invalid_scopes}
63
  def create(_scopes, _device_name), do: {:error, :invalid_scopes}
62 64
63 65
  def get_pending_by_user_code(user_code) when is_binary(user_code) do
64 66
    now = DateTime.utc_now()

@@ -95,9 +97,10 @@ defmodule OpenAgents.DeviceAuthorizations do

95 97
96 98
  def poll(_device_code), do: {:error, :access_denied}
97 99
98
  defp create(_scopes, 0), do: {:error, :authorization_unavailable}
100
  defp insert_authorization(_scopes, _device_name, 0),
101
    do: {:error, :authorization_unavailable}
99 102
100
  defp create(scopes, attempts_left) do
103
  defp insert_authorization(scopes, device_name, attempts_left) do
101 104
    device_code = random_url_token(32)
102 105
    user_code = random_user_code()
103 106
    expires_at = DateTime.add(DateTime.utc_now(), @ttl_seconds, :second)

@@ -106,6 +109,7 @@ defmodule OpenAgents.DeviceAuthorizations do

106 109
    |> DeviceAuthorization.create_changeset(%{
107 110
      device_code_digest: digest(device_code),
108 111
      user_code_digest: digest(user_code),
112
      device_name: device_name,
109 113
      expires_at: expires_at,
110 114
      interval_seconds: @interval_seconds,
111 115
      scopes: scopes

@@ -118,11 +122,26 @@ defmodule OpenAgents.DeviceAuthorizations do

118 122
      {:error, changeset} ->
119 123
        if Keyword.has_key?(changeset.errors, :device_code_digest) or
120 124
             Keyword.has_key?(changeset.errors, :user_code_digest),
121
           do: create(scopes, attempts_left - 1),
125
           do: insert_authorization(scopes, device_name, attempts_left - 1),
122 126
           else: {:error, changeset}
123 127
    end
124 128
  end
125 129
130
  # A computer name is display metadata, not authority. Normalize it rather
131
  # than letting a hostname with a control character or excessive length stop
132
  # sign-in. HEEx escapes the remaining text when the approval page renders it.
133
  defp normalize_device_name(name) when is_binary(name) do
134
    name =
135
      name
136
      |> String.replace(~r/[\x00-\x1F\x7F]/u, " ")
137
      |> String.trim()
138
      |> String.slice(0, @maximum_device_name_length)
139
140
    if name == "", do: nil, else: name
141
  end
142
143
  defp normalize_device_name(_name), do: nil
144
126 145
  defp transition(user_code, next_state, user_id, user) do
127 146
    now = DateTime.utc_now()
128 147
lib/openagents/device_authorizations/device_authorization.ex modified +3

@@ -11,6 +11,7 @@ defmodule OpenAgents.DeviceAuthorizations.DeviceAuthorization do

11 11
  schema "device_authorizations" do
12 12
    field :device_code_digest, :binary
13 13
    field :user_code_digest, :binary
14
    field :device_name, :string
14 15
    field :state, :string, default: "pending"
15 16
    field :scopes, {:array, :string}, default: ["chat:account", "forge:write"]
16 17
    field :interval_seconds, :integer, default: 5

@@ -32,6 +33,7 @@ defmodule OpenAgents.DeviceAuthorizations.DeviceAuthorization do

32 33
    |> cast(attrs, [
33 34
      :device_code_digest,
34 35
      :user_code_digest,
36
      :device_name,
35 37
      :expires_at,
36 38
      :interval_seconds,
37 39
      :scopes

@@ -47,6 +49,7 @@ defmodule OpenAgents.DeviceAuthorizations.DeviceAuthorization do

47 49
      :interval_seconds
48 50
    ])
49 51
    |> validate_number(:interval_seconds, greater_than_or_equal_to: 1, less_than_or_equal_to: 30)
52
    |> validate_length(:device_name, max: 80)
50 53
    |> unique_constraint(:device_code_digest)
51 54
    |> unique_constraint(:user_code_digest)
52 55
    |> check_constraint(:state, name: :device_authorizations_state_check)
lib/openagents_web/controllers/device_authorization_controller.ex modified +1 -1

@@ -6,7 +6,7 @@ defmodule OpenAgentsWeb.DeviceAuthorizationController do

6 6
  alias OpenAgents.{ApiTokens, DeviceAuthorizations}
7 7
8 8
  def create(conn, params) do
9
    case DeviceAuthorizations.create(requested_scopes(params)) do
9
    case DeviceAuthorizations.create(requested_scopes(params), params["device_name"]) do
10 10
      {:ok, authorization, device_code, user_code} ->
11 11
        verification_uri = OpenAgentsWeb.Endpoint.url() <> "/device"
12 12
lib/openagents_web/live/device_authorization_live.ex modified +13 -6

@@ -93,12 +93,19 @@ defmodule OpenAgentsWeb.DeviceAuthorizationLive do

93 93
            </div>
94 94
            <div>
95 95
              <%!-- Approving is a grant, and a grant with no named grantee is a
96
              reflex rather than a decision. The CLI is what this application
97
              knows is asking: it is the only client that mints a device
98
              authorization, and `DeviceAuthorizations.claim/3` names the token
99
              it walks away with "OpenAgents CLI". Which computer it is running
100
              on is not recorded, so this does not claim to say. --%>
101
              <p class="font-medium">The OpenAgents CLI is asking to act as you</p>
96
              reflex rather than a decision. The CLI supplies the computer name
97
              for recognition; the server supplies the scopes and treats the
98
              name as display metadata, never as authority. Older clients leave
99
              it empty and are identified honestly as unnamed. --%>
100
              <p
101
                id="device-requesting-computer"
102
                class="font-medium"
103
                data-device-name={@authorization.device_name}
104
              >
105
                The OpenAgents CLI on
106
                <span class="font-semibold">{@authorization.device_name || "an unnamed computer"}</span>
107
                is asking to act as you
108
              </p>
102 109
              <p class="mt-1 text-sm text-muted-foreground">
103 110
                Approving gives it these permissions, and no others:
104 111
              </p>
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -315,7 +315,8 @@

315 315
    20260826010000,
316 316
    20260826103000,
317 317
    20260826145554,
318
    20260826160000
318
    20260826160000,
319
    20260827004027
319 320
  ],
320 321
  "required_tables": [
321 322
    "users",
priv/repo/migrations/20260827004027_add_device_name_to_device_authorizations.exs added +9

@@ -0,0 +1,9 @@

1
defmodule OpenAgents.Repo.Migrations.AddDeviceNameToDeviceAuthorizations do
2
  use Ecto.Migration
3
4
  def change do
5
    alter table(:device_authorizations) do
6
      add :device_name, :string, size: 80
7
    end
8
  end
9
end
test/openagents_web/controllers/device_authorization_controller_test.exs modified +19 -1

@@ -5,7 +5,10 @@ defmodule OpenAgentsWeb.DeviceAuthorizationControllerTest do

5 5
  alias OpenAgents.Repo
6 6
7 7
  test "a pending device authorization is digested and polling is paced", %{conn: conn} do
8
    created = post(conn, ~p"/api/v1/device/authorizations", %{})
8
    created =
9
      post(conn, ~p"/api/v1/device/authorizations", %{
10
        "device_name" => "Christopher's MacBook"
11
      })
9 12
10 13
    assert %{
11 14
             "device_code" => device_code,

@@ -23,6 +26,7 @@ defmodule OpenAgentsWeb.DeviceAuthorizationControllerTest do

23 26
    assert get_resp_header(created, "cache-control") == ["no-store"]
24 27
25 28
    authorization = Repo.one!(DeviceAuthorization)
29
    assert authorization.device_name == "Christopher's MacBook"
26 30
    refute authorization.device_code_digest == device_code
27 31
    refute authorization.user_code_digest == user_code
28 32
    refute inspect(authorization) =~ device_code

@@ -40,6 +44,20 @@ defmodule OpenAgentsWeb.DeviceAuthorizationControllerTest do

40 44
    assert get_resp_header(paced, "cache-control") == ["no-store"]
41 45
  end
42 46
47
  test "a computer name is bounded and control characters cannot enter the approval", %{
48
    conn: conn
49
  } do
50
    name = "  MacBook\nPro " <> String.duplicate("x", 100)
51
52
    conn = post(conn, ~p"/api/v1/device/authorizations", %{"device_name" => name})
53
54
    assert json_response(conn, 201)["user_code"]
55
    authorization = Repo.one!(DeviceAuthorization)
56
    assert authorization.device_name =~ "MacBook Pro"
57
    refute authorization.device_name =~ "\n"
58
    assert String.length(authorization.device_name) == 80
59
  end
60
43 61
  test "approval returns one PAT exactly once", %{conn: conn} do
44 62
    %{"device_code" => device_code, "user_code" => user_code} =
45 63
      conn
test/openagents_web/live/device_authorization_live_test.exs modified +13 -1

@@ -5,12 +5,24 @@ defmodule OpenAgentsWeb.DeviceAuthorizationLiveTest do

5 5
6 6
  test "an authenticated user reviews and approves a matching terminal code", %{conn: conn} do
7 7
    user = github_user("device-live-approval", "device-live-owner")
8
    {:ok, _authorization, _device_code, user_code} = OpenAgents.DeviceAuthorizations.create()
8
9
    {:ok, _authorization, _device_code, user_code} =
10
      OpenAgents.DeviceAuthorizations.create(
11
        OpenAgents.ApiTokens.default_scopes(),
12
        "Christopher's MacBook"
13
      )
14
9 15
    conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
10 16
11 17
    {:ok, view, _html} = live(conn, ~p"/device?user_code=#{user_code}")
12 18
13 19
    assert has_element?(view, "#device-authorization-review")
20
21
    assert has_element?(
22
             view,
23
             "#device-requesting-computer[data-device-name=\"Christopher's MacBook\"]"
24
           )
25
14 26
    assert has_element?(view, "#approve-device")
15 27
    refute has_element?(view, "#device-code-invalid")
16 28

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