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.
- {aru_code-0.59.0/aru_code.egg-info → aru_code-0.60.0}/PKG-INFO +1 -1
- aru_code-0.60.0/aru/__init__.py +1 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/auth.py +3 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/cli.py +12 -0
- aru_code-0.60.0/aru/codex_oauth.py +588 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/commands.py +118 -3
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/providers.py +209 -1
- aru_code-0.60.0/aru/state.py +181 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/app.py +19 -0
- {aru_code-0.59.0 → aru_code-0.60.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/SOURCES.txt +5 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/pyproject.toml +1 -1
- aru_code-0.60.0/tests/test_codex_oauth.py +406 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_connect_command.py +3 -1
- aru_code-0.60.0/tests/test_connect_oauth.py +387 -0
- aru_code-0.60.0/tests/test_state_recent_models.py +239 -0
- aru_code-0.59.0/aru/__init__.py +0 -1
- {aru_code-0.59.0 → aru_code-0.60.0}/LICENSE +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/README.md +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/_debug/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/_debug/analyze_trace.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/_debug/loop_tracer.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/agent_factory.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/agents/base.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/agents/planner.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/cache_patch.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/checkpoints.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/config.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/context.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/display.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/doom_loop.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/events.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/format/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/format/manager.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/format/runner.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/history_blocks.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/lsp/client.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/memory/loader.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/memory/store.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/permissions.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/runner.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/runtime.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/select.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/session.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/sinks.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/streaming.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tool_policy.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/registry.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/search.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/shell.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/skill.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/web.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/log_bridge.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/notifications.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/keymap.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/session_picker.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/themes.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/ui.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/chat.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/file_link.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/prompt_area.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/prompt_queue.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/subagent_panel.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/tasklist_panel.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru/ui.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/setup.cfg +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_auth_store.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_catalog.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_codebase.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_config.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_context.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_delegate.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_doom_loop.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_format.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_lsp.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_main.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_memory.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_permission_timeout_suspension.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_permissions.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_plugins.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_providers.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_ranker.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_runtime.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_select.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_session_free_cost.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_subagent_tool_events.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_connect_wiring.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_error_display.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_file_link.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_permission_flow.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_prompt_queue.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_shell_bang.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_slash_model.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_subagent_panel.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_theme.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_worktree.py +0 -0
- {aru_code-0.59.0 → aru_code-0.60.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -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("<", "<").replace(">", ">")
|
|
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
|
+
]
|