aru-code 0.59.0__tar.gz → 0.60.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. {aru_code-0.59.0/aru_code.egg-info → aru_code-0.60.0}/PKG-INFO +1 -1
  2. aru_code-0.60.0/aru/__init__.py +1 -0
  3. {aru_code-0.59.0 → aru_code-0.60.0}/aru/auth.py +3 -0
  4. {aru_code-0.59.0 → aru_code-0.60.0}/aru/cli.py +12 -0
  5. aru_code-0.60.0/aru/codex_oauth.py +588 -0
  6. {aru_code-0.59.0 → aru_code-0.60.0}/aru/commands.py +118 -3
  7. {aru_code-0.59.0 → aru_code-0.60.0}/aru/providers.py +209 -1
  8. aru_code-0.60.0/aru/state.py +181 -0
  9. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/app.py +19 -0
  10. {aru_code-0.59.0 → aru_code-0.60.0/aru_code.egg-info}/PKG-INFO +1 -1
  11. {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/SOURCES.txt +5 -0
  12. {aru_code-0.59.0 → aru_code-0.60.0}/pyproject.toml +1 -1
  13. aru_code-0.60.0/tests/test_codex_oauth.py +406 -0
  14. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_connect_command.py +3 -1
  15. aru_code-0.60.0/tests/test_connect_oauth.py +387 -0
  16. aru_code-0.60.0/tests/test_state_recent_models.py +239 -0
  17. aru_code-0.59.0/aru/__init__.py +0 -1
  18. {aru_code-0.59.0 → aru_code-0.60.0}/LICENSE +0 -0
  19. {aru_code-0.59.0 → aru_code-0.60.0}/README.md +0 -0
  20. {aru_code-0.59.0 → aru_code-0.60.0}/aru/_debug/__init__.py +0 -0
  21. {aru_code-0.59.0 → aru_code-0.60.0}/aru/_debug/analyze_trace.py +0 -0
  22. {aru_code-0.59.0 → aru_code-0.60.0}/aru/_debug/loop_tracer.py +0 -0
  23. {aru_code-0.59.0 → aru_code-0.60.0}/aru/agent_factory.py +0 -0
  24. {aru_code-0.59.0 → aru_code-0.60.0}/aru/agents/__init__.py +0 -0
  25. {aru_code-0.59.0 → aru_code-0.60.0}/aru/agents/base.py +0 -0
  26. {aru_code-0.59.0 → aru_code-0.60.0}/aru/agents/catalog.py +0 -0
  27. {aru_code-0.59.0 → aru_code-0.60.0}/aru/agents/planner.py +0 -0
  28. {aru_code-0.59.0 → aru_code-0.60.0}/aru/cache_patch.py +0 -0
  29. {aru_code-0.59.0 → aru_code-0.60.0}/aru/checkpoints.py +0 -0
  30. {aru_code-0.59.0 → aru_code-0.60.0}/aru/config.py +0 -0
  31. {aru_code-0.59.0 → aru_code-0.60.0}/aru/context.py +0 -0
  32. {aru_code-0.59.0 → aru_code-0.60.0}/aru/display.py +0 -0
  33. {aru_code-0.59.0 → aru_code-0.60.0}/aru/doom_loop.py +0 -0
  34. {aru_code-0.59.0 → aru_code-0.60.0}/aru/events.py +0 -0
  35. {aru_code-0.59.0 → aru_code-0.60.0}/aru/format/__init__.py +0 -0
  36. {aru_code-0.59.0 → aru_code-0.60.0}/aru/format/manager.py +0 -0
  37. {aru_code-0.59.0 → aru_code-0.60.0}/aru/format/runner.py +0 -0
  38. {aru_code-0.59.0 → aru_code-0.60.0}/aru/history_blocks.py +0 -0
  39. {aru_code-0.59.0 → aru_code-0.60.0}/aru/lsp/__init__.py +0 -0
  40. {aru_code-0.59.0 → aru_code-0.60.0}/aru/lsp/client.py +0 -0
  41. {aru_code-0.59.0 → aru_code-0.60.0}/aru/lsp/manager.py +0 -0
  42. {aru_code-0.59.0 → aru_code-0.60.0}/aru/lsp/protocol.py +0 -0
  43. {aru_code-0.59.0 → aru_code-0.60.0}/aru/memory/__init__.py +0 -0
  44. {aru_code-0.59.0 → aru_code-0.60.0}/aru/memory/extractor.py +0 -0
  45. {aru_code-0.59.0 → aru_code-0.60.0}/aru/memory/loader.py +0 -0
  46. {aru_code-0.59.0 → aru_code-0.60.0}/aru/memory/store.py +0 -0
  47. {aru_code-0.59.0 → aru_code-0.60.0}/aru/permissions.py +0 -0
  48. {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugin_cache.py +0 -0
  49. {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/__init__.py +0 -0
  50. {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/custom_tools.py +0 -0
  51. {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/hooks.py +0 -0
  52. {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/manager.py +0 -0
  53. {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/tool_api.py +0 -0
  54. {aru_code-0.59.0 → aru_code-0.60.0}/aru/runner.py +0 -0
  55. {aru_code-0.59.0 → aru_code-0.60.0}/aru/runtime.py +0 -0
  56. {aru_code-0.59.0 → aru_code-0.60.0}/aru/select.py +0 -0
  57. {aru_code-0.59.0 → aru_code-0.60.0}/aru/session.py +0 -0
  58. {aru_code-0.59.0 → aru_code-0.60.0}/aru/sinks.py +0 -0
  59. {aru_code-0.59.0 → aru_code-0.60.0}/aru/streaming.py +0 -0
  60. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tool_policy.py +0 -0
  61. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/__init__.py +0 -0
  62. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/_diff.py +0 -0
  63. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/_shared.py +0 -0
  64. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/apply_patch.py +0 -0
  65. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/apply_patch_prompt.txt +0 -0
  66. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/ast_tools.py +0 -0
  67. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/codebase.py +0 -0
  68. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/delegate.py +0 -0
  69. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/delegate_prompt.txt +0 -0
  70. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/file_ops.py +0 -0
  71. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/gitignore.py +0 -0
  72. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/lsp.py +0 -0
  73. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/mcp_client.py +0 -0
  74. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/memory_tool.py +0 -0
  75. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/plan_mode.py +0 -0
  76. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/ranker.py +0 -0
  77. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/registry.py +0 -0
  78. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/search.py +0 -0
  79. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/shell.py +0 -0
  80. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/skill.py +0 -0
  81. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/tasklist.py +0 -0
  82. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/web.py +0 -0
  83. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/worktree.py +0 -0
  84. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/__init__.py +0 -0
  85. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/log_bridge.py +0 -0
  86. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/notifications.py +0 -0
  87. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/sanitize.py +0 -0
  88. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/__init__.py +0 -0
  89. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/choice.py +0 -0
  90. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/confirm.py +0 -0
  91. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/keymap.py +0 -0
  92. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/search.py +0 -0
  93. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/session_picker.py +0 -0
  94. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/text_input.py +0 -0
  95. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/sinks.py +0 -0
  96. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/slash_bridge.py +0 -0
  97. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/themes.py +0 -0
  98. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/ui.py +0 -0
  99. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/__init__.py +0 -0
  100. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/chat.py +0 -0
  101. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/completer.py +0 -0
  102. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/context_pane.py +0 -0
  103. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/file_link.py +0 -0
  104. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/header.py +0 -0
  105. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/inline_choice.py +0 -0
  106. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/loaded_pane.py +0 -0
  107. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/prompt_area.py +0 -0
  108. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/prompt_queue.py +0 -0
  109. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/status.py +0 -0
  110. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/subagent_panel.py +0 -0
  111. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/tasklist_panel.py +0 -0
  112. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/thinking.py +0 -0
  113. {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/tools.py +0 -0
  114. {aru_code-0.59.0 → aru_code-0.60.0}/aru/ui.py +0 -0
  115. {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/dependency_links.txt +0 -0
  116. {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/entry_points.txt +0 -0
  117. {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/requires.txt +0 -0
  118. {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/top_level.txt +0 -0
  119. {aru_code-0.59.0 → aru_code-0.60.0}/setup.cfg +0 -0
  120. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_agents_base.py +0 -0
  121. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_agents_md_coverage.py +0 -0
  122. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_apply_patch.py +0 -0
  123. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_async_tool_permission.py +0 -0
  124. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_auth_store.py +0 -0
  125. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cache_patch_metrics.py +0 -0
  126. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cache_patch_stop_reason.py +0 -0
  127. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_catalog.py +0 -0
  128. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_chat_scrollable.py +0 -0
  129. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_checkpoints.py +0 -0
  130. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli.py +0 -0
  131. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_advanced.py +0 -0
  132. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_base.py +0 -0
  133. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_new.py +0 -0
  134. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_session.py +0 -0
  135. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_shell.py +0 -0
  136. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_codebase.py +0 -0
  137. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_confabulation_regression.py +0 -0
  138. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_config.py +0 -0
  139. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_context.py +0 -0
  140. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_context_pane.py +0 -0
  141. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cwd_awareness.py +0 -0
  142. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_delegate.py +0 -0
  143. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_doom_loop.py +0 -0
  144. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_events_backward_compat.py +0 -0
  145. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_events_schema.py +0 -0
  146. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_fork_ctx_concurrency.py +0 -0
  147. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_format.py +0 -0
  148. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_gitignore.py +0 -0
  149. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_guardrails_scenarios.py +0 -0
  150. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_invoke_skill.py +0 -0
  151. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_invoked_skills.py +0 -0
  152. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_loaded_pane_path.py +0 -0
  153. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_lsp.py +0 -0
  154. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_lsp_rename.py +0 -0
  155. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_main.py +0 -0
  156. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_markdown_to_text.py +0 -0
  157. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_mcp_client.py +0 -0
  158. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_mcp_health.py +0 -0
  159. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_memory.py +0 -0
  160. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_memory_tool.py +0 -0
  161. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_microcompact.py +0 -0
  162. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_permission_timeout_suspension.py +0 -0
  163. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_permissions.py +0 -0
  164. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plan_mode_refactor.py +0 -0
  165. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plugin_cache.py +0 -0
  166. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plugin_errors.py +0 -0
  167. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plugin_hooks_v2.py +0 -0
  168. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plugins.py +0 -0
  169. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_providers.py +0 -0
  170. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_ranker.py +0 -0
  171. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_reasoning.py +0 -0
  172. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_runner_interrupt.py +0 -0
  173. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_runner_recovery.py +0 -0
  174. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_runtime.py +0 -0
  175. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_select.py +0 -0
  176. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_session_free_cost.py +0 -0
  177. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_skill_disallowed_tools.py +0 -0
  178. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_status_breakdown.py +0 -0
  179. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_status_cost.py +0 -0
  180. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_streaming_sink.py +0 -0
  181. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_subagent_tool_events.py +0 -0
  182. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tasklist.py +0 -0
  183. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_thread_tool_timeout.py +0 -0
  184. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tool_policy.py +0 -0
  185. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_truncation_marker.py +0 -0
  186. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_app_boot.py +0 -0
  187. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_bindings.py +0 -0
  188. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_bus_flow.py +0 -0
  189. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_chat.py +0 -0
  190. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_chat_adversarial.py +0 -0
  191. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_completer.py +0 -0
  192. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_completer_dynamic.py +0 -0
  193. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_connect_wiring.py +0 -0
  194. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_copy.py +0 -0
  195. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_error_display.py +0 -0
  196. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_file_link.py +0 -0
  197. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_input_behaviour.py +0 -0
  198. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_layer12_recovery.py +0 -0
  199. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_layer13_recovery.py +0 -0
  200. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_mention_expand.py +0 -0
  201. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_modals.py +0 -0
  202. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_mode_cycle.py +0 -0
  203. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_native_selection.py +0 -0
  204. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_permission_flow.py +0 -0
  205. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_plan_task_render.py +0 -0
  206. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_prompt_queue.py +0 -0
  207. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_shell_bang.py +0 -0
  208. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_sidebar_toggle.py +0 -0
  209. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_slash_bridge.py +0 -0
  210. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_slash_model.py +0 -0
  211. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_snapshot_smoke.py +0 -0
  212. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_subagent_panel.py +0 -0
  213. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_theme.py +0 -0
  214. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_thinking_and_boot.py +0 -0
  215. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_widgets_visual.py +0 -0
  216. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_ui_adapter.py +0 -0
  217. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_worktree.py +0 -0
  218. {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_worktree_session_restore.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.59.0
3
+ Version: 0.60.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.60.0"
@@ -13,6 +13,9 @@ Schema (``info``) — a tagged union on ``type`` so future auth methods
13
13
  "provider_type": "openai", "default_model": "...", # custom provider
14
14
  "context_limit": 128000}
15
15
  {"type": "local", "base_url": "http://..."} # keyless (e.g. Ollama)
16
+ {"type": "oauth", "refresh": "...", # ChatGPT (Codex) — wired
17
+ "access": "...", "expires": 1735689600000, # by /connect → "ChatGPT
18
+ "accountId": "acc-..."} # Pro/Plus (browser)"
16
19
 
17
20
  Consumption lives in :func:`aru.providers.apply_stored_credentials`, which
18
21
  layers these onto the in-memory provider registry at startup (and again
@@ -179,8 +179,20 @@ async def run_oneshot(prompt: str, print_only: bool = False, skip_permissions: b
179
179
  from aru.tools.skill import _update_invoke_skill_docstring
180
180
  _update_invoke_skill_docstring(config.skills)
181
181
  session = Session()
182
+ # Same precedence as run_tui: aru.json default_model wins, otherwise
183
+ # fall back to the most-recent /connect or /model selection persisted
184
+ # in ~/.aru/state.json. Built-in default kicks in only when neither
185
+ # source provides a usable ref.
182
186
  if config.default_model:
183
187
  session.model_ref = config.default_model
188
+ else:
189
+ try:
190
+ from aru import state as _state
191
+ last = _state.get_last_model()
192
+ except Exception:
193
+ last = None
194
+ if last:
195
+ session.model_ref = last
184
196
 
185
197
  ctx.session = session
186
198
  ctx.model_id = session.model_id
@@ -0,0 +1,588 @@
1
+ """ChatGPT (Codex) OAuth for OpenAI provider — Plus/Pro browser login.
2
+
3
+ Mirrors OpenCode's ``packages/opencode/src/plugin/openai/codex.ts`` so users
4
+ who have an active ChatGPT Plus / Pro subscription can sign in once via the
5
+ browser and route requests through their plan instead of pay-as-you-go API
6
+ credits. The flow is the standard OAuth 2.0 PKCE Authorization Code grant
7
+ against ``auth.openai.com`` with the Codex CLI's client_id.
8
+
9
+ Three-stage handshake:
10
+
11
+ 1. ``start_codex_oauth_flow()`` — spin up a tiny localhost callback server on
12
+ port 1455, generate a PKCE verifier/challenge, build the authorize URL
13
+ and return it. The caller opens the URL in the user's browser
14
+ (``webbrowser.open``) and awaits the callback.
15
+ 2. ``await_codex_callback(flow)`` — block until the user completes the
16
+ browser consent screen. The local server picks up the ``?code=…&state=…``
17
+ query, exchanges the code for ``{access, refresh, id_token, expires_in}``
18
+ at ``/oauth/token`` and extracts the ChatGPT account id from the JWT
19
+ claims (``chatgpt_account_id`` or fallback locations).
20
+ 3. ``refresh_codex_tokens(refresh_token)`` — swap a long-lived ``refresh``
21
+ token for a new ``access`` token when the cached one expires (handled
22
+ transparently by :class:`CodexAuth` on every request).
23
+
24
+ Persistence lives in ``~/.aru/auth.json`` under provider id ``openai`` with
25
+ ``{"type": "oauth", "refresh", "access", "expires", "accountId"}`` —
26
+ provider creation in :mod:`aru.providers` detects this and swaps an Agno
27
+ ``OpenAIResponses`` model pointed at the Codex endpoint into the registry.
28
+
29
+ This module is import-light by design: the local HTTP server and browser
30
+ launcher are only touched when ``start_codex_oauth_flow`` is invoked, so
31
+ ordinary startup paths (which only ever need :func:`refresh_codex_tokens`
32
+ and :class:`CodexAuth`) don't pay for them.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import base64
38
+ import hashlib
39
+ import http.server
40
+ import json
41
+ import logging
42
+ import os
43
+ import secrets
44
+ import socketserver
45
+ import threading
46
+ import time
47
+ import urllib.parse
48
+ import urllib.request
49
+ from dataclasses import dataclass, field
50
+ from typing import Any
51
+
52
+ import httpx
53
+
54
+ logger = logging.getLogger("aru.codex_oauth")
55
+
56
+ # Codex CLI's OAuth app id. Stable; published in OpenAI's Codex CLI source.
57
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
58
+ ISSUER = "https://auth.openai.com"
59
+ CODEX_API_BASE = "https://chatgpt.com/backend-api/codex"
60
+ CODEX_API_ENDPOINT = f"{CODEX_API_BASE}/responses"
61
+ OAUTH_PORT = 1455
62
+ OAUTH_REDIRECT_URI = f"http://localhost:{OAUTH_PORT}/auth/callback"
63
+ CALLBACK_TIMEOUT_SECONDS = 5 * 60
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # PKCE + URL building
68
+ # ---------------------------------------------------------------------------
69
+
70
+ _PKCE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
71
+
72
+
73
+ def _base64url_encode(data: bytes) -> str:
74
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
75
+
76
+
77
+ @dataclass
78
+ class PkceCodes:
79
+ verifier: str
80
+ challenge: str
81
+
82
+
83
+ def generate_pkce() -> PkceCodes:
84
+ """Generate a (verifier, challenge) PKCE pair using SHA-256.
85
+
86
+ Matches the OpenCode TS implementation: 43-byte verifier drawn from a
87
+ URL-safe alphabet, S256 challenge.
88
+ """
89
+ verifier = "".join(
90
+ _PKCE_ALPHABET[b % len(_PKCE_ALPHABET)] for b in secrets.token_bytes(43)
91
+ )
92
+ challenge = _base64url_encode(hashlib.sha256(verifier.encode("ascii")).digest())
93
+ return PkceCodes(verifier=verifier, challenge=challenge)
94
+
95
+
96
+ def build_authorize_url(pkce: PkceCodes, state: str) -> str:
97
+ """Build the OpenAI ``/oauth/authorize`` URL that opens in the browser."""
98
+ params = {
99
+ "response_type": "code",
100
+ "client_id": CLIENT_ID,
101
+ "redirect_uri": OAUTH_REDIRECT_URI,
102
+ "scope": "openid profile email offline_access",
103
+ "code_challenge": pkce.challenge,
104
+ "code_challenge_method": "S256",
105
+ "id_token_add_organizations": "true",
106
+ "codex_cli_simplified_flow": "true",
107
+ "state": state,
108
+ "originator": "aru",
109
+ }
110
+ return f"{ISSUER}/oauth/authorize?{urllib.parse.urlencode(params)}"
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # JWT claim parsing (account id extraction)
115
+ # ---------------------------------------------------------------------------
116
+
117
+ def parse_jwt_claims(token: str) -> dict[str, Any] | None:
118
+ """Decode the middle (claims) segment of a JWT without signature checks.
119
+
120
+ Returns ``None`` for malformed tokens. Signature verification is
121
+ deliberately skipped — we trust the token here because it came directly
122
+ from the OAuth endpoint over TLS and we only read it to surface a
123
+ convenience field (``chatgpt_account_id``).
124
+ """
125
+ if not token:
126
+ return None
127
+ parts = token.split(".")
128
+ if len(parts) != 3:
129
+ return None
130
+ try:
131
+ # base64url padding restoration
132
+ body = parts[1] + "=" * (-len(parts[1]) % 4)
133
+ decoded = base64.urlsafe_b64decode(body.encode("ascii"))
134
+ claims = json.loads(decoded.decode("utf-8"))
135
+ except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
136
+ return None
137
+ if not isinstance(claims, dict):
138
+ return None
139
+ return claims
140
+
141
+
142
+ def extract_account_id_from_claims(claims: dict[str, Any]) -> str | None:
143
+ """Pull the ChatGPT account id out of JWT claims.
144
+
145
+ Search order mirrors OpenCode/Codex CLI:
146
+ 1. ``chatgpt_account_id`` at the root
147
+ 2. ``["https://api.openai.com/auth"].chatgpt_account_id`` (nested)
148
+ 3. ``organizations[0].id`` (org-mode fallback)
149
+ """
150
+ if not isinstance(claims, dict):
151
+ return None
152
+ root = claims.get("chatgpt_account_id")
153
+ if isinstance(root, str) and root:
154
+ return root
155
+ nested = claims.get("https://api.openai.com/auth")
156
+ if isinstance(nested, dict):
157
+ nested_id = nested.get("chatgpt_account_id")
158
+ if isinstance(nested_id, str) and nested_id:
159
+ return nested_id
160
+ orgs = claims.get("organizations")
161
+ if isinstance(orgs, list) and orgs:
162
+ first = orgs[0]
163
+ if isinstance(first, dict):
164
+ org_id = first.get("id")
165
+ if isinstance(org_id, str) and org_id:
166
+ return org_id
167
+ return None
168
+
169
+
170
+ def extract_account_id(tokens: dict[str, Any]) -> str | None:
171
+ """Find the account id in the OAuth token response — prefer id_token."""
172
+ id_token = tokens.get("id_token")
173
+ if isinstance(id_token, str) and id_token:
174
+ claims = parse_jwt_claims(id_token)
175
+ if claims:
176
+ acc = extract_account_id_from_claims(claims)
177
+ if acc:
178
+ return acc
179
+ access = tokens.get("access_token")
180
+ if isinstance(access, str) and access:
181
+ claims = parse_jwt_claims(access)
182
+ if claims:
183
+ return extract_account_id_from_claims(claims)
184
+ return None
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Token endpoints
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def _post_form(url: str, data: dict[str, str], timeout: float = 30.0) -> dict[str, Any]:
192
+ """Tiny POST helper — used for both the code exchange and refresh.
193
+
194
+ Avoids pulling in ``httpx``/``requests`` so this module can be imported
195
+ standalone (e.g. in tests / a future CLI subcommand) without ordering
196
+ issues.
197
+ """
198
+ body = urllib.parse.urlencode(data).encode("ascii")
199
+ req = urllib.request.Request(
200
+ url,
201
+ data=body,
202
+ method="POST",
203
+ headers={
204
+ "Content-Type": "application/x-www-form-urlencoded",
205
+ "Accept": "application/json",
206
+ "User-Agent": "aru-codex-oauth",
207
+ },
208
+ )
209
+ try:
210
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
211
+ raw = resp.read()
212
+ except urllib.error.HTTPError as e:
213
+ detail = ""
214
+ try:
215
+ detail = e.read().decode("utf-8", errors="replace")
216
+ except Exception:
217
+ pass
218
+ raise RuntimeError(
219
+ f"OAuth request to {url} failed: HTTP {e.code} {detail[:200]}"
220
+ ) from e
221
+ except urllib.error.URLError as e:
222
+ raise RuntimeError(f"OAuth request to {url} failed: {e.reason}") from e
223
+ try:
224
+ return json.loads(raw.decode("utf-8"))
225
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
226
+ raise RuntimeError(f"OAuth response from {url} was not JSON") from e
227
+
228
+
229
+ def exchange_code_for_tokens(code: str, pkce: PkceCodes) -> dict[str, Any]:
230
+ """Trade an authorization code for ``{access_token, refresh_token, …}``."""
231
+ return _post_form(
232
+ f"{ISSUER}/oauth/token",
233
+ {
234
+ "grant_type": "authorization_code",
235
+ "code": code,
236
+ "redirect_uri": OAUTH_REDIRECT_URI,
237
+ "client_id": CLIENT_ID,
238
+ "code_verifier": pkce.verifier,
239
+ },
240
+ )
241
+
242
+
243
+ def refresh_codex_tokens(refresh_token: str) -> dict[str, Any]:
244
+ """Swap a refresh token for a fresh access token."""
245
+ return _post_form(
246
+ f"{ISSUER}/oauth/token",
247
+ {
248
+ "grant_type": "refresh_token",
249
+ "refresh_token": refresh_token,
250
+ "client_id": CLIENT_ID,
251
+ },
252
+ )
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # Local callback server
257
+ # ---------------------------------------------------------------------------
258
+
259
+ _HTML_SUCCESS = """<!doctype html>
260
+ <html><head><title>aru — Codex login successful</title>
261
+ <style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
262
+ align-items:center;height:100vh;margin:0;background:#131010;color:#f1ecec}
263
+ .c{text-align:center;padding:2rem}h1{margin-bottom:1rem}p{color:#b7b1b1}
264
+ </style></head><body><div class="c"><h1>You're signed in</h1>
265
+ <p>You can close this tab and return to aru.</p></div>
266
+ <script>setTimeout(()=>window.close(),1500)</script></body></html>"""
267
+
268
+
269
+ def _html_error(msg: str) -> str:
270
+ safe = (msg or "Unknown error").replace("<", "&lt;").replace(">", "&gt;")
271
+ return (
272
+ '<!doctype html><html><head><title>aru — Codex login failed</title>'
273
+ '<style>body{font-family:system-ui,sans-serif;display:flex;'
274
+ 'justify-content:center;align-items:center;height:100vh;margin:0;'
275
+ 'background:#131010;color:#f1ecec}.c{text-align:center;padding:2rem}'
276
+ 'h1{color:#fc533a;margin-bottom:1rem}p{color:#b7b1b1}'
277
+ '.e{color:#ff917b;font-family:monospace;margin-top:1rem;padding:1rem;'
278
+ 'background:#3c140d;border-radius:.5rem}</style></head><body>'
279
+ '<div class="c"><h1>Login failed</h1>'
280
+ '<p>Something went wrong during authorization.</p>'
281
+ f'<div class="e">{safe}</div></div></body></html>'
282
+ )
283
+
284
+
285
+ @dataclass
286
+ class CodexAuthFlow:
287
+ """Handle returned from :func:`start_codex_oauth_flow`.
288
+
289
+ Carries everything the caller needs: the URL to open in the browser, the
290
+ PKCE verifier (kept for the eventual code exchange), the CSRF ``state``,
291
+ and the synchronisation primitives the local HTTP server uses to deliver
292
+ the result back to :func:`await_codex_callback`.
293
+ """
294
+ authorize_url: str
295
+ pkce: PkceCodes
296
+ state: str
297
+ _server: socketserver.TCPServer
298
+ _thread: threading.Thread
299
+ _result_event: threading.Event = field(default_factory=threading.Event)
300
+ _result: dict[str, Any] | None = None
301
+ _error: BaseException | None = None
302
+
303
+
304
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
305
+ """Single-shot handler: accept ``/auth/callback`` then trigger shutdown."""
306
+ # Filled in by start_codex_oauth_flow before the server starts serving.
307
+ flow: "CodexAuthFlow | None" = None
308
+
309
+ # Silence the default request log — we have our own logger.
310
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
311
+ logger.debug("oauth callback: " + format, *args)
312
+
313
+ def _send_html(self, status: int, body: str) -> None:
314
+ encoded = body.encode("utf-8")
315
+ self.send_response(status)
316
+ self.send_header("Content-Type", "text/html; charset=utf-8")
317
+ self.send_header("Content-Length", str(len(encoded)))
318
+ self.end_headers()
319
+ self.wfile.write(encoded)
320
+
321
+ def do_GET(self) -> None: # noqa: N802 — http.server API
322
+ flow = self.flow
323
+ if flow is None:
324
+ self._send_html(404, _html_error("No active OAuth flow"))
325
+ return
326
+
327
+ parsed = urllib.parse.urlparse(self.path)
328
+ if parsed.path != "/auth/callback":
329
+ self._send_html(404, "Not found")
330
+ return
331
+
332
+ query = urllib.parse.parse_qs(parsed.query)
333
+ error = query.get("error", [None])[0]
334
+ error_desc = query.get("error_description", [None])[0]
335
+ if error:
336
+ msg = error_desc or error
337
+ flow._error = RuntimeError(f"OAuth error: {msg}")
338
+ self._send_html(200, _html_error(msg))
339
+ flow._result_event.set()
340
+ return
341
+
342
+ code = query.get("code", [None])[0]
343
+ state = query.get("state", [None])[0]
344
+ if not code:
345
+ flow._error = RuntimeError("Missing authorization code")
346
+ self._send_html(400, _html_error("Missing authorization code"))
347
+ flow._result_event.set()
348
+ return
349
+ if state != flow.state:
350
+ flow._error = RuntimeError("Invalid state — potential CSRF")
351
+ self._send_html(400, _html_error("Invalid state — potential CSRF"))
352
+ flow._result_event.set()
353
+ return
354
+
355
+ try:
356
+ tokens = exchange_code_for_tokens(code, flow.pkce)
357
+ flow._result = tokens
358
+ self._send_html(200, _HTML_SUCCESS)
359
+ except Exception as exc: # noqa: BLE001 — surface to caller
360
+ flow._error = exc
361
+ self._send_html(500, _html_error(str(exc)))
362
+ flow._result_event.set()
363
+
364
+
365
+ class _ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
366
+ daemon_threads = True
367
+ allow_reuse_address = True
368
+
369
+
370
+ def start_codex_oauth_flow() -> CodexAuthFlow:
371
+ """Bootstrap the OAuth flow and return the URL to open in the browser.
372
+
373
+ Spins up a tiny localhost server on port 1455 that will receive the
374
+ ``/auth/callback`` redirect, computes the PKCE pair and state, builds the
375
+ authorize URL and returns the :class:`CodexAuthFlow` handle. Caller is
376
+ responsible for opening ``flow.authorize_url`` (e.g. via
377
+ ``webbrowser.open``) and then awaiting the result via
378
+ :func:`await_codex_callback`.
379
+
380
+ Raises ``OSError`` (typically ``EADDRINUSE``) when port 1455 is already
381
+ occupied. The port is fixed because the OAuth app's redirect URI list is
382
+ server-side and hardcoded.
383
+ """
384
+ pkce = generate_pkce()
385
+ state = _base64url_encode(secrets.token_bytes(32))
386
+ authorize_url = build_authorize_url(pkce, state)
387
+
388
+ server = _ThreadingHTTPServer(("127.0.0.1", OAUTH_PORT), _CallbackHandler)
389
+ thread = threading.Thread(
390
+ target=server.serve_forever, name="aru-codex-oauth", daemon=True
391
+ )
392
+ flow = CodexAuthFlow(
393
+ authorize_url=authorize_url,
394
+ pkce=pkce,
395
+ state=state,
396
+ _server=server,
397
+ _thread=thread,
398
+ )
399
+ # Stash the live flow on the handler class so the per-request handler can
400
+ # see it without us needing to subclass per-flow.
401
+ _CallbackHandler.flow = flow
402
+ thread.start()
403
+ logger.info("codex oauth server listening on port %d", OAUTH_PORT)
404
+ return flow
405
+
406
+
407
+ def await_codex_callback(
408
+ flow: CodexAuthFlow, timeout: float = CALLBACK_TIMEOUT_SECONDS
409
+ ) -> dict[str, Any]:
410
+ """Block until the user finishes the browser handshake and return tokens.
411
+
412
+ Always tears down the local server (regardless of success/failure) before
413
+ returning. Raises ``TimeoutError`` if the user takes longer than
414
+ ``timeout`` seconds, or the underlying error if the exchange failed.
415
+ """
416
+ try:
417
+ got = flow._result_event.wait(timeout=timeout)
418
+ if not got:
419
+ raise TimeoutError(
420
+ "Codex OAuth: no callback received within "
421
+ f"{timeout:.0f}s — login cancelled."
422
+ )
423
+ if flow._error is not None:
424
+ raise flow._error
425
+ if flow._result is None:
426
+ raise RuntimeError("Codex OAuth: empty token response")
427
+ return flow._result
428
+ finally:
429
+ stop_codex_oauth_flow(flow)
430
+
431
+
432
+ def stop_codex_oauth_flow(flow: CodexAuthFlow) -> None:
433
+ """Tear down the local callback server. Safe to call multiple times."""
434
+ try:
435
+ flow._server.shutdown()
436
+ except Exception: # noqa: BLE001
437
+ pass
438
+ try:
439
+ flow._server.server_close()
440
+ except Exception: # noqa: BLE001
441
+ pass
442
+ if _CallbackHandler.flow is flow:
443
+ _CallbackHandler.flow = None
444
+
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # httpx Auth — injects Bearer + account-id and refreshes on the fly
448
+ # ---------------------------------------------------------------------------
449
+
450
+ # Sentinel api_key handed to the OpenAI SDK so it doesn't raise on a missing
451
+ # key. The actual ``Authorization`` header is written by :class:`CodexAuth`
452
+ # below — the SDK's value is stripped before the request goes out.
453
+ OAUTH_DUMMY_KEY = "aru-codex-oauth-dummy"
454
+
455
+ # Refresh margin: refresh slightly before the token actually expires so we
456
+ # never race the API check.
457
+ _REFRESH_MARGIN_MS = 60_000
458
+
459
+
460
+ def _load_codex_credential() -> dict[str, Any]:
461
+ from aru import auth as auth_mod
462
+
463
+ creds = auth_mod.get_credential("openai")
464
+ if not creds or creds.get("type") != "oauth":
465
+ raise RuntimeError(
466
+ "No Codex OAuth credential — run /connect and pick "
467
+ "'ChatGPT Pro/Plus (browser)' first."
468
+ )
469
+ return creds
470
+
471
+
472
+ def _save_codex_credential(creds: dict[str, Any]) -> None:
473
+ from aru import auth as auth_mod
474
+
475
+ auth_mod.set_credential("openai", creds)
476
+
477
+
478
+ def _refresh_if_needed(creds: dict[str, Any]) -> dict[str, Any]:
479
+ expires = creds.get("expires", 0)
480
+ now_ms = int(time.time() * 1000)
481
+ if isinstance(expires, (int, float)) and now_ms < int(expires) - _REFRESH_MARGIN_MS:
482
+ return creds
483
+ refresh = creds.get("refresh")
484
+ if not isinstance(refresh, str) or not refresh:
485
+ raise RuntimeError("Codex credential has no refresh token; re-run /connect.")
486
+ logger.info("refreshing Codex access token")
487
+ tokens = refresh_codex_tokens(refresh)
488
+ new_creds: dict[str, Any] = {
489
+ "type": "oauth",
490
+ "refresh": tokens.get("refresh_token") or refresh,
491
+ "access": tokens["access_token"],
492
+ "expires": now_ms + int(tokens.get("expires_in", 3600)) * 1000,
493
+ }
494
+ account_id = extract_account_id(tokens) or creds.get("accountId")
495
+ if account_id:
496
+ new_creds["accountId"] = account_id
497
+ _save_codex_credential(new_creds)
498
+ return new_creds
499
+
500
+
501
+ def get_codex_access_token() -> tuple[str, str | None]:
502
+ """Return ``(access_token, account_id)`` for an OAuth credential.
503
+
504
+ Refreshes transparently when the cached token is about to expire.
505
+ Public so the CLI can sanity-check connectivity (``/connect`` post-flow
506
+ summary, future ``aru auth status`` etc.) without going through httpx.
507
+ """
508
+ creds = _refresh_if_needed(_load_codex_credential())
509
+ return creds["access"], creds.get("accountId")
510
+
511
+
512
+ class CodexAuth(httpx.Auth):
513
+ """``httpx.Auth`` subclass that injects Codex headers per request.
514
+
515
+ Used by :mod:`aru.providers` when the openai credential is of type
516
+ ``oauth``. Responsibilities:
517
+
518
+ * strip whatever ``Authorization`` header the OpenAI SDK set (it uses
519
+ the dummy key),
520
+ * write a fresh ``Authorization: Bearer <access>`` (refreshing the token
521
+ on the fly when needed),
522
+ * add ``ChatGPT-Account-Id`` and ``originator: aru`` so the request
523
+ looks like a legitimate ChatGPT-CLI call.
524
+
525
+ The refresh path goes through :func:`refresh_codex_tokens` synchronously
526
+ via ``urllib`` — it's rare (~once per hour) so the blocking call is
527
+ fine, and httpx will run the sync flow from a thread for async clients
528
+ automatically (see the base class ``async_auth_flow`` implementation).
529
+ """
530
+
531
+ # Inherits requires_request_body / requires_response_body = False from
532
+ # the base class — we never need the body to compute the header.
533
+
534
+ def __init__(self) -> None:
535
+ self._lock = threading.Lock()
536
+
537
+ def _apply(self, request: httpx.Request) -> None:
538
+ # httpx headers are case-insensitive but request construction can
539
+ # leave duplicates if both casings got set upstream — wipe both.
540
+ for key in ("Authorization", "authorization"):
541
+ try:
542
+ del request.headers[key]
543
+ except KeyError:
544
+ pass
545
+
546
+ with self._lock:
547
+ access, account_id = get_codex_access_token()
548
+ request.headers["Authorization"] = f"Bearer {access}"
549
+ if account_id:
550
+ request.headers["ChatGPT-Account-Id"] = account_id
551
+ request.headers.setdefault("originator", "aru")
552
+ # OpenAI's Codex backend tags requests with this for retry dedup;
553
+ # keep it stable per process.
554
+ request.headers.setdefault("session_id", _PROCESS_SESSION_ID)
555
+
556
+ def auth_flow(self, request: httpx.Request):
557
+ self._apply(request)
558
+ yield request
559
+
560
+
561
+ # Process-wide session id — Codex tags requests with this so its servers can
562
+ # de-dup retries. Doesn't need to map to anything meaningful for us.
563
+ _PROCESS_SESSION_ID = _base64url_encode(secrets.token_bytes(16))
564
+
565
+
566
+ __all__ = [
567
+ "CODEX_API_BASE",
568
+ "CODEX_API_ENDPOINT",
569
+ "CLIENT_ID",
570
+ "ISSUER",
571
+ "OAUTH_PORT",
572
+ "OAUTH_REDIRECT_URI",
573
+ "OAUTH_DUMMY_KEY",
574
+ "CodexAuth",
575
+ "CodexAuthFlow",
576
+ "PkceCodes",
577
+ "await_codex_callback",
578
+ "build_authorize_url",
579
+ "exchange_code_for_tokens",
580
+ "extract_account_id",
581
+ "extract_account_id_from_claims",
582
+ "generate_pkce",
583
+ "get_codex_access_token",
584
+ "parse_jwt_claims",
585
+ "refresh_codex_tokens",
586
+ "start_codex_oauth_flow",
587
+ "stop_codex_oauth_flow",
588
+ ]