Wait for Codex account readiness

e6a56834afb3 · AtlantisPleb · · parent c4513dc0d911

Wait for Codex account readiness

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/scv/codex_accounts.ex
  • modified lib/openagents/scv/codex_login.ex
  • modified lib/openagents/scv/driver_account.ex
  • modified test/openagents/scv/codex_accounts_test.exs
  • modified test/openagents/scv/codex_app_server_test.exs
  • modified test/support/fake_codex_app_server.sh

Diff

6 files changed, +179 -26

lib/openagents/scv/codex_accounts.ex modified +39 -16

@@ -84,6 +84,11 @@ defmodule OpenAgents.SCV.CodexAccounts do

84 84
    end
85 85
  end
86 86
87
  @doc false
88
  def mark_login_completed(%DriverAccount{} = account, %DriverLoginAttempt{} = attempt) do
89
    emit("device_login_completed", account.id, attempt.id)
90
  end
91
87 92
  @doc false
88 93
  def mark_ready(%DriverAccount{} = account, %DriverLoginAttempt{} = attempt, attributes) do
89 94
    Repo.transaction(fn ->

@@ -153,35 +158,46 @@ defmodule OpenAgents.SCV.CodexAccounts do

153 158
154 159
  defp create_pending(operator, attributes) do
155 160
    Repo.transaction(fn ->
156
      used_refs =
161
      refs = credential_refs()
162
163
      accounts_by_ref =
157 164
        Repo.all(
158 165
          from(account in DriverAccount,
159
            where: account.status != "disconnected",
160
            select: account.secret_ref,
166
            where: account.secret_ref in ^refs,
161 167
            lock: "FOR UPDATE"
162 168
          )
163 169
        )
170
        |> Map.new(&{&1.secret_ref, &1})
164 171
165
      secret_ref =
166
        credential_refs()
167
        |> Enum.find(&(&1 not in used_refs))
172
      account_slot =
173
        refs
174
        |> Enum.find_value(fn ref ->
175
          available_account_slot(Map.get(accounts_by_ref, ref), ref)
176
        end)
168 177
        |> case do
169 178
          nil -> Repo.rollback(:account_capacity_reached)
170
          ref -> ref
179
          slot -> slot
171 180
        end
172 181
173
      account_id = Ecto.UUID.generate()
174 182
      label = normalized_label(Map.get(attributes, "label") || Map.get(attributes, :label))
175 183
176 184
      account =
177
        %DriverAccount{}
178
        |> DriverAccount.create_changeset(%{
179
          id: account_id,
180
          operator_id: operator.id,
181
          label: label,
182
          secret_ref: secret_ref
183
        })
184
        |> Repo.insert!()
185
        case account_slot do
186
          {:new, secret_ref} ->
187
            %DriverAccount{}
188
            |> DriverAccount.create_changeset(%{
189
              id: Ecto.UUID.generate(),
190
              operator_id: operator.id,
191
              label: label,
192
              secret_ref: secret_ref
193
            })
194
            |> Repo.insert!()
195
196
          {:reuse, failed_account} ->
197
            failed_account
198
            |> DriverAccount.retry_changeset(operator.id, label)
199
            |> Repo.update!()
200
        end
185 201
186 202
      attempt =
187 203
        %DriverLoginAttempt{}

@@ -210,6 +226,13 @@ defmodule OpenAgents.SCV.CodexAccounts do

210 226
211 227
  defp normalized_label(_value), do: "Operator Codex account"
212 228
229
  defp available_account_slot(nil, ref), do: {:new, ref}
230
231
  defp available_account_slot(%DriverAccount{status: "failed"} = account, _ref),
232
    do: {:reuse, account}
233
234
  defp available_account_slot(%DriverAccount{}, _ref), do: nil
235
213 236
  defp credential_refs do
214 237
    config()
215 238
    |> Keyword.get(:credential_refs, [])
lib/openagents/scv/codex_login.ex modified +73 -8

@@ -9,6 +9,8 @@ defmodule OpenAgents.SCV.CodexLogin do

9 9
10 10
  @required_model "gpt-5.6-luna"
11 11
  @maximum_auth_bytes 65_536
12
  @verification_retry_delay_ms 250
13
  @maximum_verification_attempts 40
12 14
13 15
  def child_spec(options) do
14 16
    attempt = Keyword.fetch!(options, :attempt)

@@ -52,7 +54,10 @@ defmodule OpenAgents.SCV.CodexLogin do

52 54
         ceremony: nil,
53 55
         codex_home: root,
54 56
         expiry_timer: nil,
55
         login_id: nil
57
         login_completed?: false,
58
         login_id: nil,
59
         verification_attempts: 0,
60
         verification_timer: nil
56 61
       }}
57 62
    else
58 63
      _error -> {:stop, :login_home_failed}

