Recover SCV Codex login ceremonies

7160157a7a5f · AtlantisPleb · · parent a773f61b6164

Recover SCV Codex login ceremonies

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 lib/openagents/runtime_supervisor.ex
  • modified lib/openagents/scv/codex_accounts.ex
  • modified lib/openagents/scv/codex_login.ex
  • modified lib/openagents/scv/codex_login_supervisor.ex
  • modified lib/openagents_web/live/admin_scv_accounts_live.ex
  • modified test/openagents/scv/codex_accounts_test.exs
  • modified test/openagents_web/live/admin_scv_accounts_live_test.exs
  • modified test/support/fake_codex_app_server.sh

Diff

8 files changed, +254 -38

lib/openagents/runtime_supervisor.ex modified -2

@@ -33,8 +33,6 @@ defmodule OpenAgents.RuntimeSupervisor do

33 33
        {Registry, keys: :unique, name: OpenAgents.VoiceSessionRegistry},
34 34
        {DynamicSupervisor, strategy: :one_for_one, name: OpenAgents.VoiceSessionSupervisor},
35 35
        OpenAgents.SCV.Activity,
36
        {Registry, keys: :unique, name: OpenAgents.SCV.CodexLoginRegistry},
37
        OpenAgents.SCV.CodexLoginSupervisor,
38 36
        OpenAgents.Leaderboard.Server,
39 37
        {Task.Supervisor, name: OpenAgents.ProviderTaskSupervisor},
40 38
        {Task.Supervisor, name: OpenAgents.ToolTaskSupervisor},
lib/openagents/scv/codex_accounts.ex modified +60 -9

@@ -26,20 +26,38 @@ defmodule OpenAgents.SCV.CodexAccounts do

26 26
    Repo.all(from(account in DriverAccount, order_by: [desc: account.inserted_at]))
27 27
  end
28 28
29
  @spec recover_device_login(User.t()) ::
30
          {:ok, map()} | {:error, :login_not_running, Ecto.UUID.t()} | :none
31
  def recover_device_login(%User{} = operator) do
32
    if Accounts.admin?(operator) do
33
      case active_attempt(operator) do
34
        %DriverLoginAttempt{} = attempt -> recover_attempt(attempt)
35
        nil -> :none
36
      end
37
    else
38
      :none
39
    end
40
  end
41
42
  def recover_device_login(_operator), do: :none
43
29 44
  @spec start_device_login(User.t(), map()) ::
30 45
          {:ok, DriverAccount.t(), DriverLoginAttempt.t(), map()} | {:error, atom()}
31 46
  def start_device_login(%User{} = operator, attributes) when is_map(attributes) do
32 47
    with true <- Accounts.admin?(operator) or {:error, :not_authorized},
33 48
         true <- enabled?() or {:error, :codex_not_enabled},
34
         {:ok, account, attempt} <- create_pending(operator, attributes),
35
         {:ok, ceremony} <- CodexLoginSupervisor.start_login(account, attempt) do
36
      {:ok, account, attempt, ceremony}
49
         {:ok, account, attempt} <- create_pending(operator, attributes) do
50
      case CodexLoginSupervisor.start_login(account, attempt) do
51
        {:ok, ceremony} ->
52
          {:ok, account, attempt, ceremony}
53
54
        {:error, reason} ->
55
          mark_failed(account, attempt, reason)
56
          {:error, reason}
57
      end
37 58
    else
38 59
      {:error, _reason} = error ->
39 60
        error
40
41
      false ->
42
        {:error, :not_authorized}
43 61
    end
44 62
  end
45 63

@@ -51,8 +69,16 @@ defmodule OpenAgents.SCV.CodexAccounts do

51 69
    with true <- Accounts.admin?(operator) or {:error, :not_authorized},
52 70
         %DriverLoginAttempt{operator_id: operator_id} = attempt <-
53 71
           Repo.get(DriverLoginAttempt, attempt_id),
54
         true <- operator_id == operator.id or {:error, :not_authorized} do
55
      CodexLoginSupervisor.cancel(attempt)
72
         true <- operator_id == operator.id or {:error, :not_authorized},
73
         true <- attempt.status in ["starting", "waiting"] or {:error, :login_not_running} do
74
      case CodexLoginSupervisor.cancel(attempt) do
75
        :ok ->
76
          :ok
77
78
        {:error, :login_not_running} ->
79
          account = Repo.get!(DriverAccount, attempt.account_id)
80
          mark_cancelled(account, attempt)
81
      end
56 82
    else
57 83
      nil -> {:error, :login_not_found}
58 84
      {:error, reason} -> {:error, reason}

