Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ Odoo-style versioning (`17.0.MAJOR.MINOR.PATCH`). Add new entries under

## [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`
Expand Down
14 changes: 12 additions & 2 deletions codexoo/models/res_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions codexoo/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions codexoo/tests/test_login_parse.py
Original file line number Diff line number Diff line change
@@ -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")
Loading