@@ -98,14 +103,12 @@ defmodule OpenAgents.SCV.CodexLogin do

98 103
          }}},
99 104
        %{app_server: client, login_id: login_id} = state
100 105
      ) do
101
    case complete_login(state) do
102
      {:ok, updated} ->
103
        {:stop, :normal, updated}
106
    CodexAccounts.mark_login_completed(state.account, state.attempt)
104 107
105
      {:error, code, updated} ->
106
        CodexAccounts.mark_failed(updated.account, updated.attempt, code)
107
        {:stop, :normal, updated}
108
    end
108
    {:noreply,
109
     state
110
     |> Map.put(:login_completed?, true)
111
     |> schedule_verification()}
109 112
  end
110 113
111 114
  def handle_info(

@@ -122,6 +125,53 @@ defmodule OpenAgents.SCV.CodexLogin do

122 125
    {:stop, :normal, state}
123 126
  end
124 127
128
  def handle_info(
129
        {:codex_app_server, client,
130
         {:notification,
131
          %{
132
            "method" => "account/updated",
133
            "params" => %{"authMode" => "chatgpt"}
134
          }}},
135
        %{app_server: client, login_completed?: true} = state
136
      ) do
137
    {:noreply, trigger_verification(state)}
138
  end
139
140
  def handle_info(
141
        {:codex_app_server, client,
142
         {:notification,
143
          %{
144
            "method" => "account/updated",
145
            "params" => %{"authMode" => auth_mode}
146
          }}},
147
        %{app_server: client, login_completed?: true} = state
148
      )
149
      when not is_nil(auth_mode) do
150
    CodexAccounts.mark_failed(state.account, state.attempt, "chatgpt_account_required")
151
    {:stop, :normal, state}
152
  end
153
154
  def handle_info(:verify_login, state) do
155
    state = %{
156
      state
157
      | verification_attempts: state.verification_attempts + 1,
158
        verification_timer: nil
159
    }
160
161
    case complete_login(state) do
162
      {:ok, updated} ->
163
        {:stop, :normal, updated}
164
165
      {:error, :account_not_ready, updated}
166
      when updated.verification_attempts < @maximum_verification_attempts ->
167
        {:noreply, schedule_verification(updated)}
168
169
      {:error, code, updated} ->
170
        CodexAccounts.mark_failed(updated.account, updated.attempt, code)
171
        {:stop, :normal, updated}
172
    end
173
  end
174
125 175
  def handle_info(:expire, state) do
126 176
    if is_pid(state.app_server) and is_binary(state.login_id) do
127 177
      _result =

@@ -155,6 +205,7 @@ defmodule OpenAgents.SCV.CodexLogin do

155 205
  @impl true
156 206
  def terminate(_reason, state) do
157 207
    if is_reference(state.expiry_timer), do: Process.cancel_timer(state.expiry_timer)
208
    if is_reference(state.verification_timer), do: Process.cancel_timer(state.verification_timer)
158 209
    if is_pid(state.app_server), do: CodexAppServer.stop(state.app_server)
159 210
    File.rm_rf(state.codex_home)
160 211
    :ok

@@ -265,6 +316,7 @@ defmodule OpenAgents.SCV.CodexLogin do

265 316
    {:ok, %{email: email, plan_type: plan_type}}
266 317
  end
267 318
319
  defp account_metadata(%{"account" => nil}), do: {:error, :account_not_ready}
268 320
  defp account_metadata(_response), do: {:error, :chatgpt_account_required}
269 321
270 322
  defp model_metadata(%{"data" => models}) when is_list(models) do

@@ -325,6 +377,19 @@ defmodule OpenAgents.SCV.CodexLogin do

325 377
  defp snapshot_response(%{ceremony: ceremony}) when is_map(ceremony), do: {:ok, ceremony}
326 378
  defp snapshot_response(_state), do: {:error, :login_not_ready}
327 379
380
  defp trigger_verification(state) do
381
    if is_reference(state.verification_timer), do: Process.cancel_timer(state.verification_timer)
382
    send(self(), :verify_login)
383
    %{state | verification_timer: nil}
384
  end
385
386
  defp schedule_verification(%{verification_timer: nil} = state) do
387
    timer = Process.send_after(self(), :verify_login, @verification_retry_delay_ms)
388
    %{state | verification_timer: timer}
389
  end
390
391
  defp schedule_verification(state), do: state
392
328 393
  defp write_config(codex_home) do
329 394
    path = Path.join(codex_home, "config.toml")
330 395
    contents = "cli_auth_credentials_store = \"file\"\n"
lib/openagents/scv/driver_account.ex modified +23

@@ -76,4 +76,27 @@ defmodule OpenAgents.SCV.DriverAccount do

76 76
    |> change(status: "failed", last_error_code: String.slice(code, 0, 80))
77 77
    |> check_constraint(:status, name: :scv_driver_accounts_status_check)
78 78
  end
79
80
  @doc false
81
  def retry_changeset(account, operator_id, label)
82
      when is_binary(operator_id) and is_binary(label) do
83
    account
84
    |> change(
85
      operator_id: operator_id,
86
      label: label,
87
      status: "pending",
88
      credential_version: nil,
89
      account_email: nil,
90
      plan_type: nil,
91
      available_models: [],
92
      reasoning_efforts: [],
93
      last_verified_at: nil,
94
      last_error_code: nil,
95
      disconnected_at: nil
96
    )
97
    |> validate_required([:operator_id, :label, :secret_ref])
98
    |> validate_length(:label, min: 1, max: 80)
99
    |> foreign_key_constraint(:operator_id)
100
    |> check_constraint(:status, name: :scv_driver_accounts_status_check)
101
  end
79 102
end
test/openagents/scv/codex_accounts_test.exs modified +28 -1

@@ -28,7 +28,7 @@ defmodule OpenAgents.SCV.CodexAccountsTest do

28 28
      File.rm_rf(root)
29 29
    end)