@@ -217,6 +243,31 @@ defmodule OpenAgents.SCV.CodexAccounts do

217 243
    end
218 244
  end
219 245
246
  defp active_attempt(operator) do
247
    Repo.one(
248
      from(attempt in DriverLoginAttempt,
249
        where: attempt.operator_id == ^operator.id,
250
        where: attempt.status in ["starting", "waiting"],
251
        order_by: [desc: attempt.inserted_at],
252
        limit: 1,
253
        preload: [:account]
254
      )
255
    )
256
  end
257
258
  defp recover_attempt(%DriverLoginAttempt{} = attempt) do
259
    if DateTime.after?(attempt.expires_at, DateTime.utc_now()) do
260
      case CodexLoginSupervisor.snapshot(attempt) do
261
        {:ok, ceremony} -> {:ok, ceremony}
262
        {:error, :login_not_running} -> {:error, :login_not_running, attempt.id}
263
        {:error, _reason} -> :none
264
      end
265
    else
266
      mark_failed(attempt.account, attempt, "login_expired")
267
      :none
268
    end
269
  end
270
220 271
  defp normalized_label(value) when is_binary(value) do
221 272
    case String.trim(value) do
222 273
      "" -> "Operator Codex account"

@@ -256,7 +307,7 @@ defmodule OpenAgents.SCV.CodexAccounts do

256 307
      )
257 308
258 309
    :telemetry.execute([:openagents, :scv, :codex_account, :event], %{count: 1}, metadata)
259
    Logger.info("SCV Codex account lifecycle event", Map.to_list(metadata))
310
    Logger.info("SCV Codex account lifecycle event #{Jason.encode!(metadata)}")
260 311
    :ok
261 312
  end
262 313
lib/openagents/scv/codex_login.ex modified +19 -4

@@ -24,18 +24,33 @@ defmodule OpenAgents.SCV.CodexLogin do

24 24
25 25
  def start_link(options) do
26 26
    attempt = Keyword.fetch!(options, :attempt)
27
    name = {:via, Registry, {OpenAgents.SCV.CodexLoginRegistry, attempt.id}}
27
    name = {:via, Horde.Registry, {OpenAgents.HordeRegistry, registry_key(attempt.id)}}
28 28
    GenServer.start_link(__MODULE__, options, name: name)
29 29
  end
30 30
31
  @doc false
32
  def registry_key(attempt_id), do: {:scv_codex_login, attempt_id}
33
31 34
  @spec begin(pid()) :: {:ok, map()} | {:error, atom()}
32
  def begin(server), do: GenServer.call(server, :begin, 30_000)
35
  def begin(server) do
36
    GenServer.call(server, :begin, 30_000)
37
  catch
38
    :exit, _reason -> {:error, :login_start_failed}
39
  end
33 40
34 41
  @spec snapshot(pid()) :: {:ok, map()} | {:error, atom()}
35
  def snapshot(server), do: GenServer.call(server, :snapshot)
42
  def snapshot(server) do
43
    GenServer.call(server, :snapshot)
44
  catch
45
    :exit, _reason -> {:error, :login_not_running}
46
  end
36 47
37 48
  @spec cancel(pid()) :: :ok | {:error, atom()}
38
  def cancel(server), do: GenServer.call(server, :cancel, 15_000)
49
  def cancel(server) do
50
    GenServer.call(server, :cancel, 15_000)
51
  catch
52
    :exit, _reason -> {:error, :login_not_running}
53
  end
39 54
40 55
  @impl true
41 56
  def init(options) do
lib/openagents/scv/codex_login_supervisor.ex modified +22 -12

@@ -1,24 +1,15 @@

1 1
defmodule OpenAgents.SCV.CodexLoginSupervisor do
2
  @moduledoc "Supervises one isolated Codex app-server process per pending account login."
3
4
  use DynamicSupervisor
2
  @moduledoc "Routes each isolated Codex device login through the cluster supervisor."
5 3
6 4
  alias OpenAgents.SCV.CodexLogin
7 5
  alias OpenAgents.SCV.DriverAccount
8 6
  alias OpenAgents.SCV.DriverLoginAttempt
9 7
10
  def start_link(options) do
11
    DynamicSupervisor.start_link(__MODULE__, options, name: __MODULE__)
12
  end
13
14
  @impl true
15
  def init(_options), do: DynamicSupervisor.init(strategy: :one_for_one)
16
17 8
  @spec start_login(DriverAccount.t(), DriverLoginAttempt.t()) :: {:ok, map()} | {:error, atom()}
18 9
  def start_login(%DriverAccount{} = account, %DriverLoginAttempt{} = attempt) do
