Summary
cmd login codex (and the /connect Codex OAuth flow) appears to succeed in the browser but the credential is never written to auth.json, so GPT/Codex models remain unusable. /connect shows the Codex lane as connected even when no credential exists.
Root cause
In @byokkit/cmd-provider-openai, the OAuth redirect_uri is computed from two different sources in the authorize request vs. the token exchange request. When the values disagree, RFC 6749 §4.1.3 is violated and OpenAI returns 400 invalid_request_error / token_exchange_user_error. The browser still shows "Authorization successful" because the local loopback does receive the callback — but the credential is never obtained, never written, and /connect has no way to tell.
The current shipped package has ADVERTISED_HOST = "localhost" (used for REDIRECT_URI) but binds the loopback on BIND_HOST = "127.0.0.1". Any code that pulls the loopback's actual redirectUri into the authorize URL but uses the static REDIRECT_URI constant for the token exchange will hit this. This is a class of bug — adding a port-0 fallback (or any other dynamic-port logic) to startLoopback is what exposes it.
Two paths lead to the same symptom:
Path A (current shipped 0.1.2, port 1455 free). buildAuthorizeUrl hard-codes redirect_uri: REDIRECT_URI (= http://localhost:1455/auth/callback). The token exchange in awaitCodeAndExchange also uses REDIRECT_URI. They match on the happy path. With a real code, the token exchange should succeed.
Path B (any build where loopback.redirectUri is used in authorize but not in token exchange). buildAuthorizeUrl is given params.redirectUri || REDIRECT_URI; the caller passes loopback.redirectUri (= http://127.0.0.1:<port>/auth/callback) but awaitCodeAndExchange still uses static REDIRECT_URI (= http://localhost:1455/auth/callback). The authorize URL is on 127.0.0.1:<port>, the token exchange is on localhost:1455 — host disagreement, OpenAI rejects.
If port 1455 is held (EADDRINUSE) and a port-0 fallback is added, Path B always fires. The shipped 0.1.2 has no port-0 fallback, so it only fires if the user adds one — but it's the most common failure mode in the field, because port 1455 is routinely held by a stale loopback from a prior session.
Reproduction (Path B — what the user's diagnosis looks like)
// /tmp/test-codex-oauth.mjs — point this at the actual cmd cache, not the npm copy
const CACHE = "/home/ronya/.commandcode/cache/providers/@byokkit/cmd-provider-openai/node_modules/@byokkit/cmd-provider-openai/dist/index.js";
const openai = (await import(CACHE)).default;
const pending = await openai.auth.methods[0].authorize();
const url = new URL(pending.url);
console.log("authorize redirect_uri:", url.searchParams.get("redirect_uri"));
// → http://127.0.0.1:1455/auth/callback (or http://127.0.0.1:<random>/auth/callback after port-0 fallback)
const cb = new URL(url.searchParams.get("redirect_uri"));
cb.searchParams.set("code", "ac_test");
cb.searchParams.set("state", url.searchParams.get("state"));
fetch(cb.toString()); // 200 "Authorization successful" — loopback is happy
await pending.callback(); // throws: 400 token_exchange_user_error
// Why: redirect_uri sent to /oauth/token is REDIRECT_URI = http://localhost:1455/...
// host doesn't match the authorize redirect_uri → OpenAI rejects
Real cmd login codex produces the same 400 token_exchange_user_error. cmd's service.login lets the error propagate, performProviderAuth catches it and returns {success: false, error: "Token exchange failed: 400 ..."}, and loginAction prints Login failed: <error> and exits 1. The user reports "nothing changed" because the visible failure mode is the credential never being written, regardless of whether the error message scrolled by.
Suggested fix (4 changes in dist/index.js)
function buildAuthorizeUrl(params) {
const query = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
- redirect_uri: REDIRECT_URI,
+ redirect_uri: params.redirectUri || REDIRECT_URI,
scope: SCOPE,
code_challenge: params.challenge,
code_challenge_method: "S256",
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
state: params.state,
originator: "codex_cli_rs"
});
return `${AUTHORIZE_ENDPOINT}?${query.toString()}`;
}
authorize: async () => {
const pkce = generatePkce();
const state = generateState();
const loopback = await startLoopback();
- const url = buildAuthorizeUrl({ challenge: pkce.challenge, state });
+ const url = buildAuthorizeUrl({ challenge: pkce.challenge, state, redirectUri: loopback.redirectUri });
return {
type: "pending",
url,
instructions: "...",
callback: () => awaitCodeAndExchange({
loopback,
verifier: pkce.verifier,
expectedState: state,
- deps
+ deps,
+ redirectUri: loopback.redirectUri
})
};
}
async function awaitCodeAndExchange(params) {
const { loopback, verifier, expectedState, deps } = params;
+ const redirectUri = params.redirectUri ?? REDIRECT_URI;
try {
const { code, state } = await withTimeout(loopback.waitForCode(), CALLBACK_TIMEOUT_MS);
if (state !== expectedState) throw new Error("state mismatch");
const token = await exchangeCodeForToken({
tokenEndpoint: TOKEN_ENDPOINT,
code, verifier, clientId: CLIENT_ID,
- redirectUri: REDIRECT_URI,
+ redirectUri,
fetchImpl: deps.fetchImpl,
now: deps.now
});
...
After this, authorize and token-exchange both use whatever redirectUri was actually sent. The default fallback to REDIRECT_URI is preserved for any caller that doesn't pass one.
Secondary findings
/connect reports Codex as connected even with no credential. getProviderConfig("codex").checkAuth is undefined. The lane-status check in /connect is:
if (!t?.requiresAuth || !t.checkAuth) return [e.value, !0];
When checkAuth is undefined the condition is truthy and the lane is reported as connected. Adding checkAuth: () => hasProviderCredential("codex") to the codex lane config would have surfaced this bug immediately — the lane would have shown a red ○ not connected and the user would have known to re-run the OAuth.
- Port 1455 doesn't unref cleanly across sessions.
createLoopbackCallbackServer.close() calls server.unref() but server.close() is async and waits for active connections. The 2-second setTimeout(window.close) in the success HTML can keep a connection open past process exit, holding port 1455 for the next session. Worth a setImmediate(() => server.close()) and dropping keepAliveTimeout to 0. (Also, the shipped 0.1.2 has no port-0 fallback, so EADDRINUSE → user must fuser -k 1455/tcp manually.)
Environment
- command-code: bundled
cli.mjs from the global install, loads providers from $HOME/.commandcode/cache/providers/<pkg>/node_modules/<pkg>/dist/index.js (NOT from the nvm node_modules/command-code/node_modules/... location — that path is a leftover)
@byokkit/cmd-provider-openai: 0.1.2
- Node: 24.15.0
- OS: Linux (WSL2 on Windows 11);
/etc/hosts has 127.0.0.1 localhost
Evidence
- Source:
$HOME/.commandcode/cache/providers/@byokkit/cmd-provider-openai/node_modules/@byokkit/cmd-provider-openai/dist/index.js
buildAuthorizeUrl
oauthMethod.authorize (callback closure)
awaitCodeAndExchange
- Constants:
dist/chunk-34MJFIMS.js
ADVERTISED_HOST = "localhost", BIND_HOST = "127.0.0.1", OAUTH_PORT = 1455, CALLBACK_PATH = "/auth/callback"
- Local symptom:
~/.commandcode/auth.json has no codex or openai key after a login attempt that "succeeded" in the browser
- Repro script:
/tmp/test-codex-oauth.mjs (kept in this report's environment)
Summary
cmd login codex(and the/connectCodex OAuth flow) appears to succeed in the browser but the credential is never written toauth.json, so GPT/Codex models remain unusable./connectshows the Codex lane as connected even when no credential exists.Root cause
In
@byokkit/cmd-provider-openai, the OAuthredirect_uriis computed from two different sources in the authorize request vs. the token exchange request. When the values disagree, RFC 6749 §4.1.3 is violated and OpenAI returns400 invalid_request_error / token_exchange_user_error. The browser still shows "Authorization successful" because the local loopback does receive the callback — but the credential is never obtained, never written, and/connecthas no way to tell.The current shipped package has
ADVERTISED_HOST = "localhost"(used forREDIRECT_URI) but binds the loopback onBIND_HOST = "127.0.0.1". Any code that pulls the loopback's actualredirectUriinto the authorize URL but uses the staticREDIRECT_URIconstant for the token exchange will hit this. This is a class of bug — adding a port-0 fallback (or any other dynamic-port logic) tostartLoopbackis what exposes it.Two paths lead to the same symptom:
Path A (current shipped 0.1.2, port 1455 free).
buildAuthorizeUrlhard-codesredirect_uri: REDIRECT_URI(=http://localhost:1455/auth/callback). The token exchange inawaitCodeAndExchangealso usesREDIRECT_URI. They match on the happy path. With a real code, the token exchange should succeed.Path B (any build where
loopback.redirectUriis used in authorize but not in token exchange).buildAuthorizeUrlis givenparams.redirectUri || REDIRECT_URI; the caller passesloopback.redirectUri(=http://127.0.0.1:<port>/auth/callback) butawaitCodeAndExchangestill uses staticREDIRECT_URI(=http://localhost:1455/auth/callback). The authorize URL is on127.0.0.1:<port>, the token exchange is onlocalhost:1455— host disagreement, OpenAI rejects.If port 1455 is held (EADDRINUSE) and a port-0 fallback is added, Path B always fires. The shipped 0.1.2 has no port-0 fallback, so it only fires if the user adds one — but it's the most common failure mode in the field, because port 1455 is routinely held by a stale loopback from a prior session.
Reproduction (Path B — what the user's diagnosis looks like)
Real
cmd login codexproduces the same400 token_exchange_user_error. cmd'sservice.loginlets the error propagate,performProviderAuthcatches it and returns{success: false, error: "Token exchange failed: 400 ..."}, andloginActionprintsLogin failed: <error>and exits 1. The user reports "nothing changed" because the visible failure mode is the credential never being written, regardless of whether the error message scrolled by.Suggested fix (4 changes in
dist/index.js)function buildAuthorizeUrl(params) { const query = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, - redirect_uri: REDIRECT_URI, + redirect_uri: params.redirectUri || REDIRECT_URI, scope: SCOPE, code_challenge: params.challenge, code_challenge_method: "S256", id_token_add_organizations: "true", codex_cli_simplified_flow: "true", state: params.state, originator: "codex_cli_rs" }); return `${AUTHORIZE_ENDPOINT}?${query.toString()}`; } authorize: async () => { const pkce = generatePkce(); const state = generateState(); const loopback = await startLoopback(); - const url = buildAuthorizeUrl({ challenge: pkce.challenge, state }); + const url = buildAuthorizeUrl({ challenge: pkce.challenge, state, redirectUri: loopback.redirectUri }); return { type: "pending", url, instructions: "...", callback: () => awaitCodeAndExchange({ loopback, verifier: pkce.verifier, expectedState: state, - deps + deps, + redirectUri: loopback.redirectUri }) }; } async function awaitCodeAndExchange(params) { const { loopback, verifier, expectedState, deps } = params; + const redirectUri = params.redirectUri ?? REDIRECT_URI; try { const { code, state } = await withTimeout(loopback.waitForCode(), CALLBACK_TIMEOUT_MS); if (state !== expectedState) throw new Error("state mismatch"); const token = await exchangeCodeForToken({ tokenEndpoint: TOKEN_ENDPOINT, code, verifier, clientId: CLIENT_ID, - redirectUri: REDIRECT_URI, + redirectUri, fetchImpl: deps.fetchImpl, now: deps.now }); ...After this,
authorizeandtoken-exchangeboth use whateverredirectUriwas actually sent. The default fallback toREDIRECT_URIis preserved for any caller that doesn't pass one.Secondary findings
/connectreports Codex as connected even with no credential.getProviderConfig("codex").checkAuthis undefined. The lane-status check in/connectis:checkAuthis undefined the condition is truthy and the lane is reported as connected. AddingcheckAuth: () => hasProviderCredential("codex")to the codex lane config would have surfaced this bug immediately — the lane would have shown a red○ not connectedand the user would have known to re-run the OAuth.createLoopbackCallbackServer.close()callsserver.unref()butserver.close()is async and waits for active connections. The 2-secondsetTimeout(window.close)in the success HTML can keep a connection open past process exit, holding port 1455 for the next session. Worth asetImmediate(() => server.close())and droppingkeepAliveTimeoutto 0. (Also, the shipped 0.1.2 has no port-0 fallback, so EADDRINUSE → user mustfuser -k 1455/tcpmanually.)Environment
cli.mjsfrom the global install, loads providers from$HOME/.commandcode/cache/providers/<pkg>/node_modules/<pkg>/dist/index.js(NOT from the nvmnode_modules/command-code/node_modules/...location — that path is a leftover)@byokkit/cmd-provider-openai: 0.1.2/etc/hostshas127.0.0.1 localhostEvidence
$HOME/.commandcode/cache/providers/@byokkit/cmd-provider-openai/node_modules/@byokkit/cmd-provider-openai/dist/index.jsbuildAuthorizeUrloauthMethod.authorize(callback closure)awaitCodeAndExchangedist/chunk-34MJFIMS.jsADVERTISED_HOST = "localhost",BIND_HOST = "127.0.0.1",OAUTH_PORT = 1455,CALLBACK_PATH = "/auth/callback"~/.commandcode/auth.jsonhas nocodexoropenaikey after a login attempt that "succeeded" in the browser/tmp/test-codex-oauth.mjs(kept in this report's environment)