30 30
31
    {:ok, root: root}
31
    {:ok, credential_ref: hd(refs), root: root}
32 32
  end
33 33
34 34
  test "connects an individual operator account and persists only a credential reference", %{

@@ -129,6 +129,33 @@ defmodule OpenAgents.SCV.CodexAccountsTest do

129 129
    assert account_id == account.id
130 130
  end
131 131
132
  test "reuses a failed account slot for a new device login", %{credential_ref: credential_ref} do
133
    operator = operator("codex-retry")
134
    :ok = CodexAccounts.subscribe()
135
136
    failed_account =
137
      %DriverAccount{}
138
      |> DriverAccount.create_changeset(%{
139
        operator_id: operator.id,
140
        label: "Failed Codex",
141
        secret_ref: credential_ref
142
      })
143
      |> Repo.insert!()
144
      |> DriverAccount.failed_changeset("account_not_ready")
145
      |> Repo.update!()
146
147
    assert {:ok, retried_account, _attempt, _ceremony} =
148
             CodexAccounts.start_device_login(operator, %{"label" => "Retried Codex"})
149
150
    assert retried_account.id == failed_account.id
151
    assert retried_account.status == "pending"
152
    assert retried_account.label == "Retried Codex"
153
    assert retried_account.last_error_code == nil
154
155
    assert_receive {:scv_codex_accounts, {:account_ready, account_id}}, 5_000
156
    assert account_id == failed_account.id
157
  end
158
132 159
  defp operator(key) do
133 160
    account = user(key)
134 161
test/openagents/scv/codex_app_server_test.exs modified +7

@@ -37,6 +37,13 @@ defmodule OpenAgents.SCV.CodexAppServerTest do

37 37
                       "method" => "account/login/completed",
38 38
                       "params" => %{"success" => true}
39 39
                     }}}
40
41
    assert_receive {:codex_app_server, ^server,
42
                    {:notification,
43
                     %{
44
                       "method" => "account/updated",
45
                       "params" => %{"authMode" => "chatgpt", "planType" => "plus"}
46
                     }}}
40 47
  end
41 48
42 49
  defp fixture do
test/support/fake_codex_app_server.sh modified +9 -1

@@ -3,6 +3,7 @@ set -eu

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

@@ -18,9 +19,16 @@ while IFS= read -r line; do

18 19
      chmod 600 "${CODEX_HOME}/auth.json"
19 20
      printf '{"id":%s,"result":{"type":"chatgptDeviceCode","loginId":"fake-login-id","verificationUrl":"https://auth.openai.com/codex/device","userCode":"TEST-CODE"}}\n' "${id}"
20 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"}}'
21 23
      ;;
22 24
    *'"method":"account/read"'*)
23
      printf '{"id":%s,"result":{"account":{"type":"chatgpt","email":"operator@example.test","planType":"plus"},"requiresOpenaiAuth":true}}\n' "${id}"
25
      account_reads=$((account_reads + 1))
26
27
      if [ "${account_reads}" -eq 1 ]; then
28
        printf '{"id":%s,"result":{"account":null,"requiresOpenaiAuth":true}}\n' "${id}"
29
      else
30
        printf '{"id":%s,"result":{"account":{"type":"chatgpt","email":"operator@example.test","planType":"plus"},"requiresOpenaiAuth":true}}\n' "${id}"
31
      fi
24 32
      ;;
25 33
    *'"method":"model/list"'*)
26 34
      printf '{"id":%s,"result":{"data":[{"id":"gpt-5.6-luna","model":"gpt-5.6-luna","supportedReasoningEfforts":[{"reasoningEffort":"none","description":"None"},{"reasoningEffort":"low","description":"Low"}]}],"nextCursor":null}}\n' "${id}"

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