19 10
    child = {CodexLogin, account: account, attempt: attempt}
20 11
21
    with {:ok, pid} <- DynamicSupervisor.start_child(__MODULE__, child),
12
    with {:ok, pid} <- start_child(child),
22 13
         {:ok, ceremony} <- CodexLogin.begin(pid) do
23 14
      {:ok, ceremony}
24 15
    else

@@ -30,9 +21,28 @@ defmodule OpenAgents.SCV.CodexLoginSupervisor do

30 21
31 22
  @spec cancel(DriverLoginAttempt.t()) :: :ok | {:error, atom()}
32 23
  def cancel(%DriverLoginAttempt{id: attempt_id}) do
33
    case Registry.lookup(OpenAgents.SCV.CodexLoginRegistry, attempt_id) do
24
    case lookup(attempt_id) do
34 25
      [{pid, _value}] -> CodexLogin.cancel(pid)
35 26
      [] -> {:error, :login_not_running}
36 27
    end
37 28
  end
29
30
  @spec snapshot(DriverLoginAttempt.t()) :: {:ok, map()} | {:error, atom()}
31
  def snapshot(%DriverLoginAttempt{id: attempt_id}) do
32
    case lookup(attempt_id) do
33
      [{pid, _value}] -> CodexLogin.snapshot(pid)
34
      [] -> {:error, :login_not_running}
35
    end
36
  end
37
38
  defp start_child(child) do
39
    case OpenAgents.Cluster.DynamicSupervisor.start_child(OpenAgents.HordeSupervisor, child) do
40
      {:ok, pid, _info} -> {:ok, pid}
41
      result -> result
42
    end
43
  end
44
45
  defp lookup(attempt_id) do
46
    Horde.Registry.lookup(OpenAgents.HordeRegistry, CodexLogin.registry_key(attempt_id))
47
  end
38 48
end
lib/openagents_web/live/admin_scv_accounts_live.ex modified +62 -9

@@ -11,11 +11,14 @@ defmodule OpenAgentsWeb.AdminScvAccountsLive do

11 11
    if Accounts.admin?(socket.assigns.current_user) do
12 12
      if connected?(socket), do: CodexAccounts.subscribe()
13 13
14
      {pending, interrupted_attempt_id} = recover_pending(socket.assigns.current_user)
15
14 16
      {:ok,
15 17
       socket
16 18
       |> assign(:page_title, "Operator · SCV Codex accounts")
17 19
       |> assign(:codex_enabled, CodexAccounts.enabled?())
18
       |> assign(:pending, nil)
20
       |> assign(:pending, pending)
21
       |> assign(:interrupted_attempt_id, interrupted_attempt_id)
19 22
       |> assign(:form, to_form(%{"label" => ""}, as: :account))
20 23
       |> load_accounts()}
21 24
    else

@@ -31,6 +34,7 @@ defmodule OpenAgentsWeb.AdminScvAccountsLive do

31 34
      {:noreply,
32 35
       socket
33 36
       |> assign(:pending, ceremony)
37
       |> assign(:interrupted_attempt_id, nil)
34 38
       |> assign(:form, to_form(%{"label" => ""}, as: :account))
35 39
       |> put_flash(:info, "Codex supplied a one-time device code.")
36 40
       |> load_accounts()}

@@ -46,18 +50,33 @@ defmodule OpenAgentsWeb.AdminScvAccountsLive do

46 50
  def handle_event("cancel_login", _params, socket) do
47 51
    if Accounts.admin?(socket.assigns.current_user) do
48 52
      result =
49
        case socket.assigns.pending do
50
          %{attempt_id: attempt_id} ->
51
            CodexAccounts.cancel_device_login(socket.assigns.current_user, attempt_id)
52
53
          nil ->
53
        cond do
54
          match?(%{attempt_id: _attempt_id}, socket.assigns.pending) ->
55
            CodexAccounts.cancel_device_login(
56
              socket.assigns.current_user,
57
              socket.assigns.pending.attempt_id
58
            )
59
60
          is_binary(socket.assigns.interrupted_attempt_id) ->
61
            CodexAccounts.cancel_device_login(
62
              socket.assigns.current_user,
63
              socket.assigns.interrupted_attempt_id
64
            )
65
66
          true ->
54 67
            {:error, :login_not_found}
55 68
        end
56 69
57 70
      socket =
58 71
        case result do
59
          :ok -> socket |> assign(:pending, nil) |> put_flash(:info, "Codex login cancelled.")
60
          {:error, reason} -> put_flash(socket, :error, error_message(reason))
72
          :ok ->
73
            socket
74
            |> assign(:pending, nil)
