Repository navigation
[Feature]: ChatGPT/Codex login via OpenAI deviceauth (device code) for headless/remote hubs #3366
Description
Activity
- addedenhancementNew feature or requestNew feature or requestaccount-poolOAuth, credentials, Codex pool, quota, failover, plansOAuth, credentials, Codex pool, quota, failover, plans
on Sep 3, 2026 리뷰 · 우선순위 55 / 80
이 이슈가 무엇을 하자는 것인지 아주 쉬운 말로 먼저 풀어 보겠습니다. 지금 ChatGPT(Codex) 계정을 OpenCodex 계정 풀에 새로 넣으려면, 프록시가 돌아가는 바로 그 컴퓨터에서 브라우저를 열어 로그인을 끝내야 합니다. 로그인이 끝나면 OpenAI가
http://localhost:1455/auth/callback주소로 사용자를 되돌려 보내 주는데, 이 주소는 "프록시가 돌아가는 그 컴퓨터의 1455번 문"이라는 뜻입니다. 그런데 이 이슈를 쓴 사람은 프록시를 화면 없는 원격 k3s 컨테이너에서 돌리고 있습니다. 원격 서버에는 사람이 볼 브라우저가 없고, 작성자의 노트북 브라우저에서localhost:1455를 열면 그건 노트북 자신의 1455번 문이라 프록시에는 절대 닿지 않습니다. 그래서 "브라우저는 아무 기기(휴대폰이든 노트북이든)에서 열고, 화면에 뜬 짧은 코드(예: ABCD-EFGH)만 입력해서 로그인을 끝내는" 방식, 즉 device code(deviceauth) 로그인을 openai 공급자에도 붙여 달라는 요청입니다. 이슈에는 OpenAI가 Codex CLI용으로 실제 쓰는 엔드포인트 3개(usercode 발급 → token 폴링 → oauth/token 교환)와 확인 페이지 주소까지 정확히 적혀 있어서, 요구사항 자체는 매우 구체적입니다.왜 이게 지금 dev에서 의미가 있는지도 짚겠습니다. 현재 HEAD(b5777aa, 2026-09-04 오전 KST 기준)에는 kimi/nous/github-copilot 세 공급자에만 device code 경로가 있고, 그 경로는
src/oauth/kimi.ts의loginKimi가onAuth로deviceCode를 올려 주고 →src/oauth/index.ts의startLoginFlow가 그대로 통과시키고 →/api/oauth/login이 JSON으로 내려 주고 → GUI가 그 값을 복사 버튼과 함께 보여 주는 식으로 이미 잘 이어져 있습니다. 반면 ChatGPT 로그인은 이 배관을 전혀 타지 않습니다.chatgpt는 일부러 "공개 OAuth 공급자"에서 빠져 있고(isPublicOAuthProvider), 그래서/api/oauth/login은provider: "chatgpt"를 400으로 거절합니다. 게다가 이 거절은 실수가 아니라tests/oauth-public-surface.test.ts가 명시적으로 잠가 둔 규칙입니다. Codex 계정 로그인은/api/codex-auth/login이라는 완전히 다른 라우트 계열(src/codex/auth-api.ts)이 담당하고, CLI도openai/codex/chatgpt이름을 그 라우트로 보냅니다. 즉 이슈 본문의 "chatgpt에 deviceCode만 실어 주면 기존 device-code UI가 공짜로 켜진다"는 기대는 현재 코드 기준으로는 성립하지 않습니다. 공짜 구간은 없고, Codex 전용 라우트와 Codex 전용 GUI 모달에 같은 배관을 새로 깔아야 합니다.지금도 부분적으로 가능한 것과 진짜로 막혀 있는 것을 정확히 나누는 것도 중요합니다. 원격 호스트에서 브라우저가 멋대로 뜨는 문제는 이미 손잡이가 있습니다.
src/oauth/open-browser-choice.ts의shouldOpenBrowserForLogin이 요청 바디의openBrowser와 설정값oauthOpenBrowser를 함께 보기 때문에, 헤드리스 허브는oauthOpenBrowser: false로 브라우저 자동 실행을 끌 수 있습니다. 콜백 서버도 컨테이너 안에서는 보통 그냥 잘 뜹니다(1455번 문이 비어 있으니Bun.serve가 성공합니다). 그래서 이슈 본문의 "콜백 서버도, 브라우저도 헤드리스 허브에는 존재하지 않는다"는 문장은 절반만 맞습니다. 콜백 서버는 뜨지만, 조작자의 브라우저에서 그 주소에 도달할 수 없다는 것이 실제 병목입니다. 남는 진짜 불편은 딱 하나입니다. 브라우저가 실패 화면을 띄운 뒤 주소창의 아주 긴code=...&state=...URL을 사람이 복사해서ocx account code openai --flow <flow-id>로 밀어 넣어야 한다는 점입니다. 이건 #2538 덕분에 동작은 하지만, OAuth 공급자 중 가장 거친 UX라는 지적은 타당합니다.기술적으로 조심해야 할 부분도 미리 말해 두겠습니다. deviceauth 방식은
code_verifier를 서버가 내려 주는 구조라서(이슈 2단계 응답에authorization_code와code_verifier가 함께 옵니다), 로컬에서 미리 PKCE를 만들어 두고 그것을 쓰는ChatGPTOAuthFlow.exchangeToken을 그대로 재사용할 수 없습니다. 또 이 방식은 콜백 리스너가 아예 필요 없는데,ChatGPTOAuthFlow가 상속하는OAuthCallbackFlow.login()은 항상 콜백 서버를 먼저 띄우고 브라우저 콜백을 기다립니다. 그래서 device 경로는 기존 클래스에 옵션을 끼워 넣는 방식보다 별도 함수/파일로 만드는 편이 훨씬 깔끔합니다. 시간 예산도 걸립니다. 콜백 흐름의 기본 타임아웃은 300초이고, Codex 로그인 상태 폴링도 150회 × 2초 = 300초, 종료 상태 보관 TTL도 300초입니다. 사람이 휴대폰을 꺼내 코드를 입력하는 시간을 감안하면 device 모드에서는 이 300초 예산이 가장 먼저 터질 지점입니다.마지막으로 정책 성격의 위험을 짚습니다. 이슈는 Cloudflare가 비-CLI 클라이언트에 530을 준다며
codex_cli_rsUser-Agent를 그대로 보내라고 제안합니다. 그런데 현재src/oauth/chatgpt.ts는originator를"opencodex"로 정직하게 보내고 있습니다. 즉 이 제안은 "인증 엔드포인트에서 우리 정체를 공급사 공식 CLI로 위장한다"는 뜻이고, 이건 코드 몇 줄 문제가 아니라 노선 결정입니다. 이 저장소는 벤더가 자기 클라이언트로 제한한 자격증명 경로에는 이미 보수적입니다(anthropic과 meta-muse는defaultRefreshPolicy: "disabled"로 백그라운드 트래픽을 아예 막았고, meta-muse는 ToS 경고 게이트까지 붙였습니다). 반대 선례도 있긴 합니다.src/adapters/agentrouter.ts는originator: "codex_cli_rs"를 보냅니다. 다만 그건 추론 요청 헤더고, 이번은 인증 엔드포인트라 위험 등급이 다릅니다. 또 계정 풀 등록은 토큰에서 계정 신원을 뽑아내야 성공하는데(extractAccountId가 실패하면/api/codex-auth/login이 "Could not determine account identity" 에러로 끝납니다), 콜백 흐름은 authorize 파라미터로id_token_add_organizations=true를 넣어서 그 신원을 확보합니다. deviceauth 흐름에는 그 파라미터를 끼울 자리가 없으므로, 발급된id_token에chatgpt_account_id나organizations가 실제로 들어오는지 먼저 실측하지 않으면 "로그인은 되는데 풀에 안 붙는" 결과가 날 수 있습니다.src/oauth/index.ts 라인 297 -
isPublicOAuthProvider가chatgpt를 제외하고 있어서, 이슈가 기대하는 "기존 device-code 표면 재사용"이 구조적으로 불가능하다. 이 한 줄 때문에 아래 표면 전부를 Codex 라우트 쪽에 새로 만들어야 한다.src/server/management/oauth-account-routes.ts 라인 148 -
/api/oauth/login이 공개 공급자가 아니면 400으로 거절하므로,deviceCode를 내려 주는 이 라우트(라인 189)는 openai 로그인에 절대 쓰이지 않는다.tests/oauth-public-surface.test.ts 라인 87~98 - 제네릭 OAuth 엔드포인트가
chatgpt를 거절해야 한다는 것이 테스트로 잠겨 있다. 이슈 제안대로chatgpt를 제네릭 경로에 흘리려 하면 이 불변식과 정면으로 충돌한다.src/codex/auth-api.ts 라인 2440 -
/api/codex-auth/login응답이{ ok, flowId, url, instructions }만 내려 준다. 흐름이deviceCode를 만들어도 여기서 버려지므로, 이 응답 스키마 확장이 1차 작업이다.src/codex/auth-api.ts 라인 2206 -
result.url이 있으면 무조건 서버 쪽 브라우저를 띄우려 한다.oauth-account-routes.ts라인 185처럼!deviceCode조건이 없어서, device 확인 URL을 헤드리스 허브 호스트에서 열려고 시도하게 된다.src/cli/account-auth.ts 라인 105~109 - Codex 분기의 출력 블록이
url/instructions/flowId만 찍고deviceCode는 찍지 않는다.deviceCode출력은 라인 150의 제네릭 분기에만 있으므로,ocx account login openai는 짧은 코드를 보여 줄 수단이 없다.gui/src/components/use-add-codex-account-oauth.ts 라인 148, 167 -
LoginResponse타입에deviceCode가 없고,data.url이 있을 때만 상태 기계가 진행된다. Codex 계정 추가 모달에 device 코드를 표시하려면 이 훅과 그 모달을 함께 고쳐야 한다.src/oauth/chatgpt.ts 라인 108~119 -
exchangeToken이 로컬에서 만든#verifier를 전제로 한다. deviceauth는code_verifier를 서버에서 받아 오므로 이 함수를 재사용할 수 없고, 별도 토큰 교환 경로가 필요하다.src/oauth/callback-server.ts 라인 111~124, 라인 14 -
OAuthCallbackFlow.login()은 항상 콜백 서버를 띄우고 300초 타임아웃을 건다. 리스너가 필요 없는 device 흐름을 이 클래스 안에 억지로 넣으면 불필요한 포트 점유와 짧은 시간 예산을 그대로 물려받는다.src/codex/auth-api.ts 라인 163, 라인 2214 / src/cli/account-auth.ts 라인 123 - 상태 TTL 300초, 서버 폴링 150×2초, CLI 폴링 150×2초로 모두 5분에 맞춰져 있다. 사람이 다른 기기에서 코드를 입력하는 device 흐름에는 5분이 빡빡하다.
src/oauth/chatgpt.ts 라인 11, 라인 100 -
originator: "opencodex"로 정직하게 신원을 밝히고 있고,id_token_add_organizations=true를 authorize 파라미터로 넣어 계정 신원을 확보한다. deviceauth 제안은 앞의 정직성 노선을 흔들고, 뒤의 파라미터를 넣을 자리가 없다.devlog/_plan - 이 작업에 대응하는 계획 유닛이 없다(
deviceauth문자열이 devlog/docs 전체에 없음). 설계 결정과 실측 기록을 남길 자리가 아직 만들어지지 않았다.메인테이너의 판단이 필요한 지점
codex_cli_rsUser-Agent 위장을 인증 엔드포인트에서 허용할 것인지. 허용하지 않으면 Cloudflare 530 때문에 이 기능 자체가 성립하지 않을 수 있고, 허용하면 anthropic/meta-muse에 적용한 보수적 노선과 어긋난다. 중간안은 device 로그인을 opt-in으로 두고 meta-muse처럼 ToS 경고 게이트를 붙이는 것이다.chatgpt를 계속 비공개 공급자로 유지할 것인지. 유지하면(권장) Codex 라우트 계열에 device 배관을 새로 깔아야 하고, 개방하면tests/oauth-public-surface.test.ts불변식과 계정 풀 네임스페이스 규칙을 다시 설계해야 한다.- 진입 방식: 명시적 플래그(
--device)만 둘 것인지, 브라우저/포트가 없을 때 자동 폴백까지 할 것인지. 자동 폴백은 편하지만, 컨테이너에서는 1455가 대체로 비어 있어 "포트 사용 가능"이 곧 "조작자가 도달 가능"을 뜻하지 않으므로 오탐이 나기 쉽다. - device 모드의 시간 예산을 얼마로 올릴 것인지. 300초 고정을 유지할지, device 흐름에만 더 긴 예산과 별도 만료 처리를 줄지 결정이 필요하다.
- 이 기능을 opencodex部署在远端主机,本地codex app如何实现自动注入远端模型 #2288(원격 호스트 배포)과 remote hub: four P2 follow-ups left open after the stack merged #3158(remote hub 후속 P2) 중 어느 단위에 묶을 것인지. 셋 다 "원격/헤드리스 허브"라는 같은 뿌리를 갖는다. 다만 #3366은 로그인 문법 자체를 바꾸는 독립 작업이라 중복 이슈로 닫을 대상은 아니다.
- 새 설정 키가 필요하다면
src/config.ts(3903줄)와src/types.ts에 최소 추가만 허용할 것인지. 진행 중인 types.ts/config.ts 분할 캠페인 때문에, 설정 표면을 크게 재배치하는 PR은 리베이스보다 닫는 편이 낫다.
너의 추천
먼저 코드를 쓰기 전에 실측부터 하는 것을 권합니다. 구체적으로는 새 계획 유닛(예:
devlog/_plan/260904_codex_deviceauth_login/)을 열고, 여기에 (1) 버리는 계정 하나로 usercode → token 폴링 → oauth/token 3단계를 컨테이너에서 직접 때려 본 결과, (2)codex_cli_rsUA 없이도 되는지 여부, (3) 발급된id_token에chatgpt_account_id/organizations가 실제로 들어오는지(즉extractAccountId가 성공하는지)를 기록하세요. (3)이 실패하면 계정 풀 등록까지 못 가므로 이 기능의 성립 여부 자체가 거기서 갈립니다.실측이 통과하면 작업을 네 조각으로 쪼개는 것을 권합니다. WP1은
src/oauth/chatgpt-device.ts를 새로 만들어OAuthCallbackFlow를 상속하지 않는 독립 device 흐름을 구현하고,src/oauth/index.ts의LoginOpts에 device 모드 옵션을 추가해chatgpt공급자 정의에서 분기하게 합니다. WP2는/api/codex-auth/login이 요청 바디의 device 플래그를 받고 응답에deviceCode를 실어 주도록 확장하고, 라인 2206의 브라우저 실행을oauth-account-routes.ts라인 185와 같은 모양으로!deviceCode조건으로 막습니다. WP3은 표면 두 곳, 즉src/cli/account-auth.tsCodex 분기 출력 블록과gui/src/components/use-add-codex-account-oauth.ts(+해당 모달)에 짧은 코드와 확인 URL을 노출합니다. WP4는tests/oauth-device-code-contract.test.ts를 본뜬 Codex용 계약 테스트를 추가하고,tests/oauth-public-surface.test.ts의 기존 불변식은 손대지 않은 채 유지하며, 헤드리스 허브 로그인 문서를 함께 갱신합니다.이슈 자체는 닫지 말고 열어 두는 것을 권합니다. 요청이 구체적이고 재현 맥락이 분명하며, 지금 dev의 가장 거친 로그인 경로를 정확히 지적하고 있습니다. 다만 이슈 본문의 "deviceCode만 실으면 기존 UI가 공짜로 켜진다"는 부분은 사실과 다르므로, 위 라인 근거를 들어 정정 코멘트를 남기고 실제 필요한 작업 범위(Codex 라우트 + CLI Codex 분기 + Codex GUI 모달)를 명시해 두는 것이 좋습니다. 그리고 외부 기여자가 곧바로 큰 PR을 열지 않도록, UA 위장 허용 여부에 대한 메인테이너 결정을 먼저 이슈에 적어 두는 것이 시간을 가장 많이 절약합니다.
이 댓글은 grok-bot이 작성했습니다
Thanks for the thorough review — and fair correction on the "free UI" part. I've verified the points about
isPublicOAuthProviderand the separate/api/codex-auth/*route family; I agree the device plumbing has to be built into the Codex routes + CLI branch + Codex GUI modal, not the generic OAuth surface.To give some operational context on why I filed this: the hub here runs headless in k3s, and adding/refreshing a Codex account is by far the most fragile routine operation we have. Every other provider is either device-code or a simple token import; ChatGPT is the only one where the operator has to shepherd a
localhost:1455redirect by hand from a different machine. The #2538 paste fallback does work, but it's the one flow I have to document step-by-step for anyone else touching the hub. So I'd strongly support making this a real work item rather than closing it.I'm happy to do the measurement pass you suggested before any code:
- Run the 3-step deviceauth trio (usercode → token polling → oauth/token) against
auth.openai.comfrom the hub container with a spare/disposable account. - Report whether it succeeds with the honest
originator/UA, or whether Cloudflare 530 really forcescodex_cli_rs. - Decode the issued
id_tokenand confirm whetherchatgpt_account_id/organizationsare present (i.e. whetherextractAccountIdwould succeed withoutid_token_add_organizations=true).
On the UA question: my preference is to keep the honest
originatorif the endpoints allow it. If 530s make that impossible, I'd back the middle path you outlined — device login as an explicit opt-in with a ToS warning gate (meta-muse style) — rather than defaulting to CLI spoofing. That policy call is yours; I'll record whatever you decide in this issue so any future PR follows it.The 4-way WP split and the longer device-mode time budget both sound right to me. I'll post the measurement results here.
- Run the 3-step deviceauth trio (usercode → token polling → oauth/token) against
Implemented on `dev` across two PRs.
#3369 — the deviceauth grant itself (`src/oauth/chatgpt-device.ts`): usercode -> poll -> token exchange, with `loginChatGPT(ctrl, { flow: "device" })` selecting it. The callback flow is untouched and stays the default.
#3385 — the surface. One correction to the issue's premise worth recording: `chatgpt` is deliberately excluded from the generic OAuth surface, and `openai|codex|chatgpt` route through the Codex-auth API instead — which was dropping `deviceCode` from its start DTO. So returning `deviceCode` from `src/oauth/` alone would not have lit up the existing device-code UI; the Codex-auth layer, the CLI, and the Codex account modal each needed wiring.
What you can do now:
```bash
ocx account login openai --device --no-wait --json{ "url": "https://auth.openai.com/codex/device", "deviceCode": "ABCD-EFGH", "flowId": "..." }
```
The dashboard has a Device code login row on the Codex add-account modal, and a reauth can switch to it from the waiting step.
Two things found while building it that are worth knowing:
- Both poll budgets stopped at five minutes. The grant lives fifteen, and the entire premise is that you walk to another device — so the flow would have died while its code was still valid. Both are now 480 attempts with settlement margin.
- On the User-Agent note in the issue: upstream's deviceauth builds a raw auth client with no Codex default headers, and its real UA is dynamic rather than the literal `codex_cli_rs`. We send no custom UA rather than hard-code another client's identity. If you hit Cloudflare 530s in practice, that is worth a separate report with the response detail.
Shipped on
devand verified on the current head.- fix(oauth): add the OpenAI deviceauth grant for headless ChatGPT login #3369
f825858da— the deviceauth grant (src/oauth/chatgpt-device.ts): usercode -> poll -> token exchange, selected byloginChatGPT(ctrl, { flow: "device" }). The callback flow is untouched and stays the default. - fix(codex,cli,gui): surface the device login so a headless hub can add an account #3385
d060f53ab— the surface:POST /api/codex-auth/loginacceptsdevice: trueand returnsdeviceCodewithout opening a browser,ocx account login openai --deviceprints URL/code/flow id (also under--no-wait --json), and the Codex add-account modal gained a Device code login row that a reauth can switch to.
Both commits confirmed as ancestors of
origin/dev, andsrc/cli/account-auth.tsondevdocuments the--deviceflag including the no-op arm for providers whose only login is already a device flow.Closing as completed. If you hit Cloudflare 530s in practice that is worth a separate report with the response detail — we deliberately send no custom User-Agent rather than impersonating another client's identity.
- fix(oauth): add the OpenAI deviceauth grant for headless ChatGPT login #3369
Area
Authentication and account pool
What are you trying to accomplish?
Add a new ChatGPT/Codex account to the hub's account pool from a machine that is not the one running the proxy. In my deployment the hub runs headless in a k3s container on a remote host (the same situation applies to any VPS/SSH-hosted hub). The other OAuth providers already support device-code logins (
kimi,nous,github-copilotreturn adeviceCodefrom the login flow), so I would like the same UX for theopenaiprovider.What prevents this today?
ChatGPTOAuthFlow(src/oauth/chatgpt.ts) is a callback-server flow only:redirect_uriis fixed tohttp://localhost:1455/auth/callback; the flow starts a local callback server and (tries to) open a local browser. Neither exists on a headless hub host.chatgpt, the login response does not include adeviceCode— onlykimi.ts,nous.tsandgithub-copilot.tsset it — so none of the device-code surfaces unified by Device-code logins need one consistent hint on every login surface #2529/fix(gui): show the device code and authorization link on every login surface #2530 can render one for openai.ocx account code openai --flow <flow-id>, improved by Read code and state from a redirect URL fragment in the paste fallback #2538) does work, but only after copying a longcode=...&state=...redirect URL out of the browser error page shown whenlocalhost:1455is unreachable — workable, but the roughest login flow of all the OAuth providers.OpenAI itself ships the exact wire flow needed — Codex CLI's
codex-rslogin implements a deviceauth grant:POST https://auth.openai.com/api/accounts/deviceauth/usercodeJSON{"client_id": "app_EMoamEEZ73f0CkXaXp7hrann"}→{"device_auth_id", "user_code", "interval"}POST https://auth.openai.com/api/accounts/deviceauth/tokenJSON{"device_auth_id", "user_code"}→ pending: 403/404; done:{"authorization_code", "code_verifier"}POST https://auth.openai.com/oauth/tokenformauthorization_code+code_verifier(+redirect_uri=https://auth.openai.com/deviceauth/callback) →{access_token, refresh_token, id_token, expires_in}Verification page:
https://auth.openai.com/codex/device. Same public PKCEclient_idthe callback flow already uses. One practical note: Cloudflare frequently answers 530 to non-CLI clients on these endpoints, so the requests need thecodex_cli_rsUser-Agent (what codex-rs itself sends).What should OpenCodex do?
chatgptprovider as an alternative to the callback flow (explicit flag, automatic fallback when no local browser / port 1455 is unavailable, or both).deviceCode(plus the polling flow id) from the login start forchatgpt, so the existing device-code UI surfaces render the shortuser_codeand thehttps://auth.openai.com/codex/deviceverification URL for openai like they already do for kimi/nous/github-copilot.ocx account login openaishould offer the same pending → poll UX it already has for kimi/nous/github-copilot, including code submission viaocx account code openai --flow <flow-id>.Example usage or interface
Alternatives or workarounds
http://localhost:1455/auth/callbackfail in the browser, copy the full redirect URL from the address bar and pipe it toocx account code openai --flow <flow-id>(works, thanks to Read code and state from a redirect URL fragment in the paste fallback #2538).auth.jsoninto the pool.Additional context
deviceCodethose surfaces light up for openai for free.localhost:1455dependency for hub-side account addition there too.device_code_auth.Checks