diff --git a/.flake8 b/.flake8 index f4fb3f7..772524b 100644 --- a/.flake8 +++ b/.flake8 @@ -2,3 +2,6 @@ max-line-length = 120 extend-ignore = E203, W503 exclude = .git, __pycache__, */migrations/* +# Package __init__ files exist to re-export submodules for discovery. +per-file-ignores = + */tests/__init__.py:F401 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f70ca2..33a5d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ entries under `## [Unreleased]`; a maintainer assigns the version at release tim ## [Unreleased] +### Fixed +- **ChatGPT device-code login** — extract the current Codex CLI code format + (4-then-5, e.g. `IL70-LNADU`) and strip ANSI escapes from the captured output. + The old 4-4 pattern missed the code, which both stalled the verification page + by the full capture timeout and leaked raw `[90m…` ANSI text into the login + gate. + ### Added - **Contribution gates** — a `pr-checks` CI workflow (CHANGELOG, DCO sign-off, SECURITY.md-when-relevant, flake8) plus branch protection, and `CONTRIBUTING.md` diff --git a/codexoo/models/res_users.py b/codexoo/models/res_users.py index 2398590..01bb1b6 100644 --- a/codexoo/models/res_users.py +++ b/codexoo/models/res_users.py @@ -31,7 +31,13 @@ # stdout. The exact wording is Codex-version dependent (plan item C3); these are # deliberately loose and we also return the raw text as a fallback. _URL_RE = re.compile(r"https://\S*device\S*", re.IGNORECASE) -_CODE_RE = re.compile(r"\b([A-Z0-9]{4}-[A-Z0-9]{4})\b") +# Codex device codes vary in length (e.g. the 4-then-5 "IL70-LNADU"); accept any +# reasonable XXXX-XXXX grouping rather than a fixed 4-4 so the code is extracted +# (which also lets the capture loop exit early instead of stalling the full 15s). +_CODE_RE = re.compile(r"\b([A-Z0-9]{3,8}-[A-Z0-9]{3,8})\b") +# Strip ANSI color/format escapes the CLI emits, so the captured URL/code regexes +# match cleanly and any raw fallback shown in the UI is plain text. +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") class ResUsers(models.Model): @@ -259,6 +265,7 @@ def _codexoo_login_start(self): _LOGIN_PID % self.id, str(proc.pid)) raw = self._codexoo_capture_login(proc, _LOGIN_CAPTURE_S) + raw = _ANSI_RE.sub("", raw) url_m = _URL_RE.search(raw) url = url_m.group(0) if url_m else "" m = _CODE_RE.search(raw) @@ -299,7 +306,10 @@ def _pump(pipe, out_q): if ln is None: break buf.append(ln) - text = "".join(buf) + # Strip ANSI before matching: the CLI wraps the code in color escapes + # (e.g. "\x1b[94mIL70-LNADU\x1b[0m") whose trailing "m" would otherwise + # defeat the \b boundary and prevent the early exit, stalling the run. + text = _ANSI_RE.sub("", "".join(buf)) # Stop early once we have both a device URL and a code. if _URL_RE.search(text) and _CODE_RE.search(text): break diff --git a/codexoo/tests/__init__.py b/codexoo/tests/__init__.py index 9627304..6d3f3db 100644 --- a/codexoo/tests/__init__.py +++ b/codexoo/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_runner_web from . import test_tool_log from . import test_ir_http_auth +from . import test_login_parse diff --git a/codexoo/tests/test_login_parse.py b/codexoo/tests/test_login_parse.py new file mode 100644 index 0000000..0010b50 --- /dev/null +++ b/codexoo/tests/test_login_parse.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 CICDoo (https://cicdoo.com) +# SPDX-License-Identifier: LGPL-3.0-or-later OR LicenseRef-Codexoo-Commercial +# Dual-licensed: open source (LGPL-3, see LICENSE) or commercial (see COMMERCIAL_LICENSE.md). +"""The device-code login gate scrapes the verification URL and one-time code out +of the ``codex login --device-auth`` stdout. The CLI colorises that output and +its code length is version-dependent (e.g. the 4-then-5 ``IL70-LNADU``). These +tests pin the extraction so a stricter pattern can't silently break login again: +a missed code both stalls the capture for the full timeout and leaks raw ANSI to +the UI.""" +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.codexoo.models.res_users import _URL_RE, _CODE_RE, _ANSI_RE + +# A faithful sample of the codex CLI device-auth output, with real ANSI escapes. +_SAMPLE = ( + "Welcome to Codex [v\x1b[90m0.139.0\x1b[0m]\n" + "\x1b[90mOpenAI's command-line coding agent\x1b[0m\n\n" + "1. Open this link in your browser and sign in to your account\n" + " \x1b[94mhttps://auth.openai.com/codex/device\x1b[0m\n\n" + "2. Enter this one-time code \x1b[90m(expires in 15 minutes)\x1b[0m\n" + " \x1b[94mIL70-LNADU\x1b[0m\n" +) + + +@tagged("post_install", "-at_install", "codexoo") +class TestLoginParse(TransactionCase): + + # --- ANSI stripping ----------------------------------------------------- + def test_ansi_stripped(self): + clean = _ANSI_RE.sub("", _SAMPLE) + self.assertNotIn("\x1b", clean) + self.assertNotIn("[94m", clean) + self.assertNotIn("[0m", clean) + + # --- code extraction ---------------------------------------------------- + def test_new_code_format_extracted(self): + # 4-then-5 is the format current Codex emits; the old pattern missed it. + clean = _ANSI_RE.sub("", _SAMPLE) + m = _CODE_RE.search(clean) + self.assertTrue(m) + self.assertEqual(m.group(1), "IL70-LNADU") + + def test_legacy_code_format_still_extracted(self): + self.assertEqual( + _CODE_RE.search("code ABCD-1234 expires").group(1), "ABCD-1234") + + def test_code_not_matched_through_ansi(self): + # Regression guard: the colour escape ("\x1b[94m") ends in a word char, + # which defeats the \b boundary — so the code is only found AFTER the + # ANSI strip. Matching on raw output is what stalled the capture loop. + self.assertIsNone(_CODE_RE.search(_SAMPLE)) + + # --- url extraction ----------------------------------------------------- + def test_url_extracted_without_trailing_escape(self): + clean = _ANSI_RE.sub("", _SAMPLE) + self.assertEqual( + _URL_RE.search(clean).group(0), + "https://auth.openai.com/codex/device")