75
            |> assign(:interrupted_attempt_id, nil)
76
            |> put_flash(:info, "Codex login cancelled. You can start a new connection.")
77
78
          {:error, reason} ->
79
            put_flash(socket, :error, error_message(reason))
61 80
        end
62 81
63 82
      {:noreply, load_accounts(socket)}

@@ -73,6 +92,7 @@ defmodule OpenAgentsWeb.AdminScvAccountsLive do

73 92
    {:noreply,
74 93
     socket
75 94
     |> assign(:pending, pending)
95
     |> assign(:interrupted_attempt_id, nil)
76 96
     |> put_flash(:info, "Codex account connected and verified for SCVs.")
77 97
     |> load_accounts()}
78 98
  end

@@ -83,6 +103,7 @@ defmodule OpenAgentsWeb.AdminScvAccountsLive do

83 103
    {:noreply,
84 104
     socket
85 105
     |> assign(:pending, pending)
106
     |> assign(:interrupted_attempt_id, nil)
86 107
     |> put_flash(:error, error_message(code))
87 108
     |> load_accounts()}
88 109
  end

@@ -91,6 +112,7 @@ defmodule OpenAgentsWeb.AdminScvAccountsLive do

91 112
    {:noreply,
92 113
     socket
93 114
     |> assign(:pending, clear_pending(socket.assigns.pending, account_id))
115
     |> assign(:interrupted_attempt_id, nil)
94 116
     |> load_accounts()}
95 117
  end
96 118

@@ -101,6 +123,14 @@ defmodule OpenAgentsWeb.AdminScvAccountsLive do

101 123
  defp clear_pending(%{account_id: account_id}, account_id), do: nil
102 124
  defp clear_pending(pending, _account_id), do: pending
103 125
126
  defp recover_pending(operator) do
127
    case CodexAccounts.recover_device_login(operator) do
128
      {:ok, ceremony} -> {ceremony, nil}
129
      {:error, :login_not_running, attempt_id} -> {nil, attempt_id}
130
      :none -> {nil, nil}
131
    end
132
  end
133
104 134
  @impl true
105 135
  def render(assigns) do
106 136
    ~H"""

@@ -132,7 +162,30 @@ defmodule OpenAgentsWeb.AdminScvAccountsLive do

132 162
            This deployment has not enabled the Codex account runtime.
133 163
          </.alert>
134 164
135
          <section :if={@codex_enabled && is_nil(@pending)} aria-labelledby="connect-codex-heading">
165
          <.alert
166
            :if={@codex_enabled && is_binary(@interrupted_attempt_id)}
167
            id="codex-login-interrupted"
168
            variant={:warning}
169
          >
170
            <div class="space-y-3">
171
              <p>
172
                The previous SCV Codex login process is no longer reachable. No credential was
173
                stored.
174
              </p>
175
              <.button
176
                id="clear-interrupted-codex-login"
177
                variant={:secondary}
178
                phx-click="cancel_login"
179
              >
180
                CLEAR AND RETRY
181
              </.button>
182
            </div>
183
          </.alert>
184
185
          <section
186
            :if={@codex_enabled && is_nil(@pending) && is_nil(@interrupted_attempt_id)}
187
            aria-labelledby="connect-codex-heading"
188
          >
136 189
            <.card id="connect-codex-account">
137 190
              <div class="space-y-5">
138 191
                <div class="max-w-2xl space-y-2">
test/openagents/scv/codex_accounts_test.exs modified +59

@@ -156,6 +156,65 @@ defmodule OpenAgents.SCV.CodexAccountsTest do

156 156
    assert account_id == failed_account.id
157 157
  end
158 158
159
  test "recovers an active device ceremony through the cluster registry" do
160
    operator = operator("codex-recover")
161
    configure_held_login()
162
163
    assert {:ok, account, attempt, ceremony} =
164
             CodexAccounts.start_device_login(operator, %{"label" => "Recoverable Codex"})
165
166
    assert {:ok, recovered} = CodexAccounts.recover_device_login(operator)
167
    assert recovered == ceremony
168
169
    assert [{pid, _value}] =
170
             Horde.Registry.lookup(
171
               OpenAgents.HordeRegistry,
172
               {:scv_codex_login, attempt.id}
173
             )
174
175
    assert node(pid) == node()
176
    assert :ok = CodexAccounts.cancel_device_login(operator, attempt.id)
177
178
    assert Repo.get!(DriverAccount, account.id).status == "failed"
179
    assert Repo.get!(DriverLoginAttempt, attempt.id).status == "cancelled"
180
  end
181
182
  test "surfaces and clears a device ceremony interrupted by process loss" do
183
    operator = operator("codex-interrupted")
184
    configure_held_login()
185
186
    assert {:ok, account, attempt, _ceremony} =
187
             CodexAccounts.start_device_login(operator, %{"label" => "Interrupted Codex"})
188
189
    assert [{pid, _value}] =
190
             Horde.Registry.lookup(
191
               OpenAgents.HordeRegistry,
192
               {:scv_codex_login, attempt.id}
193
             )
194
195
    monitor = Process.monitor(pid)
196
    GenServer.stop(pid, :normal)
197
    assert_receive {:DOWN, ^monitor, :process, ^pid, :normal}
198
199
    assert {:error, :login_not_running, attempt_id} =
200
             CodexAccounts.recover_device_login(operator)
201
202
    assert attempt_id == attempt.id
203
    assert :ok = CodexAccounts.cancel_device_login(operator, attempt.id)
204
    assert Repo.get!(DriverAccount, account.id).status == "failed"
205
    assert Repo.get!(DriverLoginAttempt, attempt.id).status == "cancelled"
206
  end
207
208
  defp configure_held_login do
209
    config = Application.fetch_env!(:openagents, :scv_codex)
210
211
    Application.put_env(
212
      :openagents,
213
      :scv_codex,
214
      Keyword.put(config, :client_options, args: ["hold"])
215
    )
216
  end
217
159 218
  defp operator(key) do
160 219
    account = user(key)
161 220
test/openagents_web/live/admin_scv_accounts_live_test.exs modified +27

@@ -49,4 +49,31 @@ defmodule OpenAgentsWeb.AdminScvAccountsLiveTest do

49 49
    assert has_element?(view, "#codex-accounts", "gpt-5.6-luna")
50 50
    refute has_element?(view, "#codex-device-login")
51 51
  end
52
53
  test "recovers an active device ceremony after a LiveView reconnect", %{conn: conn} do
54
    config = Application.fetch_env!(:openagents, :scv_codex)
55
56
    Application.put_env(
57
      :openagents,
58
      :scv_codex,
59
      Keyword.put(config, :client_options, args: ["hold"])
60
    )
61
62
    on_exit(fn -> Application.put_env(:openagents, :scv_codex, config) end)
63
64
    conn = log_in_admin_user(conn, "scv-codex-reconnect")
65
    {:ok, first_view, _html} = live(conn, ~p"/admin/scv/accounts")
66
67
    first_view
68
    |> form("#codex-account-form", account: %{label: "Reconnect Codex"})
69
    |> render_submit()
70
71
    assert has_element?(first_view, "#codex-device-code", "TEST-CODE")
72
73
    {:ok, recovered_view, _html} = live(conn, ~p"/admin/scv/accounts")
74
75
    assert has_element?(recovered_view, "#codex-device-login")
76
    assert has_element?(recovered_view, "#codex-device-code", "TEST-CODE")
77
    refute has_element?(recovered_view, "#codex-account-form")
78
  end
52 79
end
test/support/fake_codex_app_server.sh modified +5 -2

@@ -4,6 +4,7 @@ exec 2>/dev/null

4 4
5 5
mkdir -p "${CODEX_HOME}"
6 6
account_reads=0
7
mode="${1:-complete}"
7 8
8 9
while IFS= read -r line; do
9 10
  id=$(printf '%s' "${line}" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')

@@ -18,8 +19,10 @@ while IFS= read -r line; do

18 19
      printf '%s' '{"auth_mode":"chatgpt","tokens":{"access_token":"test-only","refresh_token":"test-only"}}' > "${CODEX_HOME}/auth.json"
19 20
      chmod 600 "${CODEX_HOME}/auth.json"
20 21
      printf '{"id":%s,"result":{"type":"chatgptDeviceCode","loginId":"fake-login-id","verificationUrl":"https://auth.openai.com/codex/device","userCode":"TEST-CODE"}}\n' "${id}"
21
      printf '%s\n' '{"method":"account/login/completed","params":{"loginId":"fake-login-id","success":true,"error":null}}'
22
      printf '%s\n' '{"method":"account/updated","params":{"authMode":"chatgpt","planType":"plus"}}'
22
      if [ "${mode}" != "hold" ]; then
23
        printf '%s\n' '{"method":"account/login/completed","params":{"loginId":"fake-login-id","success":true,"error":null}}'
24
        printf '%s\n' '{"method":"account/updated","params":{"authMode":"chatgpt","planType":"plus"}}'
25
      fi
23 26
      ;;
24 27
    *'"method":"account/read"'*)
25 28
      account_reads=$((account_reads + 1))

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